Repository: agentic-community/mcp-gateway-registry Branch: main Commit: 7484ec63a5fd Files: 1081 Total size: 13.6 MB Directory structure: gitextract_s_yfwmf8/ ├── .bandit ├── .claudeignore ├── .dockerignore ├── .env.example ├── .github/ │ └── workflows/ │ ├── auth-server-test.yml │ ├── build-auth-server.yml │ ├── build-mcpgw.yml │ ├── build-registry.yml │ ├── docs.yml │ ├── helm-chart-update.yml │ ├── helm-release-retag.yml │ ├── helm-test.yml │ ├── metrics-service-test.yml │ ├── registry-test.yml │ ├── release-images.yml │ └── terraform-test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .secrets.baseline ├── .semgrepignore ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DEV_INSTRUCTIONS.md ├── Dockerfile ├── LICENSE ├── Makefile ├── NOTICE ├── README.md ├── SECURITY.md ├── agents/ │ ├── a2a/ │ │ ├── .dockerignore │ │ ├── .env.example │ │ ├── .gitignore │ │ ├── README.md │ │ ├── deploy_live.sh │ │ ├── deploy_local.sh │ │ ├── docker-compose.arm.yml │ │ ├── docker-compose.local.yml │ │ ├── pyproject.toml │ │ ├── shutdown_local.sh │ │ ├── src/ │ │ │ ├── flight-booking-agent/ │ │ │ │ ├── Dockerfile │ │ │ │ ├── __init__.py │ │ │ │ ├── agent.py │ │ │ │ ├── database.py │ │ │ │ ├── dependencies.py │ │ │ │ ├── env_settings.py │ │ │ │ └── tools.py │ │ │ └── travel-assistant-agent/ │ │ │ ├── Dockerfile │ │ │ ├── __init__.py │ │ │ ├── agent.py │ │ │ ├── database.py │ │ │ ├── dependencies.py │ │ │ ├── env_settings.py │ │ │ ├── models.py │ │ │ ├── registry_discovery_client.py │ │ │ ├── remote_agent_client.py │ │ │ ├── server.py │ │ │ └── tools.py │ │ └── test/ │ │ ├── agent_discovery_test.py │ │ ├── agent_simple_test.py │ │ ├── check_agent_cards.sh │ │ ├── flight_booking_agent_card.json │ │ ├── simple_agents_test.py │ │ └── travel_assistant_agent_card.json │ ├── agent.py │ ├── cli_user_auth.py │ ├── client.py │ ├── registry_client.py │ └── system_prompt.txt ├── api/ │ ├── .gitignore │ ├── README.md │ ├── USER-GROUP-MANAGEMENT.md │ ├── get-m2m-token.sh │ ├── populate-registry.sh │ ├── registry_client.py │ ├── registry_management.py │ ├── test-management-api-e2e.md │ ├── test-management-api-e2e.sh │ └── test-mcp-client.sh ├── auth_server/ │ ├── __init__.py │ ├── cognito_utils.py │ ├── metrics_middleware.py │ ├── mongodb_groups_enrichment.py │ ├── oauth2_providers.yml │ ├── providers/ │ │ ├── __init__.py │ │ ├── auth0.py │ │ ├── base.py │ │ ├── cognito.py │ │ ├── entra.py │ │ ├── factory.py │ │ ├── keycloak.py │ │ └── okta.py │ ├── pyproject.toml │ ├── scopes.yml │ ├── scopes.yml.backup │ └── server.py ├── build-config.yaml ├── build_and_run.sh ├── charts/ │ ├── README.md │ ├── auth-server/ │ │ ├── Chart.yaml │ │ ├── templates/ │ │ │ ├── configmap-app-log.yaml │ │ │ ├── deployment.yaml │ │ │ ├── ingress.yaml │ │ │ ├── secret.yaml │ │ │ └── service.yaml │ │ └── values.yaml │ ├── keycloak-configure/ │ │ ├── Chart.yaml │ │ ├── templates/ │ │ │ ├── configmap.yaml │ │ │ ├── job.yaml │ │ │ ├── role.yaml │ │ │ ├── rolebinding.yaml │ │ │ ├── sa.yaml │ │ │ └── secret.yaml │ │ └── values.yaml │ ├── mcp-gateway-registry-stack/ │ │ ├── Chart.yaml │ │ ├── README.md │ │ ├── templates/ │ │ │ ├── _helpers.tpl │ │ │ ├── keycloak-admin-secret.yaml │ │ │ ├── keycloak-ingress-patch.yaml │ │ │ ├── keycloak-pg-secret.yaml │ │ │ ├── mongodb-cluster.yaml │ │ │ ├── mongodb-secret.yaml │ │ │ ├── oauth-provider-secret.yaml │ │ │ ├── shared-secret.yaml │ │ │ └── version-configmap.yaml │ │ └── values.yaml │ ├── mcpgw/ │ │ ├── Chart.yaml │ │ ├── templates/ │ │ │ ├── deployment.yaml │ │ │ ├── ingress.yaml │ │ │ ├── secret.yaml │ │ │ └── service.yaml │ │ └── values.yaml │ ├── mongodb-configure/ │ │ ├── Chart.yaml │ │ ├── templates/ │ │ │ ├── configmap.yaml │ │ │ ├── job.yaml │ │ │ └── secret.yaml │ │ └── values.yaml │ └── registry/ │ ├── Chart.yaml │ ├── templates/ │ │ ├── configmap-app-log.yaml │ │ ├── configmap-otel.yaml │ │ ├── deployment.yaml │ │ ├── ingress.yaml │ │ ├── secret.yaml │ │ └── service.yaml │ └── values.yaml ├── cli/ │ ├── agent_mgmt.py │ ├── agent_mgmt.sh │ ├── agentcore/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── discovery.py │ │ ├── models.py │ │ ├── registration.py │ │ ├── sync.py │ │ └── token_refresher.py │ ├── anthropic_transformer.py │ ├── bin/ │ │ └── registry.js │ ├── bootstrap_user_and_m2m_setup.sh │ ├── examples/ │ │ ├── README.md │ │ ├── airegistry.json │ │ ├── aws-kb-server.json │ │ ├── cloudflare-docs-server-config.json │ │ ├── code_reviewer_agent.json │ │ ├── complete-agent-example.json │ │ ├── complete-server-example.json │ │ ├── context7-server-config.json │ │ ├── context7-v2-server-config.json │ │ ├── currenttime-users.json │ │ ├── currenttime-v2.json │ │ ├── currenttime.json │ │ ├── data_analysis_agent.json │ │ ├── devops_deployment_agent.json │ │ ├── documentation_agent.json │ │ ├── federation-config-agentcore-example.json │ │ ├── federation-config-example.json │ │ ├── flight_booking_agent_card.json │ │ ├── flight_booking_agent_ecs.json │ │ ├── geospatial_route_planner_agent.json │ │ ├── invalid-config.json │ │ ├── jewel_homes_support_agent_card.json │ │ ├── minimal-server-config.json │ │ ├── peer-registry-lob-1.json.example │ │ ├── public-mcp-users.json │ │ ├── realserverfaketools.json │ │ ├── security_analyzer_agent.json │ │ ├── server-config.json │ │ ├── test-peer-config.json │ │ ├── test-timing-server.json │ │ ├── test_automation_agent.json │ │ ├── test_code_reviewer_agent.json │ │ ├── tourist_guide_agent_card.json │ │ ├── travel_assistant_agent_card.json │ │ ├── travel_assistant_agent_ecs.json │ │ ├── virtual-server-combined-example.json │ │ ├── virtual-server-scoped-example.json │ │ ├── virtual-server-scoped-users.json │ │ └── working_agent.json │ ├── get_user_token.py │ ├── import_from_anthropic_registry.sh │ ├── import_server_list.txt │ ├── mcp_client.py │ ├── mcp_security_scanner.py │ ├── mcp_utils.py │ ├── package.json │ ├── registry_cli_wrapper.py │ ├── scan_all_servers.py │ ├── service_mgmt.sh │ ├── src/ │ │ ├── agent/ │ │ │ ├── agentRunner.ts │ │ │ ├── anthropicClient.ts │ │ │ ├── bedrockClient.ts │ │ │ ├── modelClient.ts │ │ │ └── tools.ts │ │ ├── app.tsx │ │ ├── auth.ts │ │ ├── chat/ │ │ │ ├── commandParser.ts │ │ │ └── taskInterpreter.ts │ │ ├── commands/ │ │ │ └── executor.ts │ │ ├── components/ │ │ │ ├── Banner.tsx │ │ │ ├── CallToolForm.tsx │ │ │ ├── CommandSuggestions.tsx │ │ │ ├── JsonViewer.tsx │ │ │ ├── MultiStepForm.tsx │ │ │ ├── StatusMessage.tsx │ │ │ ├── TaskRunner.tsx │ │ │ ├── TokenFileEditor.tsx │ │ │ ├── TokenStatusFooter.tsx │ │ │ └── UrlEditor.tsx │ │ ├── index.tsx │ │ ├── parseArgs.ts │ │ ├── paths.ts │ │ ├── runtime/ │ │ │ ├── mcp.ts │ │ │ ├── pythonClient.ts │ │ │ └── script.ts │ │ ├── tasks/ │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── types/ │ │ │ └── mcp.ts │ │ └── utils/ │ │ ├── commands.ts │ │ ├── cost.json │ │ ├── costCalculator.ts │ │ ├── docsReader.ts │ │ ├── markdown.ts │ │ └── tokenRefresh.ts │ ├── sync_okta_m2m.py │ ├── test_a2a_agents.py │ ├── test_anthropic_api.py │ ├── test_asor_complete.py │ ├── tsconfig.json │ └── user_mgmt.sh ├── config/ │ ├── grafana/ │ │ ├── dashboards/ │ │ │ ├── dashboard.yml │ │ │ └── mcp-analytics-comprehensive.json │ │ └── datasources/ │ │ └── prometheus.yml │ └── prometheus.yml ├── credentials-provider/ │ ├── add_noauth_services.py │ ├── agentcore-auth/ │ │ ├── .env.example │ │ ├── README.md │ │ └── get_m2m_token.py │ ├── auth0/ │ │ ├── README.md │ │ ├── __init__.py │ │ └── get_m2m_token.py │ ├── check_and_refresh_creds.sh │ ├── entra/ │ │ ├── __init__.py │ │ └── get_m2m_token.py │ ├── generate_creds.sh │ ├── keycloak/ │ │ └── get_m2m_token.py │ ├── oauth/ │ │ ├── .env.example │ │ ├── egress_oauth.py │ │ ├── generic_oauth_flow.py │ │ ├── ingress_oauth.py │ │ └── oauth_providers.yaml │ ├── okta/ │ │ ├── __init__.py │ │ └── get_m2m_token.py │ ├── token_refresher.py │ └── utils.py ├── docker/ │ ├── 502.html │ ├── Dockerfile.auth │ ├── Dockerfile.mcp-server │ ├── Dockerfile.mcp-server-cpu │ ├── Dockerfile.mcp-server-light │ ├── Dockerfile.metrics-db │ ├── Dockerfile.registry │ ├── Dockerfile.registry-cpu │ ├── Dockerfile.scopes-init │ ├── auth-entrypoint.sh │ ├── keycloak/ │ │ └── Dockerfile │ ├── lua/ │ │ ├── capture_body.lua │ │ ├── emit_metrics.lua │ │ ├── flush_metrics.lua │ │ └── virtual_router.lua │ ├── nginx_rev_proxy_http_and_https.conf │ ├── nginx_rev_proxy_http_only.conf │ └── registry-entrypoint.sh ├── docker-compose.dhi.yml ├── docker-compose.podman.yml ├── docker-compose.prebuilt.yml ├── docker-compose.yml ├── docs/ │ ├── FEATURES.md │ ├── OBSERVABILITY.md │ ├── README.md │ ├── TELEMETRY.md │ ├── a2a-agent-management.md │ ├── a2a.md │ ├── agent-skills-operational-guide.md │ ├── agent-visibility-and-group-access.md │ ├── agentcore-auto-registration-prerequisites.md │ ├── agentcore.md │ ├── ai-coding-assistants-setup.md │ ├── ai-registry-tools.md │ ├── anthropic-registry-import.md │ ├── anthropic_registry_api.md │ ├── api-reference.md │ ├── audit-logging.md │ ├── auth-mgmt.md │ ├── auth.md │ ├── auth0-m2m-setup.md │ ├── auth0.md │ ├── aws-agent-registry-federation.md │ ├── cli.md │ ├── cognito.md │ ├── complete-setup-guide.md │ ├── configuration.md │ ├── custom-metadata.md │ ├── database-design.md │ ├── datastore-management.md │ ├── deployment-modes.md │ ├── design/ │ │ ├── a2a-protocol-integration.md │ │ ├── agent-skills-architecture.md │ │ ├── agentcore-scanner-design.md │ │ ├── ans-integration.md │ │ ├── anthropic-api-implementation.md │ │ ├── anthropic-api-test-commands.md │ │ ├── architectural-decision-reverse-proxy-vs-application-layer-gateway.md │ │ ├── authentication-design.md │ │ ├── aws-agent-registry-federation.md │ │ ├── cookie-security-design.md │ │ ├── database-abstraction-layer.md │ │ ├── federation-architecture.md │ │ ├── hybrid-search-architecture.md │ │ ├── idp-provider-support.md │ │ ├── server-versioning.md │ │ ├── storage-architecture-mongodb-documentdb.md │ │ ├── virtual-mcp-server-explained.md │ │ └── virtual-mcp-server.md │ ├── dynamic-tool-discovery.md │ ├── embeddings.md │ ├── entra-id-setup.md │ ├── entra.md │ ├── faq/ │ │ ├── agent-autonomous-tool-discovery.md │ │ ├── connecting-multiple-mcp-servers.md │ │ ├── deploying-and-registering-servers-agents.md │ │ ├── discovering-mcp-tools.md │ │ ├── filtering-agents-by-tags-and-fields.md │ │ ├── group-restricted-agent-visibility.md │ │ ├── index.md │ │ ├── local-testing-agent-integration.md │ │ ├── monitoring-server-health.md │ │ ├── registering-auth-protected-servers.md │ │ ├── registering-m2m-client-without-idp-admin-token.md │ │ ├── registry-api-auth-faq.md │ │ ├── restrict-server-visibility-by-entra-group.md │ │ ├── use-entra-token-for-registry-api.md │ │ └── what-is-mcp-and-gateway.md │ ├── federation-operational-guide.md │ ├── federation.md │ ├── iam-settings-ui.md │ ├── img/ │ │ ├── MCPGW-Registry.drawio │ │ └── architecture-with-dataplane.md │ ├── index.md │ ├── installation.md │ ├── jwt-token-vending.md │ ├── keycloak-integration.md │ ├── llms.txt │ ├── logging.md │ ├── macos-setup-guide.md │ ├── mcp-registry-cli.md │ ├── metrics-architecture.md │ ├── mongodb-m2m-collections.md │ ├── okta-setup.md │ ├── podman-apple-silicon.md │ ├── prebuilt-images.md │ ├── quickstart.md │ ├── registration-webhooks.md │ ├── registry-api-auth.md │ ├── registry-auth-architecture.md │ ├── registry-auth-detailed.md │ ├── registry-deployment-modes.md │ ├── registry_api.md │ ├── remote-desktop-setup.md │ ├── scan_report_example.md │ ├── scopes-mgmt.md │ ├── scopes.md │ ├── security-posture.md │ ├── security-scanner.md │ ├── server-versioning-operations.md │ ├── service-management.md │ ├── static-token-auth.md │ ├── supported-protocol-and-trust-fields.md │ ├── testing/ │ │ ├── MAINTENANCE.md │ │ ├── QUICK-START.md │ │ ├── README.md │ │ ├── WRITING_TESTS.md │ │ ├── memory-management.md │ │ └── test-categories.md │ ├── testing.md │ ├── token-refresh-service.md │ └── virtual-server-operations.md ├── frontend/ │ ├── .gitignore │ ├── README.md │ ├── e2e/ │ │ ├── helpers/ │ │ │ └── auth.ts │ │ ├── virtual-server-accessibility.spec.ts │ │ ├── virtual-server-crud.spec.ts │ │ ├── virtual-server-dashboard.spec.ts │ │ ├── virtual-server-e2e-full.spec.ts │ │ └── virtual-server-form.spec.ts │ ├── package.json │ ├── patches/ │ │ └── react-scripts+5.0.1.patch │ ├── playwright.config.ts │ ├── postcss.config.js │ ├── public/ │ │ └── index.html │ ├── src/ │ │ ├── App.tsx │ │ ├── components/ │ │ │ ├── ANSBadge.tsx │ │ │ ├── AddRegistryEntryModal.tsx │ │ │ ├── AgentCard.tsx │ │ │ ├── AgentDetailsModal.tsx │ │ │ ├── ApplicationLogs.tsx │ │ │ ├── AuditEventDetail.tsx │ │ │ ├── AuditFilterBar.tsx │ │ │ ├── AuditLogTable.tsx │ │ │ ├── AuditStatistics.tsx │ │ │ ├── ConfigPanel.tsx │ │ │ ├── ConfirmModal.tsx │ │ │ ├── DataExport.tsx │ │ │ ├── DeleteConfirmation.tsx │ │ │ ├── DeploymentModeIndicator.tsx │ │ │ ├── DetailsModal.tsx │ │ │ ├── DiscoverListRow.tsx │ │ │ ├── DiscoverTab.tsx │ │ │ ├── ExternalRegistries.tsx │ │ │ ├── FederationPeerForm.tsx │ │ │ ├── FederationPeers.tsx │ │ │ ├── IAMGroups.tsx │ │ │ ├── IAMM2M.tsx │ │ │ ├── IAMUsers.tsx │ │ │ ├── Layout.tsx │ │ │ ├── ProtectedRoute.tsx │ │ │ ├── RegistryCardSettings.tsx │ │ │ ├── SearchableSelect.tsx │ │ │ ├── SecurityScanModal.tsx │ │ │ ├── SemanticSearchResults.tsx │ │ │ ├── ServerCard.tsx │ │ │ ├── ServerConfigModal.tsx │ │ │ ├── ServerDetailsModal.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── SkillCard.tsx │ │ │ ├── StarRatingWidget.tsx │ │ │ ├── StatusBadge.tsx │ │ │ ├── ToolSelector.tsx │ │ │ ├── UptimeDisplay.tsx │ │ │ ├── VersionBadge.tsx │ │ │ ├── VersionSelectorModal.tsx │ │ │ ├── VirtualServerCard.tsx │ │ │ ├── VirtualServerDetailsModal.tsx │ │ │ ├── VirtualServerForm.tsx │ │ │ ├── VirtualServerList.tsx │ │ │ └── __tests__/ │ │ │ ├── ConfigPanel.test.tsx │ │ │ ├── DiscoverTab.test.tsx │ │ │ ├── ServerConfigModal.test.tsx │ │ │ └── SettingsPageConfigIntegration.test.tsx │ │ ├── contexts/ │ │ │ ├── AuthContext.tsx │ │ │ └── ThemeContext.tsx │ │ ├── hooks/ │ │ │ ├── useAgentList.ts │ │ │ ├── useEscapeKey.ts │ │ │ ├── useFederationPeers.ts │ │ │ ├── useIAM.ts │ │ │ ├── useRegistryConfig.ts │ │ │ ├── useSemanticSearch.ts │ │ │ ├── useServerStats.ts │ │ │ ├── useSkills.ts │ │ │ ├── useToolCatalog.ts │ │ │ └── useVirtualServers.ts │ │ ├── index.css │ │ ├── index.tsx │ │ ├── pages/ │ │ │ ├── AuditLogsPage.tsx │ │ │ ├── Dashboard.tsx │ │ │ ├── Login.tsx │ │ │ ├── Logout.tsx │ │ │ ├── OAuthCallback.tsx │ │ │ ├── RegisterPage.tsx │ │ │ ├── SettingsPage.tsx │ │ │ └── TokenGeneration.tsx │ │ ├── react-app-env.d.ts │ │ ├── setupTests.ts │ │ ├── types/ │ │ │ ├── skill.ts │ │ │ ├── stats.ts │ │ │ └── virtualServer.ts │ │ └── utils/ │ │ ├── dateUtils.ts │ │ └── permissions.ts │ ├── tailwind.config.js │ ├── tests/ │ │ └── reports/ │ │ ├── report.html │ │ └── report.json │ ├── tsconfig.e2e.json │ └── tsconfig.json ├── get_asor_token.py ├── keycloak/ │ ├── README.md │ ├── import/ │ │ └── realm-config.json │ └── setup/ │ ├── clean-keycloak.sh │ ├── disable-ssl.sh │ ├── generate-agent-token.sh │ ├── get-all-client-credentials.sh │ ├── init-keycloak.sh │ ├── setup-agent-service-account.sh │ ├── setup-federation-service-account.sh │ └── setup-m2m-service-account.sh ├── metrics-service/ │ ├── .env.example │ ├── Dockerfile │ ├── add_test_key.py │ ├── app/ │ │ ├── __init__.py │ │ ├── api/ │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ └── routes.py │ │ ├── config.py │ │ ├── core/ │ │ │ ├── __init__.py │ │ │ ├── models.py │ │ │ ├── processor.py │ │ │ ├── rate_limiter.py │ │ │ ├── retention.py │ │ │ └── validator.py │ │ ├── main.py │ │ ├── otel/ │ │ │ ├── __init__.py │ │ │ ├── exporters.py │ │ │ └── instruments.py │ │ ├── storage/ │ │ │ ├── __init__.py │ │ │ ├── database.py │ │ │ └── migrations.py │ │ └── utils/ │ │ ├── __init__.py │ │ └── helpers.py │ ├── create_api_key.py │ ├── docs/ │ │ ├── README.md │ │ ├── api-reference.md │ │ ├── data-retention.md │ │ ├── database-schema.md │ │ └── deployment.md │ ├── metrics_client.py │ ├── migrate.py │ ├── pyproject.toml │ ├── pytest.ini │ └── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── test_api.py │ ├── test_auth.py │ ├── test_database.py │ ├── test_migrations.py │ ├── test_processor.py │ ├── test_rate_limiter.py │ ├── test_retention.py │ └── test_validator.py ├── mkdocs.yml ├── package.json ├── pyproject.toml ├── registry/ │ ├── api/ │ │ ├── __init__.py │ │ ├── agent_routes.py │ │ ├── ans_routes.py │ │ ├── auth0_m2m_routes.py │ │ ├── config_routes.py │ │ ├── export_routes.py │ │ ├── federation_export_routes.py │ │ ├── federation_routes.py │ │ ├── internal_routes.py │ │ ├── log_routes.py │ │ ├── m2m_management_routes.py │ │ ├── management_routes.py │ │ ├── okta_m2m_routes.py │ │ ├── peer_management_routes.py │ │ ├── registry_management_routes.py │ │ ├── registry_routes.py │ │ ├── search_routes.py │ │ ├── server_routes.py │ │ ├── skill_routes.py │ │ ├── system_routes.py │ │ ├── virtual_server_routes.py │ │ └── wellknown_routes.py │ ├── audit/ │ │ ├── __init__.py │ │ ├── context.py │ │ ├── mcp_logger.py │ │ ├── middleware.py │ │ ├── models.py │ │ ├── routes.py │ │ └── service.py │ ├── auth/ │ │ ├── __init__.py │ │ ├── csrf.py │ │ ├── dependencies.py │ │ ├── internal.py │ │ └── routes.py │ ├── common/ │ │ ├── __init__.py │ │ └── scopes_loader.py │ ├── config/ │ │ └── scopes.yml │ ├── constants.py │ ├── core/ │ │ ├── __init__.py │ │ ├── config.py │ │ ├── endpoint_utils.py │ │ ├── mcp_client.py │ │ ├── metrics.py │ │ ├── nginx_service.py │ │ ├── schemas.py │ │ └── telemetry.py │ ├── embeddings/ │ │ ├── README.md │ │ ├── __init__.py │ │ └── client.py │ ├── exceptions.py │ ├── health/ │ │ ├── __init__.py │ │ ├── routes.py │ │ └── service.py │ ├── main.py │ ├── metrics/ │ │ ├── __init__.py │ │ ├── client.py │ │ ├── middleware.py │ │ └── utils.py │ ├── middleware/ │ │ ├── __init__.py │ │ └── mode_filter.py │ ├── models/ │ │ └── idp_m2m_client.py │ ├── repositories/ │ │ ├── __init__.py │ │ ├── app_log_repository.py │ │ ├── audit_repository.py │ │ ├── documentdb/ │ │ │ ├── __init__.py │ │ │ ├── agent_repository.py │ │ │ ├── backend_session_repository.py │ │ │ ├── client.py │ │ │ ├── federation_config_repository.py │ │ │ ├── peer_federation_repository.py │ │ │ ├── registry_card_repository.py │ │ │ ├── scope_repository.py │ │ │ ├── search_repository.py │ │ │ ├── security_scan_repository.py │ │ │ ├── server_repository.py │ │ │ ├── skill_repository.py │ │ │ ├── skill_security_scan_repository.py │ │ │ └── virtual_server_repository.py │ │ ├── factory.py │ │ ├── file/ │ │ │ ├── __init__.py │ │ │ ├── agent_repository.py │ │ │ ├── federation_config_repository.py │ │ │ ├── peer_federation_repository.py │ │ │ ├── scope_repository.py │ │ │ ├── search_repository.py │ │ │ ├── security_scan_repository.py │ │ │ ├── server_repository.py │ │ │ └── skill_security_scan_repository.py │ │ ├── interfaces.py │ │ └── stats_repository.py │ ├── schemas/ │ │ ├── __init__.py │ │ ├── agent_models.py │ │ ├── agent_security.py │ │ ├── ans_models.py │ │ ├── anthropic_schema.py │ │ ├── backend_session_models.py │ │ ├── federation_schema.py │ │ ├── idp_m2m_client.py │ │ ├── management.py │ │ ├── okta_m2m_client.py │ │ ├── peer_federation_schema.py │ │ ├── registration_gate_models.py │ │ ├── registry_card.py │ │ ├── security.py │ │ ├── skill_models.py │ │ ├── skill_security.py │ │ └── virtual_server_models.py │ ├── scripts/ │ │ └── inspect-documentdb.py │ ├── search/ │ │ ├── __init__.py │ │ └── service.py │ ├── servers/ │ │ ├── atlassian.json │ │ ├── currenttime.json │ │ ├── fininfo.json │ │ ├── mcpgw.json │ │ ├── realserverfaketools.json │ │ ├── server_state.json │ │ └── sre-gateway.json │ ├── services/ │ │ ├── __init__.py │ │ ├── agent_scanner.py │ │ ├── agent_service.py │ │ ├── agent_transform_service.py │ │ ├── ans_client.py │ │ ├── ans_service.py │ │ ├── ans_sync_scheduler.py │ │ ├── auth0_m2m_sync.py │ │ ├── demo_servers_init.py │ │ ├── federation/ │ │ │ ├── __init__.py │ │ │ ├── agentcore_client.py │ │ │ ├── anthropic_client.py │ │ │ ├── asor_client.py │ │ │ ├── base_client.py │ │ │ ├── federation_auth.py │ │ │ └── peer_registry_client.py │ │ ├── federation_audit_service.py │ │ ├── federation_reconciliation.py │ │ ├── github_auth.py │ │ ├── m2m_management_service.py │ │ ├── okta_m2m_sync.py │ │ ├── peer_federation_service.py │ │ ├── peer_sync_scheduler.py │ │ ├── rating_service.py │ │ ├── registration_gate_service.py │ │ ├── scope_service.py │ │ ├── security_scanner.py │ │ ├── server_service.py │ │ ├── skill_scanner.py │ │ ├── skill_service.py │ │ ├── tool_catalog_service.py │ │ ├── tool_validation_service.py │ │ ├── transform_service.py │ │ ├── virtual_server_service.py │ │ └── webhook_service.py │ ├── static/ │ │ ├── asset-manifest.json │ │ ├── index.html │ │ └── static/ │ │ ├── css/ │ │ │ └── main.509e9b60.css │ │ └── js/ │ │ ├── main.d2eb0b7d.js │ │ └── main.d2eb0b7d.js.LICENSE.txt │ ├── templates/ │ │ ├── components/ │ │ │ ├── server_card.html │ │ │ └── sidebar.html │ │ ├── edit_server.html │ │ ├── index.html │ │ ├── login.html │ │ ├── pages/ │ │ │ └── dashboard.html │ │ └── token_generation.html │ ├── utils/ │ │ ├── __init__.py │ │ ├── agent_validator.py │ │ ├── auth0_manager.py │ │ ├── credential_encryption.py │ │ ├── entra_manager.py │ │ ├── federation_encryption.py │ │ ├── iam_manager.py │ │ ├── keycloak_manager.py │ │ ├── logging_setup.py │ │ ├── metadata.py │ │ ├── mongodb_connection.py │ │ ├── mongodb_log_handler.py │ │ ├── okta_manager.py │ │ ├── path_utils.py │ │ ├── request_utils.py │ │ ├── scopes_manager.py │ │ ├── scopes_manager_old.py │ │ ├── url_utils.py │ │ └── visibility.py │ └── version.py ├── release-notes/ │ ├── DISCLAIMER.md │ ├── v1.0.10.md │ ├── v1.0.12.md │ ├── v1.0.13.md │ ├── v1.0.14.md │ ├── v1.0.15.md │ ├── v1.0.16.md │ ├── v1.0.17.md │ ├── v1.0.18.md │ ├── v1.0.19.md │ ├── v1.0.20.md │ ├── v1.0.21.md │ ├── v1.0.3.md │ ├── v1.0.4.md │ ├── v1.0.5.md │ ├── v1.0.6.md │ ├── v1.0.9-patch1.md │ └── v1.0.9.md ├── scripts/ │ ├── README.md │ ├── backfill_agent_fields.py │ ├── build-images.sh │ ├── debug-scopes.py │ ├── deploy.sh │ ├── docs-dev.sh │ ├── download-documentdb-ca-bundle.sh │ ├── fix_auth_tests.py │ ├── generate-image-manifest.sh │ ├── generate-mongodb-keyfile.sh │ ├── init-documentdb-indexes.py │ ├── init-documentdb.sh │ ├── init-mongodb-ce.py │ ├── init-mongodb.sh │ ├── load-scopes.py │ ├── manage-documentdb.py │ ├── mcp-registry-admin.json │ ├── mcp-servers-unrestricted-execute.json │ ├── mcp-servers-unrestricted-read.json │ ├── migrate-file-to-mongodb.py │ ├── migrate-servers-add-is-active.py │ ├── mongodb-entrypoint.sh │ ├── opensearch-schemas/ │ │ ├── hybrid-search-pipeline.json │ │ ├── mcp-agents.json │ │ ├── mcp-embeddings-serverless.json │ │ ├── mcp-embeddings.json │ │ ├── mcp-scopes.json │ │ ├── mcp-security-scans.json │ │ └── mcp-servers.json │ ├── publish_containers.sh │ ├── refresh_m2m_token.sh │ ├── registry-admins.json │ ├── run-oauth-setup.sh │ ├── scan-images-trivy.sh │ ├── setup-atlassian-env.sh │ ├── test-mcpgw-tools-README.md │ ├── test-mcpgw-tools.sh │ ├── test-peer-federation-docker.sh │ ├── test-peer-federation.sh │ ├── test.py │ └── validate-dockerfiles.sh ├── servers/ │ ├── currenttime/ │ │ ├── .dockerignore │ │ ├── pyproject.toml │ │ └── server.py │ ├── example-server/ │ │ ├── pyproject.toml │ │ └── server.py │ ├── fininfo/ │ │ ├── .dockerignore │ │ ├── .keys.yml.template │ │ ├── README.md │ │ ├── README_SECRETS.md │ │ ├── client.py │ │ ├── encrypt_secrets.py │ │ ├── pyproject.toml │ │ ├── secrets_manager.py │ │ └── server.py │ ├── mcpgw/ │ │ ├── .dockerignore │ │ ├── models.py │ │ ├── pyproject.toml │ │ └── server.py │ └── realserverfaketools/ │ ├── .dockerignore │ ├── README.md │ ├── pyproject.toml │ └── server.py ├── start_token_refresher.sh ├── terraform/ │ ├── README.md │ ├── aws-ecs/ │ │ ├── .gitignore │ │ ├── OPERATIONS.md │ │ ├── README.md │ │ ├── alb-logging.tf │ │ ├── build-and-push-all.sh │ │ ├── build-minimal.sh │ │ ├── cloudfront-acm.tf │ │ ├── cloudfront-logging.tf │ │ ├── cloudfront.tf │ │ ├── cloudwatch-alarms.tf │ │ ├── codebuild.tf │ │ ├── docs/ │ │ │ └── observability-architecture.md │ │ ├── documentdb-elastic.tf.disabled │ │ ├── documentdb.tf │ │ ├── ecs.tf │ │ ├── grafana/ │ │ │ ├── Dockerfile │ │ │ ├── dashboards/ │ │ │ │ └── mcp-analytics-comprehensive.json │ │ │ └── provisioning/ │ │ │ ├── dashboards/ │ │ │ │ └── dashboards.yaml │ │ │ └── datasources/ │ │ │ └── datasources.yaml │ │ ├── keycloak-alb.tf │ │ ├── keycloak-database.tf │ │ ├── keycloak-dns.tf │ │ ├── keycloak-ecr.tf │ │ ├── keycloak-ecs.tf │ │ ├── keycloak-security-groups.tf │ │ ├── lambda/ │ │ │ ├── README.md │ │ │ ├── rotate-documentdb/ │ │ │ │ ├── index.py │ │ │ │ └── requirements.txt │ │ │ ├── rotate-rds/ │ │ │ │ ├── index.py │ │ │ │ └── requirements.txt │ │ │ └── verify-deployment.sh │ │ ├── locals.tf │ │ ├── main.tf │ │ ├── modules/ │ │ │ └── mcp-gateway/ │ │ │ ├── data.tf │ │ │ ├── ecs-services.tf │ │ │ ├── iam.tf │ │ │ ├── locals.tf │ │ │ ├── main.tf │ │ │ ├── monitoring.tf │ │ │ ├── networking.tf │ │ │ ├── observability.tf │ │ │ ├── outputs.tf │ │ │ ├── secrets.tf │ │ │ ├── storage.tf │ │ │ ├── variables.tf │ │ │ └── versions.tf │ │ ├── outputs.tf │ │ ├── push-all-images-to-ecr.sh │ │ ├── registry-dns.tf │ │ ├── scripts/ │ │ │ ├── README-DOCUMENTDB-CLI.md │ │ │ ├── README.md │ │ │ ├── ecs-ssh.sh │ │ │ ├── init-documentdb.sh │ │ │ ├── init-keycloak.sh │ │ │ ├── post-deployment-setup.sh │ │ │ ├── pre-destroy-cleanup.sh │ │ │ ├── requirements.txt │ │ │ ├── rotate-keycloak-web-client-secret.sh │ │ │ ├── run-documentdb-cli.sh │ │ │ ├── run-documentdb-init.sh │ │ │ ├── run-scopes-init-task.sh │ │ │ ├── save-terraform-outputs.sh │ │ │ ├── service_mgmt.sh │ │ │ ├── user_mgmt.sh │ │ │ ├── view-cloudwatch-logs.sh │ │ │ └── view-logs.sh │ │ ├── secret-rotation-config.tf │ │ ├── secret-rotation.tf │ │ ├── setup-documentdb-env.sh │ │ ├── terraform.tfvars.example │ │ ├── variables.tf │ │ ├── vpc.tf │ │ └── waf.tf │ └── telemetry-collector/ │ ├── README.md │ ├── bastion-scripts/ │ │ ├── connect.sh │ │ ├── query.sh │ │ ├── setup-bastion.sh │ │ └── telemetry_db.py │ ├── bastion.tf │ ├── check-status.sh │ ├── cloudwatch.tf │ ├── create-indexes.js │ ├── deploy.sh │ ├── destroy.sh │ ├── documentdb.tf │ ├── domain.tf │ ├── dynamodb.tf │ ├── iam.tf │ ├── lambda/ │ │ ├── collector/ │ │ │ ├── index.py │ │ │ ├── requirements.txt │ │ │ └── schemas.py │ │ └── index-setup/ │ │ ├── index.py │ │ └── requirements.txt │ ├── lambda.tf │ ├── main.tf │ ├── outputs.tf │ ├── secrets.tf │ ├── terraform.tfvars.example │ ├── variables.tf │ └── vpc.tf ├── test-keycloak-mcp.sh └── tests/ ├── README.md ├── __init__.py ├── auth_server/ │ ├── __init__.py │ ├── conftest.py │ ├── fixtures/ │ │ ├── __init__.py │ │ ├── mock_jwt.py │ │ └── mock_providers.py │ └── unit/ │ ├── __init__.py │ ├── providers/ │ │ ├── __init__.py │ │ ├── test_auth0.py │ │ ├── test_base.py │ │ ├── test_keycloak.py │ │ └── test_okta.py │ └── test_server.py ├── conftest.py ├── e2e/ │ ├── __init__.py │ ├── test_virtual_mcp_latency.py │ ├── test_virtual_mcp_protocol.py │ └── test_virtual_mcp_stress.py ├── e2e_agent_skills_test.py ├── fixtures/ │ ├── __init__.py │ ├── constants.py │ ├── factories.py │ ├── helpers.py │ ├── mocks/ │ │ ├── __init__.py │ │ ├── mock_auth.py │ │ ├── mock_embeddings.py │ │ ├── mock_faiss.py │ │ └── mock_http.py │ ├── skill_scan_medium_output.json │ ├── skill_scan_safe_output.json │ └── skill_scan_unsafe_output.json ├── integration/ │ ├── __init__.py │ ├── conftest.py │ ├── test_agentcore_sync_integration.py │ ├── test_deployment_mode_integration.py │ ├── test_mongodb_connectivity.py │ ├── test_peer_federation_e2e.py │ ├── test_search_integration.py │ ├── test_server_lifecycle.py │ ├── test_skill_api.py │ ├── test_skill_scanner_repository.py │ ├── test_telemetry_e2e.py │ ├── test_virtual_server_api.py │ └── test_virtual_server_scopes_e2e.sh ├── security/ │ └── test_container_security.py ├── test_infrastructure.py └── unit/ ├── __init__.py ├── api/ │ ├── __init__.py │ ├── test_agent_routes.py │ ├── test_config_export.py │ ├── test_federation_export_routes.py │ ├── test_log_routes.py │ ├── test_m2m_management_routes.py │ ├── test_management_routes.py │ ├── test_peer_management_routes.py │ ├── test_search_routes.py │ ├── test_server_get_endpoint.py │ ├── test_server_routes.py │ ├── test_skill_inline_content.py │ └── test_wellknown_routes.py ├── audit/ │ ├── __init__.py │ ├── test_audit_composite_key.py │ ├── test_audit_repository.py │ ├── test_filter_statistics.py │ ├── test_mcp_logger.py │ ├── test_middleware.py │ ├── test_models_properties.py │ ├── test_routes.py │ └── test_service.py ├── auth/ │ ├── __init__.py │ ├── test_csrf.py │ └── test_dependencies.py ├── cli/ │ ├── __init__.py │ ├── test_agentcore_cross_account.py │ ├── test_agentcore_discovery.py │ ├── test_agentcore_registration.py │ └── test_agentcore_token_refresher.py ├── conftest.py ├── core/ │ ├── __init__.py │ ├── test_config.py │ ├── test_endpoint_utils.py │ ├── test_mcp_client.py │ ├── test_nginx_service.py │ ├── test_schemas_protocol_trust_fields.py │ ├── test_schemas_registry_card_fields.py │ ├── test_telemetry.py │ └── test_visibility_normalization.py ├── embeddings/ │ ├── __init__.py │ └── test_embeddings_client.py ├── health/ │ ├── __init__.py │ └── test_health_service.py ├── lambda/ │ ├── __init__.py │ ├── conftest.py │ └── test_collector.py ├── middleware/ │ ├── __init__.py │ └── test_mode_filter.py ├── repositories/ │ ├── __init__.py │ ├── test_app_log_repository.py │ ├── test_file_server_repository.py │ ├── test_registry_card_repository.py │ └── test_search_result_distribution.py ├── schemas/ │ ├── __init__.py │ ├── test_agent_models.py │ ├── test_agentcore_federation_schema.py │ ├── test_peer_federation_schema.py │ ├── test_registry_card.py │ ├── test_skill_models_registry_card_fields.py │ ├── test_uuid_federation.py │ └── test_uuid_fields.py ├── search/ │ ├── __init__.py │ └── test_faiss_service.py ├── servers/ │ ├── __init__.py │ └── mcpgw/ │ ├── __init__.py │ └── test_intelligent_tool_finder.py ├── services/ │ ├── __init__.py │ ├── federation/ │ │ ├── __init__.py │ │ ├── test_agentcore_client.py │ │ ├── test_federation_auth.py │ │ └── test_peer_registry_client.py │ ├── test_agent_service.py │ ├── test_agentcore_reconciliation.py │ ├── test_m2m_management_service.py │ ├── test_peer_federation_service.py │ ├── test_peer_federation_sync.py │ ├── test_registration_gate_service.py │ ├── test_server_service.py │ └── test_webhook_service.py ├── test_backend_session_repository.py ├── test_deployment_mode.py ├── test_entra_manager.py ├── test_github_auth.py ├── test_iam_manager.py ├── test_lifecycle_status.py ├── test_safe_eval_arithmetic.py ├── test_skill_models.py ├── test_skill_routes_github_auth.py ├── test_skill_routes_security.py ├── test_skill_scanner_service.py ├── test_skill_security_schemas.py ├── test_skill_service_github_auth.py ├── test_skill_service_parsing.py ├── test_stats_endpoint.py ├── test_url_validation.py ├── test_virtual_server_models.py ├── test_virtual_server_nginx.py ├── test_virtual_server_service.py └── utils/ ├── __init__.py ├── test_credential_encryption.py ├── test_logging_setup.py ├── test_metadata.py ├── test_mongodb_log_handler.py ├── test_okta_manager.py ├── test_request_utils.py ├── test_url_utils.py └── test_visibility.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .bandit ================================================ # Exclude test directories and virtual environment from Bandit scans # B101 (assert_used) only appears in test code; excluding test dirs resolves it # For pyproject.toml config (used by pre-commit), see [tool.bandit] in pyproject.toml # NOTE: cli/ and scripts/ are NOT excluded - they contain operational code that should be scanned exclude_dirs: - ./tests - ./agents/a2a/test - ./metrics-service/tests - ./.venv ================================================ FILE: .claudeignore ================================================ cat .claudeignore ``` Should look like this: ``` # Dependencies **/node_modules/ node_modules/ # Python **/.venv/ **/__pycache__/ *.pyc # Terraform **/.terraform/ *.tfstate *.tfstate.* *.log tfplan # Test/Build outputs htmlcov/ site/ .coverage *.egg-info/ # Caches .hypothesis/ .ruff_cache/ .pytest_cache/ .scratchpad/ .tmp/ .oauth-tokens/ # Log files *.log **/*.log ================================================ FILE: .dockerignore ================================================ # Virtual environments **/.venv .venv/ .venv registry/.venv/ servers/*/.venv/ venv/ # Node.js node_modules/ frontend/node_modules/ npm-debug.log* yarn-debug.log* yarn-error.log* # Python build artifacts __pycache__/ *.pyc *.pyo *.pyd *.egg-info/ **/*.egg-info/ dist/ build/ # Logs logs/ *.log oauth_cognito.log registry.log *_tests*.log full-test-run.log # Git .git/ .gitignore # Documentation docs/ *.md README* # Tests tests/ test/ **/tests/ **/*test* # IDE/Editor files .vscode/ .idea/ *.swp *.swo # OS files .DS_Store Thumbs.db # Temporary files tmp/ temp/ *.tmp # Large binaries *.bin *.model *.pkl *.h5 # Specific large directories mcp-atlassian/ terraform/ htmlcov/ site/ cli/* !cli/examples/ security_scans/ agent_security_scans/ skill_security_scans/ credentials-provider/ .oauth-tokens/ .scratchpad/ # frontend/build/ - KEEP THIS, needed by registry service ================================================ FILE: .env.example ================================================ # ============================================================================= # MCP Gateway Registry - Environment Configuration Sample # ============================================================================= # Copy this file to .env and update with your actual values # Never commit real credentials to version control # ============================================================================= # REGISTRY CONFIGURATION # ============================================================================= # Public URL where the MCP Gateway Registry is accessible # For custom HTTPS domain: https://mcpgateway.mycorp.com REGISTRY_URL=http://localhost # ============================================================================= # REGISTRY CARD CONFIGURATION # ============================================================================= # Registry identity and metadata for federation and discovery # These values populate the registry card shown in federated environments # Human-readable registry name (display name for your registry) # If not set, a random Docker-style name will be generated (e.g., "brave-falcon-registry") # Displayed in federated registry listings and UI headers REGISTRY_NAME="AI Gateway Registry" # Organization that operates this registry # If not set, defaults to "ACME Inc." # Used to identify the organization operating this registry instance REGISTRY_ORGANIZATION_NAME="ACME Inc." # Registry description for federation # Describes the purpose and scope of this registry REGISTRY_DESCRIPTION="Central registry for all your AI assets" # Contact email for registry administrators # Leave empty if not publicly shared REGISTRY_CONTACT_EMAIL= # Documentation or support URL for this registry # Leave empty if not available REGISTRY_CONTACT_URL= # ============================================================================= # Deployment Mode Configuration # ============================================================================= # DEPLOYMENT_MODE controls how the registry integrates with the gateway/nginx # Options: # - with-gateway (default): Full integration with nginx reverse proxy # - Nginx config is regenerated when servers are registered/deleted # - Frontend shows gateway authentication instructions # - registry-only: Registry operates as catalog/discovery service only # - Nginx config is NOT updated on server changes # - Frontend shows direct connection mode (proxy_pass_url) # - Use when registry is separate from gateway infrastructure # Default: with-gateway (uncomment to change) # DEPLOYMENT_MODE=with-gateway # REGISTRY_MODE controls which features are enabled (informational - for UI feature flags) # This setting affects the /api/config response which the frontend can use # to show/hide navigation elements. Currently informational only - all APIs remain active. # Options: # - full (default): All features enabled (mcp_servers, agents, skills, federation) # - skills-only: Only skills feature flag enabled # - mcp-servers-only: Only MCP server feature flag enabled # - agents-only: Only A2A agent feature flag enabled # Note: with-gateway + skills-only is invalid and auto-corrects to registry-only + skills-only # Default: full (uncomment to change) # REGISTRY_MODE=full # Tab visibility overrides (AND-ed with REGISTRY_MODE feature flags) # These control which tabs are shown in the UI without affecting backend APIs. # REGISTRY_MODE is the master control — SHOW_*_TAB can only further restrict, never expand. # Formula: tab_visible = REGISTRY_MODE_enables_feature AND SHOW_*_TAB # All default to true (backward compatible). Set to false to hide a tab. # SHOW_SERVERS_TAB=true # SHOW_VIRTUAL_SERVERS_TAB=true # SHOW_SKILLS_TAB=true # SHOW_AGENTS_TAB=true # ============================================================================= # AUTH SERVER CONFIGURATION # ============================================================================= # Internal auth server URL (for Docker network communication) AUTH_SERVER_URL=http://auth-server:8888 # External auth server URL (public-facing, for browser redirects) # For local development: http://localhost:8888 # For custom HTTPS domain: https://mcpgateway.mycorp.com AUTH_SERVER_EXTERNAL_URL=http://localhost:8888 # ============================================================================= # NETWORK-TRUSTED API ACCESS (Enterprise Perimeter Security) # ============================================================================= # # Allow Registry API access without full token validation. # # Use case: Enterprise deployments where the MCP Gateway Registry operates # within a secure network perimeter (VPC, private subnet, VPN, etc.) # # When enabled (true): # - Registry API endpoints (/api/*, /v0.1/*) use static token auth # instead of IdP-based JWT validation # - Clients must send: Authorization: Bearer # - Useful for trusted networks, CI/CD pipelines, and internal automation # - MCP Gateway server access STILL requires full IdP authentication # # When disabled (false, default): # - All endpoints require valid JWT tokens from the configured IdP # - Standard security posture # # Security considerations: # - Always set REGISTRY_API_TOKEN when enabling this feature # - Network-level security (firewalls, security groups) should be in place # - Audit logs will show "network-trusted" as auth method # - MCP server tool invocations remain fully protected by the IdP # # Default: false REGISTRY_STATIC_TOKEN_AUTH_ENABLED=false # Static API key for Registry API when REGISTRY_STATIC_TOKEN_AUTH_ENABLED=true. # Clients must send this value as: Authorization: Bearer # This single key gets full admin access (legacy mode). For per-key scoping # see REGISTRY_API_KEYS below. # Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))" REGISTRY_API_TOKEN= # Multiple static API keys with per-key group assignments (Issue #779). # JSON map: name -> {key, groups}. Each key gets only the scopes that its # groups resolve to via group_mappings in scopes.yml / mcp_scope_default. # # When set, these keys are merged with REGISTRY_API_TOKEN (which becomes a # legacy entry with admin groups). On parse error the feature is disabled # entirely (fail-closed). # # Format (must be valid JSON on a single line, wrap in single quotes in shell): # REGISTRY_API_KEYS='{"monitoring":{"key":"","groups":["mcp-readonly"]},"deploy":{"key":"","groups":["mcp-registry-admin"]}}' # # Rules: # - name: ^[a-z0-9][a-z0-9_-]{0,63}$ (log-safe identifier) # - key: minimum 32 characters # - groups: non-empty list of group names from your scopes.yml group_mappings # - Names "legacy", "network-user", "network-trusted" are reserved # - Key values must be unique across entries # # Generate a key: python3 -c "import secrets; print(secrets.token_urlsafe(32))" # # See docs/registry-api-auth.md and docs/faq/registry-api-auth-faq.md for details. REGISTRY_API_KEYS= # ============================================================================= # REGISTRATION WEBHOOK (Issue #742) # ============================================================================= # # Fire an async POST to a URL when a server, agent, or skill is registered # (added) or deleted (removed). The call is fire-and-forget: failures are # logged but never propagated to the caller. # # REGISTRATION_WEBHOOK_URL: Full URL to POST to. Disabled when empty. # Only http:// and https:// schemes are accepted. A warning is logged # when HTTP (not HTTPS) is used. # # REGISTRATION_WEBHOOK_AUTH_HEADER: Name of the header used for auth. # Default: "Authorization". If set to "Authorization", the token is # auto-prefixed with "Bearer ". For any other header (e.g. X-API-Key) # the token is sent as-is. # # REGISTRATION_WEBHOOK_AUTH_TOKEN: Auth token value. Leave empty for # unauthenticated webhooks. # # REGISTRATION_WEBHOOK_TIMEOUT_SECONDS: HTTP timeout in seconds. # Default: 10 # REGISTRATION_WEBHOOK_URL= REGISTRATION_WEBHOOK_AUTH_HEADER=Authorization REGISTRATION_WEBHOOK_AUTH_TOKEN= REGISTRATION_WEBHOOK_TIMEOUT_SECONDS=10 # ============================================================================= # REGISTRATION GATE / ADMISSION CONTROL (Issue #809) # ============================================================================= # # Call an external endpoint to approve or deny registration and update # requests BEFORE they are persisted. The gate is fail-closed: if the # endpoint is unreachable after retries, the registration is blocked. # # REGISTRATION_GATE_ENABLED: Master switch. Default: false # # REGISTRATION_GATE_URL: Full URL to POST to. Must be set when enabled. # Only http:// and https:// schemes are accepted. HTTPS is strongly # recommended for production. # # REGISTRATION_GATE_AUTH_TYPE: How to authenticate with the gate endpoint. # Options: none, api_key, bearer. Default: none # # REGISTRATION_GATE_AUTH_CREDENTIAL: Credential value for api_key or bearer. # For bearer: sent as "Authorization: Bearer ". # For api_key: sent as ": ". # # REGISTRATION_GATE_AUTH_HEADER_NAME: Header name when auth_type=api_key. # Default: X-Api-Key # # REGISTRATION_GATE_TIMEOUT_SECONDS: HTTP timeout per attempt. Default: 5 # # REGISTRATION_GATE_MAX_RETRIES: Number of retries after the first attempt. # Uses exponential backoff (0.5s, 1s, 2s, ...). Default: 2 # REGISTRATION_GATE_ENABLED=false REGISTRATION_GATE_URL= REGISTRATION_GATE_AUTH_TYPE=none REGISTRATION_GATE_AUTH_CREDENTIAL= REGISTRATION_GATE_AUTH_HEADER_NAME=X-Api-Key REGISTRATION_GATE_TIMEOUT_SECONDS=5 REGISTRATION_GATE_MAX_RETRIES=2 # ============================================================================= # FEDERATION STATIC TOKEN AUTH (Scoped Access for Peer Registries) # ============================================================================= # # Allow peer registries to access federation and peer management endpoints # using a static Bearer token instead of OAuth2 JWT. # # IMPORTANT: This token only grants access to: # - /api/federation/* (federation export endpoints) # - /api/peers/* (peer management endpoints) # It does NOT grant access to other registry APIs. # # When enabled (true): # - Federation/peer endpoints accept: Authorization: Bearer # - Used for quick setup of peer-to-peer federation without OAuth2 infrastructure # - Audit logs will show "federation-static" as auth method # # When disabled (false, default): # - Federation endpoints require OAuth2 JWT with federation-service scope # # Default: false FEDERATION_STATIC_TOKEN_AUTH_ENABLED=false # Static token for federation API access. # Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))" FEDERATION_STATIC_TOKEN= # Encryption key for storing federation tokens in MongoDB (required on importing registry). # When peer configs contain federation_token, it is encrypted before storage using this key. # Generate with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" FEDERATION_ENCRYPTION_KEY= # ============================================================================= # M2M DIRECT CLIENT REGISTRATION (Issue #851) # ============================================================================= # # Enables the admin API at /api/iam/m2m-clients that lets operators register # M2M client_ids and their group mappings by writing directly to the # idp_m2m_clients MongoDB collection, WITHOUT requiring an IdP Admin API # token (e.g. OKTA_API_TOKEN). Useful when IdP Admin API access is gated. # # Records created via this API are tagged provider="manual" and cannot be # modified or deleted by this API if they were written by IdP sync. # # Endpoints gated by this flag: # POST /api/iam/m2m-clients (admin) # GET /api/iam/m2m-clients (any authenticated user) # GET /api/iam/m2m-clients/{id} (any authenticated user) # PATCH /api/iam/m2m-clients/{id} (admin) # DELETE /api/iam/m2m-clients/{id} (admin) # # Default: true (feature is on; set to false to disable the router entirely) M2M_DIRECT_REGISTRATION_ENABLED=true # ============================================================================= # AUTHENTICATION PROVIDER CONFIGURATION # ============================================================================= # Choose authentication provider: 'cognito', 'keycloak', 'entra', 'okta', or 'auth0' AUTH_PROVIDER=keycloak # ============================================================================= # KEYCLOAK CONFIGURATION (if AUTH_PROVIDER=keycloak) # ============================================================================= # Keycloak server URL (internal URL for server-to-server communication) # DO NOT CHANGE: This should always be http://keycloak:8080 for Docker network communication KEYCLOAK_URL=http://keycloak:8080 # Keycloak external URL (for browser redirects) # For local development: http://localhost:8080 # For custom HTTPS domain: https://mcpgateway.mycorp.com KEYCLOAK_EXTERNAL_URL=http://localhost:8080 # Keycloak admin URL (for setup scripts - internal access) # Typically http://localhost:8080 for local access to Keycloak admin # For custom HTTPS domain: https://mcpgateway.mycorp.com KEYCLOAK_ADMIN_URL=http://localhost:8080 # Keycloak realm name KEYCLOAK_REALM=mcp-gateway # Keycloak admin credentials (for initial setup) KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD=your-secure-keycloak-admin-password # Keycloak database password KEYCLOAK_DB_PASSWORD=your-secure-db-password # Keycloak client credentials for web authentication # These are auto-generated when you run keycloak/setup/init-keycloak.sh # To retrieve: Check script output or Keycloak Admin Console → Clients → Credentials tab KEYCLOAK_CLIENT_ID=mcp-gateway-web KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret-here # Keycloak M2M client credentials for machine-to-machine authentication # These are auto-generated when you run keycloak/setup/init-keycloak.sh # To retrieve: Check script output or Keycloak Admin Console → Clients → Credentials tab KEYCLOAK_M2M_CLIENT_ID=mcp-gateway-m2m KEYCLOAK_M2M_CLIENT_SECRET=your-keycloak-m2m-secret-here # Enable Keycloak in OAuth2 providers KEYCLOAK_ENABLED=true # Initial admin and test user passwords for Keycloak setup INITIAL_ADMIN_PASSWORD=your-secure-keycloak-admin-password INITIAL_USER_PASSWORD=your-secure-keycloak-user-password # ============================================================================= # MCPGW (MCP GATEWAY SERVER) CONFIGURATION # ============================================================================= # These settings configure the MCPGW MCP server that provides tool access # to the registry. Required only when running the MCPGW server component. # **WARNING**: Before enabling OIDC, review the security gaps documented in # GitHub issue #895. The M2M token flow does NOT propagate user identity to # the registry, which bypasses per-user authorization and audit logging. # Do NOT set OIDC_ENABLED=true in any environment until issue #895 is resolved. # Enable OIDC/OAuth2 authentication for the MCPGW server # When true, MCPGW uses Keycloak OAuthProxy for client authentication # When false (default), MCPGW uses bearer-token passthrough # OIDC_ENABLED=false # OIDC client credentials (used when OIDC_ENABLED=true) # These should match a Keycloak client configured for the MCPGW server # OIDC_CLIENT_ID=mcp-gateway-web # OIDC_CLIENT_SECRET=your-oidc-client-secret-here # Keycloak internal URL for server-to-server OIDC communication # Used by MCPGW to reach Keycloak within the Docker network # KEYCLOAK_INTERNAL_URL=http://keycloak:8080 # M2M (machine-to-machine) client credentials for MCPGW to call registry APIs # MCPGW uses these to obtain tokens for authenticated registry API calls # M2M_CLIENT_ID=mcp-gateway-m2m # M2M_CLIENT_SECRET=your-m2m-client-secret-here # Base URL where the MCPGW server is reachable (for OAuth redirect URIs) # MCPGW_BASE_URL=http://localhost:18003 # Bind host for the MCPGW server # Use 127.0.0.1 for local-only access (default), 0.0.0.0 for containers # HOST=127.0.0.1 # ============================================================================= # GATEWAY HOST CONFIGURATION # ============================================================================= # Optional: Additional server names for nginx reverse proxy gateway access # Use this to add custom domain names, public IPs, or private IPs to the nginx server_name directive # Supports multiple names separated by spaces # # Examples: # - Custom domain: mcpgateway.example.com # - Public IP: 54.123.45.67 # - Private IP: 10.0.1.42 # - Multiple: mcpgateway.example.com 54.123.45.67 # - Custom domain: mcpgateway.ddns.net # # Default: Empty (will auto-detect private IP if available) # WARNING: HTTP access is not recommended for production. Use HTTPS with valid SSL certificates. GATEWAY_ADDITIONAL_SERVER_NAMES= # ============================================================================= # AMAZON COGNITO OAUTH2 CONFIGURATION (if AUTH_PROVIDER=cognito) # ============================================================================= # AWS Configuration AWS_REGION=us-east-1 # Amazon Cognito User Pool ID # Format: {region}_{random_string} COGNITO_USER_POOL_ID=us-east-1_XXXXXXXXX # Cognito App Client ID # Get this from Amazon Cognito console > User Pools > App Integration > App clients COGNITO_CLIENT_ID=your_cognito_client_id_here # Cognito App Client Secret # Get this from Amazon Cognito console > User Pools > App Integration > App clients COGNITO_CLIENT_SECRET=your_cognito_client_secret_here # Enable Cognito in OAuth2 providers COGNITO_ENABLED=false # ============================================================================= # MICROSOFT ENTRA ID CONFIGURATION (if AUTH_PROVIDER=entra) # ============================================================================= # Azure AD Tenant ID (Directory/tenant ID from Azure Portal) # Format: GUID (e.g., 12345678-1234-1234-1234-123456789012) # Get from: Azure Portal → Azure Active Directory → Overview → Tenant ID ENTRA_TENANT_ID=your-tenant-id-here # Entra ID Application (client) ID # Format: GUID (e.g., 87654321-4321-4321-4321-210987654321) # Get from: Azure Portal → App registrations → Your App → Application (client) ID ENTRA_CLIENT_ID=your-client-id-here # Entra ID Client Secret (Application secret value) # Get from: Azure Portal → App registrations → Your App → Certificates & secrets # NOTE: Copy the secret VALUE immediately after creation (not the secret ID) ENTRA_CLIENT_SECRET=your-client-secret-here # Enable Entra ID in OAuth2 providers (set to true when using Entra ID) ENTRA_ENABLED=false # Entra ID Login Base URL (optional - defaults to https://login.microsoftonline.com) # Change this only if using a sovereign cloud (e.g., Azure Government, Azure China) # Examples: # - Azure Public Cloud (default): https://login.microsoftonline.com # - Azure Government: https://login.microsoftonline.us # - Azure China: https://login.chinacloudapi.cn # - Azure Germany: https://login.microsoftonline.de # ENTRA_LOGIN_BASE_URL=https://login.microsoftonline.com # Azure AD Group Object IDs for authorization (configured in scopes.yml) # Admin Group Example ENTRA_GROUP_ADMIN_ID=your-admin-group-object-id-here # Users Group Example ENTRA_GROUP_USERS_ID=your-users-group-object-id-here # IdP Group Filtering (optional, applies to all identity providers) # Comma-separated list of prefixes. Only groups whose name starts with # any of these prefixes are shown in IAM > Groups page. # For Entra ID, uses Microsoft Graph $filter for server-side filtering. # For Keycloak, Okta, Auth0, filtering is applied client-side. # Leave empty to show all groups (default). # Examples: # IDP_GROUP_FILTER_PREFIX=mcp- # IDP_GROUP_FILTER_PREFIX=mcp-,registry-,ai- IDP_GROUP_FILTER_PREFIX= # ============================================================================= # OKTA CONFIGURATION (if AUTH_PROVIDER=okta) # ============================================================================= # Okta org domain (without https://) # Format: dev-123456.okta.com # Get from: Okta Admin Console URL (remove -admin suffix) OKTA_DOMAIN=dev-123456.okta.com # Okta OAuth2 Application Client ID # Get from: Okta Admin Console → Applications → Your App → General tab OKTA_CLIENT_ID=your_okta_client_id_here # Okta OAuth2 Application Client Secret # Get from: Okta Admin Console → Applications → Your App → General tab OKTA_CLIENT_SECRET=your_okta_client_secret_here # Optional: Separate M2M client credentials (defaults to above if not set) # OKTA_M2M_CLIENT_ID=your_okta_m2m_client_id_here # OKTA_M2M_CLIENT_SECRET=your_okta_m2m_client_secret_here # Optional: Okta Admin API token for IAM operations (user/group management) # Get from: Okta Admin Console → Security → API → Tokens # OKTA_API_TOKEN=your_okta_api_token_here # Optional: Okta Custom Authorization Server ID (for M2M tokens) # Get from: Okta Admin Console → Security → API → Authorization Servers # If using custom authorization server for M2M, specify the ID here (e.g., aus1108sx6pwGzb8T698) # If not set, uses the default Org Authorization Server # OKTA_AUTH_SERVER_ID=your_auth_server_id_here # ============================================================================= # GITHUB OAUTH2 CONFIGURATION # ============================================================================= # GitHub OAuth App Client ID # Get this from GitHub > Settings > Developer settings > OAuth Apps GITHUB_CLIENT_ID=your_github_client_id_here # GitHub OAuth App Client Secret GITHUB_CLIENT_SECRET=your_github_client_secret_here # Enable GitHub in OAuth2 providers GITHUB_ENABLED=false # ============================================================================= # GITHUB PRIVATE REPOSITORY ACCESS (SKILL.md fetching) # ============================================================================= # Enable authenticated access to SKILL.md files in private GitHub repositories. # Two options: Personal Access Token (simple) or GitHub App (enterprise). # If both are configured, GitHub App takes priority. # Option 1: Personal Access Token # Generate at https://github.com/settings/tokens with 'repo' scope # Fine-grained PATs: scope to 'contents: read' on specific repos # GITHUB_PAT=ghp_your_token_here # Option 2: GitHub App (recommended for organizations) # Create at https://github.com/settings/apps # Required permissions: Contents (read-only) # GITHUB_APP_ID=123456 # GITHUB_APP_INSTALLATION_ID=78901234 # GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" # Extra GitHub hosts for enterprise instances (comma-separated) # Auth headers are sent ONLY to github.com, raw.githubusercontent.com, and hosts listed here # GITHUB_EXTRA_HOSTS=github.mycompany.com,raw.github.mycompany.com # GitHub API base URL (default: https://api.github.com) # For GitHub Enterprise Server, use: https://github.mycompany.com/api/v3 # GITHUB_API_BASE_URL=https://api.github.com # ============================================================================= # GOOGLE OAUTH2 CONFIGURATION # ============================================================================= # Google OAuth2 Client ID # Get this from Google Cloud Console > APIs & Services > Credentials GOOGLE_CLIENT_ID=your_google_client_id_here # Google OAuth2 Client Secret GOOGLE_CLIENT_SECRET=your_google_client_secret_here # Enable Google in OAuth2 providers GOOGLE_ENABLED=false # ============================================================================= # AUTH0 OAUTH2 CONFIGURATION # ============================================================================= # Auth0 Domain (your Auth0 tenant domain) # Get this from Auth0 Dashboard > Applications > Your App > Settings # Example: your-tenant.auth0.com AUTH0_DOMAIN=your-tenant.auth0.com # Auth0 Client ID AUTH0_CLIENT_ID=your_auth0_client_id_here # Auth0 Client Secret AUTH0_CLIENT_SECRET=your_auth0_client_secret_here # Auth0 API Audience (required for M2M token validation) # This is the API Identifier from Auth0 Dashboard > APIs # Use the Management API audience: https://.auth0.com/api/v2/ # Or a custom API audience you created in Auth0 # AUTH0_AUDIENCE=https://dev-example.us.auth0.com/api/v2/ # Auth0 Groups Claim (custom claim for group memberships) # Auth0 requires a custom Action/Rule to add groups to tokens. # The claim must be a namespaced URI to avoid conflicts. # Default: https://mcp-gateway/groups AUTH0_GROUPS_CLAIM=https://mcp-gateway/groups # Enable Auth0 in OAuth2 providers AUTH0_ENABLED=false # Auth0 M2M Client ID (REQUIRED for IAM Management - user/role administration) # Create an M2M application in Auth0 with Auth0 Management API permissions # See docs/auth0.md for setup instructions # AUTH0_M2M_CLIENT_ID=your_m2m_client_id # Auth0 M2M Client Secret (REQUIRED for IAM Management) # AUTH0_M2M_CLIENT_SECRET=your_m2m_client_secret # Auth0 Management API Token (alternative to M2M credentials) # You can use a static Management API token instead of M2M client credentials # Generate in Auth0 Dashboard > Applications > APIs > Auth0 Management API > API Explorer # WARNING: Static tokens expire after 24 hours - M2M credentials recommended for production # AUTH0_MANAGEMENT_API_TOKEN=your_management_api_token # ============================================================================= # APPLICATION SECURITY # ============================================================================= # CRITICAL: CHANGE THIS SECRET KEY IMMEDIATELY! # This is used for: # - JWT token signing and session security # - Backend MCP server credential encryption (Bearer tokens, API keys) # Generate a strong, random 64-character string in production # WARNING: Using the default value is a security risk! # WARNING: Changing this key will invalidate all encrypted credentials! SECRET_KEY=CHANGE-THIS-IMMEDIATELY-use-a-strong-random-key-in-production # ============================================================================= # SESSION COOKIE CONFIGURATION # ============================================================================= # Session cookie secure flag (HTTPS-only transmission) # IMPORTANT: Set based on your environment: # - Local development (localhost via HTTP): Set to false # - Production with HTTPS: Set to true # # If set to true, cookies will ONLY be sent over HTTPS connections. # Setting this to true on localhost (HTTP) will cause login to fail! # # Default: false (safe for local development) # Production: MUST be true SESSION_COOKIE_SECURE=false # Session cookie domain (for cross-subdomain authentication) # Leave unset or empty for single-domain deployments (RECOMMENDED for most cases) # Set to domain with leading dot for cross-subdomain sharing # # Examples: # Single domain (mcpgateway.ddns.net): Leave unset or set to empty string # SESSION_COOKIE_DOMAIN= # # Cross-subdomain (auth.example.com + registry.example.com): Set to .example.com # SESSION_COOKIE_DOMAIN=.example.com # # Multi-level domains (registry.region-1.corp.company.internal): Set to your org domain # SESSION_COOKIE_DOMAIN=.corp.company.internal # # Default: Empty (cookie scoped to exact host only - safest option) SESSION_COOKIE_DOMAIN= # ============================================================================= # OAUTH TOKEN STORAGE CONFIGURATION # ============================================================================= # Control whether OAuth provider tokens are stored in session cookies # When enabled (true, default): # - OAuth access_token, refresh_token, and expiration stored in session # - May cause cookie size issues with large tokens (e.g., Microsoft Entra ID) # # When disabled (false): # - OAuth tokens NOT stored in session cookies # - Reduces cookie size significantly # - Recommended for Entra ID deployments experiencing cookie size errors # # Default: false (tokens are not used functionally, reduces cookie size) OAUTH_STORE_TOKENS_IN_SESSION=false # ============================================================================= # EXTERNAL MCP SERVER AUTH TOKENS (Auto-generated from OAuth flows) # ============================================================================= # These tokens are automatically populated by the OAuth credential scripts # Do not set these manually - they are managed by credentials-provider/ # ATLASSIAN_AUTH_TOKEN="auto_generated_by_oauth_flow" # SRE_GATEWAY_AUTH_TOKEN="auto_generated_by_oauth_flow" # Smithery API Key for accessing Smithery-hosted MCP servers # Get this from https://smithery.ai/ SMITHERY_API_KEY=your_smithery_api_key_here # ============================================================================= # AI/LLM CONFIGURATION # ============================================================================= # Anthropic API Key for Claude models (required for agent functionality) # Get this from https://console.anthropic.com/ ANTHROPIC_API_KEY=your_anthropic_api_key_here # ============================================================================= # SECURITY SCANNING CONFIGURATION (Cisco AI Defense Integration) # ============================================================================= # Enable/disable security scanning for MCP servers # When enabled, servers are scanned during registration for security threats SECURITY_SCAN_ENABLED=true # Automatically scan servers when they are registered # Set to false to disable automatic scanning on registration SECURITY_SCAN_ON_REGISTRATION=true # Block (disable) servers that fail security scans # When true, unsafe servers are automatically disabled # When false, unsafe servers remain enabled but tagged SECURITY_BLOCK_UNSAFE_SERVERS=true # Analyzers to use for security scanning (comma-separated) # Available: yara, llm, api # - yara: Pattern matching with YARA rules (no API key required) # - llm: LLM-as-a-judge evaluation (requires MCP_SCANNER_LLM_API_KEY) # - api: Cisco AI Defense inspect API (requires Cisco credentials) SECURITY_ANALYZERS=yara # Security scan timeout in seconds (default: 300 = 5 minutes) SECURITY_SCAN_TIMEOUT=60 # Add 'security-pending' tag to servers that fail security scan # This helps identify servers awaiting security review SECURITY_ADD_PENDING_TAG=true # MCP Security Scanner LLM API Key (optional - only needed for LLM-based security analysis) # Default analyzer is YARA (no API key required) # To use LLM analyzer: ./cli/service_mgmt.sh add config.json yara,llm # Get OpenAI API key from https://platform.openai.com/api-keys MCP_SCANNER_LLM_API_KEY=your_openai_api_key_here # ============================================================================= # EMBEDDINGS CONFIGURATION # ============================================================================= # Embeddings provider: 'sentence-transformers' (local) or 'litellm' (cloud-based) # Default: sentence-transformers (no API key required) EMBEDDINGS_PROVIDER=litellm # Model name for embeddings generation # For sentence-transformers: model name from Hugging Face (e.g., all-MiniLM-L6-v2) # For litellm: provider-prefixed model (e.g., bedrock/amazon.titan-embed-text-v1, # openai/text-embedding-3-small, cohere/embed-english-v3.0) EMBEDDINGS_MODEL_NAME=bedrock/amazon.titan-embed-text-v2:0 # Embedding dimension (must match the model's output dimension) # all-MiniLM-L6-v2: 384 # text-embedding-3-small: 1536 # amazon.titan-embed-text-v1: 1536 # cohere/embed-english-v3.0: 1024 EMBEDDINGS_MODEL_DIMENSIONS=1024 # LiteLLM-specific settings (only used when EMBEDDINGS_PROVIDER=litellm) # API key for cloud embeddings provider (provider-specific) # For OpenAI: Get from https://platform.openai.com/api-keys # For Cohere: Get from https://dashboard.cohere.com/api-keys # For Bedrock: Not used - configure AWS credentials via standard methods (see below) # EMBEDDINGS_API_KEY=your_api_key_here # Optional: Custom API base URL for embeddings provider # EMBEDDINGS_API_BASE=https://api.custom-endpoint.com # AWS region for Amazon Bedrock embeddings (only needed for Bedrock) # Note: For Bedrock authentication, use standard AWS credential chain: # - IAM roles (recommended for EC2/EKS) # - Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) # - AWS credentials file (~/.aws/credentials) # EMBEDDINGS_AWS_REGION=us-east-1 # ============================================================================= # ANS (AGENT NAMING SERVICE) CONFIGURATION # ============================================================================= # Enable ANS integration for agent identity verification # When enabled, agents can be linked to ANS records for verified identity ANS_INTEGRATION_ENABLED=false # ANS API endpoint URL ANS_API_ENDPOINT=https://api.godaddy.com # ANS API credentials (required when ANS_INTEGRATION_ENABLED=true) # Get these from your ANS provider account ANS_API_KEY= ANS_API_SECRET= # ANS API request timeout in seconds ANS_API_TIMEOUT_SECONDS=30 # How often to re-sync ANS verification status (in hours) ANS_SYNC_INTERVAL_HOURS=6 # Cache TTL for ANS verification results (in seconds) ANS_VERIFICATION_CACHE_TTL_SECONDS=3600 # ============================================================================= # A2A AGENT SECURITY SCANNING CONFIGURATION # ============================================================================= # Enable/disable security scanning for A2A agents # When enabled, agents are scanned during registration for security threats AGENT_SECURITY_SCAN_ENABLED=true # Automatically scan agents when they are registered # Set to false to disable automatic scanning on registration AGENT_SECURITY_SCAN_ON_REGISTRATION=true # Block (disable) agents that fail security scans # When true, unsafe agents are automatically disabled # When false, unsafe agents remain enabled but tagged AGENT_SECURITY_BLOCK_UNSAFE_AGENTS=true # Analyzers to use for agent security scanning (comma-separated) # Available: yara, spec, heuristic, llm, endpoint # - yara: Pattern matching with YARA rules (no API key required) # - spec: A2A protocol specification validation (no API key required) # - heuristic: Logic-based threat detection (no API key required) # - llm: LLM-as-a-judge evaluation (requires A2A_SCANNER_LLM_API_KEY) # - endpoint: Dynamic endpoint security testing (requires live agent) AGENT_SECURITY_ANALYZERS=yara,spec # Agent security scan timeout in seconds (default: 60 = 1 minute) AGENT_SECURITY_SCAN_TIMEOUT=60 # Add 'security-pending' tag to agents that fail security scan # This helps identify agents awaiting security review AGENT_SECURITY_ADD_PENDING_TAG=true # A2A Security Scanner LLM API Key (optional - only needed for LLM-based agent analysis) # Default analyzers are YARA and Spec (no API key required) # Get Azure OpenAI API key from https://portal.azure.com/ A2A_SCANNER_LLM_API_KEY=your_azure_openai_api_key_here # ============================================================================= # CONTAINER REGISTRY CREDENTIALS (for CI/CD and local builds) # ============================================================================= # Docker Hub credentials for publishing container images # Get these from https://hub.docker.com/settings/security DOCKERHUB_USERNAME=your_dockerhub_username DOCKERHUB_TOKEN=your_dockerhub_access_token # GitHub Container Registry credentials (optional - for publishing to ghcr.io) # The GITHUB_TOKEN is automatically provided in GitHub Actions # For local builds, generate a Personal Access Token with packages:write scope # Get this from https://github.com/settings/tokens # GITHUB_USERNAME=your_github_username # GITHUB_TOKEN=your_github_personal_access_token # # Container registry organization names # DOCKERHUB_ORG=mcpgateway # GITHUB_ORG=agentic-community # ============================================================================= # EXTERNAL REGISTRY CONFIGURATION # ============================================================================= # Comma-separated list of tags that identify external registry servers # These tags are used by the frontend to separate internal MCP servers from # external registry integrations (e.g., Anthropic, Workday, AWS Agent Registry) # Servers tagged with these values will appear in the "External Registries" tab # Default: anthropic-registry,workday-asor,agentcore EXTERNAL_REGISTRY_TAGS=anthropic-registry,workday-asor,agentcore # ============================================================================= # AWS REGISTRY FEDERATION (optional) # ============================================================================= # Overrides the aws_registry.enabled flag in the federation config (MongoDB). # Registry IDs, region, sync settings are managed via /api/federation/config API. # # Required IAM permissions on the ECS task role: # - bedrock-agentcore:ListRegistries # - bedrock-agentcore:ListRegistryRecords # - bedrock-agentcore:GetRegistryRecord # # Enable AWS Agent Registry federation (default: false) AWS_REGISTRY_FEDERATION_ENABLED=false # ============================================================================= # STORAGE BACKEND CONFIGURATION # ============================================================================= # Storage Backend Selection # Options: # "file" - Uses JSON files (simple, local development) # "documentdb" - Uses Amazon DocumentDB or MongoDB (production, with native vector search) # "mongodb-ce" - Uses MongoDB Community Edition 8.2 (local dev, application-level vector search) # For production deployments, DocumentDB is recommended for scalability and concurrent access # Options: file, mongodb-ce, documentdb STORAGE_BACKEND=mongodb-ce # DocumentDB Configuration (used when STORAGE_BACKEND=documentdb or mongodb-ce) # Amazon DocumentDB (MongoDB-compatible) or MongoDB connection settings # For local MongoDB CE (mongodb-ce backend): # Authentication with SCRAM-SHA-256 (stronger than SCRAM-SHA-1) DOCUMENTDB_HOST=mongodb DOCUMENTDB_PORT=27017 DOCUMENTDB_DATABASE=mcp_registry DOCUMENTDB_USERNAME=admin DOCUMENTDB_PASSWORD=admin DOCUMENTDB_USE_TLS=false DOCUMENTDB_NAMESPACE=default # For AWS DocumentDB (documentdb backend): # Uses SCRAM-SHA-1 (AWS DocumentDB v5.0 limitation) # DOCUMENTDB_HOST=your-documentdb-cluster.cluster-xxxxx.us-east-1.docdb.amazonaws.com # DOCUMENTDB_PORT=27017 # DOCUMENTDB_DATABASE=mcp_registry # DOCUMENTDB_USERNAME=your_username # DOCUMENTDB_PASSWORD=your_password # DOCUMENTDB_USE_TLS=true # DOCUMENTDB_TLS_CA_FILE=global-bundle.pem # DOCUMENTDB_USE_IAM=false # DOCUMENTDB_REPLICA_SET=rs0 # DOCUMENTDB_READ_PREFERENCE=secondaryPreferred # DOCUMENTDB_NAMESPACE=default # ============================================================================= # GRAFANA CONFIGURATION # ============================================================================= # Grafana admin password for the local metrics dashboard # IMPORTANT: You must set a strong, random password before starting Grafana # Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(24))" GRAFANA_ADMIN_PASSWORD=CHANGE-ME-SET-STRONG-PASSWORD # ============================================================================= # OTLP PUSH EXPORT CONFIGURATION # ============================================================================= # Push OpenTelemetry metrics to an external observability platform via OTLP/HTTP. # When OTEL_OTLP_ENDPOINT is set, the metrics service pushes all 9 OTel metrics # to the configured endpoint in parallel with the existing Prometheus exporter. # When unset, only the Prometheus exporter is active (default behavior). # OTLP endpoint URL (leave empty to disable OTLP export) # OTEL_OTLP_ENDPOINT= # Datadog (US1): # OTEL_OTLP_ENDPOINT=https://otlp.datadoghq.com # OTEL_EXPORTER_OTLP_HEADERS=dd-api-key=YOUR_DATADOG_API_KEY # Datadog (EU1): # OTEL_OTLP_ENDPOINT=https://otlp.datadoghq.eu # OTEL_EXPORTER_OTLP_HEADERS=dd-api-key=YOUR_DATADOG_API_KEY # New Relic: # OTEL_OTLP_ENDPOINT=https://otlp.nr-data.net # OTEL_EXPORTER_OTLP_HEADERS=api-key=YOUR_NEW_RELIC_LICENSE_KEY # Export interval in milliseconds (default: 30000 = 30 seconds) # OTEL_OTLP_EXPORT_INTERVAL_MS=30000 # Metric temporality preference (default: cumulative) # Datadog requires "delta" — set this when using Datadog as the OTLP endpoint # Other platforms (New Relic, Honeycomb, Grafana Cloud) work with the default "cumulative" # OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=cumulative # ============================================================================= # AGENTCORE TOKEN REFRESHER - CLIENT SECRETS # ============================================================================= # Used by: uv run python -m cli.agentcore.token_refresher # # The token refresher resolves OAuth client secrets in this order: # 1. Per-client env var: OAUTH_CLIENT_SECRET_ # 2. Cognito auto-retrieval via AWS describe_user_pool_client API # 3. Vendor-level env var (AUTH0_CLIENT_SECRET, OKTA_CLIENT_SECRET, etc.) # # For Cognito gateways, no env var is needed -- secrets are auto-retrieved # from AWS if the IAM role has cognito-idp:DescribeUserPoolClient permission. # Set per-client env vars below to override auto-retrieval or for non-Cognito IdPs. # # The client_id values come from the allowed_clients field in # token_refresh_manifest.json (generated by cli.agentcore sync). # --- Cognito per-client secrets (override auto-retrieval) --- # OAUTH_CLIENT_SECRET_49ujl0b9ser72gnp6q1ph9v6vs=your_cognito_client_secret # OAUTH_CLIENT_SECRET_5m3bmqg5jjdadkqrecibp5t03j=your_cognito_client_secret # --- Auth0 (vendor-level, shared across all Auth0 gateways) --- # Falls back to AUTH0_CLIENT_SECRET defined above if per-client var not set # OAUTH_CLIENT_SECRET_your_auth0_client_id=your_auth0_client_secret # --- Okta (vendor-level, shared across all Okta gateways) --- # Falls back to OKTA_CLIENT_SECRET defined above if per-client var not set # OAUTH_CLIENT_SECRET_your_okta_client_id=your_okta_client_secret # --- Entra ID (vendor-level, shared across all Entra gateways) --- # Falls back to ENTRA_CLIENT_SECRET defined above if per-client var not set # OAUTH_CLIENT_SECRET_your_entra_client_id=your_entra_client_secret # --- Keycloak (vendor-level, shared across all Keycloak gateways) --- # Falls back to KEYCLOAK_CLIENT_SECRET defined above if per-client var not set # OAUTH_CLIENT_SECRET_your_keycloak_client_id=your_keycloak_client_secret # ============================================================================= # ADDITIONAL CONFIGURATION # ============================================================================= # Optional: Set specific Cognito domain if using custom domain # COGNITO_DOMAIN=your-custom-domain.auth.{region}.amazoncognito.com # Optional: Additional service-specific environment variables # Add any additional configuration variables your deployment requires # ============================================================================= # AUDIT LOGGING CONFIGURATION # ============================================================================= # Enable/disable audit logging # When enabled, all API and MCP requests are logged to MongoDB for compliance # Default: true AUDIT_LOG_ENABLED=true # Audit log retention period in days # Logs older than this are automatically deleted via MongoDB TTL index # Common values: 7 (dev), 30 (standard), 90 (compliance) # Default: 7 AUDIT_LOG_MONGODB_TTL_DAYS=7 # ============================================================================= # APPLICATION LOG CONFIGURATION (Issue #886) # ============================================================================= # Controls RotatingFileHandler and optional MongoDB log storage for # centralized log retrieval across pods. # Max size per log file in bytes before rotation (default: 50 MB) # APP_LOG_MAX_BYTES=52428800 # Number of rotated backup files to keep (default: 5) # APP_LOG_BACKUP_COUNT=5 # Write application logs to centralized storage (default: true) # When enabled, log entries are written to the application_logs collection # with TTL auto-expiry. Requires MongoDB/DocumentDB backend. APP_LOG_CENTRALIZED_ENABLED=true # Days to retain application logs in centralized storage (default: 1) # APP_LOG_CENTRALIZED_TTL_DAYS=1 # Number of log records to buffer before flushing to MongoDB (default: 50) # APP_LOG_MONGODB_BUFFER_SIZE=50 # Seconds between periodic flushes to MongoDB (default: 5.0) # APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS=5.0 # Application log level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO) # APP_LOG_LEVEL=INFO # Comma-separated logger names to exclude from MongoDB log writes (default: uvicorn.access,httpx,pymongo,motor) # APP_LOG_EXCLUDED_LOGGERS=uvicorn.access,httpx,pymongo,motor # ============================================================================= # FEDERATION PEER SYNC CONFIGURATION # ============================================================================= # OAuth2 client credentials for peer-to-peer registry federation # Run keycloak/setup/setup-federation-service-account.sh to create the client # FEDERATION_TOKEN_ENDPOINT=http://keycloak:8080/realms/mcp-gateway/protocol/openid-connect/token # FEDERATION_CLIENT_ID=federation-peer-m2m # FEDERATION_CLIENT_SECRET=your-federation-client-secret # ============================================================================= # WORKDAY ASOR FEDERATION CONFIGURATION (optional) # ============================================================================= # Required only if using Workday ASOR federation # Replace 'your-tenant' and 'your_instance' with your actual Workday tenant identifiers # Example: https://services.wd101.myworkday.com/ccx/oauth2/production_instance/token # IMPORTANT: Must use HTTPS in production environments # If not configured with a valid URL, ASOR federation will be automatically disabled with a warning logged WORKDAY_TOKEN_URL=https://your-tenant.workday.com/ccx/oauth2/your_instance/token # ============================================================================= # TELEMETRY CONFIGURATION # ============================================================================= # Anonymous usage telemetry for tracking registry adoption # Privacy-first: no PII, no IP addresses, no hostnames # Disable telemetry entirely (default: not set, telemetry is ON) # MCP_TELEMETRY_DISABLED=1 # Disable daily heartbeat telemetry only (default: not set, heartbeat ON) # Startup ping is still sent. Set to 1 to opt out of heartbeat only. # MCP_TELEMETRY_OPT_OUT=1 # Heartbeat telemetry interval in minutes (default: 1440 = 24 hours) MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES=1440 # Telemetry collector endpoint (default: central collector) # Override to use a self-hosted collector # MCP_TELEMETRY_ENDPOINT=https://m3ijrhd020.execute-api.us-east-1.amazonaws.com/v1/collect # Debug mode: log telemetry payloads instead of sending (default: false) # TELEMETRY_DEBUG=true # Disable built-in airegistry-tools server auto-registration # Set to true for production/GitOps deployments that manage their own server registrations # DISABLE_AI_REGISTRY_TOOLS_SERVER=false ================================================ FILE: .github/workflows/auth-server-test.yml ================================================ name: Auth Server Test Suite on: push: branches: [main, develop] paths: - 'auth_server/**' - 'tests/auth_server/**' - '.github/workflows/auth-server-test.yml' pull_request: branches: [main, develop] paths: - 'auth_server/**' - 'tests/auth_server/**' - '.github/workflows/auth-server-test.yml' workflow_dispatch: permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: name: "Auth Server Tests (Python ${{ matrix.python-version }})" runs-on: ubuntu-latest timeout-minutes: 20 strategy: matrix: python-version: ["3.14"] fail-fast: false steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Cache dependencies uses: actions/cache@v4 with: path: ~/.cache/uv key: ${{ runner.os }}-uv-authserver-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }} restore-keys: | ${{ runner.os }}-uv-authserver-${{ matrix.python-version }}- ${{ runner.os }}-uv-authserver- - name: Install dependencies run: uv sync --extra dev - name: Run auth server tests run: | uv run pytest tests/auth_server/ -v -o "addopts=" --cov=auth_server --cov-report=xml --cov-report=html --cov-report=term - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: file: ./coverage.xml flags: auth-server name: codecov-auth-server-${{ matrix.python-version }} fail_ci_if_error: false - name: Upload coverage HTML report uses: actions/upload-artifact@v4 if: always() with: name: auth-server-coverage-${{ matrix.python-version }} path: htmlcov/ retention-days: 14 lint: name: "Auth Server Code Quality" runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.14" - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Install linting tools run: uv pip install --system ruff - name: Run ruff check run: ruff check auth_server/ continue-on-error: true - name: Run ruff format check run: ruff format --check auth_server/ continue-on-error: true ================================================ FILE: .github/workflows/build-auth-server.yml ================================================ name: Build Auth Server Image on: push: branches: [main] paths: - 'auth_server/**' - 'registry/**' - 'docker/Dockerfile.auth' - 'docker/auth-entrypoint.sh' - '.github/workflows/build-auth-server.yml' workflow_dispatch: permissions: contents: read packages: write attestations: write id-token: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: REGISTRY: public.ecr.aws IMAGE_NAME: p3v1o3c6/auth-server jobs: build-and-push: if: github.repository == 'agentic-community/mcp-gateway-registry' name: Build and Push runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up QEMU uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Configure Role to Acquire Credentials uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0 with: aws-region: us-east-1 role-session-name: auth-server-build role-to-assume: ${{ secrets.ECR_ROLE }} - name: Login to Amazon ECR Public id: login-ecr-public uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 with: registry-type: public - name: Extract metadata id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest,enable={{is_default_branch}} type=sha,prefix=,format=long - name: Build and push id: push uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: docker/Dockerfile.auth push: true platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | BUILD_VERSION=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - name: Generate attestation uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true - name: Image Summary run: | echo "## Auth Server Image Published" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Tags:**" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY ================================================ FILE: .github/workflows/build-mcpgw.yml ================================================ name: Build MCPGW Image on: push: branches: [main] paths: - 'servers/mcpgw/**' - 'registry/**' - 'docker/Dockerfile.mcp-server' - '.github/workflows/build-mcpgw.yml' workflow_dispatch: permissions: contents: read packages: write attestations: write id-token: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: REGISTRY: public.ecr.aws IMAGE_NAME: p3v1o3c6/mcpgw jobs: build-and-push: if: github.repository == 'agentic-community/mcp-gateway-registry' name: Build and Push runs-on: ubuntu-latest timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up QEMU uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Configure Role to Acquire Credentials uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0 with: aws-region: us-east-1 role-session-name: mcpgw-build role-to-assume: ${{ secrets.ECR_ROLE }} - name: Login to Amazon ECR Public id: login-ecr-public uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 with: registry-type: public - name: Extract metadata id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest,enable={{is_default_branch}} type=sha,prefix=,format=long - name: Build and push id: push uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: docker/Dockerfile.mcp-server push: true platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | BUILD_VERSION=${{ github.sha }} SERVER_DIR=servers/mcpgw cache-from: type=gha cache-to: type=gha,mode=max - name: Generate attestation uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true - name: Image Summary run: | echo "## MCPGW Image Published" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Tags:**" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY ================================================ FILE: .github/workflows/build-registry.yml ================================================ name: Build Registry Image on: push: branches: [main] paths: - 'registry/**' - 'auth_server/**' - 'api/**' - 'frontend/**' - 'scripts/**' - 'docker/Dockerfile.registry' - 'docker/registry-entrypoint.sh' - 'pyproject.toml' - '.github/workflows/build-registry.yml' workflow_dispatch: permissions: contents: read packages: write attestations: write id-token: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: REGISTRY: public.ecr.aws IMAGE_NAME: p3v1o3c6/registry jobs: build-and-push: if: github.repository == 'agentic-community/mcp-gateway-registry' name: Build and Push runs-on: ubuntu-latest timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up QEMU uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Configure Role to Acquire Credentials uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0 with: aws-region: us-east-1 role-session-name: registry-build role-to-assume: ${{ secrets.ECR_ROLE }} - name: Login to Amazon ECR Public id: login-ecr-public uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 with: registry-type: public - name: Extract metadata id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest,enable={{is_default_branch}} type=sha,prefix=,format=long - name: Build and push id: push uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: docker/Dockerfile.registry push: true platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | BUILD_VERSION=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - name: Generate attestation uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 with: subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true - name: Image Summary run: | echo "## Registry Image Published" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Tags:**" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY ================================================ FILE: .github/workflows/docs.yml ================================================ name: Build and Deploy Documentation on: push: branches: [main] paths: - 'docs/**' - 'mkdocs.yml' - 'README.md' - '.github/workflows/docs.yml' pull_request: branches: [main] paths: - 'docs/**' - 'mkdocs.yml' - 'README.md' workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: pages-${{ github.ref }} cancel-in-progress: false jobs: build: name: "Build Documentation" runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 # Fetch all history for git plugins - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.14' - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Cache dependencies uses: actions/cache@v4 with: path: ~/.cache/uv key: ${{ runner.os }}-uv-${{ hashFiles('pyproject.toml') }} restore-keys: | ${{ runner.os }}-uv- - name: Install dependencies run: | uv pip install --system -e ".[docs]" - name: Setup Pages id: pages uses: actions/configure-pages@v4 - name: Build documentation run: | mkdocs build --clean - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ./site deploy: name: "Deploy to GitHub Pages" if: github.ref == 'refs/heads/main' environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest timeout-minutes: 10 needs: build steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 ================================================ FILE: .github/workflows/helm-chart-update.yml ================================================ name: Update Helm Charts on Release on: workflow_run: workflows: ["Release Docker Images"] types: [completed] permissions: contents: write pull-requests: write jobs: update-helm-charts: name: Update Helm Chart Image Tags runs-on: ubuntu-latest if: >- github.event.workflow_run.conclusion == 'success' && github.repository == 'agentic-community/mcp-gateway-registry' steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 persist-credentials: false - name: Extract version from tag id: version env: HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} run: | TAG="$HEAD_BRANCH" VERSION="${TAG#v}" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" echo "Extracted version: $VERSION from tag: $TAG" - name: Update image tags in Helm charts id: update run: | VERSION="${{ steps.version.outputs.version }}" for VALUES_FILE in \ charts/mcp-gateway-registry-stack/values.yaml \ charts/auth-server/values.yaml \ charts/registry/values.yaml \ charts/mcpgw/values.yaml; do sed -i "s/^\(\s*tag:\s*\).*/\1${VERSION}/" "$VALUES_FILE" echo "Updated $VALUES_FILE" done if git diff --quiet; then echo "Charts already at version $VERSION, skipping PR creation" echo "changed=false" >> "$GITHUB_OUTPUT" else echo "changed=true" >> "$GITHUB_OUTPUT" fi - name: Check for new environment variables id: envcheck env: TAG: ${{ steps.version.outputs.tag }} run: | TAG="$TAG" # Find the previous release tag PREV_TAG=$(git tag --list 'v*.*.*' --sort=-v:refname | grep -v "^${TAG}$" | head -n 1) if [ -z "$PREV_TAG" ]; then echo "No previous tag found, skipping env var check" echo "comment=" >> "$GITHUB_OUTPUT" exit 0 fi echo "Comparing env vars between $PREV_TAG and $TAG" # Extract env var names from app code at each tag extract_env_vars() { local ref="$1" git show "${ref}:registry/core/config.py" 2>/dev/null | \ grep -oP '(?:env=")[A-Z_][A-Z0-9_]*(?:")' | sed 's/env="//;s/"//' || true for f in auth_server/server.py servers/mcpgw/server.py; do git show "${ref}:${f}" 2>/dev/null | \ grep -oP '(?:os\.environ\.get|os\.getenv|os\.environ\[)\s*\(?\s*["\x27]([A-Z_][A-Z0-9_]*)["\x27]' | \ grep -oP '[A-Z_][A-Z0-9_]+' || true done } extract_env_vars "$PREV_TAG" | sort -u > /tmp/env_old.txt extract_env_vars "$TAG" | sort -u > /tmp/env_new.txt # Find newly added env vars NEW_VARS=$(comm -13 /tmp/env_old.txt /tmp/env_new.txt) if [ -z "$NEW_VARS" ]; then echo "No new environment variables detected" echo "comment=" >> "$GITHUB_OUTPUT" exit 0 fi # Check which new vars are missing from helm templates MISSING="" for VAR in $NEW_VARS; do if ! grep -rq "$VAR" charts/*/templates/; then MISSING="${MISSING}\n- \`${VAR}\`" fi done if [ -z "$MISSING" ]; then echo "All new env vars are already in helm templates" echo "comment=" >> "$GITHUB_OUTPUT" else COMMENT=$(cat <> "$GITHUB_OUTPUT" fi - name: Create Pull Request id: create-pr if: steps.update.outputs.changed == 'true' uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 with: branch: helm-update-${{ steps.version.outputs.version }} commit-message: "chore: update Helm chart image tags to ${{ steps.version.outputs.version }}" title: "chore: update Helm chart image tags to ${{ steps.version.outputs.version }}" body: | Automated update of Helm chart image tags to `${{ steps.version.outputs.version }}` following release `${{ steps.version.outputs.tag }}`. Updated files: - `charts/mcp-gateway-registry-stack/values.yaml` - `charts/auth-server/values.yaml` - `charts/registry/values.yaml` - `charts/mcpgw/values.yaml` labels: helm - name: Comment on PR with missing env vars if: steps.update.outputs.changed == 'true' && steps.envcheck.outputs.comment != '' uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0 with: issue-number: ${{ steps.create-pr.outputs.pull-request-number }} body: ${{ steps.envcheck.outputs.comment }} ================================================ FILE: .github/workflows/helm-release-retag.yml ================================================ name: Move Release Tag After Helm Chart Update on: pull_request: types: [closed] branches: [main] permissions: contents: write jobs: retag-release: name: Move Release Tag to Main runs-on: ubuntu-latest if: >- github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'helm-update-') && github.repository == 'agentic-community/mcp-gateway-registry' steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Extract version and move tag run: | BRANCH="${{ github.event.pull_request.head.ref }}" VERSION="${BRANCH#helm-update-}" TAG="v${VERSION}" echo "Moving tag $TAG to current main HEAD" git tag -f "$TAG" git push origin "$TAG" --force ================================================ FILE: .github/workflows/helm-test.yml ================================================ name: Helm Chart Tests on: push: branches: [main, develop] paths: - 'charts/**' - '.github/workflows/helm-test.yml' pull_request: paths: - 'charts/**' - '.github/workflows/helm-test.yml' workflow_dispatch: permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: lint: name: "Helm Lint" runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Helm uses: azure/setup-helm@v4 with: version: '3.14.0' - name: Add Helm repositories run: | helm repo add bitnami https://charts.bitnami.com/bitnami || true helm repo update - name: Build chart dependencies run: | for chart in charts/*/; do if [ -f "${chart}Chart.yaml" ]; then echo "Building dependencies for ${chart}..." helm dependency build "$chart" || true fi done - name: Lint all charts run: | echo "## Helm Lint Results" >> $GITHUB_STEP_SUMMARY failed=0 for chart in charts/*/; do if [ -f "${chart}Chart.yaml" ]; then echo "Linting ${chart}..." if helm lint "$chart" 2>&1; then echo "- ${chart}: PASSED" >> $GITHUB_STEP_SUMMARY else echo "- ${chart}: WARNING (lint issues found)" >> $GITHUB_STEP_SUMMARY # Don't fail on lint warnings, only errors fi fi done template: name: "Helm Template Validation" runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Helm uses: azure/setup-helm@v4 with: version: '3.14.0' - name: Template validation run: | echo "## Helm Template Results" >> $GITHUB_STEP_SUMMARY for chart in charts/*/; do if [ -f "${chart}Chart.yaml" ]; then echo "Validating template for ${chart}..." if helm template test "$chart" --debug > /dev/null 2>&1; then echo "- ${chart}: PASSED" >> $GITHUB_STEP_SUMMARY else echo "- ${chart}: FAILED" >> $GITHUB_STEP_SUMMARY helm template test "$chart" --debug || true fi fi done kubeconform: name: "Kubernetes Manifest Validation" runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Helm uses: azure/setup-helm@v4 with: version: '3.14.0' - name: Install kubeconform run: | curl -sL https://github.com/yannh/kubeconform/releases/download/v0.6.4/kubeconform-linux-amd64.tar.gz | tar xz sudo mv kubeconform /usr/local/bin/ - name: Validate Kubernetes manifests run: | echo "## Kubeconform Results" >> $GITHUB_STEP_SUMMARY for chart in charts/*/; do if [ -f "${chart}Chart.yaml" ]; then echo "Validating ${chart}..." helm template test "$chart" 2>/dev/null | kubeconform -strict -summary -ignore-missing-schemas || true fi done dependency-check: name: "Helm Dependency Check" runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Helm uses: azure/setup-helm@v4 with: version: '3.14.0' - name: Add Helm repositories run: | helm repo add bitnami https://charts.bitnami.com/bitnami || true helm repo update - name: Build dependencies for umbrella chart run: | if [ -f "charts/mcp-gateway-registry-stack/Chart.yaml" ]; then echo "Building dependencies for umbrella chart..." helm dependency build charts/mcp-gateway-registry-stack || true fi summary: name: "Helm Test Summary" runs-on: ubuntu-latest timeout-minutes: 5 needs: [lint, template, kubeconform, dependency-check] if: always() steps: - name: Results Summary run: | echo "## Helm Chart Test Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY echo "| Lint | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Template | ${{ needs.template.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Kubeconform | ${{ needs.kubeconform.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Dependencies | ${{ needs.dependency-check.result }} |" >> $GITHUB_STEP_SUMMARY ================================================ FILE: .github/workflows/metrics-service-test.yml ================================================ name: Metrics Service Test Suite on: push: branches: [main, develop] paths: - 'metrics-service/**' - '.github/workflows/metrics-service-test.yml' pull_request: branches: [main, develop] paths: - 'metrics-service/**' - '.github/workflows/metrics-service-test.yml' workflow_dispatch: permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: name: "Metrics Service Tests (Python ${{ matrix.python-version }})" runs-on: ubuntu-latest timeout-minutes: 20 strategy: matrix: python-version: ["3.14"] fail-fast: false steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Cache dependencies uses: actions/cache@v4 with: path: ~/.cache/uv key: ${{ runner.os }}-uv-metrics-${{ matrix.python-version }}-${{ hashFiles('metrics-service/pyproject.toml') }} restore-keys: | ${{ runner.os }}-uv-metrics-${{ matrix.python-version }}- ${{ runner.os }}-uv-metrics- - name: Install dependencies working-directory: metrics-service run: uv sync --extra dev - name: Run metrics service tests working-directory: metrics-service run: | uv run pytest tests/ -v --cov=. --cov-report=xml --cov-report=html --cov-report=term - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: file: ./metrics-service/coverage.xml flags: metrics-service name: codecov-metrics-service-${{ matrix.python-version }} fail_ci_if_error: false - name: Upload coverage HTML report uses: actions/upload-artifact@v4 if: always() with: name: metrics-service-coverage-${{ matrix.python-version }} path: metrics-service/htmlcov/ retention-days: 14 lint: name: "Metrics Service Code Quality" runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.14" - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Install linting tools run: uv pip install --system ruff - name: Run ruff check working-directory: metrics-service run: ruff check . - name: Run ruff format check working-directory: metrics-service run: ruff format --check . continue-on-error: true ================================================ FILE: .github/workflows/registry-test.yml ================================================ name: Registry Test Suite on: push: branches: [main, develop] # No path filters - run on every merge to main/develop pull_request: branches: [main, develop] paths: - 'registry/**' - 'tests/**' - 'pyproject.toml' - 'scripts/test.py' - '.github/workflows/registry-test.yml' workflow_dispatch: permissions: contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: name: "Test (Python ${{ matrix.python-version }})" runs-on: ubuntu-latest timeout-minutes: 30 strategy: matrix: python-version: ["3.14"] fail-fast: false steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Cache dependencies uses: actions/cache@v4 with: path: ~/.cache/uv key: ${{ runner.os }}-uv-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }} restore-keys: | ${{ runner.os }}-uv-${{ matrix.python-version }}- ${{ runner.os }}-uv- - name: Install dependencies run: | uv sync --extra dev - name: Check dependencies run: | uv run python scripts/test.py check - name: Run all tests with coverage run: | uv run python scripts/test.py coverage -n 8 - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: file: ./coverage.xml flags: unittests name: codecov-python-${{ matrix.python-version }} fail_ci_if_error: false - name: Upload coverage HTML report uses: actions/upload-artifact@v4 if: always() with: name: coverage-report-${{ matrix.python-version }} path: htmlcov/ retention-days: 14 - name: Upload test reports uses: actions/upload-artifact@v4 if: always() with: name: test-reports-${{ matrix.python-version }} path: tests/reports/ retention-days: 14 lint: name: "Code Quality" runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.14" - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Cache dependencies uses: actions/cache@v4 with: path: ~/.cache/uv key: ${{ runner.os }}-uv-lint-${{ hashFiles('pyproject.toml') }} restore-keys: | ${{ runner.os }}-uv-lint- - name: Install dependencies run: | uv pip install --system ruff - name: Run ruff check run: | ruff check registry/ tests/ continue-on-error: true - name: Run ruff format check run: | ruff format --check registry/ tests/ continue-on-error: true security: name: "Security Check" runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.14" - name: Install uv uses: astral-sh/setup-uv@v4 with: version: "latest" - name: Install bandit run: | uv pip install --system bandit - name: Run bandit security scan run: | bandit -r registry/ -f json -o bandit-report.json || true - name: Upload security report uses: actions/upload-artifact@v4 if: always() with: name: security-report path: bandit-report.json retention-days: 14 summary: name: "Test Summary" runs-on: ubuntu-latest timeout-minutes: 5 needs: [test, lint, security] if: always() steps: - name: Test Results Summary run: | echo "## Test Results Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY echo "| Tests | ${{ needs.test.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Code Quality | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if [[ "${{ needs.test.result }}" == "success" && "${{ needs.lint.result }}" == "success" && "${{ needs.security.result }}" == "success" ]]; then echo "All checks passed!" >> $GITHUB_STEP_SUMMARY else echo "Some checks failed. Please review the logs." >> $GITHUB_STEP_SUMMARY fi ================================================ FILE: .github/workflows/release-images.yml ================================================ name: Release Docker Images on: push: tags: - 'v*.*.*' workflow_dispatch: inputs: tag: description: 'Release tag (e.g., v1.0.0)' required: true type: string permissions: contents: read packages: write attestations: write id-token: write env: REGISTRY: public.ecr.aws NAMESPACE: p3v1o3c6 jobs: build-release-images: name: Build ${{ matrix.service }} Release runs-on: ubuntu-latest timeout-minutes: 45 if: github.repository == 'agentic-community/mcp-gateway-registry' strategy: matrix: include: - service: auth-server dockerfile: docker/Dockerfile.auth - service: registry dockerfile: docker/Dockerfile.registry - service: mcpgw dockerfile: docker/Dockerfile.mcp-server extra_build_args: |- SERVER_DIR=servers/mcpgw steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up QEMU uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0 with: aws-region: us-east-1 role-session-name: ${{ matrix.service }}-release role-to-assume: ${{ secrets.ECR_ROLE }} - name: Login to Amazon ECR Public uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1 with: registry-type: public - name: Extract metadata id: meta uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ matrix.service }} tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value=latest - name: Build and push id: push uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . file: ${{ matrix.dockerfile }} push: true platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | BUILD_VERSION=${{ github.ref_name }} ${{ matrix.extra_build_args }} cache-from: type=gha cache-to: type=gha,mode=max - name: Generate attestation uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 with: subject-name: ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/${{ matrix.service }} subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true - name: Image Summary run: | echo "## ${{ matrix.service }} Release Image Published" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Tags:**" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY ================================================ FILE: .github/workflows/terraform-test.yml ================================================ name: Terraform Tests on: push: branches: [main, develop] paths: - 'terraform/**' - '.github/workflows/terraform-test.yml' pull_request: paths: - 'terraform/**' - '.github/workflows/terraform-test.yml' workflow_dispatch: permissions: contents: read security-events: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: validate: name: "Terraform Validate" runs-on: ubuntu-latest timeout-minutes: 10 defaults: run: working-directory: terraform/aws-ecs steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup Terraform uses: hashicorp/setup-terraform@v3 with: terraform_version: "1.12.0" - name: Terraform fmt check id: fmt run: terraform fmt -check -recursive continue-on-error: true - name: Terraform init id: init run: terraform init -backend=false - name: Terraform validate id: validate run: terraform validate continue-on-error: true - name: Post validation results run: | echo "## Terraform Validation Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Check | Status |" >> $GITHUB_STEP_SUMMARY echo "|-------|--------|" >> $GITHUB_STEP_SUMMARY echo "| Format | ${{ steps.fmt.outcome }} |" >> $GITHUB_STEP_SUMMARY echo "| Init | ${{ steps.init.outcome }} |" >> $GITHUB_STEP_SUMMARY echo "| Validate | ${{ steps.validate.outcome }} |" >> $GITHUB_STEP_SUMMARY tflint: name: "TFLint" runs-on: ubuntu-latest timeout-minutes: 10 defaults: run: working-directory: terraform/aws-ecs steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup TFLint uses: terraform-linters/setup-tflint@v4 with: tflint_version: v0.50.0 - name: Init TFLint run: tflint --init continue-on-error: true - name: Run TFLint run: tflint --recursive --format compact continue-on-error: true tfsec: name: "TFSec Security Scan" runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout code uses: actions/checkout@v4 - name: Run tfsec uses: aquasecurity/tfsec-action@v1.0.3 with: working_directory: terraform/aws-ecs soft_fail: true format: sarif out: tfsec-results.sarif continue-on-error: true - name: Upload SARIF file uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: tfsec-results.sarif continue-on-error: true checkov: name: "Checkov Security Scan" runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout code uses: actions/checkout@v4 - name: Run Checkov uses: bridgecrewio/checkov-action@v12 with: directory: terraform/aws-ecs framework: terraform soft_fail: true output_format: cli,sarif output_file_path: console,checkov-results.sarif download_external_modules: true - name: Upload SARIF file uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: checkov-results.sarif continue-on-error: true summary: name: "Terraform Test Summary" runs-on: ubuntu-latest timeout-minutes: 5 needs: [validate, tflint, tfsec, checkov] if: always() steps: - name: Results Summary run: | echo "## Terraform Test Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY echo "| Validate | ${{ needs.validate.result }} |" >> $GITHUB_STEP_SUMMARY echo "| TFLint | ${{ needs.tflint.result }} |" >> $GITHUB_STEP_SUMMARY echo "| TFSec | ${{ needs.tfsec.result }} |" >> $GITHUB_STEP_SUMMARY echo "| Checkov | ${{ needs.checkov.result }} |" >> $GITHUB_STEP_SUMMARY ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # Models .models/ # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ tests/reports/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # UV # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. #uv.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/latest/usage/project/#working-with-version-control .pdm.toml .pdm-python .pdm-build/ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .env.* !.env.example .env.backup .env.user .env.docker # Configuration files with sensitive data credentials-provider/agentcore-auth/config.yaml credentials-provider/oauth/config.yaml cli/examples/peer-registry-lob-1.json cli/examples/peer-sales-registry.json .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ # Ruff stuff: .ruff_cache/ .cache/ # PyPI configuration file .pypirc cookies.txt .cookies # Scratchpad for temporary notes and planning .scratchpad/ # MongoDB keyfile for replica set authentication .mongodb-keyfile # Roo IDE files .roo/ # VS Code / IDE files .vscode/ # Kiro files .kiro .kiro/ # Agent config agents/agent_config.json # Jules files .Jules/ # OAuth tokens and credentials - never commit these! .oauth-tokens/ .agentcore-params .cognito_access_token .network-trusted-token .token* api/.token api/.mcp-session # Keycloak client secrets (generated by init-keycloak.sh) keycloak/setup/keycloak-client-secrets.txt keycloak/setup/retrieved-keycloak-secrets.txt # MCP Gateway specific registry/server_state.json registry/nginx_mcp_revproxy.conf registry/agents/ registry/data/ logs/ token_refresher.pid token_refresher.log token_refresh_manifest.json .mcp.json # Secrets and API keys - never commit these! .keys.yml .keys.yml.encrypted *.keys.yml *.keys.yml.encrypted # SSL certificates and keys - never commit these! *.pem *.key *.crt *.csr *.p12 *.pfx /etc/ssl/ # Agent testing agents/test_results/ agents/.env.user ssl_data/ agents/.env.agent # Frontend / Node.js / React / TypeScript frontend/node_modules/ frontend/build/ frontend/dist/ frontend/.env frontend/.env.local frontend/.env.development.local frontend/.env.test.local frontend/.env.production.local frontend/npm-debug.log* frontend/yarn-debug.log* frontend/yarn-error.log* frontend/.pnpm-debug.log* frontend/lerna-debug.log* frontend/.DS_Store frontend/.vscode/ frontend/.idea/ frontend/*.tsbuildinfo frontend/.nyc_output frontend/coverage/ frontend/.cache/ frontend/.parcel-cache/ frontend/.next/ frontend/out/ frontend/.nuxt/ frontend/.vuepress/dist frontend/.serverless/ frontend/.fusebox/ frontend/.dynamodb/ frontend/.tern-port frontend/storybook-static/ # Node.js (global patterns) node_modules/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* lerna-debug.log* .DS_Store *.tsbuildinfo .nyc_output coverage/ .cache/ .parcel-cache/ .scratchpad/ #MCP Json .tmp/anthropic-import # Anthropic registry temporary files anthropic_servers_*.json curated_import_list.txt #Security scans security_scans/ #Temporary directories .tmp #AgentCore CLI generated files .bedrock_agentcore .bedrock_agentcore.yaml # Terraform user-specific configuration (NEVER COMMIT!) # Users should copy terraform.tfvars.example to terraform.tfvars and edit it terraform.tfvars terraform.tfvars.json override.tf override.tf.json *_override.tf *_override.tf.json .terraform/ .terraform.lock.hcl crash.log crash.*.log tfplan* terraform.tfstate* terraform-outputs.json.backup* # Terraform outputs and region-specific configs (environment-specific, do not commit) terraform-outputs.json terraform-outputs.txt terraform/aws-ecs/scripts/terraform-outputs.json terraform/aws-ecs/terraform-outputs.txt terraform.tfvars.* !terraform.tfvars.example terraform/aws-ecs/terraform.tfvars.* !terraform/aws-ecs/terraform.tfvars.example # Generated image manifest for container builds (generated by Makefile) image-manifest.json# Admin password files *.admin_password terraform/.admin_password image-manifest.json agent_security_scans/ skill_security_scans/ # Helm dependency charts and lock files (fetched via helm dependency build) charts/*/charts/ charts/*/Chart.lock # Shell config artifacts .ash/ # Claude .claude/* !.claude/skills/ .claude/skills/search-registry/ .token? # Telemetry collector build artifacts and state terraform/telemetry-collector/terraform.tfstate terraform/telemetry-collector/terraform.tfstate.backup terraform/telemetry-collector/tfplan terraform/telemetry-collector/terraform-apply.log terraform/telemetry-collector/deployment-info-testing.txt terraform/telemetry-collector/lambda_function.zip terraform/telemetry-collector/lambda/collector/lambda_function_linux.zip terraform/telemetry-collector/lambda/index-setup/index_setup.zip terraform/telemetry-collector/lambda/lambda_function.zip terraform/telemetry-collector/global-bundle.pem terraform/telemetry-collector/terraform.tfvars terraform/telemetry-collector/DEPLOYMENT-SUMMARY.md terraform/telemetry-collector/INTEGRATION-TEST-SUMMARY.md terraform/telemetry-collector/MONITORING-GUIDE.md terraform/telemetry-collector/PROGRESS.md terraform/telemetry-collector/lambda/collector/package/ terraform/telemetry-collector/lambda/index-setup/package/ terraform/telemetry-collector/.terraform/ terraform/telemetry-collector/.terraform.lock.hcl # Vendored Python packages in Lambda directories (build artifacts) terraform/telemetry-collector/lambda/collector/*.dist-info/ terraform/telemetry-collector/lambda/collector/bson/ terraform/telemetry-collector/lambda/collector/dns/ terraform/telemetry-collector/lambda/collector/gridfs/ terraform/telemetry-collector/lambda/collector/motor/ terraform/telemetry-collector/lambda/collector/pymongo/ terraform/telemetry-collector/lambda/collector/pydantic/ terraform/telemetry-collector/lambda/collector/pydantic_core/ terraform/telemetry-collector/lambda/collector/annotated_types/ terraform/telemetry-collector/lambda/collector/typing_inspection/ terraform/telemetry-collector/lambda/collector/typing_extensions.py terraform/telemetry-collector/lambda/collector/boto3/ terraform/telemetry-collector/lambda/collector/botocore/ terraform/telemetry-collector/lambda/collector/dateutil/ terraform/telemetry-collector/lambda/collector/jmespath/ terraform/telemetry-collector/lambda/collector/s3transfer/ terraform/telemetry-collector/lambda/collector/urllib3/ terraform/telemetry-collector/lambda/collector/bin/ terraform/telemetry-collector/lambda/collector/six.py terraform/telemetry-collector/lambda/collector/*.dist-info/ terraform/telemetry-collector/lambda/index-setup/*.dist-info/ terraform/telemetry-collector/lambda/index-setup/bson/ terraform/telemetry-collector/lambda/index-setup/pymongo/ # Root-level telemetry test scripts (not part of the project) test-telemetry-*.sh test-telemetry-*.py verify-telemetry-test.sh watch-collector-logs.sh NEXT-STEPS-TELEMETRY.md .env.telemetry-test registry_metrics.csv .claude/skills/usage-report/known-internal-instances.md ================================================ FILE: .pre-commit-config.yaml ================================================ # Pre-commit hooks for MCP Gateway Registry # Install with: pre-commit install # Run manually: pre-commit run --all-files repos: # Ruff - Fast Python linter and formatter - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.8.2 hooks: # Run the linter with auto-fixes - id: ruff args: [--fix] name: Ruff linter description: Run ruff linter with auto-fixes # Run the formatter - id: ruff-format name: Ruff formatter description: Run ruff formatter # Pre-commit hooks for file quality - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: # Remove trailing whitespace - id: trailing-whitespace name: Trim trailing whitespace description: Remove trailing whitespace # Ensure files end with newline - id: end-of-file-fixer name: Fix end of files description: Ensure files end with a newline # Check YAML syntax - id: check-yaml name: Check YAML description: Validate YAML file syntax exclude: ^(docker/|\.github/) # Check JSON syntax - id: check-json name: Check JSON description: Validate JSON file syntax # Prevent large files from being committed - id: check-added-large-files name: Check for large files description: Prevent files larger than 500KB args: ['--maxkb=500'] # Check for merge conflict markers - id: check-merge-conflict name: Check for merge conflicts description: Check for merge conflict markers # Detect private keys - id: detect-private-key name: Detect private keys description: Check for private SSH keys # Check for case conflicts in filenames - id: check-case-conflict name: Check filename case conflicts description: Check for case conflicts in filenames # Check Python docstrings - id: check-docstring-first name: Check docstring is first description: Ensure docstring comes first in Python files # Check for debugger imports - id: debug-statements name: Check for debugger statements description: Check for pdb and ipdb debugger statements # Detect-secrets - Prevent secrets from being committed - repo: https://github.com/Yelp/detect-secrets rev: v1.5.0 hooks: - id: detect-secrets name: Detect secrets description: Prevent hardcoded secrets from being committed args: ['--baseline', '.secrets.baseline'] exclude: ^(tests/|docs/|cli/examples/) # Bandit - Security vulnerability scanner - repo: https://github.com/PyCQA/bandit rev: '1.8.3' hooks: - id: bandit name: Bandit security scan description: Scan for security vulnerabilities args: ['-c', 'pyproject.toml'] additional_dependencies: ['bandit[toml]'] exclude: ^tests/ # MyPy - Static type checker - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.11.2 hooks: - id: mypy name: MyPy type checking description: Static type checking additional_dependencies: - types-requests - types-PyYAML - pydantic args: [--ignore-missing-imports, --no-strict-optional] exclude: ^(tests/|scripts/) # Local hooks for project-specific checks - repo: local hooks: # Run fast unit tests - id: pytest-fast name: Run fast tests entry: uv run pytest -m "not slow" --tb=short language: system pass_filenames: false always_run: true stages: [commit] # Python syntax check - id: python-syntax name: Check Python syntax entry: python -m py_compile language: system types: [python] # Shell script syntax check - id: shell-syntax name: Check shell script syntax entry: bash -n language: system types: [shell] exclude: ^(docker/|scripts/setup/) # Default stages default_stages: [commit] # Default language version default_language_version: python: python3.14 # Fail fast - stop on first error fail_fast: false # Minimum pre-commit version minimum_pre_commit_version: '2.20.0' ================================================ FILE: .secrets.baseline ================================================ { "version": "1.5.0", "plugins_used": [ { "name": "ArtifactoryDetector" }, { "name": "AWSKeyDetector" }, { "name": "AzureStorageKeyDetector" }, { "name": "Base64HighEntropyString", "limit": 4.5 }, { "name": "BasicAuthDetector" }, { "name": "CloudantDetector" }, { "name": "DiscordBotTokenDetector" }, { "name": "GitHubTokenDetector" }, { "name": "GitLabTokenDetector" }, { "name": "HexHighEntropyString", "limit": 3.0 }, { "name": "IbmCloudIamDetector" }, { "name": "IbmCosHmacDetector" }, { "name": "IPPublicDetector" }, { "name": "JwtTokenDetector" }, { "name": "KeywordDetector", "keyword_exclude": "" }, { "name": "MailchimpDetector" }, { "name": "NpmDetector" }, { "name": "OpenAIDetector" }, { "name": "PrivateKeyDetector" }, { "name": "PypiTokenDetector" }, { "name": "SendGridDetector" }, { "name": "SlackDetector" }, { "name": "SoftlayerDetector" }, { "name": "SquareOAuthDetector" }, { "name": "StripeDetector" }, { "name": "TelegramBotTokenDetector" }, { "name": "TwilioKeyDetector" } ], "filters_used": [ { "path": "detect_secrets.filters.allowlist.is_line_allowlisted" }, { "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", "min_level": 2 }, { "path": "detect_secrets.filters.heuristic.is_indirect_reference" }, { "path": "detect_secrets.filters.heuristic.is_likely_id_string" }, { "path": "detect_secrets.filters.heuristic.is_lock_file" }, { "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" }, { "path": "detect_secrets.filters.heuristic.is_potential_uuid" }, { "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" }, { "path": "detect_secrets.filters.heuristic.is_sequential_string" }, { "path": "detect_secrets.filters.heuristic.is_swagger_file" }, { "path": "detect_secrets.filters.heuristic.is_templated_secret" }, { "path": "detect_secrets.filters.regex.should_exclude_file", "pattern": [ "^(tests/|docs/|cli/examples/|\\.git/)" ] } ], "results": { "api/get-m2m-token.sh": [ { "type": "Secret Keyword", "filename": "api/get-m2m-token.sh", "hashed_secret": "2be88ca4242c76e8253ac62474851065032d6833", "is_verified": false, "line_number": 211 } ], "api/registry_client.py": [ { "type": "Secret Keyword", "filename": "api/registry_client.py", "hashed_secret": "fca71afec681b7c2932610046e8e524820317e47", "is_verified": false, "line_number": 268 } ], "api/registry_management.py": [ { "type": "Secret Keyword", "filename": "api/registry_management.py", "hashed_secret": "fca71afec681b7c2932610046e8e524820317e47", "is_verified": false, "line_number": 1519 }, { "type": "Secret Keyword", "filename": "api/registry_management.py", "hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155", "is_verified": false, "line_number": 1733 } ], "api/test-management-api-e2e.md": [ { "type": "Secret Keyword", "filename": "api/test-management-api-e2e.md", "hashed_secret": "b60c1b0150f701d3ea5375a34a43e3e9b63ada2c", "is_verified": false, "line_number": 65 } ], "auth_server/.env.template": [ { "type": "Secret Keyword", "filename": "auth_server/.env.template", "hashed_secret": "1bb9fef4dcaec0c4c0ba677e927f904500ab6c4b", "is_verified": false, "line_number": 11 }, { "type": "Secret Keyword", "filename": "auth_server/.env.template", "hashed_secret": "29b8dca3de5ff27bcf8bd3b622adf9970f29381c", "is_verified": false, "line_number": 23 } ], "build_and_run.sh": [ { "type": "Secret Keyword", "filename": "build_and_run.sh", "hashed_secret": "c35bdb821a941808a150db95d0f934f449bbff17", "is_verified": false, "line_number": 433 } ], "charts/auth-server/values.yaml": [ { "type": "Secret Keyword", "filename": "charts/auth-server/values.yaml", "hashed_secret": "8d44de1035672968b3e922b3d15e08c1dce4f9b6", "is_verified": false, "line_number": 12 } ], "charts/keycloak-configure/templates/configmap.yaml": [ { "type": "Secret Keyword", "filename": "charts/keycloak-configure/templates/configmap.yaml", "hashed_secret": "5ffe533b830f08a0326348a9160afafc8ada44db", "is_verified": false, "line_number": 95 }, { "type": "Secret Keyword", "filename": "charts/keycloak-configure/templates/configmap.yaml", "hashed_secret": "9723444fb302ebd3cac2b5e5f0a1ade0d40c03c7", "is_verified": false, "line_number": 724 } ], "charts/mcp-gateway-registry-stack/README.md": [ { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/README.md", "hashed_secret": "2d5978d21d2072d7922a49935dcb363378eab0bc", "is_verified": false, "line_number": 118 } ], "charts/mcp-gateway-registry-stack/templates/mongodb-cluster.yaml": [ { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/templates/mongodb-cluster.yaml", "hashed_secret": "7d4295ea62a0fb8fb7f8f5707db8cd4db689d9c2", "is_verified": false, "line_number": 26 } ], "charts/mcp-gateway-registry-stack/templates/oauth-provider-secret.yaml": [ { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/templates/oauth-provider-secret.yaml", "hashed_secret": "e3568c17ddb547dd50c4b4990152e9ad46ac29ea", "is_verified": false, "line_number": 42 } ], "charts/mcp-gateway-registry-stack/templates/shared-secret.yaml": [ { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/templates/shared-secret.yaml", "hashed_secret": "e3568c17ddb547dd50c4b4990152e9ad46ac29ea", "is_verified": false, "line_number": 12 }, { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/templates/shared-secret.yaml", "hashed_secret": "94c6c8fdccfc8f4fe660af892feaabdc8d8d2201", "is_verified": false, "line_number": 14 } ], "charts/mcp-gateway-registry-stack/values.yaml": [ { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/values.yaml", "hashed_secret": "76ed0a056aa77060de25754586440cff390791d0", "is_verified": false, "line_number": 18 }, { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/values.yaml", "hashed_secret": "f880fa90169f5214a7e9c6a817b3f31aeb71f5c7", "is_verified": false, "line_number": 22 }, { "type": "Secret Keyword", "filename": "charts/mcp-gateway-registry-stack/values.yaml", "hashed_secret": "54053db99b49b4cc046f7b4854a80de3d6dfae71", "is_verified": false, "line_number": 70 } ], "charts/mcpgw/values.yaml": [ { "type": "Secret Keyword", "filename": "charts/mcpgw/values.yaml", "hashed_secret": "aa90ae690498f4d84834974d12a9990b594e338e", "is_verified": false, "line_number": 12 } ], "charts/mongodb-configure/templates/configmap.yaml": [ { "type": "Secret Keyword", "filename": "charts/mongodb-configure/templates/configmap.yaml", "hashed_secret": "3442496b96dd01591a8cd44b1eec1368ab728aba", "is_verified": false, "line_number": 226 } ], "charts/mongodb-configure/values.yaml": [ { "type": "Secret Keyword", "filename": "charts/mongodb-configure/values.yaml", "hashed_secret": "54053db99b49b4cc046f7b4854a80de3d6dfae71", "is_verified": false, "line_number": 15 } ], "charts/registry/values.yaml": [ { "type": "Secret Keyword", "filename": "charts/registry/values.yaml", "hashed_secret": "c83acc39662eea92bcfbd9dc69d4dbe5fc0f2951", "is_verified": false, "line_number": 12 } ], "cli/mcp_security_scanner.py": [ { "type": "Secret Keyword", "filename": "cli/mcp_security_scanner.py", "hashed_secret": "80bcbe9821472b00da2dcece9bf1f7ee27acf22c", "is_verified": false, "line_number": 31 } ], "cli/src/utils/cost.json": [ { "type": "Base64 High Entropy String", "filename": "cli/src/utils/cost.json", "hashed_secret": "0e58cba3de592ca22002e9b5a355102bfc738f05", "is_verified": false, "line_number": 3142 }, { "type": "Base64 High Entropy String", "filename": "cli/src/utils/cost.json", "hashed_secret": "9b45b018ce366a8d8b440df12fadc183406c92d6", "is_verified": false, "line_number": 7148 }, { "type": "Base64 High Entropy String", "filename": "cli/src/utils/cost.json", "hashed_secret": "4ad9c5ebcdbd110afa5ca680854dd5bd72314bb8", "is_verified": false, "line_number": 7453 }, { "type": "Base64 High Entropy String", "filename": "cli/src/utils/cost.json", "hashed_secret": "8927d5a0b386ac18deffa37f02fd808f3fb8bcbd", "is_verified": false, "line_number": 8488 }, { "type": "Base64 High Entropy String", "filename": "cli/src/utils/cost.json", "hashed_secret": "c8883fc592bf698b29fd2304fa1ad570df1f9abf", "is_verified": false, "line_number": 14119 }, { "type": "Base64 High Entropy String", "filename": "cli/src/utils/cost.json", "hashed_secret": "61da47b9d42215793e5604b478982f4cb21fdee1", "is_verified": false, "line_number": 20303 }, { "type": "Base64 High Entropy String", "filename": "cli/src/utils/cost.json", "hashed_secret": "aa684a0841bf2d1fd7e9b774262fcddc9920ffc6", "is_verified": false, "line_number": 20388 } ], "cli/user_mgmt.sh": [ { "type": "Secret Keyword", "filename": "cli/user_mgmt.sh", "hashed_secret": "2be88ca4242c76e8253ac62474851065032d6833", "is_verified": false, "line_number": 244 } ], "credentials-provider/entra/generate_tokens.py": [ { "type": "Secret Keyword", "filename": "credentials-provider/entra/generate_tokens.py", "hashed_secret": "c303df00cd0a72b21c62900b758b06fc541664ce", "is_verified": false, "line_number": 327 } ], "frontend/e2e/helpers/auth.ts": [ { "type": "Secret Keyword", "filename": "frontend/e2e/helpers/auth.ts", "hashed_secret": "d033e22ae348aeb5660fc2140aec35850c4da997", "is_verified": false, "line_number": 7 } ], "frontend/src/components/IAMUsers.tsx": [ { "type": "Secret Keyword", "filename": "frontend/src/components/IAMUsers.tsx", "hashed_secret": "6c56a9249cba324d029f725f1f7c0e47184e2dcf", "is_verified": false, "line_number": 111 } ], "frontend/src/pages/Login.tsx": [ { "type": "Secret Keyword", "filename": "frontend/src/pages/Login.tsx", "hashed_secret": "6c56a9249cba324d029f725f1f7c0e47184e2dcf", "is_verified": false, "line_number": 93 }, { "type": "Secret Keyword", "filename": "frontend/src/pages/Login.tsx", "hashed_secret": "73e350f9131d07e887b1e22e114101a90d44ebb0", "is_verified": false, "line_number": 95 } ], "keycloak/README.md": [ { "type": "Secret Keyword", "filename": "keycloak/README.md", "hashed_secret": "534c57bf48f9277e7ee50c5febcdb3dab99f0051", "is_verified": false, "line_number": 12 }, { "type": "Secret Keyword", "filename": "keycloak/README.md", "hashed_secret": "001c1654cb8dff7c4ddb1ae6d2203d0dd15a6096", "is_verified": false, "line_number": 13 }, { "type": "Secret Keyword", "filename": "keycloak/README.md", "hashed_secret": "354b3a4b7715d3694c88a4fa7db49e41de86568e", "is_verified": false, "line_number": 82 }, { "type": "Secret Keyword", "filename": "keycloak/README.md", "hashed_secret": "7b0e6379ca79d9a02abc556232d503a86c37012e", "is_verified": false, "line_number": 83 }, { "type": "Secret Keyword", "filename": "keycloak/README.md", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, "line_number": 165 } ], "keycloak/setup/disable-ssl.sh": [ { "type": "Secret Keyword", "filename": "keycloak/setup/disable-ssl.sh", "hashed_secret": "6eef6648406c333a4035cd5e60d0bf2ecf2606d7", "is_verified": false, "line_number": 80 } ], "keycloak/setup/get-all-client-credentials.sh": [ { "type": "Secret Keyword", "filename": "keycloak/setup/get-all-client-credentials.sh", "hashed_secret": "08d2e98e6754af941484848930ccbaddfefe13d6", "is_verified": false, "line_number": 104 } ], "keycloak/setup/setup-federation-service-account.sh": [ { "type": "Secret Keyword", "filename": "keycloak/setup/setup-federation-service-account.sh", "hashed_secret": "45d676e7c6ab44cf4b8fa366ef2d8fccd3e6d6e6", "is_verified": false, "line_number": 17 }, { "type": "Secret Keyword", "filename": "keycloak/setup/setup-federation-service-account.sh", "hashed_secret": "2be88ca4242c76e8253ac62474851065032d6833", "is_verified": false, "line_number": 156 } ], "metrics-service/add_test_key.py": [ { "type": "Hex High Entropy String", "filename": "metrics-service/add_test_key.py", "hashed_secret": "41bc5baca453bd6dc49f421ece29f5d57bb581bb", "is_verified": false, "line_number": 13 } ], "metrics-service/docs/README.md": [ { "type": "Secret Keyword", "filename": "metrics-service/docs/README.md", "hashed_secret": "b310da45b1ebf444106a41b7832ab2fbe25dab41", "is_verified": false, "line_number": 446 } ], "metrics-service/tests/conftest.py": [ { "type": "Secret Keyword", "filename": "metrics-service/tests/conftest.py", "hashed_secret": "bd33830043487aed705b9aff291a77d69f27adb3", "is_verified": false, "line_number": 98 } ], "metrics-service/tests/test_auth.py": [ { "type": "Hex High Entropy String", "filename": "metrics-service/tests/test_auth.py", "hashed_secret": "244f421f896bdcdd2784dccf4eaf7c8dfd5189b5", "is_verified": false, "line_number": 151 }, { "type": "Secret Keyword", "filename": "metrics-service/tests/test_auth.py", "hashed_secret": "52adafa10bb9e78a57950036e8b266c51ef8ef88", "is_verified": false, "line_number": 243 } ], "registry/constants.py": [ { "type": "Secret Keyword", "filename": "registry/constants.py", "hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155", "is_verified": false, "line_number": 46 } ], "registry/embeddings/README.md": [ { "type": "Secret Keyword", "filename": "registry/embeddings/README.md", "hashed_secret": "235ca8ecd22dbaae08d2971367bebdc1d1bd0224", "is_verified": false, "line_number": 65 } ], "registry/utils/credential_encryption.py": [ { "type": "Secret Keyword", "filename": "registry/utils/credential_encryption.py", "hashed_secret": "665b1e3851eefefa3fb878654292f16597d25155", "is_verified": false, "line_number": 211 } ], "release-notes/v1.0.9.md": [ { "type": "Basic Auth Credentials", "filename": "release-notes/v1.0.9.md", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, "line_number": 104 } ], "scripts/init-mongodb.sh": [ { "type": "Secret Keyword", "filename": "scripts/init-mongodb.sh", "hashed_secret": "d033e22ae348aeb5660fc2140aec35850c4da997", "is_verified": false, "line_number": 27 } ], "scripts/refresh_m2m_token.sh": [ { "type": "Secret Keyword", "filename": "scripts/refresh_m2m_token.sh", "hashed_secret": "2be88ca4242c76e8253ac62474851065032d6833", "is_verified": false, "line_number": 49 } ], "servers/fininfo/README.md": [ { "type": "Secret Keyword", "filename": "servers/fininfo/README.md", "hashed_secret": "af2fdf068ba0c919287d6931c8dc993edaf01f3b", "is_verified": false, "line_number": 24 } ], "terraform/aws-ecs/README.md": [ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/README.md", "hashed_secret": "4d0d3c53f51abc7660789000a958332860aa8280", "is_verified": false, "line_number": 335 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/README.md", "hashed_secret": "145f85ed29830a933e12fb56dcfb94ce29172f65", "is_verified": false, "line_number": 336 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/README.md", "hashed_secret": "19a4df734b1b7b83858d6002352ba67c91f1f4b5", "is_verified": false, "line_number": 359 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/README.md", "hashed_secret": "8b603b119fa2980e0e6d3b186fe5e7c02d9d9bd1", "is_verified": false, "line_number": 429 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/README.md", "hashed_secret": "c303df00cd0a72b21c62900b758b06fc541664ce", "is_verified": false, "line_number": 803 } ], "terraform/aws-ecs/documentdb-elastic.tf.disabled": [ { "type": "Basic Auth Credentials", "filename": "terraform/aws-ecs/documentdb-elastic.tf.disabled", "hashed_secret": "347cd9c53ff77d41a7b22aa56c7b4efaf54658e3", "is_verified": false, "line_number": 226 } ], "terraform/aws-ecs/documentdb.tf": [ { "type": "Basic Auth Credentials", "filename": "terraform/aws-ecs/documentdb.tf", "hashed_secret": "347cd9c53ff77d41a7b22aa56c7b4efaf54658e3", "is_verified": false, "line_number": 356 } ], "terraform/aws-ecs/keycloak-database.tf": [ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/keycloak-database.tf", "hashed_secret": "f8be3d043f32db05fe41961eb713644aa21b6222", "is_verified": false, "line_number": 13 } ], "terraform/aws-ecs/modules/mcp-gateway/secrets.tf": [ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/modules/mcp-gateway/secrets.tf", "hashed_secret": "be4c27293b0757101cbef01b36ac78028aefc399", "is_verified": false, "line_number": 56 } ], "terraform/aws-ecs/scripts/init-keycloak.sh": [ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/init-keycloak.sh", "hashed_secret": "2be88ca4242c76e8253ac62474851065032d6833", "is_verified": false, "line_number": 1103 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/init-keycloak.sh", "hashed_secret": "e3eba309413812b94096a6477501e13853a616b4", "is_verified": false, "line_number": 1124 } ], "terraform/aws-ecs/scripts/post-deployment-setup.sh": [ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/post-deployment-setup.sh", "hashed_secret": "6eef6648406c333a4035cd5e60d0bf2ecf2606d7", "is_verified": false, "line_number": 469 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/post-deployment-setup.sh", "hashed_secret": "e3eba309413812b94096a6477501e13853a616b4", "is_verified": false, "line_number": 487 } ], "terraform/aws-ecs/scripts/run-documentdb-cli.sh": [ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/run-documentdb-cli.sh", "hashed_secret": "6eef6648406c333a4035cd5e60d0bf2ecf2606d7", "is_verified": false, "line_number": 178 } ], "terraform/aws-ecs/scripts/run-documentdb-init.sh": [ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/run-documentdb-init.sh", "hashed_secret": "6eef6648406c333a4035cd5e60d0bf2ecf2606d7", "is_verified": false, "line_number": 179 } ], "terraform/aws-ecs/scripts/user_mgmt.sh": [ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/scripts/user_mgmt.sh", "hashed_secret": "2be88ca4242c76e8253ac62474851065032d6833", "is_verified": false, "line_number": 261 } ], "terraform/aws-ecs/setup-documentdb-env.sh": [ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/setup-documentdb-env.sh", "hashed_secret": "d4758e20bc459a501939d69dd4bfa383debac93a", "is_verified": false, "line_number": 20 } ], "terraform/aws-ecs/terraform.tfvars.example": [ { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", "hashed_secret": "b81a4503bd668cde97ef070bfe9cf2baca9872e0", "is_verified": false, "line_number": 53 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", "hashed_secret": "f60d623e416a938ffa3a98bba1d5cdcd38eba18a", "is_verified": false, "line_number": 57 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", "hashed_secret": "01b1a021a74c4b51fe616e4c1487962a96ccaa78", "is_verified": false, "line_number": 184 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", "hashed_secret": "4d0d3c53f51abc7660789000a958332860aa8280", "is_verified": false, "line_number": 201 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", "hashed_secret": "5fe9c3b9f7d89f322a2b0749e74652ec152c05c3", "is_verified": false, "line_number": 205 }, { "type": "Base64 High Entropy String", "filename": "terraform/aws-ecs/terraform.tfvars.example", "hashed_secret": "e5575d5cd84e9e2f6620e721e2b71b88cdb47bba", "is_verified": false, "line_number": 234 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", "hashed_secret": "c303df00cd0a72b21c62900b758b06fc541664ce", "is_verified": false, "line_number": 299 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", "hashed_secret": "a6778f1880744bd1a342a8e3789135412d8f9da2", "is_verified": false, "line_number": 354 }, { "type": "Secret Keyword", "filename": "terraform/aws-ecs/terraform.tfvars.example", "hashed_secret": "788b6b2bfd50bb3353254fb8a62d7388cf6f7aa6", "is_verified": false, "line_number": 355 } ] }, "generated_at": "2026-03-10T06:00:15Z" } ================================================ FILE: .semgrepignore ================================================ # Documentation - contains example credentials and test data docs/ *.md # Test files - contains test credentials and mock data **/test/** **/tests/** *test*.py *test*.sh *test*.ts *test*.js cli/test_*.py cli/test_*.sh agents/*/test/ # Test configurations docker-compose.federation-test.yml # Reports and build artifacts - not source code *.json **/semgrep_report.json **/bandit_report.json build/ dist/ .pytest_cache/ *.log # Node modules and dependencies node_modules/ .venv/ venv/ ENV/ # CI/CD and generated files .github/workflows/ *.pyc __pycache__/ ================================================ FILE: CLAUDE.md ================================================ # Claude Coding Rules ## Overview This document contains coding standards and best practices that must be followed for all code development. These rules prioritize maintainability, simplicity, and modern Python development practices. ## Core Principles - Write code with minimal complexity for maximum maintainability and clarity - Choose simple, readable solutions over clever or complex implementations - Prioritize code that any team member can confidently understand, modify, and debug ## Pull Request Evaluation When evaluating pull requests for merge, adopt the **Merge Specialist** persona defined in [TEAM.md](TEAM.md). This persona provides comprehensive guidelines for: - Running and verifying tests - Assessing code quality against these standards - Reviewing architecture and design decisions - Checking for breaking changes - Evaluating performance impact - Ensuring documentation is complete **IMPORTANT**: Before approving any PR for merge, the Merge Specialist must verify that all tests pass and no existing functionality is broken. A PR with failing tests should NEVER be approved for merge. ## Technology Stack ### Package Management - Always use `uv` and `pyproject.toml` for package management - Never use `pip` directly ### Modern Python Libraries - **Data Processing**: Use `polars` instead of `pandas` - **Web APIs**: Use `fastapi` instead of `flask` - **Code Formatting/Linting**: Use `ruff` for both linting and formatting - **Type Checking**: Use `mypy` - type checks have become actually useful and should be part of CI/CD - **Performance**: Leverage modern CPython improvements - CPython is now much faster ## Code Style Guidelines ### Function Structure - All internal/private functions must start with an underscore (`_`) - Private functions should be placed at the top of the file, followed by public functions - Functions should be modular, containing no more than 30-50 lines - Use two blank lines between function definitions - One function parameter per line for better readability ### Type Annotations - Use clear type annotations for all function parameters - One function parameter per line for better readability - Use modern Python 3.10+ type hint syntax (PEP 604/585) - Example: ```python def process_data( input_file: str, output_format: str, validate: bool = True ) -> dict[str, Any]: pass ``` ### Modern Type Hint Standards (Python 3.10+) **IMPORTANT**: This codebase uses modern Python 3.10+ type hint syntax (PEP 604 and PEP 585). Always use built-in types instead of importing from `typing` module. #### PEP 604: Union Types with `|` Use `X | None` instead of `Optional[X]`: ```python # Good - Modern syntax (Python 3.10+) def process_data( sample_size: int | None = None, language: str | None = None ) -> list[dict[str, Any]]: pass # Avoid - Legacy syntax from typing import Optional, List, Dict, Any def process_data( sample_size: Optional[int] = None, language: Optional[str] = None ) -> List[Dict[str, Any]]: pass ``` #### PEP 585: Built-in Generic Types Use `list`, `dict`, `tuple`, `set` directly instead of importing from `typing`: ```python # Good - Built-in generic types def process_items( data: list[dict[str, Any]], filters: set[str], metadata: tuple[str, int] ) -> dict[str, list[Any]]: pass # Avoid - typing module imports from typing import List, Dict, Set, Tuple, Any def process_items( data: List[Dict[str, Any]], filters: Set[str], metadata: Tuple[str, int] ) -> Dict[str, List[Any]]: pass ``` #### Type Hint Migration Examples **Example 1: Optional Parameters** ```python # Old style from typing import Optional def get_user(user_id: int, token: Optional[str] = None) -> Optional[dict]: pass # New style - no imports needed def get_user(user_id: int, token: str | None = None) -> dict | None: pass ``` **Example 2: Complex Types** ```python # Old style from typing import List, Dict, Optional, Tuple def process_samples( sample_size: Optional[int] = None, language: Optional[str] = None ) -> List[dict]: """Process dataset samples. Args: sample_size: Number of samples. None uses default, 0 means all. language: Language filter. None means all languages. """ if sample_size == 0: return process_all() elif sample_size is None: sample_size = DEFAULT_SAMPLE_SIZE return process_with_size(sample_size) # New style - cleaner and more Pythonic def process_samples( sample_size: int | None = None, language: str | None = None ) -> list[dict[str, Any]]: """Process dataset samples. Args: sample_size: Number of samples. None uses default, 0 means all. language: Language filter. None means all languages. """ if sample_size == 0: return process_all() elif sample_size is None: sample_size = DEFAULT_SAMPLE_SIZE return process_with_size(sample_size) ``` **Example 3: Nested Generic Types** ```python # Old style from typing import Dict, List, Tuple, Optional def get_user_data( user_id: int ) -> Optional[Dict[str, List[Tuple[str, int]]]]: pass # New style - much cleaner def get_user_data( user_id: int ) -> dict[str, list[tuple[str, int]]] | None: pass ``` #### Benefits of Modern Type Hints 1. **Fewer imports**: No need to import from `typing` for basic types 2. **More readable**: `X | None` is clearer than `Optional[X]` 3. **Consistent with Python evolution**: PEP 585 and PEP 604 are the future 4. **Better IDE support**: Native type inference without imports 5. **Simpler syntax**: Less typing, easier to understand ### Class Definitions with Pydantic - Consider using Pydantic BaseModel for all class definitions to leverage validation, serialization, and other powerful features - Pydantic provides automatic validation, type coercion, and serialization capabilities - Use modern type hints (PEP 604/585) in Pydantic models - Example: ```python from pydantic import BaseModel, Field, validator class UserConfig(BaseModel): """User configuration settings.""" username: str = Field(..., min_length=3, max_length=50) email: str = Field(..., regex=r'^[\w\.-]+@[\w\.-]+\.\w+$') timeout_seconds: int = Field(default=30, ge=1, le=300) debug_enabled: bool = False tags: list[str] = Field(default_factory=list) metadata: dict[str, str] | None = None @validator('username') def username_alphanumeric(cls, v: str) -> str: if not v.replace('_', '').isalnum(): raise ValueError('Username must be alphanumeric') return v.lower() ``` ### Main Function Pattern - The main function should act as a control flow orchestrator - Parse command line arguments and delegate to other functions - Avoid implementing business logic directly in main() ### Command-Line Interface Design When creating CLI applications: 1. **Use argparse with comprehensive help**: ```python parser = argparse.ArgumentParser( description="Clear description of what the tool does", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Example usage: # Basic usage uv run python -m module --param value # With environment variable export PARAM=value uv run python -m module """ ) ``` 2. **Support both CLI args and environment variables**: ```python def _get_config_value(cli_value: Optional[str] = None) -> str: if cli_value: return cli_value env_value = os.getenv("CONFIG_VAR") if env_value: return env_value raise ValueError("Value must be provided via --param or CONFIG_VAR env var") ``` 3. **Provide sensible defaults**: ```python parser.add_argument( "--sample-size", type=int, help=f"Number of samples (default: {DEFAULT_SIZE}). Use 0 for all", ) ``` 4. **Use special values for "all" options**: ```python if sample_size == 0 or sample_size is None: # Process entire dataset else: # Process sample ``` ### Imports - Write imports as multi-line imports for better readability - Example: ```python from .services.output_formatter import ( _display_evaluation_results, _print_results_summary, _check_mcp_generation_criteria ) ``` ### Constants - Don't hard code constants within functions - For trivial constants, declare them at the top of the file: ```python STARTUP_DELAY: int = 10 MAX_RETRIES: int = 3 ``` - For many constants, create a separate `constants.py` file with a class structure ### Logging Configuration - Always use the following logging configuration: ```python import logging # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) ``` ### Logging Best Practices - Add sufficient log messages throughout the code to aid in debugging and monitoring - Don't shy away from adding debug logs using `logging.debug()` for detailed tracing - When printing a dictionary as part of a trace message, always pretty print it: ```python logger.info(f"Processing data:\n{json.dumps(data_dict, indent=2, default=str)}") ``` - Consider adding a `--debug` flag to the application that sets the logging level to DEBUG: ```python if args.debug: logging.getLogger().setLevel(logging.DEBUG) ``` ### Performance Feedback Provide users with feedback on long-running operations: 1. **Display elapsed time after completion**: ```python start_time = time.time() # ... perform operation ... elapsed_time = time.time() - start_time minutes = int(elapsed_time // 60) seconds = elapsed_time % 60 if minutes > 0: logger.info(f"Completed in {minutes} minutes and {seconds:.1f} seconds") else: logger.info(f"Completed in {seconds:.1f} seconds") ``` 2. **Warn about potentially long operations**: ```python if processing_full_dataset: logger.warning("Processing FULL dataset. This may take a long time.") else: logger.info(f"Processing {sample_size} samples.") ``` 3. **Show configuration at startup**: ```python logger.info(f"Configuration: {config.model_dump()}") ``` ### Performance Optimization - Use `@lru_cache` decorator where appropriate for expensive computations ### External Resource Management When working with external data sources (APIs, datasets, databases): 1. **Version/pin external dependencies**: ```python # Specify exact versions or commits for reproducibility API_VERSION = "v2" SCHEMA_VERSION = "2024-01-15" ``` 2. **Document external resources in code**: ```python # Constants file with clear documentation DATA_SOURCE: str = "source-name" # Documentation URL: https://... API_ENDPOINT: str = "https://api.example.com/v1" # API docs: https://... ``` 3. **Handle data filtering and edge cases gracefully**: ```python def load_filtered_data( filters: Dict[str, Any], limit: Optional[int] = None ) -> List[dict]: data = fetch_from_source() # Apply filters with clear feedback for key, value in filters.items(): filtered = [item for item in data if item.get(key) == value] logger.info(f"Filter '{key}={value}': {len(data)} -> {len(filtered)} items") data = filtered if not data: raise ValueError(f"No data found matching filters: {filters}") # Handle size limits if limit and len(data) < limit: logger.warning(f"Only {len(data)} items available (requested: {limit})") return data[:limit] if limit else data ``` 4. **Provide actionable error messages**: ```python if not data: raise ValueError( f"No data retrieved from {DATA_SOURCE}. " f"Check connection and credentials. " f"Documentation: {DOCS_URL}" ) ``` ### Decorators and Functional Patterns #### Guidelines for Using Decorators and Functional Patterns Appropriately **Use Decorators When:** - They're built-in or widely known (`@property`, `@staticmethod`, `@dataclass`) - They have a single, clear purpose (`@login_required`, `@cache`) - They don't change function behavior dramatically Example - Good use of decorators: ```python # Good - clear, single purpose @dataclass class User: name: str email: str @lru_cache(maxsize=128) def expensive_calculation(n: int) -> int: return sum(i**2 for i in range(n)) ``` **Use Functional Patterns When:** - Simple transformations are clearer than loops - You need pure functions for testing - The functional approach is more readable Example - Good use of functional patterns: ```python # Good - simple and clear numbers = [1, 2, 3, 4, 5] squared = [n**2 for n in numbers] evens = [n for n in numbers if n % 2 == 0] # Good - simple map operation names = ["alice", "bob", "charlie"] capitalized = list(map(str.capitalize, names)) ``` **Avoid When:** - You're chaining multiple complex operations - The code requires explaining how it works - An entry-level developer would struggle to modify it - You're using advanced functional programming concepts Example - Avoid complex patterns: ```python # Bad - too complex, hard to understand result = reduce(lambda x, y: x + y, filter(lambda x: x % 2 == 0, map(lambda x: x**2, range(10)))) # Good - clear and simple total = 0 for i in range(10): squared = i ** 2 if squared % 2 == 0: total += squared ``` #### Avoid Deep Nesting - Limit nesting to 2-3 levels maximum - Extract nested logic into well-named functions - Use early returns to reduce nesting Example - Reducing nesting: ```python # Bad - too much nesting def process_data(data): if data: if data.get("users"): for user in data["users"]: if user.get("active"): if user.get("email"): send_email(user["email"]) # Good - reduced nesting with early returns def process_data(data): if not data: return users = data.get("users", []) if not users: return for user in users: _process_active_user(user) def _process_active_user(user): if not user.get("active"): return email = user.get("email") if email: send_email(email) ``` ### Code Validation - Always run `uv run python -m py_compile ` after making changes to Python files - Always run `bash -n ` after making changes to bash/shell scripts to check syntax ## Error Handling and Exceptions ### Exception Handling Principles - Use specific exception types, avoid bare `except:` clauses - Always log exceptions with proper context - Fail fast and fail clearly - don't suppress errors silently - Use custom exceptions for domain-specific errors ### Exception Pattern ```python import logging logger = logging.getLogger(__name__) class DomainSpecificError(Exception): """Base exception for our application""" pass def process_data(data: dict) -> dict: try: # Process data result = _validate_and_transform(data) return result except ValidationError as e: logger.error(f"Validation failed for data: {e}") raise DomainSpecificError(f"Invalid input data: {e}") from e except Exception as e: logger.exception("Unexpected error in process_data") raise ``` ### Error Messages - Write clear, actionable error messages - Include context about what was being attempted - Suggest possible solutions when appropriate ## Testing Standards ### Testing Framework - Use `pytest` as the primary testing framework - Maintain minimum 80% code coverage - Use `pytest-cov` for coverage reporting ### Test Structure ```python import pytest from unittest.mock import Mock, patch class TestFeatureName: """Tests for feature_name module""" def test_happy_path(self): """Test normal operation with valid inputs""" # Arrange input_data = {"key": "value"} # Act result = function_under_test(input_data) # Assert assert result["status"] == "success" def test_edge_case(self): """Test boundary conditions""" pass def test_error_handling(self): """Test error scenarios""" with pytest.raises(ValueError, match="Invalid input"): function_under_test(None) ``` ### Testing Best Practices - Follow AAA pattern: Arrange, Act, Assert - One assertion per test when possible - Use descriptive test names that explain what is being tested - Mock external dependencies - Use fixtures for common test data - Test both happy paths and error cases ### Running Tests Before Pull Requests **CRITICAL**: Always run the full test suite before submitting a pull request or after completing a major feature. #### When to Run Tests 1. **Before submitting a pull request**: All tests must pass before creating a PR 2. **After completing a major feature**: Verify no regressions were introduced 3. **After making significant refactoring changes**: Ensure existing functionality still works 4. **After updating dependencies**: Verify compatibility with new versions #### How to Run Tests Run the complete test suite with parallel execution: ```bash # Run all tests in parallel (using 8 workers) uv run pytest tests/ -n 8 # Expected output (as of 2026-01-06): # - 701 passed # - 57 skipped # - Coverage: ~39.50% # - Execution time: ~30 seconds ``` #### Test Execution Options ```bash # Run tests serially (slower, but uses less memory) uv run pytest tests/ # Run only unit tests uv run pytest tests/unit/ # Run only integration tests uv run pytest tests/integration/ # Run with verbose output uv run pytest tests/ -n 8 -v # Run and stop at first failure uv run pytest tests/ -n 8 -x # Run with coverage report uv run pytest tests/ -n 8 --cov=registry --cov-report=term-missing ``` #### Test Prerequisites Before running tests, ensure: 1. **MongoDB is running** (for integration tests): ```bash docker ps | grep mongo # Should show: mcp-mongodb running on 0.0.0.0:27017 ``` 2. **Test environment is configured**: - Tests automatically set `DOCUMENTDB_HOST=localhost` - Tests use `mongodb-ce` storage backend - Tests use `directConnection=true` for single-node MongoDB #### Continuous Integration Tests run automatically via GitHub Actions when: - Pull requests are created targeting `main` or `develop` branches - Code is pushed to `main` or `develop` branches See [.github/workflows/registry-test.yml](.github/workflows/registry-test.yml:7-8) for CI configuration. #### Acceptable Test Results - **All unit tests must pass** (no failures allowed in unit tests) - **Integration tests**: Some tests may be skipped due to known issues - **Coverage**: Minimum 35% coverage required (configured in pyproject.toml:87) - **Warnings**: Minor warnings are acceptable, but investigate new warnings #### What to Do If Tests Fail 1. Review the test failure output carefully 2. Fix the failing test(s) before submitting PR 3. Re-run tests to verify the fix 4. Never submit a PR with failing tests 5. If a test failure is unrelated to your changes, investigate and fix it or document why it should be skipped ## Async/Await Best Practices ### Async Code Structure ```python import asyncio from typing import List async def fetch_data(url: str) -> dict: """Fetch data from URL asynchronously""" async with aiohttp.ClientSession() as session: async with session.get(url) as response: return await response.json() async def process_urls(urls: List[str]) -> List[dict]: """Process multiple URLs concurrently""" tasks = [fetch_data(url) for url in urls] return await asyncio.gather(*tasks, return_exceptions=True) ``` ### Async Guidelines - Use `async with` for async context managers - Use `asyncio.gather()` for concurrent operations - Handle exceptions in async code properly - Don't mix blocking and async code - Use `asyncio.run()` to run async functions from sync code ## Documentation Standards ### Docstring Format Use Google-style docstrings: ```python def calculate_metrics( data: List[float], threshold: float = 0.5 ) -> Dict[str, float]: """Calculate statistical metrics for the given data. Args: data: List of numerical values to analyze threshold: Minimum value to include in calculations Returns: Dictionary containing calculated metrics: - mean: Average value - std: Standard deviation - count: Number of values above threshold Raises: ValueError: If data is empty or contains non-numeric values Example: >>> metrics = calculate_metrics([1.0, 2.0, 3.0]) >>> print(metrics['mean']) 2.0 """ pass ``` ### Documentation Requirements - All public functions must have docstrings - Include type hints in function signatures - Document exceptions that can be raised - Provide usage examples for complex functions - Keep docstrings up-to-date with code changes ## Security Guidelines ### Input Validation - Always validate and sanitize user inputs - Use Pydantic models for request/response validation - Never trust external data ### Secrets Management ```python import os from typing import Optional def get_secret(key: str, default: Optional[str] = None) -> str: """Retrieve secret from environment variable. Never hardcode secrets in source code. """ value = os.environ.get(key, default) if value is None: raise ValueError(f"Required secret '{key}' not found in environment") return value ``` ### Security Best Practices - Never log sensitive information (passwords, tokens, PII) - Use environment variables for configuration - Validate all inputs, especially from external sources - Use parameterized queries for database operations - Keep dependencies updated for security patches ### Security Scanning with Bandit - Run Bandit regularly as part of the development workflow - Handle false positives with `# nosec` comments and clear justification - Common patterns to handle: ```python # When using random for ML reproducibility (not cryptography) # This is not for security/cryptographic purposes - nosec B311 random.seed(random_seed) samples = random.sample(dataset, size) # nosec B311 # When loading from trusted sources with version pinning # This is acceptable for evaluation tools using well-known datasets - nosec B615 ds = load_dataset(DATASET_NAME, revision="main") # nosec B615 ``` - Run security scans with: `uv run bandit -r src/` ### Server Binding Security - When starting a server, never bind it to `0.0.0.0` unless absolutely necessary - Prefer binding to `127.0.0.1` for local-only access - If external access is needed, bind to the specific private IP address: ```python # Bad - exposes to all interfaces app.run(host="0.0.0.0", port=8000) # Good - local only app.run(host="127.0.0.1", port=8000) # Good - specific private IP import socket private_ip = socket.gethostbyname(socket.gethostname()) app.run(host=private_ip, port=8000) ``` ### Subprocess Security Guidelines When using the `subprocess` module, follow these security patterns to prevent Bandit B603/B607 findings and avoid shell injection vulnerabilities. #### ✅ ALWAYS Use List Form (Not String Commands) ```python # Good - list form prevents shell injection result = subprocess.run( ["nginx", "-s", "reload"], capture_output=True, text=True, timeout=5, ) # Bad - string form with shell=True is vulnerable to injection result = subprocess.run("nginx -s reload", shell=True) # NEVER DO THIS ``` #### ✅ ALWAYS Add Timeout ```python # Good - prevents DoS from hanging processes result = subprocess.run(cmd, timeout=30, capture_output=True) # Bad - no timeout can cause infinite hangs result = subprocess.run(cmd, capture_output=True) # Missing timeout! ``` #### ✅ ALWAYS Handle Errors ```python # Good - proper error handling try: result = subprocess.run( cmd, capture_output=True, text=True, check=True, # Raises CalledProcessError on non-zero exit timeout=30, ) except subprocess.TimeoutExpired: logger.error("Command timed out") return False except subprocess.CalledProcessError as e: logger.error(f"Command failed: {e.stderr}") return False ``` #### ✅ Approved Subprocess Patterns **Pattern 1: System Utilities (hardcoded commands)** ```python # System commands with hardcoded paths and flags result = subprocess.run( ["nginx", "-t"], # nosec B603 B607 - hardcoded command capture_output=True, text=True, timeout=5, ) result = subprocess.run( ["hostname", "-I"], # nosec B603 B607 - hardcoded command capture_output=True, text=True, timeout=2, ) ``` **Pattern 2: Internal Scripts (controlled paths)** ```python # Internal scripts with validated arguments script_path = os.path.join(project_root, "scripts/generate_token.sh") result = subprocess.run( [script_path, validated_arg], # nosec B603 - hardcoded internal script path capture_output=True, text=True, timeout=30, cwd=working_directory, ) ``` **Pattern 3: External Tools (hardcoded flags, data as arguments)** ```python # External tools with hardcoded flags - user data passed as arguments, not commands cmd = ["mcp-scanner", "--format", "json", "--url", user_provided_url] result = subprocess.run( # nosec B603 - args are hardcoded flags passed to mcp-scanner tool cmd, capture_output=True, text=True, check=True, timeout=60, ) ``` #### ✅ Security Comment Standards for Subprocess When suppressing Bandit warnings for subprocess calls, **always include a clear justification**: ```python # Good - explains why it's safe subprocess.run( ["nginx", "-s", "reload"], ... ) # nosec B603 B607 - hardcoded command # Good - explains the security model subprocess.run( [script_path, arg], ... ) # nosec B603 - hardcoded internal script path # Good - explains what's hardcoded subprocess.run( cmd, ... ) # nosec B603 - args are hardcoded flags passed to tool # Bad - no justification subprocess.run(cmd, ...) # nosec B603 ``` **Valid Justification Templates:** - `# nosec B603 B607 - hardcoded command` - for system utilities (nginx, hostname, etc.) - `# nosec B603 - hardcoded internal script path` - for internal project scripts - `# nosec B603 - hardcoded internal script path and flags` - when both path and flags are hardcoded - `# nosec B603 - args are hardcoded flags passed to [tool-name]` - for external tools #### ❌ NEVER Do These With Subprocess ```python # NEVER use shell=True with any user input user_cmd = f"tool --arg {user_input}" subprocess.run(user_cmd, shell=True) # VULNERABLE TO INJECTION # NEVER construct commands from user input cmd = f"grep {user_search_term} file.txt" # VULNERABLE subprocess.run(cmd, shell=True) # NEVER skip timeout - can hang forever subprocess.run(["long-running-command"]) # NO TIMEOUT # NEVER ignore errors without logging result = subprocess.run(cmd, capture_output=True) # No error handling - failures go unnoticed ``` ### SQL Security Guidelines When working with databases, follow these patterns to prevent SQL injection vulnerabilities (Bandit B608). #### ✅ ALWAYS Use Parameterized Queries ```python # Good - parameterized query with placeholders cutoff = datetime.now().isoformat() query = "DELETE FROM table_name WHERE created_at < ?" cursor.execute(query, (cutoff,)) # Bad - string formatting is vulnerable to SQL injection cutoff_str = f"'{datetime.now().isoformat()}'" query = f"DELETE FROM table_name WHERE created_at < {cutoff_str}" # VULNERABLE cursor.execute(query) ``` #### ✅ Validate Identifiers Against Allowlists For table names and column names that cannot be parameterized, use allowlist validation: ```python # Define allowlists for table and column names ALLOWED_TABLES = {"users", "metrics", "auth_logs"} ALLOWED_COLUMNS = {"created_at", "updated_at", "timestamp"} def validate_table_name(table: str) -> str: """Validate table name against allowlist.""" if table not in ALLOWED_TABLES: raise ValueError(f"Invalid table: {table}") return table def validate_column_name(column: str) -> str: """Validate column name against allowlist.""" if column not in ALLOWED_COLUMNS: raise ValueError(f"Invalid column: {column}") return column # Use validated identifiers with nosec comment table = validate_table_name(user_provided_table) column = validate_column_name(user_provided_column) query = f"SELECT * FROM {table} WHERE {column} = ?" # nosec B608 - table and column validated against allowlists cursor.execute(query, (value,)) ``` #### ✅ Return Query and Parameters as Tuple For query-building methods, return both query string and parameters: ```python def get_cleanup_query( table_name: str, days: int ) -> tuple[str, tuple]: """Get cleanup query and parameters. Returns: Tuple of (query_string, parameters) """ # Validate table name against allowlist table_name = validate_table_name(table_name) # Calculate cutoff date cutoff = (datetime.now() - timedelta(days=days)).isoformat() # Build parameterized query query = f"DELETE FROM {table_name} WHERE created_at < ?" # nosec B608 - table_name validated against allowlist return query, (cutoff,) # Use the query and parameters query, params = get_cleanup_query("metrics", 90) cursor.execute(query, params) ``` #### ✅ Security Comment Standards for SQL When suppressing B608 warnings, **always document the validation**: ```python # Good - documents allowlist validation query = f"SELECT * FROM {table}" # nosec B608 - table name validated against allowlist cursor.execute(query, params) # Good - references validation function query = f"DELETE FROM {table}" # nosec B608 - table validated by validate_table_name() cursor.execute(query, params) # Good - explains multiple validations query = f"SELECT {column} FROM {table}" # nosec B608 - table and column validated against allowlists cursor.execute(query, params) # Bad - no justification query = f"SELECT * FROM {table}" # nosec B608 cursor.execute(query) ``` **Valid Justification Templates:** - `# nosec B608 - table name validated against allowlist` - `# nosec B608 - column name validated against allowlist` - `# nosec B608 - table and column validated against allowlists` - `# nosec B608 - identifier validated by _validate_identifier()` #### ❌ NEVER Do These With SQL ```python # NEVER use string formatting for values value = user_input query = f"SELECT * FROM users WHERE name = '{value}'" # VULNERABLE TO SQL INJECTION cursor.execute(query) # NEVER concatenate user input into queries query = "SELECT * FROM " + user_table + " WHERE id = " + user_id # VULNERABLE cursor.execute(query) # NEVER skip validation for identifiers table = request.args.get('table') # No validation! query = f"SELECT * FROM {table}" # VULNERABLE cursor.execute(query) # NEVER use datetime() SQL functions with interpolated values days = user_input query = f"DELETE FROM t WHERE created_at < datetime('now', '-{days} days')" # VULNERABLE cursor.execute(query) ``` ### Security Checklist for Code Review When reviewing code with subprocess or SQL operations, verify: **Subprocess Checklist:** - [ ] Using list form (not string commands) - [ ] No `shell=True` anywhere - [ ] Timeout specified - [ ] Error handling includes `TimeoutExpired` and `CalledProcessError` - [ ] Commands are hardcoded (no dynamic construction from user input) - [ ] `# nosec` comments include clear justifications - [ ] Arguments passed as list elements (not interpolated into commands) **SQL Checklist:** - [ ] Using parameterized queries for all values - [ ] Table and column names validated against allowlists - [ ] No string formatting or concatenation for SQL values - [ ] Query methods return `tuple[str, tuple]` - [ ] `# nosec` comments document validation method - [ ] No datetime() SQL functions with interpolated parameters ## Development Workflow ### Recommended Development Tools - **Ruff**: For linting and formatting (replaces multiple tools like isort and many flake8 plugins) - **Bandit**: For security vulnerability scanning - **MyPy**: For type checking - **Pytest**: For testing ### Pre-commit Workflow #### Option 1: Automated Pre-commit Hooks (Recommended) Install pre-commit hooks to automatically run checks before each commit: ```bash # Install pre-commit (one-time setup) uv pip install pre-commit # Install the git hooks (one-time per repo clone) pre-commit install # Now all checks run automatically on git commit git add file.py git commit -m "Your message" # Hooks run automatically # Run hooks manually on all files pre-commit run --all-files ``` **What runs automatically:** - ✅ Ruff linter with auto-fixes - ✅ Ruff formatter (PEP 604/585 modernization) - ✅ Trailing whitespace removal - ✅ End-of-file fixes - ✅ YAML/JSON validation - ✅ Bandit security scan - ✅ MyPy type checking - ✅ Fast unit tests - ✅ Python/shell syntax checks #### Option 2: Manual Workflow Before committing code, run these checks in order: ```bash # 1. Format and lint with auto-fixes uv run ruff check --fix . && uv run ruff format . # 2. Security scanning uv run bandit -r src/ # 3. Type checking uv run mypy src/ # 4. Run tests uv run pytest # Or run all checks in one command: uv run ruff check --fix . && uv run ruff format . && uv run bandit -r src/ && uv run mypy src/ && uv run pytest ``` ### Code Formatting Standards **Ruff Configuration**: This project uses ruff for formatting with the following key settings (see `pyproject.toml`): - **Target Python**: 3.10+ (enables PEP 604/585) - **Line Length**: 100 characters - **Type Hint Modernization**: Automatic via ruff rules: - `UP006`: Use PEP 585 built-in generics (`list`, `dict`, `tuple`) - `UP007`: Use PEP 604 union syntax (`X | Y` instead of `Union[X, Y]`) - `UP037`: Remove quotes from type annotations - `I001`: Auto-sort imports (isort compatible) **Formatting automatically handles:** - Type hint modernization (PEP 604/585) - Import organization (stdlib, third-party, local) - Trailing whitespace removal - Consistent indentation (4 spaces) - Line length enforcement - Docstring formatting **Example ruff modernizations:** ```python # Before ruff format from typing import Optional, List, Dict def func(x: Optional[List[Dict]]) -> Optional[str]: pass # After ruff format (automatic) def func(x: list[dict] | None) -> str | None: pass ``` ### Adding Development Dependencies ```bash # Add development dependencies uv add --dev ruff mypy bandit pytest pytest-cov pre-commit ``` ## Dependency Management ### Project Configuration Always specify Python version in `pyproject.toml` to avoid warnings: ```toml [project] name = "project-name" version = "0.1.0" description = "Project description" requires-python = ">=3.14" # Always specify this! dependencies = [ # ... dependencies ] ``` ### Version Pinning In `pyproject.toml`: ```toml [project] dependencies = [ "fastapi>=0.100.0,<0.200.0", # Minor version flexibility "pydantic==2.5.0", # Exact version for critical dependencies "polars>=0.19.0", # Minimum version only ] [tool.uv] dev-dependencies = [ "pytest>=7.0.0", "ruff>=0.1.0", "mypy>=1.0.0", "bandit>=1.7.0", ] ``` ### Dependency Guidelines - Pin exact versions for critical dependencies - Use version ranges for stable libraries - Separate dev dependencies from runtime dependencies - Regularly update dependencies for security patches - Document why specific versions are pinned ## Project Structure ### Standard Layout ``` project_name/ ├── src/ │ └── project_name/ │ ├── __init__.py │ ├── main.py │ ├── models/ │ │ ├── __init__.py │ │ └── domain.py │ ├── services/ │ │ ├── __init__.py │ │ └── business_logic.py │ ├── api/ │ │ ├── __init__.py │ │ └── endpoints.py │ └── utils/ │ ├── __init__.py │ └── helpers.py ├── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── unit/ │ └── integration/ ├── scripts/ │ └── deploy.sh ├── docs/ ├── pyproject.toml ├── README.md └── .env.example ``` ### Module Organization - Keep related functionality together - Use clear, descriptive module names - Avoid circular imports - Keep modules focused on a single responsibility ### Comprehensive .gitignore Ensure your `.gitignore` includes all necessary entries: ```gitignore # Python __pycache__/ *.py[cod] *$py.class *.so .Python build/ dist/ *.egg-info/ *.egg # Virtual environments .env .venv env/ venv/ ENV/ # Testing and linting caches .ruff_cache/ .mypy_cache/ .pytest_cache/ .coverage htmlcov/ # Security reports bandit_report.json # IDE .vscode/ .idea/ *.swp *.swo # OS .DS_Store Thumbs.db # Project specific *.csv # Or specific output files .scratchpad/ logs/ output/ # AWS .aws/ ``` ## Scratchpad for Planning & Design The `.scratchpad/` folder contains intermediate and temporary documents used during development that are not meant for long-term storage or committed to the repository. **Contents:** - Design discussions and architecture sketches - Todo lists and task planning documents - GitHub issue creation planning - LinkedIn posts and social media drafts - Session notes and decision logs - Meeting minutes and action items - Prototype diagrams and brainstorming documents - Any other context-specific content created during active work **Important:** - `.scratchpad/` is in `.gitignore` and will NOT be committed - These files are temporary and may be deleted at any time - Only relevant within the context of current work sessions - Not suitable for documentation or long-term reference - Use for active planning, not for finalized documentation **Naming Convention:** - Design files: `design-feature-name.md` or `design-YYYY-MM-DD.md` - Planning files: `plan-feature-name.md` or `task-status.md` - Drafts: `draft-linkedin-post.md`, `draft-github-issue.md` - Notes: `session-notes-YYYY-MM-DD.md`, `meeting-minutes.md` - Sub-tasks: `sub-tasks-issue-NUMBER-feature-name.md` ## Environment Configuration ### Environment Variables ```python from pydantic import BaseSettings from typing import Optional class Settings(BaseSettings): """Application settings from environment variables.""" app_name: str = "MyApp" debug: bool = False database_url: str api_key: str redis_url: Optional[str] = None class Config: env_file = ".env" env_file_encoding = "utf-8" case_sensitive = False # Global settings instance settings = Settings() ``` ### Configuration Best Practices - Use Pydantic Settings for type-safe configuration - Provide `.env.example` with all required variables - Never commit `.env` files to version control - Document all environment variables - Use sensible defaults where appropriate ## Data Validation with Pydantic ### Model Definition ```python from pydantic import BaseModel, Field, validator from typing import Optional from datetime import datetime class UserRequest(BaseModel): """User creation request model.""" username: str = Field(..., min_length=3, max_length=50) email: str = Field(..., regex=r'^[\w\.-]+@[\w\.-]+\.\w+$') age: Optional[int] = Field(None, ge=0, le=150) created_at: datetime = Field(default_factory=datetime.utcnow) @validator('username') def username_alphanumeric(cls, v: str) -> str: if not v.replace('_', '').isalnum(): raise ValueError('Username must be alphanumeric') return v.lower() class Config: json_schema_extra = { "example": { "username": "john_doe", "email": "john@example.com", "age": 25 } } ``` ### Validation Guidelines - Use Pydantic for all API request/response models - Define clear validation rules with Field() - Use custom validators for complex logic - Provide examples in model configuration - Return validation errors with clear messages ## Platform Naming - Always refer to the service as "Amazon Bedrock" (never "AWS Bedrock") ## GitHub Commit and Pull Request Guidelines - Never include auto-generated messages like "🤖 Generated with [Claude Code]" - Never include "Co-Authored-By: Claude " - Keep commit messages clean and professional - When creating pull requests, do not include Claude Code attribution or generation messages - Pull request descriptions should be professional and focus on the technical changes ## Documentation Guidelines - Never add emojis to README.md files in repositories - Keep README files professional and emoji-free ### Emoji Usage Guidelines - **Code**: Absolutely no emojis in source code, comments, or docstrings - **Documentation**: Avoid emojis in all documentation files (.md, .rst, etc.) - **Log Messages**: Use plain text only for log messages - no emojis - **Shell Scripts**: Avoid emojis in shell scripts - prefer plain text status messages - **Comments**: Use clear, descriptive text instead of emojis in code comments **Rationale**: Emojis can cause encoding issues, reduce accessibility, appear unprofessional in enterprise environments, and may not render consistently across different systems and terminals. ### README Best Practices A well-structured README should include: 1. **Prerequisites Section**: List external dependencies and setup requirements ```markdown ## Prerequisites - Python 3.14+ - AWS credentials configured - Amazon Bedrock Guardrail with sensitive information filters ``` 2. **Links to External Resources**: Provide links to datasets, documentation, and services ```markdown - Evaluate performance on the [dataset-name](https://link-to-dataset) - See [AWS documentation](https://docs.aws.amazon.com/...) for setup ``` 3. **Clear Command Examples**: Show all command-line options with examples ```markdown ## Usage # Basic usage uv run python -m module_name --required-param value # With all options uv run python -m module_name --param1 value1 --param2 value2 # Using environment variables export CONFIG_VAR=value uv run python -m module_name ``` 4. **Development Workflow**: Include a section on development practices ```markdown ## Development Workflow # Run all checks before committing uv run ruff check --fix . && uv run ruff format . && uv run bandit -r src/ ``` 5. **Performance Warnings**: Alert users about time-intensive operations ```markdown # Evaluate full dataset (warning: this may take a long time) uv run python -m module_name --sample-size 0 ``` ## Project Notes and Planning Guidelines ### Scratchpad Usage - Always create and maintain a `.scratchpad/` folder in each project root for temporary markdown files, task status, and planning documents - Add `.scratchpad/` to the project's `.gitignore` file to keep notes local - Use this folder to store: - Technical analysis and findings (`analysis-YYYY-MM-DD.md`) - Implementation plans and strategies (`plan-feature-name.md`) - Code refactoring ideas (`refactor-component-name.md`) - Architecture decisions and considerations (`architecture-decisions.md`) - Development progress and next steps (`progress-notes.md`) - Task status and temporary working documents ### Plan Documentation Process 1. **Default Behavior**: When asked to create plans, create individual markdown files in `.scratchpad/` folder 2. **File Naming**: Use descriptive names with dates when relevant: - `plan-agent-refactoring-2024-07-31.md` - `analysis-memory-system.md` - `task-status-current.md` 3. **Organization**: Each file should have clear headings, timestamps, and be self-contained ### Scratchpad Folder Structure Example ``` project_root/ ├── .scratchpad/ │ ├── plan-agent-refactoring-2024-07-31.md │ ├── analysis-hardcoded-names.md │ ├── task-status-current.md │ ├── architecture-decisions.md │ └── progress-notes.md ├── .gitignore # Contains .scratchpad/ └── ... other project files ``` ### Individual File Structure Example ```markdown # Agent Name Refactoring Plan *Created: 2024-07-31* ## Investigation Summary - Found hardcoded constants in multiple files - Plan to centralize in constants.py ## Implementation Strategy - Phase 1: Extend constants - Phase 2: Update core infrastructure - [Detailed steps follow...] ## Next Steps - [ ] Implement constants centralization - [ ] Create utility methods ``` ## Docker Build and Deployment When building and pushing Docker containers, create a shell script following this pattern: ```bash #!/bin/bash # Exit on error set -e # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PARENT_DIR="$(dirname "$SCRIPT_DIR")" # Configuration AWS_REGION="${AWS_REGION:-us-east-1}" ECR_REPO_NAME="your_app_name" AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) ECR_REPO_URI="$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$ECR_REPO_NAME" # Login to Amazon ECR echo "Logging in to Amazon ECR..." aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin "$AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com" # Create repository if it doesn't exist echo "Creating ECR repository if it doesn't exist..." aws ecr describe-repositories --repository-names "$ECR_REPO_NAME" --region "$AWS_REGION" || \ aws ecr create-repository --repository-name "$ECR_REPO_NAME" --region "$AWS_REGION" # Build the Docker image echo "Building Docker image..." docker build -f "$PARENT_DIR/Dockerfile" -t "$ECR_REPO_NAME" "$PARENT_DIR" # Tag the image echo "Tagging image..." docker tag "$ECR_REPO_NAME":latest "$ECR_REPO_URI":latest # Push the image to ECR echo "Pushing image to ECR..." docker push "$ECR_REPO_URI":latest echo "Successfully built and pushed image to:" echo "$ECR_REPO_URI:latest" # Save the container URI to a file for reference echo "$ECR_REPO_URI:latest" > "$SCRIPT_DIR/.container_uri" ``` ### Docker Script Best Practices - Always use `set -e` to exit on error - Use environment variables for configuration with sensible defaults - Login to ECR before pushing - Create ECR repository if it doesn't exist - Use clear echo statements to show progress (avoid emojis for compatibility) - Save container URI to a file for reference by other scripts ### ARM64 Support For ARM64 builds, add QEMU setup: ```bash docker run --rm --privileged multiarch/qemu-user-static --reset -p yes DOCKER_BUILDKIT=0 docker build -f "$PARENT_DIR/Dockerfile" -t "$ECR_REPO_NAME" "$PARENT_DIR" ``` ## GitHub Issue Management ### Label Management Best Practices When creating GitHub issues: 1. **Check Available Labels First**: Always get a list of available labels for the repository before creating issues ```bash gh label list ``` 2. **Use Only Existing Labels**: Only apply labels that already exist in the repository to avoid errors during issue creation 3. **Suggest New Labels**: If you believe a new label would be beneficial, make a suggestion in the issue description or as a separate comment, but don't attempt to add non-existent labels during issue creation 4. **Label Application**: Apply labels that are available and relevant to the issue type and scope **Example Workflow**: ```bash # First check available labels gh label list # Create issue with only existing labels gh issue create --title "..." --body-file "..." --label "enhancement,bug" # If new labels are needed, suggest them in issue comments gh issue comment 123 --body "Suggest adding 'agentcore' label for AgentCore-related issues" ``` ## Summary These guidelines ensure consistent, maintainable, and modern Python code. Key principles: - **Simplicity First**: Write code maintainable by entry-level developers - **Modern Python**: Use Python 3.10+ features (PEP 604/585 type hints) - **Automated Quality**: Use pre-commit hooks for consistent formatting - **Security**: Follow subprocess and SQL security patterns - **Type Safety**: Clear type annotations with modern syntax Always prioritize simplicity and clarity over cleverness. ## Federated Registry Implementation Workflow When implementing the federated registry feature, follow this 3-agent workflow for each sub-feature: ### Agent Roles 1. **Writer Agent** - Implement code following CLAUDE.md standards 2. **Reviewer Agent** - Analyze time/space complexity, evaluate trade-offs, check production readiness 3. **Tester Agent** - Write property-based tests, integration tests, validate acceptance criteria ### Workflow Per Sub-Feature 1. Writer Agent implements all tasks 2. Reviewer Agent analyzes and suggests improvements 3. Writer Agent addresses reviewer suggestions 4. Tester Agent writes tests and validates 5. Update plan if new scope discovered 6. Final validation before marking complete ### Quality Gates - All acceptance criteria verified with tests - Reviewer approved production readiness - Property-based tests cover invariants - No TODO or FIXME left unaddressed - Code compiles without warnings - Existing tests still pass ================================================ FILE: CODE_OF_CONDUCT.md ================================================ ## Code of Conduct This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact opensource-codeofconduct@amazon.com with any additional questions or comments. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Guidelines Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional documentation, we greatly value feedback and contributions from our community. Please read through this document before submitting any issues or pull requests to ensure we have all the necessary information to effectively respond to your bug report or contribution. ## Reporting Bugs/Feature Requests We welcome you to use the GitHub issue tracker to report bugs or suggest features. When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: * A reproducible test case or series of steps * The version of our code being used * Any modifications you've made relevant to the bug * Anything unusual about your environment or deployment ## Contributing via Pull Requests Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 1. You are working against the latest source on the *main* branch. 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. To send us a pull request, please: 1. Fork the repository. 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 3. Ensure local tests pass. 4. Commit to your fork using clear commit messages. 5. Send us a pull request, answering any default questions in the pull request interface. 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). ## Finding contributions to work on Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. ## Code of Conduct This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact opensource-codeofconduct@amazon.com with any additional questions or comments. ## Security issue notifications If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. ## Licensing See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. ================================================ FILE: DEV_INSTRUCTIONS.md ================================================ # Getting Started ## Prerequisite Reading **READ THIS FIRST:** [CONTRIBUTING.md](CONTRIBUTING.md) Before you start contributing, please review the project's contribution guidelines. ## Setup Instructions for Contributors ### Step 1: Choose Your Development Environment We recommend the fastest option to get started: #### Option A: macOS Setup (Fastest ⚡) Complete this setup guide first: - [macOS Setup Guide](macos-setup-guide.md) - Time to first run: ~30 minutes #### Option B: EC2 Complete Configuration (Preferred for Server Setup) If working on EC2 or a Linux server, complete this guide first: - [Complete Configuration Guide](complete-configuration-guide.md) - Time to first run: ~60 minutes ## Before You Start Coding ### 1. Ask Your Coding Assistant to Read Documentation Before making any code changes, ask your AI coding assistant to read: **LLM/AI Documentation (Critical for understanding the project):** - [docs/llms.txt](docs/llms.txt) **Coding Standards and Guidelines:** - [CLAUDE.md](CLAUDE.md) - Project-specific coding standards ### 2. Review the CLAUDE.md File This project uses [CLAUDE.md](CLAUDE.md) for coding standards. The file is already included in the repository root - make sure to review it before contributing. ## Testing Your Changes Before submitting a pull request, you must run and pass the test suite: ### Quick Start Testing ```bash # Generate fresh credentials (tokens expire in 5 minutes) ./credentials-provider/generate_creds.sh # Run tests locally (skip production for fast iteration) ./tests/run_all_tests.sh --skip-production ``` ### For PR Merge (REQUIRED) ```bash # Full test suite including production tests ./tests/run_all_tests.sh # All tests must pass (0 failures) before merging ``` ### Understanding the Tests See the comprehensive testing documentation: - **[tests/README.md](tests/README.md)** - Start here! Navigation guide with access control overview - **[tests/TEST_QUICK_REFERENCE.md](tests/TEST_QUICK_REFERENCE.md)** - Quick reference for how-to guides - **[tests/lob-bot-access-control-testing.md](tests/lob-bot-access-control-testing.md)** - Access control test details - **[auth_server/scopes.yml](auth_server/scopes.yml)** - Permission definitions (admin, LOB1, LOB2) ### Common Testing Workflows **Agent CRUD Testing:** ```bash ./credentials-provider/generate_creds.sh bash tests/agent_crud_test.sh ``` **Access Control Testing (LOB Bots):** ```bash ./keycloak/setup/generate-agent-token.sh admin-bot ./keycloak/setup/generate-agent-token.sh lob1-bot ./keycloak/setup/generate-agent-token.sh lob2-bot bash tests/run-lob-bot-tests.sh ``` **Check Test Logs:** ```bash ls -lh /tmp/*_*.log grep -i "error\|fail" /tmp/*.log ``` ## Fork and Contribute ### Repository Access **Important:** There is no direct access to this repository. To contribute: 1. **Fork the repository on GitHub** ``` https://github.com/agentic-community/mcp-gateway-registry ``` 2. **Clone your fork locally** ```bash git clone https://github.com/YOUR-USERNAME/mcp-gateway-registry.git cd mcp-gateway-registry ``` 3. **Create a feature branch** ```bash git checkout -b feat/your-feature-name ``` 4. **Make your changes** following the coding standards in CLAUDE.md 5. **Commit and push to your fork** ```bash git push origin feat/your-feature-name ``` 6. **Create a Pull Request** to the main repository - Use a clear, descriptive PR title - Reference any related issues - Include test results and screenshots if applicable ## Development Checklist Before submitting a pull request: - [ ] Completed one of the setup guides (macOS or EC2) - [ ] Read docs/llms.txt - [ ] Read CLAUDE.md (coding standards) - [ ] Code follows project conventions (use ruff, mypy, pytest) - [ ] Generated fresh credentials: `./credentials-provider/generate_creds.sh` - [ ] Local tests pass: `./tests/run_all_tests.sh --skip-production` - [ ] PR merge tests pass: `./tests/run_all_tests.sh` (all tests must pass) - [ ] Reviewed test documentation: [tests/README.md](tests/README.md) - [ ] Changes are pushed to a fork, not directly to this repo - [ ] Pull request is created with clear description ## Questions? - Check the [CONTRIBUTING.md](CONTRIBUTING.md) file for more details - Review existing PRs to see contribution patterns - Ask your coding assistant to review the documentation with you Happy coding! 🚀 ================================================ FILE: Dockerfile ================================================ # Use an official Python runtime as a parent image FROM python:3.14-slim # Set environment variables to prevent interactive prompts during installation ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ DEBIAN_FRONTEND=noninteractive # Install system dependencies including nginx with lua module RUN apt-get update && apt-get install -y --no-install-recommends \ nginx \ nginx-extras \ lua-cjson \ curl \ procps \ openssl \ git \ build-essential \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Set the working directory in the container WORKDIR /app # Copy the application code COPY . /app/ # Copy nginx configurations (both HTTP-only and HTTP+HTTPS versions) COPY docker/nginx_rev_proxy_http_only.conf /app/docker/nginx_rev_proxy_http_only.conf COPY docker/nginx_rev_proxy_http_and_https.conf /app/docker/nginx_rev_proxy_http_and_https.conf # Copy custom error pages for nginx COPY docker/502.html /usr/share/nginx/html/502.html # Make the entrypoint script executable COPY docker/entrypoint.sh /app/docker/entrypoint.sh RUN chmod +x /app/docker/entrypoint.sh # Create nginx lua directories and remove default sites (needed by entrypoint script) RUN mkdir -p /etc/nginx/lua/virtual_mappings && \ rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default && \ mkdir -p /var/lib/nginx/body /var/lib/nginx/proxy /var/lib/nginx/fastcgi /var/lib/nginx/uwsgi /var/lib/nginx/scgi && \ mkdir -p /var/log/nginx && \ mkdir -p /run/nginx # Expose ports for Nginx (HTTP/HTTPS on high ports for non-root) and the Registry EXPOSE 8080 8443 7860 # Define environment variables for registry/server configuration (can be overridden at runtime) # Provide sensible defaults or leave empty if they should be explicitly set ARG BUILD_VERSION="1.0.0" ARG SECRET_KEY="" ARG POLYGON_API_KEY="" ENV BUILD_VERSION=$BUILD_VERSION ENV SECRET_KEY=$SECRET_KEY ENV POLYGON_API_KEY=$POLYGON_API_KEY # Add health check using the new HTTP endpoint HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD curl -f http://localhost:7860/health || exit 1 # Create non-root user for security (CIS Docker Benchmark 4.1) RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser appuser # Create security scan directories and certs directory with proper permissions RUN mkdir -p /app/security_scans /app/skill_security_scans /app/agent_security_scans /app/certs && \ chown -R appuser:appuser /app/security_scans /app/skill_security_scans /app/agent_security_scans /app/certs # Set ownership of application files, nginx configs, and entrypoint RUN chown -R appuser:appuser /app /etc/nginx /var/log/nginx /var/lib/nginx /run/nginx /app/docker/entrypoint.sh # Switch to non-root user USER appuser # Run the entrypoint script when the container launches ENTRYPOINT ["/app/docker/entrypoint.sh"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. ================================================ FILE: Makefile ================================================ .PHONY: help test test-unit test-integration test-e2e test-fast test-coverage test-auth test-servers test-search test-health test-core install-dev lint format check-deps clean build-keycloak push-keycloak build-and-push-keycloak deploy-keycloak update-keycloak save-outputs view-logs view-logs-keycloak view-logs-registry view-logs-auth view-logs-follow list-images build push build-push generate-manifest validate-config publish-dockerhub publish-dockerhub-component publish-dockerhub-version publish-dockerhub-no-mirror publish-local compose-up-agents compose-down-agents compose-logs-agents build-agents push-agents # Default target help: @echo "🧪 MCP Registry Testing Commands" @echo "" @echo "Setup:" @echo " install-dev Install development dependencies" @echo " check-deps Check if test dependencies are installed" @echo "" @echo "Testing:" @echo " test Run full test suite with coverage" @echo " test-unit Run unit tests only" @echo " test-integration Run integration tests only" @echo " test-e2e Run end-to-end tests only" @echo " test-fast Run fast tests (exclude slow tests)" @echo " test-coverage Generate coverage reports" @echo "" @echo "Domain Testing:" @echo " test-auth Run authentication domain tests" @echo " test-servers Run server management domain tests" @echo " test-search Run search domain tests" @echo " test-health Run health monitoring domain tests" @echo " test-core Run core infrastructure tests" @echo "" @echo "Code Quality:" @echo " lint Run linting checks" @echo " format Format code" @echo " clean Clean up test artifacts" @echo "" @echo "Keycloak Build & Deploy:" @echo " build-keycloak Build Keycloak Docker image locally" @echo " build-and-push-keycloak Build and push to ECR" @echo " deploy-keycloak Update ECS service (after push)" @echo " update-keycloak Build, push, and deploy in one command" @echo "" @echo "Infrastructure Documentation:" @echo " save-outputs Save Terraform outputs as JSON" @echo "" @echo "CloudWatch Logs Viewing:" @echo " view-logs View logs from all components (last 30 min)" @echo " view-logs-keycloak View Keycloak logs (last 30 min)" @echo " view-logs-registry View Registry logs (last 30 min)" @echo " view-logs-auth View Auth Server logs (last 30 min)" @echo " view-logs-follow Follow logs in real-time for all components" @echo "" @echo "Container Build & Registry:" @echo " list-images List all configured container images" @echo " build Build all images locally" @echo " build IMAGE=name Build specific image (e.g., IMAGE=registry)" @echo " push Push all images to ECR" @echo " push IMAGE=name Push specific image to ECR" @echo " build-push Build and push all images" @echo " build-push IMAGE=name Build and push specific image" @echo " build-push-deploy Build, push, and deploy (default: both services)" @echo " build-push-deploy IMAGE=x Build, push, deploy specific (registry or auth_server)" @echo " generate-manifest Generate image-manifest.json for Terraform" @echo " validate-config Validate build-config.yaml syntax" @echo "" @echo "DockerHub Publishing:" @echo " publish-dockerhub Publish all images to DockerHub" @echo " publish-dockerhub-component Publish specific component (COMPONENT=name)" @echo " publish-dockerhub-version Publish with version tag (VERSION=v1.0.0)" @echo " publish-dockerhub-no-mirror Publish without external images" @echo " publish-local Build locally without pushing" @echo "" @echo "Local A2A Agent Development:" @echo " compose-up-agents Start A2A agents with docker-compose" @echo " compose-down-agents Stop A2A agents" @echo " compose-logs-agents Follow A2A agent logs in real-time" @echo " build-agents Build both A2A agent images locally" @echo " push-agents Push both A2A agent images to ECR" # Installation install-dev: @echo "📦 Installing development dependencies..." pip install -e .[dev] check-deps: @python scripts/test.py check # Full test suite test: @python scripts/test.py full # Test types test-unit: @python scripts/test.py unit test-integration: @python scripts/test.py integration test-e2e: @python scripts/test.py e2e test-fast: @python scripts/test.py fast test-coverage: @python scripts/test.py coverage # Domain-specific tests test-auth: @python scripts/test.py auth test-servers: @python scripts/test.py servers test-search: @python scripts/test.py search test-health: @python scripts/test.py health test-core: @python scripts/test.py core # Code quality lint: @echo "🔍 Running linting checks..." @python -m bandit -r registry/ -f json || true @echo "✅ Linting complete" format: @echo "🎨 Formatting code..." @python -m black registry/ tests/ --diff --color @echo "✅ Code formatting complete" # Cleanup clean: @echo "🧹 Cleaning up test artifacts..." @rm -rf htmlcov/ @rm -rf tests/reports/ @rm -rf .coverage @rm -rf coverage.xml @rm -rf .pytest_cache/ @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true @find . -type f -name "*.pyc" -delete 2>/dev/null || true @echo "✅ Cleanup complete" # Development workflow dev-test: clean install-dev test-fast @echo "🚀 Development test cycle complete!" # CI/CD workflow ci-test: clean check-deps test test-coverage @echo "🏗️ CI/CD test cycle complete!" # Keycloak Build & Deployment # Variables AWS_REGION ?= us-west-2 AWS_PROFILE ?= default IMAGE_TAG ?= latest build-keycloak: @echo "🐋 Building Keycloak Docker image..." @$(MAKE) build IMAGE=keycloak @echo "✅ Image built: keycloak:$(IMAGE_TAG)" build-and-push-keycloak: @echo "📦 Building and pushing Keycloak to ECR..." @$(MAKE) build-push IMAGE=keycloak @echo "✅ Keycloak image built and pushed successfully" deploy-keycloak: @echo "🚀 Deploying Keycloak ECS service..." aws ecs update-service \ --cluster keycloak \ --service keycloak \ --force-new-deployment \ --region $(AWS_REGION) \ --profile $(AWS_PROFILE) \ --output table @echo "✅ ECS service update initiated" update-keycloak: build-and-push-keycloak deploy-keycloak @echo "" @echo "✅ Keycloak update complete!" @echo "" @echo "Service URLs:" @echo " Admin Console: https://kc.mycorp.click/admin" @echo " Service URL: https://kc.mycorp.click" @echo "" @echo "Monitor deployment:" @echo " aws ecs describe-services --cluster keycloak --services keycloak --region $(AWS_REGION) --query 'services[0].[serviceName,status,runningCount,desiredCount]' --output table" save-outputs: @echo "💾 Saving Terraform outputs as JSON..." ./terraform/aws-ecs/scripts/save-terraform-outputs.sh @echo "" @echo "✅ Outputs saved to terraform/aws-ecs/terraform-outputs.json" view-logs: @echo "📋 Viewing CloudWatch logs from last 30 minutes for all components..." ./terraform/aws-ecs/scripts/view-cloudwatch-logs.sh view-logs-keycloak: @echo "📋 Viewing Keycloak CloudWatch logs from last 30 minutes..." ./terraform/aws-ecs/scripts/view-cloudwatch-logs.sh --component keycloak --minutes 30 view-logs-registry: @echo "📋 Viewing Registry CloudWatch logs from last 30 minutes..." ./terraform/aws-ecs/scripts/view-cloudwatch-logs.sh --component registry --minutes 30 view-logs-auth: @echo "📋 Viewing Auth Server CloudWatch logs from last 30 minutes..." ./terraform/aws-ecs/scripts/view-cloudwatch-logs.sh --component auth-server --minutes 30 view-logs-follow: @echo "📋 Following CloudWatch logs in real-time for all components..." ./terraform/aws-ecs/scripts/view-cloudwatch-logs.sh --follow # ======================================== # Unified Container Build System # ======================================== list-images: @./scripts/generate-image-manifest.sh --list generate-manifest: @./scripts/generate-image-manifest.sh validate-config: @python3 -c "import yaml; yaml.safe_load(open('build-config.yaml'))" && echo "Config is valid!" build: @$(if $(IMAGE),IMAGE=$(IMAGE),) ./scripts/build-images.sh build push: @$(if $(IMAGE),IMAGE=$(IMAGE),) ./scripts/build-images.sh push build-push: @$(if $(NO_CACHE),NO_CACHE=$(NO_CACHE),) $(if $(IMAGE),IMAGE=$(IMAGE),) ./scripts/build-images.sh build-push build-push-deploy: @./scripts/deploy.sh $(if $(IMAGE),--service $(IMAGE),) $(if $(NO_CACHE),--no-cache,) --skip-monitor # ======================================== # DockerHub Publishing # ======================================== publish-dockerhub: @echo "Publishing all images to DockerHub..." ./scripts/publish_containers.sh --dockerhub publish-dockerhub-component: @echo "Publishing $(COMPONENT) to DockerHub..." ./scripts/publish_containers.sh --dockerhub --component $(COMPONENT) publish-dockerhub-version: @echo "Publishing all images to DockerHub with version $(VERSION)..." ./scripts/publish_containers.sh --dockerhub --version $(VERSION) publish-dockerhub-no-mirror: @echo "Publishing all images to DockerHub (skipping external images)..." ./scripts/publish_containers.sh --dockerhub --skip-mirror publish-local: @echo "Building all images locally (no push)..." ./scripts/publish_containers.sh --local # ======================================== # Local A2A Agent Development # ======================================== compose-up-agents: @echo "Starting A2A agents with docker-compose..." cd agents/a2a && docker-compose -f docker-compose.local.yml up -d @echo "Agents started:" @echo " Flight Booking Agent: http://localhost:9002/ping" @echo " Travel Assistant Agent: http://localhost:9001/ping" compose-down-agents: @echo "Stopping A2A agents..." cd agents/a2a && docker-compose -f docker-compose.local.yml down compose-logs-agents: @echo "Following A2A agent logs..." cd agents/a2a && docker-compose -f docker-compose.local.yml logs -f build-agents: @echo "Building A2A agent images locally..." @$(MAKE) build IMAGE=flight_booking_agent @$(MAKE) build IMAGE=travel_assistant_agent @echo "Both agents built successfully" push-agents: @echo "Pushing A2A agent images to ECR..." @$(MAKE) push IMAGE=flight_booking_agent @$(MAKE) push IMAGE=travel_assistant_agent @echo "Both agents pushed to ECR" ================================================ FILE: NOTICE ================================================ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. Q2 2025 Contributions Copyright Dheeraj Oruganty under MIT License. ================================================ FILE: README.md ================================================
MCP Gateway & Registry Logo **Unified Agent & MCP Server Registry – Gateway for AI Development Tools** [![GitHub stars](https://img.shields.io/github/stars/agentic-community/mcp-gateway-registry?style=flat&logo=github)](https://github.com/agentic-community/mcp-gateway-registry/stargazers) [![GitHub forks](https://img.shields.io/github/forks/agentic-community/mcp-gateway-registry?style=flat&logo=github)](https://github.com/agentic-community/mcp-gateway-registry/network) [![GitHub issues](https://img.shields.io/github/issues/agentic-community/mcp-gateway-registry?style=flat&logo=github)](https://github.com/agentic-community/mcp-gateway-registry/issues) [![License](https://img.shields.io/github/license/agentic-community/mcp-gateway-registry?style=flat)](https://github.com/agentic-community/mcp-gateway-registry/blob/main/LICENSE) [![GitHub release](https://img.shields.io/github/v/release/agentic-community/mcp-gateway-registry?style=flat&logo=github)](https://github.com/agentic-community/mcp-gateway-registry/releases) [🚀 Get Running Now](#option-a-pre-built-images-instant-setup) | [macOS Setup Skill](.claude/skills/macos-setup/SKILL.md) | [AWS Workshop Studio](https://catalog.us-east-1.prod.workshops.aws/workshops/0c3265a6-1a4a-467b-ae56-e4d019184b0e/en-US) | [AWS Deployment](terraform/aws-ecs/README.md) | [Quick Start](#quick-start) | [Documentation](docs/) | [Community](#community) **Demo Videos:** 🎥 [AWS Show & Tell](https://www.youtube.com/watch?v=dk0qVukHLGU) | ⭐ [MCP Registry CLI Demo](https://github.com/user-attachments/assets/98200866-e8bd-4ac3-bad6-c6d42b261dbe) | [Full End-to-End Functionality](https://github.com/user-attachments/assets/5ffd8e81-8885-4412-a4d4-3339bbdba4fb) | [OAuth 3-Legged Authentication](https://github.com/user-attachments/assets/3c3a570b-29e6-4dd3-b213-4175884396cc) | [Dynamic Tool Discovery](https://github.com/user-attachments/assets/cee25b31-61e4-4089-918c-c3757f84518c) | [Agent Skills](https://github.com/user-attachments/assets/5d1f227a-25f8-480d-9ff9-acba2498844b) | [Virtual MCP Servers](https://app.vidcast.io/share/954e6296-f217-4559-8d86-88cec25af763) | [Slide Deck](docs/slides/mcp-gateway-registry-presentation.pdf)
--- ## What is MCP Gateway & Registry? The **MCP Gateway & Registry** is a unified platform designed for centralizing access to both MCP Servers and AI Agents using the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction). It serves three core functions: 1. **Unified MCP Server Gateway** – Centralized access point for multiple MCP servers 2. **MCP Servers Registry** – Register, discover, and manage access to MCP servers with unified governance 3. **Agent Registry & A2A Communication Hub** – Agent registration, discovery, governance, and direct agent-to-agent communication through the [A2A (Agent-to-Agent) Protocol](https://a2a-protocol.org/latest/specification/) The platform integrates with external registries such as Anthropic's MCP Registry (and more to come), providing a single control plane for both tool access, agent orchestration, and agent-to-agent communication patterns. **Why unified?** Instead of managing hundreds of individual MCP server configurations, agent connections, and separate governance systems across your development teams, this platform provides secure, governed access to curated MCP servers and registered agents through a single, unified control plane. **Transform this chaos:** ``` ❌ AI agents require separate connections to each MCP server ❌ Each developer configures VS Code, Cursor, Claude Code individually ❌ Developers must install and manage MCP servers locally ❌ No standard authentication flow for enterprise tools ❌ Scattered API keys and credentials across tools ❌ No visibility into what tools teams are using ❌ Security risks from unmanaged tool sprawl ❌ No dynamic tool discovery for autonomous agents ❌ No curated tool catalog for multi-tenant environments ❌ A2A provides agent cards but no way for agents to discover other agents ❌ Maintaining separate MCP server and agent registries is a non-starter for governance ❌ Impossible to maintain unified policies across server and agent access ``` **Into this organized approach:** ``` ✅ AI agents connect to one gateway, access multiple MCP servers ✅ Single configuration point for VS Code, Cursor, Claude Code ✅ Central IT manages cloud-hosted MCP infrastructure via streamable HTTP ✅ Developers use standard OAuth 2LO/3LO flows for enterprise MCP servers ✅ Centralized credential management with secure vault integration ✅ Complete visibility and audit trail for all tool usage ✅ Security features with governed tool access ✅ Dynamic tool discovery and invocation for autonomous workflows ✅ Registry provides discoverable, curated MCP servers for multi-tenant use ✅ Agents can discover and communicate with other agents through unified Agent Registry ✅ Single control plane for both MCP servers and agent governance ✅ Unified policies and audit trails for both server and agent access ``` ``` ┌─────────────────────────────────────┐ ┌──────────────────────────────────────────────────────┐ │ BEFORE: Chaos │ │ AFTER: MCP Gateway & Registry │ ├─────────────────────────────────────┤ ├──────────────────────────────────────────────────────┤ │ │ │ │ │ Developer 1 ──┬──► MCP Server A │ │ Developer 1 ──┐ ┌─ MCP Server A │ │ ├──► MCP Server B │ │ │ ├─ MCP Server B │ │ └──► MCP Server C │ │ Developer 2 ──┼──► MCP Gateway │ │ │ │ │ │ & Registry ───┼─ MCP Server C │ │ Developer 2 ──┬──► MCP Server A │ ──► │ AI Agent 1 ───┘ │ │ │ │ ├──► MCP Server D │ │ │ ├─ AI Agent 1 │ │ └──► MCP Server E │ │ AI Agent 2 ──────────────┤ ├─ AI Agent 2 │ │ │ │ │ │ │ │ AI Agent 1 ───┬──► MCP Server B │ │ AI Agent 3 ──────────────┘ └─ AI Agent 3 │ │ ├──► MCP Server C │ │ │ │ └──► MCP Server F │ │ Single Connection Point │ │ │ │ │ │ ❌ Multiple connections per user │ │ ✅ One gateway for all │ │ ❌ No centralized control │ │ ✅ Unified server & agent access │ │ ❌ Credential sprawl │ │ ✅ Unified governance & audit trails │ └─────────────────────────────────────┘ └──────────────────────────────────────────────────────┘ ``` > **Note on Agent-to-Agent Communication:** AI Agents discover other AI Agents through the unified Agent Registry and communicate with them **directly** (peer-to-peer) without routing through the MCP Gateway. The Registry handles discovery, authentication, and access control, while agents maintain direct connections for efficient, low-latency communication. ## Unified Agent & Server Registry This platform serves as a comprehensive, unified registry supporting: - ✅ **MCP Server Registration & Discovery** – Register, discover, and manage access to MCP servers - ✅ **AI Agent Registration & Discovery** – Register agents and enable them to discover other agents - ✅ **Agent-to-Agent (A2A) Communication** – Direct agent-to-agent communication patterns using the A2A protocol - ✅ **Multi-Protocol Support** – Support for various agent communication protocols and patterns - ✅ **Unified Governance** – Single policy and access control system for both agents and servers - ✅ **Cross-Protocol Agent Discovery** – Agents can discover each other regardless of implementation - ✅ **Integrated External Registries** – Connect with Anthropic's MCP Registry and other external sources - ✅ **Agent Cards & Metadata** – Rich metadata for agent capabilities, skills, and authentication schemes Key distinction: **Unlike separate point solutions, this unified registry eliminates the need to maintain separate MCP server and agent systems**, providing a single control plane for agent orchestration, MCP server access, and agent-to-agent communication. ## MCP Servers, Agents and Skills Registry Watch how MCP Servers, A2A Agents, and External Registries work together for dynamic tool discovery: https://github.com/user-attachments/assets/97c640db-f78b-4a6c-9662-894f975f66e2 --- ## MCP Tools in Action [View MCP Tools Demo](docs/img/MCP_tools.gif) --- ## MCP Registry CLI Interactive terminal interface for chatting with AI models and discovering MCP tools in natural language. Talk to the registry using a Claude Code-like conversational interface with real-time token status, cost tracking, and AI model selection.
MCP Registry CLI Screenshot
**Quick Start:** `registry --url https://mcpgateway.ddns.net` | [Full Guide](docs/mcp-registry-cli.md) --- ## What's New - **Group-Restricted Agent Visibility** - Agent publishers can now restrict which IdP groups can see their agent by setting `visibility: "group-restricted"` and specifying `allowedGroups` at registration time, without needing an admin to change IAM scopes. Works as a second filter on top of the existing IAM group scope layer: users must pass both the IAM scope check and the allowed_groups check. Nginx forwards JWT group claims via X-Groups header, the list endpoint enforces group filtering for all non-admin users, and the CLI supports `--allowed-groups` for both registration and filtering. Frontend registration and edit forms include a Visibility dropdown and Allowed Groups input. Compatible with all supported IdPs (Keycloak, Entra ID, Cognito, Okta, Auth0). ([#883](https://github.com/agentic-community/mcp-gateway-registry/issues/883), [#922](https://github.com/agentic-community/mcp-gateway-registry/issues/922)) [Full Guide](docs/agent-visibility-and-group-access.md) | [FAQ](docs/faq/group-restricted-agent-visibility.md) - **Admin Data Export** - Download registry data as JSON files for debugging, auditing, and backup. A new Data Export section in the admin Settings page supports 11 collections: Servers, Agents, Skills, Virtual Servers, Federation Peers, Federation Configs, Registry Card, IAM Users, IAM Groups, IAM M2M Clients, and Scopes. Download individual collections or use the Download All as ZIP button (powered by JSZip) with per-collection progress indicators. Includes a sensitive data warning banner and a dedicated scopes export endpoint that dumps full server_access rules. Admin-only access, not visible to non-admin users. - **Centralized Log Rotation, Storage, and Retrieval** - Production-grade application logging with RotatingFileHandler (50 MB, 5 backups) for both the registry and auth-server. Optional MongoDB storage via a non-blocking MongoDBLogHandler with buffered background writes and TTL-based auto-expiry. Admin REST API endpoints (`GET /api/admin/logs` for querying with filters, `GET /api/admin/logs/export` for JSONL download, `GET /api/admin/logs/metadata` for available services and levels) and a Settings UI Log Viewer with filtering by service, level, hostname, search text, and time range. Security includes MongoDB regex injection prevention via `re.escape()`, rate limiting (10 requests per 60 seconds per user), and max search length validation. MongoDB logging is OFF by default; enable with `APP_LOG_MONGODB_ENABLED=true`. File-based rotation is always active. - **Registration Webhooks and Gate** - Two external integration points for registration lifecycle events. **Registration Gate (Admission Control)**: call an external endpoint to approve or deny registration and update requests before they are persisted. Supports all asset types (servers, agents, skills) for both register and update operations. Fail-closed design: if the gate endpoint is unreachable after configurable retries with exponential backoff, the registration is blocked. Sensitive fields (credentials, tokens, passwords) are automatically stripped from the payload sent to the gate. Supports Bearer token, API key, or unauthenticated access. Gate returns 200 to allow, 403 to deny with a custom error message. **Registration Webhooks**: send HTTP POST notifications to an external URL when servers, agents, or skills are registered or deleted. Enables real-time integration with CMDBs, CI/CD pipelines, Slack, or any external system. Fire-and-forget delivery (failures are logged, never block the caller). Supports Bearer token and custom API key authentication with configurable headers and timeouts. Both are configured across Docker Compose, Terraform/ECS, and Helm/EKS. ([#809](https://github.com/agentic-community/mcp-gateway-registry/issues/809), [#742](https://github.com/agentic-community/mcp-gateway-registry/issues/742)) [Webhooks and Gate Guide](docs/registration-webhooks.md) - **Multi-Key Static Tokens with Per-Key Groups** - Replace the single `REGISTRY_API_TOKEN` with a `REGISTRY_API_KEYS` JSON object where each key carries its own name, secret, and group list. Groups resolve to scopes through the standard `group_mappings` pipeline, so each key gets exactly the privileges its groups grant. Supports zero-downtime rotation (add new key, migrate clients, remove old key), timing-safe comparison via `hmac.compare_digest`, and full coexistence with the legacy single token. The legacy token is auto-promoted to a `"legacy"` entry in the internal token map with `mcp-registry-admin` scopes for backward compatibility. Key names appear in audit logs as the username for traceability. Scope changes propagate to static tokens via the auth server reload mechanism without requiring a restart. ([#779](https://github.com/agentic-community/mcp-gateway-registry/issues/779)) [Registry API Authentication Guide](docs/registry-api-auth.md) | [FAQ](docs/faq/registry-api-auth-faq.md) - **Registry API Authentication: Unified Model** - The Registry API (`/api/*`, `/v0.1/*`) accepts four credential types **concurrently**: session cookies (browser UI), IdP-issued JWTs (Okta/Entra/Cognito/Keycloak) and UI-issued self-signed JWTs, a static `REGISTRY_API_TOKEN` for trusted service-to-service callers, and a separate `FEDERATION_STATIC_TOKEN` scoped to federation/peer endpoints only. Previously, enabling static-token mode silently blocked JWT callers on `/api/*` ([#871](https://github.com/agentic-community/mcp-gateway-registry/issues/871)). One improvement remains on the roadmap: external user access tokens that let a frontend app call the registry on behalf of its logged-in users without sharing a registry credential ([#826](https://github.com/agentic-community/mcp-gateway-registry/issues/826)). [Registry API Authentication Guide](docs/registry-api-auth.md) | [FAQ](docs/faq/registry-api-auth-faq.md) - **GitHub Private Repository Auth for Agent Skills** - Fetch SKILL.md files from private GitHub repositories using Personal Access Tokens (PAT) or GitHub App authentication (recommended for organizations). Supports GitHub Enterprise Server with configurable API base URLs and extra host matching. Auth headers are only sent to github.com, raw.githubusercontent.com, and explicitly listed hosts. Helm deployments support Kubernetes secrets for credential injection. Configured across Docker Compose, Helm, and Terraform/ECS. See [`.env.example`](.env.example) for all parameters. [Configuration Guide](docs/configuration.md#github-private-repository-access) - **Configurable Tab Visibility, Pagination, and Lifecycle Filtering** - Four new `SHOW_*_TAB` environment variables control which dashboard tabs are visible independently of `REGISTRY_MODE` (formula: `tab_visible = mode enables feature AND SHOW_*_TAB`). All APIs (`GET /api/servers`, `/api/agents`, `/api/skills`) now support `limit`/`offset` pagination. New lifecycle status filtering in the sidebar lets you filter by active, deprecated, or experimental assets. Also adds `GET /api/servers/{path}` for single server retrieval, improved semantic search ranking (global ranking replaces the old 3-per-type cap), network-trusted JWT token generation, and heartbeat telemetry opt-out. See [`.env.example`](.env.example) for all new configuration parameters. [Tab Visibility Configuration](docs/configuration.md#tab-visibility-overrides) - **AWS Agent Registry Federation** - Federate MCP servers, A2A agents, and agent skills from [AWS Agent Registry](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/registry.html) into MCP Gateway Registry. Add multiple AgentCore registries (same or different AWS accounts/regions), select which descriptor types to sync (MCP, A2A, CUSTOM, AGENT_SKILLS), and manage everything from the External Registries settings page. Supports cross-account access via IAM role assumption, cascade cleanup on registry removal, and automatic sync on startup. Enable with a single environment variable (`AWS_REGISTRY_FEDERATION_ENABLED=true`) for ECS/Terraform or Helm deployments. [Operations Guide](docs/aws-agent-registry-federation.md) | [Design Document](docs/design/aws-agent-registry-federation.md) - **Register Any Agent (A2A and Non-A2A)** - The Agent Registry now supports registering any agent, not just A2A protocol agents. A new `supported_protocol` field (`a2a` or `other`) distinguishes agent types. Register through the UI (checkbox for A2A agents, dropdown for protocol selection on edit), the API (`supportedProtocol` field in registration payload), or the CLI (`--supported-protocol` flag). Default `trust_level` updated to `community` and `visibility` to `public` for consistency. A one-time [backfill script](scripts/backfill_agent_fields.py) normalizes existing agents in MongoDB. Two new Claude Code skills -- [generate-agent-card](.claude/skills/generate-agent-card/SKILL.md) and [generate-server-card](.claude/skills/generate-server-card/SKILL.md) -- analyze source code and generate registration-ready agent or server card JSON files. [Documentation](docs/supported-protocol-and-trust-fields.md) - **Amazon Bedrock AgentCore Bulk Import** - Auto-discover and register all AgentCore Gateways and Agent Runtimes from your AWS account in a single command. The CLI scans for READY resources, registers gateways as MCP Servers and runtimes as MCP Servers or A2A Agents based on protocol, and writes a token refresh manifest for automated credential rotation. Supports multi-account scanning, OIDC-compliant identity providers (Cognito, Auth0, Okta, Entra ID, Keycloak), and overwrite mode for updating existing registrations. [AgentCore Operations Guide](docs/agentcore.md) | [Design Document](docs/design/agentcore-scanner-design.md) - **Anonymous Usage Telemetry** - Privacy-first telemetry to track registry adoption patterns. Sends only non-sensitive deployment metadata (version, OS, storage backend, auth provider) -- no PII, no hostnames, no user data. Opt-out by default (startup ping is ON, set `MCP_TELEMETRY_DISABLED=1` to disable). Opt-in daily heartbeat with aggregate counts (server/agent/skill totals). HMAC-signed requests, IP-hashed rate limiting, strict schema validation, and fail-silent design ensure zero impact on registry operation. Admin API to force heartbeat/startup events on demand. [Telemetry Documentation](docs/TELEMETRY.md) - **Agent Name Service (ANS) Integration** - Adds PKI-based trust verification for registered agents and MCP servers through GoDaddy's [Agent Name Service](https://www.godaddy.com/ans). Agent owners link their ANS Agent ID to their registry entry, and the registry verifies the identity via the ANS API, displaying a clickable trust badge on agent cards and semantic search results. A background scheduler re-verifies all linked identities every 6 hours with circuit breaker protection. Supports verified, expired, and revoked status tracking with admin endpoints for manual sync, metrics, and health checks. [Design and Operations Guide](docs/design/ans-integration.md) | [Demo Video](https://app.vidcast.io/share/c2240a78-8899-46ad-9375-6fb0cc1345f3?playerMode=vidcast) - **Registry Card for Federation Discovery** - As registries increasingly need to discover and communicate with each other, we've implemented the Registry Card specification—a standardized discovery document accessible via `/.well-known/registry-card`. This provides essential metadata including authentication endpoints, capabilities, and contact information for any registry instance. Enhanced server, agent, and skills cards with richer metadata enable better federation workflows. [Registry Card Configuration Guide](docs/federation-operational-guide.md#registry-card-configuration) - 🔑 **Auth0 Identity Provider Support** - Full enterprise SSO integration with Auth0 as an identity provider. The harmonized IAM API now supports Auth0 alongside Keycloak, Microsoft Entra ID, and Okta, providing a unified interface to create users, groups, and M2M service accounts regardless of your IdP choice. Features include Auth0 Actions for group claims injection, M2M client sync with database-driven groups enrichment for OAuth2 Client Credentials tokens, and complete Docker Compose and Terraform/ECS deployment support. Switch identity providers with a single environment variable while using the same management APIs and UI. [Auth0 Setup Guide](docs/auth0.md) - 🔑 **Okta Identity Provider Support** - Full enterprise SSO integration with Okta as an identity provider. The existing harmonized IAM API now supports Okta alongside Keycloak and Microsoft Entra ID, providing a unified interface to create users, groups, and M2M service accounts regardless of your IdP choice. Features include custom authorization server support for scalable M2M authentication, database-driven groups enrichment for OAuth2 Client Credentials tokens, and complete Docker Compose and Terraform/ECS deployment support. Switch identity providers with a single environment variable while using the same management APIs and UI. [Okta Setup Guide](docs/okta-setup.md) - 🔐 **Enterprise Security Posture Documentation** - Comprehensive security architecture documentation covering defense-in-depth across all deployment platforms (ECS, EKS, Docker Compose). Details infrastructure security, encryption at rest/in-transit with KMS, secrets management with automated rotation, container hardening following CIS benchmarks, application security with automated scanning (Semgrep, Bandit), supply chain security for MCP servers, and compliance with SOC 2/GDPR standards. [Security Posture Guide](docs/security-posture.md) - **📊 Direct OTLP Push Export for Metrics** - Push metrics directly to any OTLP-compatible observability platform (Datadog, New Relic, Honeycomb, Grafana Cloud) without requiring an intermediate OTEL Collector. Configure via environment variables (`OTEL_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`) for instant integration with commercial observability platforms. Supports both Docker Compose and Terraform/ECS deployments with secure credential handling via AWS Secrets Manager. Works alongside existing Prometheus/Grafana setup for hybrid monitoring. [Metrics Architecture Guide - Direct OTLP Push](docs/metrics-architecture.md#direct-otlp-push-export-simplified-setup) - ⭐ **AWS Workshop Studio: Securing AI Agent Ecosystems with MCP Gateway and Registry** - Hands-on workshop covering deployment, authentication, governance, and security best practices for production AI agent ecosystems. Learn to deploy the MCP Gateway & Registry on AWS, configure enterprise authentication, implement fine-grained access control, and secure AI agent communications. [Start Workshop](https://catalog.us-east-1.prod.workshops.aws/workshops/0c3265a6-1a4a-467b-ae56-e4d019184b0e/en-US) - 💻 **One-Command macOS Setup** - The quickest way to get started and experiment with the solution on your MacBook. Simply ask Claude Code or your favorite AI coding assistant to use the [macOS Setup Skill](.claude/skills/macos-setup/SKILL.md) and it will automatically clone the repository, install all dependencies, configure services (MongoDB, Keycloak, registry), register sample servers, and verify the complete stack is running. Perfect for single-developer environments and hands-on exploration. Supports both full setup and complete teardown with a single command. *ECS/EKS deployment skill coming very soon.* - **AI Registry MCP Server (airegistry-tools)** - Enables AI coding assistants (Claude Code, Roo Code, Cursor, etc.) to discover and query MCP servers, agents, and skills directly from the registry. Provides 5 tools: `list_services`, `list_agents`, `list_skills`, `intelligent_tool_finder` (semantic search), and `healthcheck`. Auto-registered on registry startup with no manual setup required. See [AI Registry Tools documentation](docs/ai-registry-tools.md) for details. - **Governance & Security Enhancements** - Enhanced audit logging with searchable filters (username, MCP server) and statistics dashboard showing top users, operations, timeline charts, and per-user activity breakdowns. System uptime and health stats now visible in the header with deployment info, registry statistics, and database status. Comprehensive security hardening via Bandit scanning addressed subprocess security (B603/B607), SQL injection prevention (B608), hardcoded credentials detection (B105), and other vulnerability patterns across the codebase. All security findings documented and resolved with proper justifications for necessary exceptions. - **IAM Settings UI** - Visual interface for managing users, groups, and M2M service accounts directly from the web UI. Create and configure access control groups with fine-grained permissions for servers, tools, agents, and UI features. Manage human users with group assignments, and create M2M service accounts for AI agents with OAuth2 client credentials. Features include searchable server/agent/tool selectors, JSON import/export for scope configurations, and support for both MCP servers and virtual servers in access rules. Works with both Keycloak and Microsoft Entra ID identity providers. [IAM Settings Guide](docs/iam-settings-ui.md) - **System Configuration Viewer** - View and export all registry configuration parameters through the Settings UI. Admin-only panel displays 11 configuration groups (Deployment, Storage, Auth, Embeddings, Health, WebSocket, Security Scanning, Audit, Federation, Discovery) with sensitive value masking. Export configuration in ENV, JSON, TFVARS, or YAML formats for deployment automation. API endpoints provide programmatic access at `/api/config/full` and `/api/config/export`. [Configuration Guide](docs/configuration.md#viewing-configuration-via-ui) - **Virtual MCP Server Support** - Aggregate tools, resources, and prompts from multiple backend MCP servers into a single unified endpoint. Clients connect to one virtual server that presents a curated, access-controlled view of capabilities from any combination of registered backends. Features include tool aliasing (resolve naming conflicts), version pinning (lock to specific backend versions), per-tool scope-based access control, session multiplexing (one client session maps to N backend sessions transparently), and 60-second cached aggregation for `tools/list`, `resources/list`, and `prompts/list`. Supports all MCP JSON-RPC methods including `initialize`, `ping`, `tools/call`, `resources/read`, and `prompts/get`. [Design Document](docs/design/virtual-mcp-server.md) | [Operations Guide](docs/virtual-server-operations.md) - **Registry-Only Deployment Mode** - Run the registry as a standalone catalog/discovery service without nginx gateway integration. In `registry-only` mode, nginx configuration is not updated when servers are registered, and MCP proxy requests return 503 with instructions to use direct connection. The frontend adapts to show `proxy_pass_url` instead of gateway URLs. Combined with `REGISTRY_MODE` settings (`full`, `skills-only`, `mcp-servers-only`, `agents-only`), you can configure the registry for specific use cases. For example, set `REGISTRY_MODE=skills-only` to run a dedicated Skills Registry that only manages Agent Skills (SKILL.md files) without MCP servers or A2A agents - ideal for teams that want a lightweight skill library. The UI automatically adapts to show only relevant features, and API endpoints for disabled features return 503. Invalid combinations like `with-gateway + skills-only` are auto-corrected with warnings. [Registry Deployment Modes Guide](docs/registry-deployment-modes.md) - **Agent Skills Registry** - Register, discover, and manage reusable instruction sets (SKILL.md files) that enhance AI coding assistants with specialized workflows. Skills are hosted on GitHub, GitLab, or Bitbucket and registered in the MCP Gateway Registry for discovery and access control. Features include YAML frontmatter parsing for metadata extraction, health monitoring with URL accessibility checks, visibility controls (public/private/group), star ratings, semantic search integration, tool dependency validation, and a rich UI with SKILL.md content modals. Security includes automatic security scanning during registration using [Cisco AI Defense Skill Scanner](https://github.com/cisco-ai-defense/cisco-ai-skill-scanner) with YARA pattern matching, LLM analysis, and static code inspection. SSRF protection with redirect validation ensures safe URL handling. [Agent Skills Guide](docs/agent-skills-operational-guide.md) | [Architecture](docs/design/agent-skills-architecture.md) | [Security Scanning](docs/security-scanner.md#agent-skills-security-scanning) - **📋 Compliance Audit Logging** - Comprehensive audit logging for security monitoring and compliance. Captures all Registry API and MCP Gateway access events with user identity, operation details, and timing. Features include automatic credential masking (tokens, cookies, passwords are never logged), TTL-based log retention (default 7 days, configurable), admin-only audit viewer UI with filtering and export (JSONL/CSV), and non-blocking async design. Supports SOC 2 and GDPR requirements with who/what/when/where/outcome tracking. [Audit Logging Guide](docs/audit-logging.md) - **🌐 Peer-to-Peer Registry Federation** - Connect multiple MCP Gateway Registry instances for bidirectional server and agent synchronization. Central IT teams can aggregate visibility across Line of Business registries, or LOBs can inherit shared tools from a central hub. Features include configurable sync modes (all, whitelist, tag filter), scheduled and on-demand sync, static token authentication for IdP-agnostic deployments, Fernet-encrypted credential storage, generation-based orphan detection, and path namespacing to prevent collisions. Synced items are read-only and display their source registry. A VS Code-style Settings UI provides peer management, sync triggering, and status monitoring. [Architecture Design](docs/design/federation-architecture.md) | [Operational Guide](docs/federation-operational-guide.md) - **🔑 Static Token Auth for Registry API** - Access Registry API endpoints (`/api/*`, `/v0.1/*`) using a static API key instead of IdP-based JWT validation. Designed for trusted network environments, CI/CD pipelines, and CLI tooling where configuring a full identity provider may not be practical. MCP Gateway endpoints continue to require full IdP authentication. Includes startup validation that disables the feature if no token is configured. [Registry API Authentication Guide](docs/registry-api-auth.md) - **🔀 MCP Server Version Routing** - Run multiple versions of the same MCP server simultaneously behind a single gateway endpoint. Register new versions as inactive, test them with the `X-MCP-Server-Version` header, then promote to active with a single API call or UI click. Features include instant rollback, version pinning for clients, deprecation lifecycle with sunset dates, automatic nginx map-based O(1) routing, cascade deletion of all versions, and post-swap health checks. The dashboard displays both the admin-controlled routing version and the MCP server-reported software version independently. Only the active version appears in search results and health checks. [Design Document](docs/design/server-versioning.md) | [Operations Guide](docs/server-versioning-operations.md) - **👥 Multi-Provider IAM with Harmonized API** - Full Identity and Access Management support for Keycloak, Microsoft Entra ID, Okta, and Auth0. The registry API provides a unified experience for user and group management regardless of which IdP you use. Human users can log in via the UI and generate self-signed JWT tokens (with the same permissions as their session) for CLI tools and AI coding assistants. Service accounts (M2M) enable AI agent identity with OAuth2 Client Credentials flow. Fine-grained access control through scopes defines exactly which MCP servers, methods, tools, and agents each user can access. [Authentication Design](docs/design/authentication-design.md) | [IdP Provider Architecture](docs/design/idp-provider-support.md) | [Scopes Management](docs/scopes-mgmt.md) | [Entra ID Setup](docs/entra-id-setup.md) | [Okta Setup](docs/okta-setup.md) | [Auth0 Setup](docs/auth0.md) - **🏷️ Custom Metadata for Servers & Agents** - Add rich custom metadata to MCP servers and agents for organization, compliance, and integration tracking. Metadata is fully searchable via semantic search, enabling queries like "team:data-platform", "PCI-DSS compliant", or "owner:alice@example.com". Use cases include team ownership, compliance tracking (PCI-DSS, HIPAA), cost center allocation, deployment regions, JIRA tickets, and custom tags. Backward compatible with existing registrations. [Metadata Usage Guide](docs/custom-metadata.md) - **🔎 Enhanced Hybrid Search** - Improved semantic search combining vector similarity with tokenized keyword matching for servers, tools, and agents. Explicit name references now boost relevance scores, ensuring exact matches appear first. [Hybrid Search Architecture](docs/design/hybrid-search-architecture.md) - **🛡️ Security Scan Results in UI** - Security scan results are now displayed directly on Server and Agent cards with color-coded shield icons (gray/green/red). Click the shield icon to view detailed scan results and trigger rescans from the UI. [Security Scanner Documentation](docs/security-scanner.md) - **🧪 Comprehensive Test Suite & Updated LLM Documentation** - Full pytest test suite with 701+ passing tests (unit, integration, E2E) running automatically on all PRs via GitHub Actions. 35% minimum coverage (targeting 80%), ~30 second execution with 8 parallel workers. Updated llms.txt provides comprehensive documentation for LLM coding assistants covering storage backend migration (file → DocumentDB/MongoDB), repository patterns, AWS ECS deployment, Microsoft Entra ID integration, dual security scanning, federation architecture, rating system, testing standards, and critical code organization antipatterns. [Testing Guide](docs/testing/README.md) | [docs/llms.txt](docs/llms.txt) - **📊 DocumentDB & MongoDB CE Storage Backend** - Distributed storage with MongoDB-compatible backends. DocumentDB provides native HNSW vector search for sub-100ms semantic queries in production deployments, while MongoDB Community Edition 8.2 enables full-featured local development with replica sets. Both backends use the same repository abstraction layer with automatic collection management, optimized indexes, and application-level vector search for MongoDB CE. Switch between MongoDB CE (local testing) and DocumentDB (production) with a single environment variable. Note: File-based storage is deprecated and will be removed in a future release. MongoDB CE is recommended for local development. [Configuration Guide](docs/configuration.md#storage-backend-configuration) | [Storage Architecture](docs/design/storage-architecture-mongodb-documentdb.md) - **🔒 A2A Agent Security Scanning** - Integrated security scanning for A2A agents using [Cisco AI Defense A2A Scanner](https://github.com/cisco-ai-defense/a2a-scanner). Automatic security scans during agent registration with YARA pattern matching, A2A specification validation, and heuristic threat detection. Features include automatic tagging of unsafe agents, configurable blocking policies, and detailed scan reports with API endpoints for viewing results and triggering rescans. - **🔧 Registry Management API** - New programmatic API for managing servers, groups, and users. Python client (`api/registry_client.py`) with type-safe interfaces, RESTful HTTP endpoints (`/api/management/*`), and comprehensive error handling. Replaces shell scripts with modern API approach while maintaining backward compatibility. [API Documentation](api/README.md) | [Service Management Guide](docs/service-management.md) - **⭐ Server & Agent Rating System** - Rate and review agents with an interactive 5-star rating widget. Users can submit ratings via the UI or CLI, view aggregate ratings with individual rating details, and update their existing ratings. Features include a rotating buffer (max 100 ratings per agent), one rating per user, float average calculations, and full OpenAPI documentation. Enables community-driven agent quality assessment and discovery. - **🧠 Flexible Embeddings Support** - Choose from three embedding provider options for semantic search: local sentence-transformers, OpenAI, or any LiteLLM-supported provider including Amazon Bedrock Titan, Cohere, and 100+ other models. Switch providers with simple configuration changes. [Embeddings Guide](docs/embeddings.md) - **☁️ AWS ECS Deployment** - Deployment configuration on Amazon ECS Fargate with multi-AZ architecture, Application Load Balancer with HTTPS, auto-scaling, CloudWatch monitoring, and NAT Gateway redundancy. Complete Terraform configuration for deploying the entire stack. [ECS Deployment Guide](terraform/aws-ecs/README.md) - **📦 Flexible Deployment Modes** - Three deployment options to match your requirements: (1) CloudFront Only for quick setup without custom domains, (2) Custom Domain with Route53/ACM for branded URLs, or (3) CloudFront + Custom Domain for production with CDN benefits. [Deployment Modes Guide](docs/deployment-modes.md) - **🔗 Federated Registry** - MCP Gateway registry now supports federation of servers and agents from other registries. [Federation Guide](docs/federation.md) - **🔗 Agent-to-Agent (A2A) Protocol Support** - Agents can now register, discover, and communicate with other agents through a secure, centralized registry. Enable autonomous agent ecosystems with Keycloak-based access control and fine-grained permissions. [A2A Guide](docs/a2a.md) - **🏢 Microsoft Entra ID Integration** - Enterprise SSO with Microsoft Entra ID (Azure AD) authentication. Group-based access control, conditional access policies, and seamless integration with existing Microsoft 365 environments. [Entra ID Setup Guide](docs/entra-id-setup.md) - **🤖 Agentic CLI for MCP Registry** - Talk to the Registry in natural language using a Claude Code-like interface. Discover tools, ask questions, and execute MCP commands conversationally. [Learn more](docs/mcp-registry-cli.md) - **🔒 MCP Server Security Scanning** - Integrated vulnerability scanning with [Cisco AI Defense MCP Scanner](https://github.com/cisco-ai-defense/mcp-scanner). Automatic security scans during server registration, periodic registry-wide scans with detailed markdown reports, and automatic disabling of servers with security issues. - **📥 Import Servers from Anthropic MCP Registry** - Import curated MCP servers from Anthropic's registry with a single command. [Import Guide](docs/anthropic-registry-import.md) - **🔌 Anthropic MCP Registry REST API Compatibility** - Full compatibility with Anthropic's MCP Registry REST API specification. [API Documentation](docs/anthropic_registry_api.md) - **🔎 Unified Semantic Search for Servers, Tools & Agents** - Natural-language search across every MCP server, its tools, and registered A2A agents using `POST /api/search/semantic`. Works from the dashboard UI (session cookie auth) or programmatically with JWT Bearer tokens, returning relevance-scored matches per entity type in a single response. - **🚀 Pre-built Images** - Deploy instantly with pre-built Docker images. [Get Started](#option-a-pre-built-images-instant-setup) | [macOS Guide](docs/macos-setup-guide.md) - **🔐 Keycloak Integration** - Enterprise authentication with AI agent audit trails and group-based authorization. [Learn more](docs/keycloak-integration.md) - **⚡ Amazon Bedrock AgentCore Integration** - AgentCore Gateway support with dual authentication. [Integration Guide](docs/agentcore.md) --- ## A2A Agents - Example Implementations The registry includes two example A2A agents that demonstrate how both human developers and autonomous AI agents can discover, register, and use agents through the unified Agent Registry. Agents can programmatically discover other agents via semantic search and use them through the A2A protocol, enabling dynamic agent composition and autonomous agent orchestration. ### Example Agents | Agent | Path | Skills | |-------|------|--------| | **Travel Assistant Agent** | `/travel-assistant-agent` | Flight search, pricing checks, recommendations, trip planning | | **Flight Booking Agent** | `/flight-booking-agent` | Availability checks, flight reservations, payments, reservation management | ### Agent Discovery **View in Registry UI:** Open the registry and navigate to the **A2A Agents** tab to browse registered agents with their full metadata, capabilities, and skills. **Search via CLI:** Developers can search for agents by natural language description: ```bash # Search for agents that can help book a trip cli/agent_mgmt.sh search "need an agent to book a trip" ``` **Example Output:** ``` Found 4 agent(s) matching 'need an agent to book a trip': -------------------------------------------------------------------------------------------------------------- Agent Name | Path | Score -------------------------------------------------------------------------------------------------------------- Travel Assistant Agent | /travel-assistant-agent | 0.8610 Flight Booking Agent | /flight-booking-agent | 1.2134 -------------------------------------------------------------------------------------------------------------- ``` ### Agent-to-Agent Discovery API The registry provides a **semantic search API** that agents can use as a tool to discover other A2A agents at runtime. This API enables dynamic agent composition where agents find collaborators based on capabilities rather than hardcoded references. **Discovery API Endpoint:** ``` POST /api/agents/discover/semantic?query=&max_results=5 Authorization: Bearer ``` **Response includes:** - Agent name, description, and endpoint URL - Agent card metadata with skills and capabilities - Relevance score for ranking matches - Trust level and visibility settings **How agents use it:** 1. An agent calls the registry's semantic search API with a natural language query (e.g., "agent that can book flights") 2. The registry returns matching agents with their endpoint URLs and full agent card metadata 3. The agent uses the agent card to understand capabilities and invokes the discovered agent via A2A protocol **Example - Travel Assistant discovering and invoking Flight Booking Agent:** ``` User: "I need to book a flight from NYC to LA" Travel Assistant: 1. Calls registry API: POST /api/agents/discover/semantic?query="book flights" 2. Registry returns Flight Booking Agent with endpoint URL and agent card 3. Uses agent card to understand capabilities, then sends A2A message to Flight Booking Agent 4. Returns booking confirmation to user ``` This pattern enables agents to dynamically extend their capabilities by discovering specialized agents for tasks they cannot handle directly. **Agent Cards:** View the agent card metadata at [agents/a2a/test/](agents/a2a/test/) to see the complete agent definitions including skills, protocols, and capabilities. For complete agent deployment and testing documentation, see [agents/a2a/README.md](agents/a2a/README.md). --- ## Core Use Cases ### AI Agent & Coding Assistant Governance Provide both autonomous AI agents and human developers with secure access to approved tools through AI coding assistants (VS Code, Cursor, Claude Code) while maintaining IT oversight and compliance. ### Enterprise Security & Compliance Centralized authentication, fine-grained permissions, and comprehensive audit trails for SOX/GDPR compliance pathways across both human and AI agent access patterns. ### Dynamic Tool Discovery AI agents can autonomously discover and execute specialized tools beyond their initial capabilities using intelligent semantic search, while developers get guided tool discovery through their coding assistants. ### Unified Access Gateway Single gateway supporting both autonomous AI agents (machine-to-machine) and AI coding assistants (human-guided) with consistent authentication and tool access patterns. --- ## Architecture The MCP Gateway & Registry provides a unified platform for both autonomous AI agents and AI coding assistants to access enterprise-curated tools through a centralized gateway with comprehensive authentication and governance. ```mermaid flowchart TB subgraph Human_Users["Human Users"] User1["Human User 1"] User2["Human User 2"] UserN["Human User N"] end subgraph AI_Agents["AI Agents"] Agent1["AI Agent 1"] Agent2["AI Agent 2"] Agent3["AI Agent 3"] AgentN["AI Agent N"] end subgraph EC2_Gateway["MCP Gateway & Registry (Amazon EC2 Instance)"] subgraph NGINX["NGINX Reverse Proxy"] RP["Reverse Proxy Router"] end subgraph AuthRegistry["Authentication & Registry Services"] AuthServer["Auth Server
(Dual Auth)"] Registry["Registry
Web UI"] RegistryMCP["Registry
MCP Server"] end subgraph LocalMCPServers["Local MCP Servers"] MCP_Local1["MCP Server 1"] MCP_Local2["MCP Server 2"] end end %% Identity Provider IdP[Identity Provider
Keycloak/Cognito] subgraph EKS_Cluster["Amazon EKS/EC2 Cluster"] MCP_EKS1["MCP Server 3"] MCP_EKS2["MCP Server 4"] end subgraph APIGW_Lambda["Amazon API Gateway + AWS Lambda"] API_GW["Amazon API Gateway"] Lambda1["AWS Lambda Function 1"] Lambda2["AWS Lambda Function 2"] end subgraph External_Systems["External Data Sources & APIs"] DB1[(Database 1)] DB2[(Database 2)] API1["External API 1"] API2["External API 2"] API3["External API 3"] end %% Connections from Human Users User1 -->|Web Browser
Authentication| IdP User2 -->|Web Browser
Authentication| IdP UserN -->|Web Browser
Authentication| IdP User1 -->|Web Browser
HTTPS| Registry User2 -->|Web Browser
HTTPS| Registry UserN -->|Web Browser
HTTPS| Registry %% Connections from Agents to Gateway Agent1 -->|MCP Protocol
SSE with Auth| RP Agent2 -->|MCP Protocol
SSE with Auth| RP Agent3 -->|MCP Protocol
Streamable HTTP with Auth| RP AgentN -->|MCP Protocol
Streamable HTTP with Auth| RP %% Auth flow connections RP -->|Auth validation| AuthServer AuthServer -.->|Validate credentials| IdP Registry -.->|User authentication| IdP RP -->|Tool discovery| RegistryMCP RP -->|Web UI access| Registry %% Connections from Gateway to MCP Servers RP -->|SSE| MCP_Local1 RP -->|SSE| MCP_Local2 RP -->|SSE| MCP_EKS1 RP -->|SSE| MCP_EKS2 RP -->|Streamable HTTP| API_GW %% Connections within API GW + Lambda API_GW --> Lambda1 API_GW --> Lambda2 %% Connections to External Systems MCP_Local1 -->|Tool Connection| DB1 MCP_Local2 -->|Tool Connection| DB2 MCP_EKS1 -->|Tool Connection| API1 MCP_EKS2 -->|Tool Connection| API2 Lambda1 -->|Tool Connection| API3 %% Style definitions classDef user fill:#fff9c4,stroke:#f57f17,stroke-width:2px classDef agent fill:#e1f5fe,stroke:#29b6f6,stroke-width:2px classDef gateway fill:#e8f5e9,stroke:#66bb6a,stroke-width:2px classDef nginx fill:#f3e5f5,stroke:#ab47bc,stroke-width:2px classDef mcpServer fill:#fff3e0,stroke:#ffa726,stroke-width:2px classDef eks fill:#ede7f6,stroke:#7e57c2,stroke-width:2px classDef apiGw fill:#fce4ec,stroke:#ec407a,stroke-width:2px classDef lambda fill:#ffebee,stroke:#ef5350,stroke-width:2px classDef dataSource fill:#e3f2fd,stroke:#2196f3,stroke-width:2px %% Apply styles class User1,User2,UserN user class Agent1,Agent2,Agent3,AgentN agent class EC2_Gateway,NGINX gateway class RP nginx class AuthServer,Registry,RegistryMCP gateway class IdP apiGw class MCP_Local1,MCP_Local2 mcpServer class EKS_Cluster,MCP_EKS1,MCP_EKS2 eks class API_GW apiGw class Lambda1,Lambda2 lambda class DB1,DB2,API1,API2,API3 dataSource ``` **Key Architectural Benefits:** - **Unified Gateway**: Single point of access for both AI agents and human developers through coding assistants - **Dual Authentication**: Supports both human user authentication and machine-to-machine agent authentication - **Scalable Infrastructure**: Nginx reverse proxy with horizontal scaling capabilities - **Multiple Transports**: SSE and Streamable HTTP support for different client requirements --- ## Key Advantages ### **Security Features** - OAuth 2.0/3.0 compliance with IdP integration - Fine-grained access control at tool and method level - Zero-trust network architecture - Complete audit trails and comprehensive analytics for compliance ### **AI Agent & Developer Experience** - Single configuration works across autonomous AI agents and AI coding assistants (VS Code, Cursor, Claude Code, Cline) - Dynamic tool discovery with natural language queries for both agents and humans - Instant onboarding for new team members and AI agent deployments - Unified governance for both AI agents and human developers ### **Deployment Features** - Container-native (Docker/Kubernetes) - Real-time health monitoring and alerting - Dual authentication supporting both human and machine authentication --- ## Quick Start There are 4 options for setting up the MCP Gateway & Registry: - **Option A: AI-Assisted macOS Setup** — The absolute fastest way to get started on macOS. Ask your AI coding assistant to use the [macOS Setup Skill](.claude/skills/macos-setup/SKILL.md) for fully automated one-command setup. Perfect for experimentation. - **Option B: Pre-built Images** — Fast setup using pre-built Docker or Podman containers. Recommended for most users. - **Option C: Podman (Rootless)** — Detailed Podman-specific instructions for macOS and rootless Linux environments. - **Option D: Build from Source** — Full source build for customization or development. ### Option A: AI-Assisted macOS Setup (Fastest) **The easiest way to get started on macOS.** Simply ask Claude Code or your AI coding assistant: > "Use the macOS setup skill to install and configure the MCP Gateway & Registry" The [macOS Setup Skill](.claude/skills/macos-setup/SKILL.md) will automatically: - ✅ Clone the repository and install all dependencies (Homebrew, Python, UV, Docker, Node.js) - ✅ Configure and start MongoDB with replica set - ✅ Set up and initialize Keycloak with admin user - ✅ Start the registry and auth server - ✅ Register the Cloudflare MCP docs server - ✅ Verify the complete stack is operational **Perfect for:** Single-developer experimentation, quick demos, hands-on exploration **What you need:** macOS with an AI coding assistant (Claude Code, Cursor, etc.) **Clean up:** When done, ask your AI assistant to "teardown the MCP Gateway setup" for complete removal. *Note: ECS/EKS deployment skill coming very soon for production deployments.* --- ### Option B: Pre-built Images (Instant Setup) Get running with pre-built Docker containers in minutes. This is the recommended approach for most users. ```bash # Clone and configure git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry cp .env.example .env # Edit .env with your passwords (KEYCLOAK_ADMIN_PASSWORD, etc.) nano .env # Deploy with pre-built images export DOCKERHUB_ORG=mcpgateway ./build_and_run.sh --prebuilt # Access the Registry UI open http://localhost:7860 # macOS # xdg-open http://localhost:7860 # Linux ``` **[Complete Quick Start Guide](docs/quickstart.md)** - Full step-by-step instructions including: - Prerequisites installation (Docker, Python, UV) - Environment configuration - MongoDB and Keycloak initialization - User and service account setup - Server and agent registration - Testing the gateway functionality **Benefits:** No build time | No Node.js required | No frontend compilation | Consistent tested images --- ### Option C: Podman (Rootless Container Deployment) **Perfect for macOS and rootless Linux environments** Podman provides rootless container execution without requiring privileged ports, making it ideal for: - **macOS** users with Podman Desktop - **Linux** users preferring rootless containers - **Development** environments where Docker daemon isn't available **Quick Podman Setup (macOS non-Apple Silicon):** ```bash # Install Podman Desktop brew install podman-desktop # OR download from: https://podman-desktop.io/ ``` Inside Podman Desktop, go to Preferences > Podman Machine and create a new machine with at least 4 CPUs and 8GB RAM. Alternatively, see more detailed [Podman installation guide](docs/installation.md#podman-installation) for instructions on setting this up on CLI. ```bash # Initialize Podman machine podman machine init podman machine start # Verify installation podman --version podman compose version # Configure environment cp .env.example .env # Edit .env with your credentials ``` **Deploy with Podman** see full Podman setup instructions (downloading, installing, and initializing a first Podman container, as well as troubleshooting) in our [Installation Guide](docs/installation.md#podman-installation). **Build with Podman:** ```bash # Auto-detect (will use Podman if Docker not available) ./build_and_run.sh --prebuilt # Explicit Podman mode (only non-Apple Silicon) ./build_and_run.sh --prebuilt --podman # Access registry at non-privileged ports # On macOS: open http://localhost:8080 # On Linux: xdg-open http://localhost:8080 ``` > Note: **Apple Silicon (M1/M2/M3)?** Don't use `--prebuilt` with Podman on ARM64. This will cause a "proxy already running" error. See [Podman on Apple Silicon Guide](docs/podman-apple-silicon.md). ```bash # To run on Apple Silicon Macs: ./build_and_run.sh --podman ``` **Key Differences vs. Docker:** - No root/sudo required - Works on macOS without privileged port access - HTTP port: `8080` (instead of `80`) - HTTPS port: `8443` (instead of `443`) - All other service ports unchanged For detailed Podman setup instructions, see [Installation Guide](docs/installation.md#podman-installation) and [macOS Setup Guide](docs/macos-setup-guide.md#podman-deployment). ### Option D: Build from Source **New to MCP Gateway?** Start with our [Complete Setup Guide](docs/complete-setup-guide.md) for detailed step-by-step instructions from scratch on AWS EC2. **Running on macOS?** See our [macOS Setup Guide](docs/macos-setup-guide.md) for platform-specific instructions and optimizations. ### Testing & Integration Options **Test Suite:** The project includes comprehensive automated testing with pytest: ```bash # Run all tests make test # Run only unit tests (fast) make test-unit # Run with coverage report make test-coverage # Run specific test categories uv run pytest -m unit # Unit tests only uv run pytest -m integration # Integration tests uv run pytest -m "not slow" # Skip slow tests ``` **Test Structure:** - **Unit Tests** (`tests/unit/`) - Fast, isolated component tests - **Integration Tests** (`tests/integration/`) - Component interaction tests - **E2E Tests** (`tests/integration/test_e2e_workflows.py`) - Complete workflow tests **Python Agent:** - `agents/agent.py` - Full-featured Python agent with advanced AI capabilities **Testing Documentation:** - [Testing Guide](docs/testing/README.md) - Comprehensive testing documentation - [Writing Tests](docs/testing/WRITING_TESTS.md) - How to write effective tests - [Test Maintenance](docs/testing/MAINTENANCE.md) - Maintaining test suite health **Pre-commit Hooks:** ```bash # Install pre-commit hooks pip install pre-commit pre-commit install # Run hooks manually pre-commit run --all-files ``` **Next Steps:** [Complete Installation Guide](docs/installation.md) | [Authentication Setup](docs/auth.md) | [AI Assistant Integration](docs/ai-coding-assistants-setup.md) --- ## Enterprise Features ### AI Agents & Coding Assistants Integration Transform how both autonomous AI agents and development teams access enterprise tools with centralized governance:
Roo Code MCP Configuration

Enterprise-curated MCP servers accessible through unified gateway

Roo Code Agent in Action

AI assistants executing approved enterprise tools with governance

### Observability Comprehensive real-time metrics and monitoring through Grafana dashboards with dual-path storage: SQLite for detailed historical analysis and OpenTelemetry (OTEL) export for integration with Prometheus, CloudWatch, Datadog, and other monitoring platforms. Track authentication events, tool executions, discovery queries, and system performance metrics. [Learn more](docs/OBSERVABILITY.md) Grafana Metrics Dashboard

Real-time metrics and observability dashboard tracking server health, tool usage, and authentication events

### Anthropic MCP Registry Integration Seamlessly integrate with Anthropic's official MCP Registry to import and access curated MCP servers through your gateway: - **Import Servers**: Select and import desired servers from Anthropic's registry with a single command - **Unified Access**: Access imported servers through your gateway with centralized authentication and governance - **API Compatibility**: Full support for Anthropic's Registry REST API specification - point your Anthropic API clients to this registry to discover available servers Anthropic Registry Integration

Import and access curated MCP servers from Anthropic's official registry

[Import Guide](docs/anthropic-registry-import.md) | [Registry API Documentation](docs/anthropic_registry_api.md) ### Federation - External Registry Integration **Unified Multi-Registry Access:** - **Anthropic MCP Registry** - Import curated MCP servers with purple `ANTHROPIC` visual tags - **Workday ASOR** - Import AI agents from Agent System of Record with orange `ASOR` visual tags - **Automatic Sync** - Scheduled synchronization with external registries - **Visual Identification** - Clear visual tags distinguish federation sources in the UI - **Centralized Management** - Single control plane for all federated servers and agents **Quick Setup:** ```bash # Configure federation sources echo 'ASOR_ACCESS_TOKEN=your_token' >> .env # Update federation.json with your sources # Restart services ./build_and_run.sh ``` [**📖 Complete Federation Guide**](docs/federation.md) - Environment setup, authentication, configuration, and troubleshooting ### Security Scanning **Integrated Vulnerability Detection:** - **Automated Security Scanning** - Integrated vulnerability scanning for MCP servers using [Cisco AI Defence MCP Scanner](https://github.com/cisco-ai-defense/mcp-scanner), with automatic scans during registration and support for periodic registry-wide scans - **Detailed Security Reports** - Comprehensive markdown reports with vulnerability details, severity assessments, and remediation recommendations - **Automatic Protection** - Servers with security issues are automatically disabled with security-pending status to protect your infrastructure - **Compliance Ready** - Security audit trails and vulnerability tracking for enterprise compliance requirements ### Authentication & Authorization **Multiple Identity Modes:** - **Machine-to-Machine (M2M)** - For autonomous AI agents and automated systems - **Three-Legged OAuth (3LO)** - For external service integration (Atlassian, Google, GitHub) - **Session-Based** - For human developers using AI coding assistants and web interface **Supported Identity Providers:** Keycloak, Microsoft Entra ID, Okta, Auth0, Amazon Cognito, and any OAuth 2.0 compatible provider. [Learn more](docs/auth.md) **Fine-Grained Permissions:** Tool-level, method-level, team-based, and temporary access controls. [Learn more](docs/scopes.md) ### Deployment Options **Cloud Platforms:** Amazon EC2, Amazon EKS --- ## Telemetry The registry collects **anonymous, non-sensitive** usage telemetry to help us understand adoption patterns and improve the product. Both tiers are **opt-out** and **on by default**. **What is sent (Tier 1 -- startup ping):** Registry version, Python version, OS, CPU architecture, cloud provider, storage backend, auth provider, and deployment mode. No IP addresses, hostnames, file paths, user data, or any PII. **Also sent by default (Tier 2 -- daily heartbeat):** Aggregate counts (number of servers, agents, skills, peers), search backend, embeddings provider, and uptime. Same privacy guarantees as Tier 1. Disable heartbeat only: `MCP_TELEMETRY_OPT_OUT=1`. > **Behavior change (post v1.0.18):** The daily heartbeat was previously opt-in (`MCP_TELEMETRY_OPT_IN=1`). It is now opt-out and sent by default. Since the heartbeat contains only aggregate counts (no PII), this aligns it with the startup ping behavior. **To opt out completely:** ```bash export MCP_TELEMETRY_DISABLED=1 # Disables both startup ping and heartbeat ``` **To disable heartbeat only (startup ping still sent):** ```bash export MCP_TELEMETRY_OPT_OUT=1 ``` All requests are HMAC-signed, rate-limited, and schema-validated. Telemetry is fail-silent and never impacts registry operation. Full details in the [Telemetry Documentation](docs/TELEMETRY.md). --- ## Deployments ### AWS Elastic Container Service (ECS)
MCP Gateway Registry on AWS ECS
**Deployment configuration** on Amazon ECS Fargate with comprehensive enterprise features: - **Multi-AZ Architecture** - Redundancy across multiple availability zones - **Application Load Balancer** - HTTPS/SSL termination with automatic certificate management via ACM - **Auto-scaling** - Dynamic scaling based on CPU and memory utilization - **CloudWatch Integration** - Comprehensive monitoring, logging, and alerting - **NAT Gateway HA** - Redundant NAT gateway configuration for secure outbound connectivity - **Keycloak Integration** - Enterprise authentication with RDS Aurora PostgreSQL backend - **EFS Shared Storage** - Persistent storage for models, logs, and configuration - **Service Discovery** - AWS Cloud Map for service-to-service communication **[Complete ECS Deployment Guide](terraform/aws-ecs/README.md)** - Step-by-step instructions for deploying the entire stack with Terraform. ### Amazon EKS (Kubernetes) **Coming Soon** - Kubernetes deployment on Amazon EKS with Helm charts for container orchestration at scale. --- ## Documentation | Getting Started | Enterprise Setup | Developer & Operations | |------------------|-------------------|------------------------| | [Complete Setup Guide](docs/complete-setup-guide.md)
**NEW!** Step-by-step from scratch on AWS EC2 | [Authentication Guide](docs/auth.md)
OAuth and identity provider integration | [AI Coding Assistants Setup](docs/ai-coding-assistants-setup.md)
VS Code, Cursor, Claude Code integration | | [Installation Guide](docs/installation.md)
Complete setup instructions for EC2 and EKS | [AWS ECS Deployment](terraform/aws-ecs/README.md)
Deployment guide for AWS ECS Fargate | [API Reference](docs/registry_api.md)
Programmatic registry management | | [Keycloak Integration](docs/keycloak-integration.md)
Enterprise identity with agent audit trails | [Token Refresh Service](docs/token-refresh-service.md)
Automated token refresh and lifecycle management | [MCP Registry CLI](docs/mcp-registry-cli.md)
Command-line client for registry management | | [Configuration Reference](docs/configuration.md)
Environment variables and settings | [Amazon Cognito Setup](docs/cognito.md)
Step-by-step IdP configuration | [Observability Guide](docs/OBSERVABILITY.md)
**NEW!** Metrics, monitoring, and OpenTelemetry setup | | [Auth0 Integration](docs/auth0.md)
Auth0 SSO with M2M support | [Okta Setup](docs/okta-setup.md)
Okta IdP configuration | [Entra ID Setup](docs/entra-id-setup.md)
Microsoft Entra ID integration | | | [Anthropic Registry Import](docs/anthropic-registry-import.md)
**NEW!** Import servers from Anthropic MCP Registry | [Federation Guide](docs/federation.md)
External registry integration (Anthropic, ASOR) | | | | [P2P Federation Guide](docs/federation-operational-guide.md)
**NEW!** Peer-to-peer registry federation | | | [Service Management](docs/service-management.md)
Server lifecycle and operations | [Anthropic Registry API](docs/anthropic_registry_api.md)
**NEW!** REST API compatibility | | | | [Fine-Grained Access Control](docs/scopes.md)
Permission management and security | | | | [Dynamic Tool Discovery](docs/dynamic-tool-discovery.md)
Autonomous agent capabilities | | | | [Deployment Guide](docs/installation.md)
Complete setup for deployment environments | | | | [Troubleshooting Guide](docs/faq/index.md)
Common issues and solutions | --- ## Community ### Get Involved **Join the Discussion** - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - Feature requests and general discussion - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - Bug reports and feature requests **Contributing** - [Contributing Guide](CONTRIBUTING.md) - How to contribute code and documentation - [Code of Conduct](CODE_OF_CONDUCT.md) - Community guidelines - [Security Policy](SECURITY.md) - Responsible disclosure process ### Star History [![Star History Chart](https://api.star-history.com/svg?repos=agentic-community/mcp-gateway-registry&type=Date)](https://star-history.com/#agentic-community/mcp-gateway-registry&Date) ### Roadmap Our development roadmap is organized into release milestones with clear deliverables and progress tracking: | Milestone | Progress | Status | Key Issues | |-----------|----------|--------|------------| | **v1.0.20** | 100% (11/11) | Complete | [#871 - Unified Auth](https://github.com/agentic-community/mcp-gateway-registry/issues/871), [#851 - M2M Registration](https://github.com/agentic-community/mcp-gateway-registry/issues/851), [#824 - Python 3.14](https://github.com/agentic-community/mcp-gateway-registry/issues/824), [#809 - Registration Gate](https://github.com/agentic-community/mcp-gateway-registry/issues/809), [#779 - Multi API Keys](https://github.com/agentic-community/mcp-gateway-registry/issues/779), [#742 - Webhooks](https://github.com/agentic-community/mcp-gateway-registry/issues/742) and 5 more | | **v1.0.21** | 100% (5/5) | Complete | [#906 - Admin Data Export](https://github.com/agentic-community/mcp-gateway-registry/issues/906), [#897 - Per-skill Auth Credentials UI](https://github.com/agentic-community/mcp-gateway-registry/issues/897), [#891 - CSRF Toggle Fix](https://github.com/agentic-community/mcp-gateway-registry/issues/891), [#886 - Centralized Log Rotation](https://github.com/agentic-community/mcp-gateway-registry/issues/886), [#856 - ARM64 Images](https://github.com/agentic-community/mcp-gateway-registry/issues/856) | | **v1.0.22** | 0% (0/5) | Planned | [#867 - Prometheus Metrics Endpoint](https://github.com/agentic-community/mcp-gateway-registry/issues/867), [#847 - A2A Reverse Proxy Gateway](https://github.com/agentic-community/mcp-gateway-registry/issues/847), [#844 - Dependency Management](https://github.com/agentic-community/mcp-gateway-registry/issues/844), [#744 - AI Chat Assistant](https://github.com/agentic-community/mcp-gateway-registry/issues/744), [#500 - Logout Routing Fix](https://github.com/agentic-community/mcp-gateway-registry/issues/500) | | **Parking Lot** | Backlog | Backlog | 23 open issues awaiting prioritization | **Status Legend:** Complete, Planned, Backlog --- #### Major Features The following major features span multiple milestones and represent significant architectural improvements: - **[#867 - Prometheus Metrics Endpoint](https://github.com/agentic-community/mcp-gateway-registry/issues/867)** **PLANNED** (v1.0.22) Expose a `/metrics` endpoint on the registry for in-process Prometheus counters. - **[#847 - A2A Reverse Proxy Gateway](https://github.com/agentic-community/mcp-gateway-registry/issues/847)** **PLANNED** (v1.0.22) Add reverse proxy gateway support for A2A agents. - **[#744 - AI Chat Assistant](https://github.com/agentic-community/mcp-gateway-registry/issues/744)** **PLANNED** (v1.0.22) Embedded AI chat assistant for registry operations, discovery, and agent design. - **[#665 - Agent-to-Agent Knowledge Sharing](https://github.com/agentic-community/mcp-gateway-registry/issues/665)** **BACKLOG** Enable agents to share and discover knowledge through the AI Registry, forming a collaborative knowledge network. - **[#666 - Context Hub MVP](https://github.com/agentic-community/mcp-gateway-registry/issues/666)** **BACKLOG** Implement Context Hub with card creation, search, and auto-discovery for agent knowledge management. - **[#614 - MCP OAuth 2.1 Authorization Spec](https://github.com/agentic-community/mcp-gateway-registry/issues/614)** **BACKLOG** Implement RFC 9728 Protected Resource Metadata with native IDE support for MCP OAuth 2.1 authorization. - **[#556 - AI Gateway & Registry Rebrand](https://github.com/agentic-community/mcp-gateway-registry/issues/556)** **BACKLOG** Rename "MCP Gateway Registry" to "AI Gateway & Registry" to reflect expanded support for agents and tools beyond MCP. - **[#605 - AgentCore Auto-Registration](https://github.com/agentic-community/mcp-gateway-registry/issues/605)** **COMPLETED** (April 2026) Automated discovery and registration of Bedrock AgentCore gateways with credential management integration. Full `cli/agentcore/` module with boto3 discovery, registration, token refresh, and security scheme support. - **[#641 - Okta Identity Provider](https://github.com/agentic-community/mcp-gateway-registry/issues/641)** **COMPLETED** Added Okta as an identity provider option alongside Keycloak, Entra ID, Auth0, GitHub, and Google OAuth2. - **[#557-559 - Observability & Telemetry Suite](https://github.com/agentic-community/mcp-gateway-registry/issues/557)** **COMPLETED** Comprehensive telemetry infrastructure with server-side collector, client-side instrumentation, and end-to-end enhancements. [Telemetry docs](docs/TELEMETRY.md). - **[#129 - Virtual MCP Server Support](https://github.com/agentic-community/mcp-gateway-registry/issues/129)** **COMPLETED** Dynamic tool aggregation and intelligent routing using Lua scripting. Enables logical grouping of tools from multiple backend servers into a single virtual endpoint. - **[#232 - A2A Curated Registry Discovery](https://github.com/agentic-community/mcp-gateway-registry/issues/232)** **COMPLETED** Enable agent-to-agent discovery and tool invocation through curated registry patterns. - **[#260 - Federation Between MCP Registry Instances](https://github.com/agentic-community/mcp-gateway-registry/issues/260)** **COMPLETED** Federated registry with bi-directional sync, peer management, chain prevention, orphan detection, and security scan propagation across registries. - **[#297 - Unified UI Registration Flow](https://github.com/agentic-community/mcp-gateway-registry/issues/297)** **COMPLETED** Streamlined registration experience for both MCP servers and A2A agents through a unified interface. - **[#295 - Multi-Level Tool Usage Rate Limiting](https://github.com/agentic-community/mcp-gateway-registry/issues/295)** **BACKLOG** Comprehensive rate limiting architecture with detailed implementation guide for tool usage control. --- #### Recently Completed (April 2026) - **[#906 - Admin Data Export](https://github.com/agentic-community/mcp-gateway-registry/issues/906)** - Admin-only Data Export page for downloading registry collections as JSON. - **[#897 - Per-skill Auth Credentials UI](https://github.com/agentic-community/mcp-gateway-registry/issues/897)** - Frontend UI for managing per-skill authentication credentials. - **[#886 - Centralized Log Rotation](https://github.com/agentic-community/mcp-gateway-registry/issues/886)** - Centralized log rotation, auth-server file logging, and log retrieval via MongoDB storage. - **[#871 - Unified JWT and Static Token Auth](https://github.com/agentic-community/mcp-gateway-registry/issues/871)** - JWT/session auth coexists with static token auth, supporting four credential types concurrently. - **[#856 - ARM64 Docker Images](https://github.com/agentic-community/mcp-gateway-registry/issues/856)** - Multi-architecture Docker images with ARM64 support. - **[#851 - Direct M2M Client Registration](https://github.com/agentic-community/mcp-gateway-registry/issues/851)** - Direct machine-to-machine client registration API that bypasses IdP sync. - **[#824 - Python 3.14 Runtime Upgrade](https://github.com/agentic-community/mcp-gateway-registry/issues/824)** - Upgraded Python runtime from 3.12 to 3.14 to resolve CVE-2025-13836. - **[#809 - Registration Gate Admission Control](https://github.com/agentic-community/mcp-gateway-registry/issues/809)** - Admission control webhook for agent, server, and skill registration. - **[#779 - Multiple Static API Keys](https://github.com/agentic-community/mcp-gateway-registry/issues/779)** - Multiple static API keys with per-key group and scope assignments. - **[#742 - Webhook Notifications](https://github.com/agentic-community/mcp-gateway-registry/issues/742)** - Configurable webhook notification on server, agent, and skill registration events. For the complete list of all issues, feature requests, and detailed release history, visit: - [All GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [All GitHub Milestones](https://github.com/agentic-community/mcp-gateway-registry/milestones) - [Release Notes](release-notes/) --- ## License This project is licensed under the Apache-2.0 License - see the [LICENSE](LICENSE) file for details. ---
**⭐ Star this repository if it helps your organization!** [Get Started](docs/installation.md) | [Documentation](docs/) | [Contribute](CONTRIBUTING.md)
================================================ FILE: SECURITY.md ================================================ # Reporting Security Issues We take all security reports seriously. When we receive such reports, we will investigate and subsequently address any potential vulnerabilities as quickly as possible. If you discover a potential security issue in this project, please notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/) or directly via email to [AWS Security](mailto:aws-security@amazon.com). Please do *not* create a public GitHub issue in this project. ================================================ FILE: agents/a2a/.dockerignore ================================================ # Python __pycache__/ *.py[cod] *$py.class *.so .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # Virtual environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # IDE .vscode/ .idea/ *.swp *.swo # OS .DS_Store Thumbs.db # Git .git/ .gitignore # Documentation *.md docs/ # Tests tests/ *_test.py test_*.py # Agent-specific exclusions agent-langgraph/ */data/ */__pycache__/ ================================================ FILE: agents/a2a/.env.example ================================================ # MCP Registry URL (use Docker service name when running in Docker network) MCP_REGISTRY_URL=http://registry # JWT Token for registry authentication # Get a valid token from the registry UI or API, then paste it here. # The agent uses this token to call the registry's semantic search API. REGISTRY_JWT_TOKEN= ================================================ FILE: agents/a2a/.gitignore ================================================ # Environment variables with secrets .env # Python __pycache__/ *.py[cod] *$py.class *.so .Python # Virtual environments venv/ env/ .venv/ # Docker .tmp/ # IDE .vscode/ .idea/ *.swp *.swo *~ # OS .DS_Store Thumbs.db ================================================ FILE: agents/a2a/README.md ================================================ # Travel Booking Agents Two AI agents built with AWS Bedrock AgentCore and the Strands framework for flight search and booking. ## Agents **Travel Assistant Agent** (`travel_assistant_agent`) - Searches for available flights between cities - Provides flight recommendations based on price and preferences - Returns detailed flight information (times, prices, airlines) - **Discovers other agents** through the MCP Gateway Registry and dynamically adds them as tools - [Full specification](https://github.com/agentic-community/mcp-gateway-registry/issues/196) **Flight Booking Agent** (`flight_booking_agent`) - Checks flight availability and seat counts - Creates flight reservations - Manages booking database - [Full specification](https://github.com/agentic-community/mcp-gateway-registry/issues/197) ## Deployment Options ### Local Docker Container Run agents locally with full FastAPI server including custom API endpoints. **Prerequisites:** - Docker and Docker Compose - AWS credentials configured (via AWS_PROFILE, EC2 IAM role, or ~/.aws/credentials) - `uv sync --extra dev` to install main dependencies and development ones **Deploy:** ```bash # 1. Get AWS credentials (for Isengard users) isengard credentials --account YOUR_ACCOUNT --role YOUR_ROLE --export # This exports: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN # Docker will automatically pick these up from your environment # 2. Deploy (auto-detects your system architecture) # From repo root: agents/a2a/deploy_local.sh # Or from agents/a2a directory: ./deploy_local.sh ``` **Architecture Support:** The script automatically detects your system architecture: - **Intel/AMD Macs and Linux:** Uses `docker-compose.local.yml` (x86_64) - **Apple Silicon Macs:** Uses `docker-compose.arm.yml` (ARM64) To override auto-detection: ```bash # Force ARM64 (Apple Silicon) - from repo root agents/a2a/deploy_local.sh --arm64 # Force x86_64 (Intel/AMD) - from repo root agents/a2a/deploy_local.sh --x86_64 # Show help - from repo root agents/a2a/deploy_local.sh --help # Or from agents/a2a directory: ./deploy_local.sh --arm64 ./deploy_local.sh --x86_64 ./deploy_local.sh --help ``` **Endpoints:** - Travel Assistant: `http://localhost:9001` - Flight Booking: `http://localhost:9002` - Custom APIs: `/api/search-flights`, `/api/recommendations`, `/api/check-availability` - Health check: `/ping` ### AgentCore Runtime (AWS) Deploy agents to AWS managed infrastructure with automatic scaling. **Prerequisites:** - AWS credentials configured (via AWS_PROFILE, EC2 IAM role, or ~/.aws/credentials) - AgentCore CLI: `pip install bedrock-agentcore-starter-toolkit` **Deploy:** ```bash # Configure AWS credentials (one of these methods) export AWS_PROFILE=your_profile_name # Or use EC2 IAM role (no export needed) # Then deploy ./deploy_live.sh ``` **Note:** The deployment script automatically builds ARM64 images for AgentCore Runtime compatibility. The `docker-compose.arm.yml` file defines the ARM64 build targets used during deployment. **Access:** - Agents accessible via A2A protocol only - ARNs shown in deployment output - CloudWatch logs for monitoring ## Testing ### Agent Card Endpoint (Local) Test the agent card endpoint locally to verify agent metadata. The script retrieves and displays agent card information, and saves JSON files locally for reference. **Run the check:** ```bash # From repo root agents/a2a/test/check_agent_cards.sh # Or from agents/a2a directory cd agents/a2a ./test/check_agent_cards.sh ``` **Output Files:** Agent cards are saved to the `agents/a2a/test/` directory: - `travel_assistant_agent_card.json` - Travel Assistant agent metadata - `flight_booking_agent_card.json` - Flight Booking agent metadata These files contain: - Agent name and description - Available tools and capabilities - API endpoints and methods - Input/output schemas > **Next Steps:** For remote testing of deployed agents, consider using the [A2A Inspector](https://docs.aws.amazon.com/bedrock/latest/userguide/agentcore-testing.html) to interact with and debug your AgentCore Runtime deployments. ### Agent and API Tests Run comprehensive tests against local or live deployments to verify agent functionality: **Test Coverage:** - **Health Checks:** Verify agents are responsive via `/ping` endpoint - **Agent Communication (A2A Protocol):** Send natural language requests to agents and verify responses - Travel Assistant: Flight search queries - Flight Booking: Availability checks and reservations - **Direct API Endpoints:** Test custom FastAPI endpoints (local only) - `/api/search-flights` - Flight search with parameters - `/api/recommendations` - Price-based recommendations - `/api/check-availability` - Seat availability checks - **Response Validation:** Verify response structure and content accuracy **Run Tests:** ```bash # Test local Docker containers (from repo root) uv run python agents/a2a/test/simple_agents_test.py --endpoint local # Test local Docker containers (from agents/a2a directory) cd agents/a2a uv run python test/simple_agents_test.py --endpoint local # Test AgentCore Runtime (from repo root) uv run python agents/a2a/test/simple_agents_test.py --endpoint live ``` **Debug Mode:** For detailed request/response tracing, use the `--debug` flag: ```bash # View full JSON-RPC payloads, response bodies, and timing (from repo root) uv run python agents/a2a/test/simple_agents_test.py --endpoint local --debug # Or from agents/a2a directory: uv run python test/simple_agents_test.py --endpoint local --debug ``` This displays: - Complete JSON-RPC request payloads - Full agent response bodies with artifacts - Response timing and HTTP status codes - Streaming data for agent reasoning ## Deployment Scripts ### deploy_local.sh Deploys and starts the agents locally in Docker containers. **Features:** - Auto-detects your system architecture (x86_64 or ARM64) - Validates AWS credentials using the credential chain - Removes and recreates containers and volumes for a clean deployment - Builds Docker images locally before starting **Usage (from repo root):** ```bash agents/a2a/deploy_local.sh # Auto-detect architecture agents/a2a/deploy_local.sh --arm64 # Force ARM64 (Apple Silicon) agents/a2a/deploy_local.sh --x86_64 # Force x86_64 (Intel/AMD) agents/a2a/deploy_local.sh --help # Show usage options ``` **Usage (from agents/a2a directory):** ```bash ./deploy_local.sh # Auto-detect architecture ./deploy_local.sh --arm64 # Force ARM64 (Apple Silicon) ./deploy_local.sh --x86_64 # Force x86_64 (Intel/AMD) ./deploy_local.sh --help # Show usage options ``` ### shutdown_local.sh Stops and removes all containers, networks, and volumes. **Usage (from repo root):** ```bash agents/a2a/shutdown_local.sh ``` **Usage (from agents/a2a directory):** ```bash ./shutdown_local.sh ``` This is useful when you want to completely clean up before redeploying or when done testing locally. ## Agent-to-Agent Discovery The Travel Assistant Agent can discover and invoke other agents at runtime using the MCP Gateway Registry's semantic search API. This enables dynamic agent composition where agents find collaborators based on capabilities. ### Configuration 1. Copy the example environment file: ```bash cp agents/a2a/.env.example agents/a2a/.env ``` 2. Edit `agents/a2a/.env` and set your JWT token: ```bash # Registry URL (default works for Docker network) MCP_REGISTRY_URL=http://registry # Paste a valid JWT token from the registry UI or API REGISTRY_JWT_TOKEN= ``` The agent uses this token to authenticate with the registry's semantic search API. The `deploy_local.sh` script automatically loads `.env` before starting containers. ### Prerequisites - MCP Gateway Registry running (`docker-compose up -d`) - Flight Booking Agent registered in the registry (via UI or CLI) - Valid JWT token configured in `agents/a2a/.env` ### Discovery Tools The Travel Assistant Agent provides three tools for agent discovery: | Tool | Description | |------|-------------| | `discover_remote_agents` | Search registry for agents by natural language query | | `view_cached_remote_agents` | List all discovered agents in cache | | `invoke_remote_agent` | Send a message to a cached agent via A2A protocol | ### Testing Discovery 1. Start the MCP Gateway Registry and register the Flight Booking Agent 2. Deploy agents locally: ```bash agents/a2a/deploy_local.sh ``` 3. Run the test suite (includes discovery test): ```bash uv run python agents/a2a/test/simple_agents_test.py --endpoint local --debug ``` 4. View agent logs to see discovery in action: ```bash docker logs -f travel-assistant-agent ``` You should see logs like: ``` RegistryDiscoveryClient initialized with direct JWT token for http://registry Tool called: discover_remote_agents(query='book flights', max_results=5) Found 1 agents Cached agent: Flight Booking Agent (ID: /flight-booking-agent) ``` ### Example Flow ``` User: "I need to book a flight from NYC to LA" Travel Assistant Agent: 1. discover_remote_agents("agent that can book flights") -> Returns: Flight Booking Agent (score: 0.85) 2. invoke_remote_agent("/flight-booking-agent", "Book flight NYC to LA") -> Flight Booking Agent processes request -> Returns booking confirmation 3. Returns combined response to user ``` --- ## Key Differences | Feature | Local Docker | AgentCore Runtime | |---------|-------------|-------------------| | A2A Protocol | ✅ | ✅ | | Custom API Endpoints | ✅ | ❌ | | Health Check `/ping` | ✅ | ❌ | | Agent Discovery | ✅ | ✅ | | Deployment | Docker Compose | AgentCore CLI | **Note:** Custom FastAPI endpoints (like `/api/search-flights`) are only available in local Docker deployments. **AgentCore Runtime only wraps the container and exposes the standard A2A conversational interface.** ================================================ FILE: agents/a2a/deploy_live.sh ================================================ #!/bin/bash # AgentCore Live Deployment Script # # Deploys A2A agents to AWS using AgentCore CLI with custom Dockerfiles. # - Builds locally with Docker, pushes to ECR, deploys to AgentCore Runtime # - Uses container mode with custom Dockerfiles for full control # - Targets ARM64 platform for AWS AgentCore Runtime (which runs on ARM64) # # For local testing before live deployment: # - Use docker-compose.local.yml for x86_64 testing on local machines # - Use docker-compose.arm.yml with docker buildx if testing ARM64 locally # # File Management: # During deployment, the following files are copied from agents-strands root into each agent directory: # - pyproject.toml, uv.lock -> src//.tmp/ (for dependency installation in Docker) # - .dockerignore -> src// (to optimize Docker build context) # These files are automatically cleaned up after deployment completes. set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color echo -e "${BLUE}AgentCore Live Deployment Script${NC}" echo "======================================" # Check if AWS credentials are set echo -e "\nValidating AWS credentials..." IDENTITY_OUTPUT=$(aws sts get-caller-identity 2>&1) EXIT_CODE=$? if [ $EXIT_CODE -ne 0 ]; then echo -e "${RED}❌ Error: Unable to retrieve AWS credentials${NC}" echo "" echo "AWS credentials not found. Please provide credentials using one of these methods:" echo "" echo "1. AWS Profile (recommended):" echo " export AWS_PROFILE=your_profile_name" echo "" echo "2. EC2 IAM Role (automatic when running on EC2 instance)" echo "" echo "Debug info:" echo "$IDENTITY_OUTPUT" exit 1 fi ACCOUNT_ID=$(echo "$IDENTITY_OUTPUT" | grep -o '"Account": "[^"]*"' | cut -d'"' -f4) REGION=${AWS_REGION:-us-east-1} echo -e "${GREEN}✅ AWS credentials validated${NC}" echo -e " Account: ${ACCOUNT_ID}" echo -e " Region: ${REGION}" # Check if agentcore CLI is installed echo -e "\nChecking AgentCore CLI..." if ! command -v agentcore &> /dev/null; then echo -e "${RED}❌ Error: agentcore CLI not found${NC}" echo "Please install it with: pip install bedrock-agentcore-starter-toolkit" exit 1 fi echo -e "${GREEN}✅ AgentCore CLI found${NC}" # Agent configurations FLIGHT_AGENT_NAME="flight_booking_agent" FLIGHT_AGENT_ENTRYPOINT="src/flight-booking-agent/agent.py" TRAVEL_AGENT_NAME="travel_assistant_agent" TRAVEL_AGENT_ENTRYPOINT="src/travel-assistant-agent/agent.py" # Function to configure and deploy an agent deploy_agent() { local agent_name=$1 local entrypoint=$2 echo -e "\nDeploying ${agent_name}..." echo " Entrypoint: ${entrypoint}" echo " Protocol: A2A" echo " Deployment: container (custom Dockerfile in agent directory)" echo " Database: /app/data/bookings.db" echo " Build: Local Docker build, then push to ECR" # Get the entrypoint directory where our Dockerfile lives local entrypoint_dir=$(dirname "${entrypoint}") # Check if agent is already configured if agentcore configure list 2>/dev/null | grep -q "${agent_name}"; then echo "Agent ${agent_name} already configured, will update" else echo "Configuring ${agent_name}..." agentcore configure \ --entrypoint "${entrypoint}" \ --name "${agent_name}" \ --region "${REGION}" \ --protocol A2A \ --deployment-type container \ --non-interactive \ --disable-memory fi # Copy files from agents-strands root into agent directory for Docker build # Files copied: # - pyproject.toml, uv.lock -> ${entrypoint_dir}/.tmp/ (for dependency installation) # - .dockerignore -> ${entrypoint_dir}/ (to optimize Docker build context) # These files are cleaned up after deployment completes echo " Copying dependency files to .tmp directory" mkdir -p "${entrypoint_dir}/.tmp" cp pyproject.toml uv.lock "${entrypoint_dir}/.tmp/" # Copy .dockerignore to agent directory if it doesn't exist if [ ! -f "${entrypoint_dir}/.dockerignore" ] && [ -f ".dockerignore" ]; then echo " Copying .dockerignore to agent directory" cp .dockerignore "${entrypoint_dir}/.dockerignore" fi # Replace AgentCore's generated Dockerfile with our custom one local agentcore_dockerfile=".bedrock_agentcore/${agent_name}/Dockerfile" if [ -f "${entrypoint_dir}/Dockerfile" ]; then echo " Replacing generated Dockerfile with custom one" cp "${entrypoint_dir}/Dockerfile" "${agentcore_dockerfile}" fi # Create a docker-compose override for ARM64 build if it exists # This ensures the agentcore CLI builds for ARM64 (the target platform for AgentCore Runtime) local docker_compose_override=".bedrock_agentcore/${agent_name}/docker-compose.override.yml" if [ -f "docker-compose.arm.yml" ]; then echo " Creating ARM64 docker-compose override for AgentCore Runtime target" mkdir -p ".bedrock_agentcore/${agent_name}" # Extract the service definition for this agent from docker-compose.arm.yml # The agentcore CLI will use this for building cat > "${docker_compose_override}" <" echo " • View logs: Check CloudWatch logs (shown in status above)" echo " • Update agents: Run this script again to deploy changes" echo " • Destroy agents: agentcore destroy --agent " ================================================ FILE: agents/a2a/deploy_local.sh ================================================ #!/bin/bash set -e # Find the agents/a2a directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" A2A_DIR="$SCRIPT_DIR" # If running from repo root, adjust the path if [ ! -f "$A2A_DIR/docker-compose.local.yml" ]; then A2A_DIR="$SCRIPT_DIR/agents/a2a" fi # Verify we found the agents/a2a directory if [ ! -f "$A2A_DIR/docker-compose.local.yml" ]; then echo "❌ Error: docker-compose.local.yml not found" echo "" echo "This script must be run from either:" echo " - The agents/a2a directory: ./deploy_local.sh" echo " - The repository root: agents/a2a/deploy_local.sh" exit 1 fi # Change to agents/a2a directory for the deployment cd "$A2A_DIR" # Load environment variables from .env file if it exists if [ -f ".env" ]; then echo "Loading configuration from .env file..." set -a source .env set +a else echo "Warning: No .env file found in $A2A_DIR" echo "Copy .env.example to .env and configure REGISTRY_JWT_TOKEN for agent discovery." fi # Parse command line arguments COMPOSE_FILE="docker-compose.local.yml" ARCHITECTURE="" TARGETPLATFORM="" for arg in "$@"; do case "$arg" in --arm64) COMPOSE_FILE="docker-compose.arm.yml" ARCHITECTURE="ARM64" TARGETPLATFORM="linux/arm64" ;; --x86_64) COMPOSE_FILE="docker-compose.local.yml" ARCHITECTURE="x86_64" TARGETPLATFORM="linux/amd64" ;; --help) echo "Usage: ./deploy_local.sh [OPTIONS]" echo "" echo "Options:" echo " --arm64 Use ARM64 docker-compose file (for Apple Silicon Macs)" echo " --x86_64 Use x86_64 docker-compose file (default for Intel/AMD)" echo " --help Show this help message" echo "" echo "Examples:" echo " ./deploy_local.sh # Auto-detect architecture" echo " ./deploy_local.sh --arm64 # Force ARM64 (Apple Silicon)" echo " ./deploy_local.sh --x86_64 # Force x86_64 (Intel/AMD)" exit 0 ;; *) echo "Unknown option: $arg" echo "Use --help for usage information" exit 1 ;; esac done # Auto-detect architecture if not specified if [ -z "$ARCHITECTURE" ]; then SYSTEM_ARCH=$(uname -m) if [ "$SYSTEM_ARCH" = "arm64" ] || [ "$SYSTEM_ARCH" = "aarch64" ]; then COMPOSE_FILE="docker-compose.arm.yml" ARCHITECTURE="ARM64 (auto-detected)" TARGETPLATFORM="linux/arm64" else COMPOSE_FILE="docker-compose.local.yml" ARCHITECTURE="x86_64 (auto-detected)" TARGETPLATFORM="linux/amd64" fi fi # Export TARGETPLATFORM for docker-compose to use export TARGETPLATFORM echo "Deploying agents for: $ARCHITECTURE" echo "" echo "Validating AWS credentials..." # Check if AWS credentials are available through the credential chain # This checks: explicit env vars, AWS_PROFILE, EC2 IAM role, ~/.aws/credentials, etc. IDENTITY_OUTPUT=$(aws sts get-caller-identity 2>&1) EXIT_CODE=$? if [ $EXIT_CODE -ne 0 ]; then echo "❌ Error: Unable to retrieve AWS credentials" echo "" echo "AWS credentials not found. Please provide credentials using one of these methods:" echo "" echo "1. AWS Profile (recommended):" echo " export AWS_PROFILE=your_profile_name" echo "" echo "2. EC2 IAM Role (automatic when running on EC2 instance)" echo "" echo "Debug info:" echo "$IDENTITY_OUTPUT" exit 1 fi # Extract and display credential information ACCOUNT_ID=$(echo "$IDENTITY_OUTPUT" | grep -o '"Account": "[^"]*"' | cut -d'"' -f4) ARN=$(echo "$IDENTITY_OUTPUT" | grep -o '"Arn": "[^"]*"' | cut -d'"' -f4) echo "✅ AWS credentials validated" echo " Account ID: $ACCOUNT_ID" echo " Principal: $ARN" echo "Stopping existing containers and removing volumes..." docker compose -f "$COMPOSE_FILE" down -v echo "Building images..." # Copy dependency files to .tmp directories for build echo "Copying dependency files to .tmp directories..." mkdir -p src/flight-booking-agent/.tmp src/travel-assistant-agent/.tmp cp pyproject.toml uv.lock src/flight-booking-agent/.tmp/ cp pyproject.toml uv.lock src/travel-assistant-agent/.tmp/ # Build images docker compose -f "$COMPOSE_FILE" build --no-cache # Clean up .tmp directories echo "Cleaning up .tmp directories..." rm -rf src/flight-booking-agent/.tmp rm -rf src/travel-assistant-agent/.tmp echo "Starting containers..." docker compose -f "$COMPOSE_FILE" up -d echo "✅ Deployment complete!" echo "" echo "Waiting for containers to be ready and starting to display live logs..." echo "Press Ctrl+C to stop viewing logs (containers will continue running)" echo "" # Wait a moment for containers to start sleep 2 # Display live logs docker compose -f "$COMPOSE_FILE" logs -f ================================================ FILE: agents/a2a/docker-compose.arm.yml ================================================ services: travel-assistant-agent: image: travel-assistant-agent:latest build: context: ./src/travel-assistant-agent dockerfile: Dockerfile args: TARGETPLATFORM: linux/arm64 container_name: travel-assistant-agent ports: - "9001:9000" # Map host port 9001 to associated container port 9000 for local testing environment: - DB_PATH=/app/data/flights.db - AWS_REGION=us-east-1 - AWS_DEFAULT_REGION=us-east-1 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-} - AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-} - AGENT_NAME=travel-assistant - AGENTCORE_RUNTIME_URL=http://travel-assistant-agent:9000/ - KEYCLOAK_URL=${KEYCLOAK_URL:-} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-} - MCP_REGISTRY_URL=${MCP_REGISTRY_URL:-http://registry} - REGISTRY_JWT_TOKEN=${REGISTRY_JWT_TOKEN:-} - M2M_CLIENT_ID=${TRAVEL_AGENT_M2M_CLIENT_ID:-} - M2M_CLIENT_SECRET=${TRAVEL_AGENT_M2M_CLIENT_SECRET:-} volumes: - travel_assistant_data:/app/data healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9000/ping"] interval: 30s timeout: 10s retries: 3 start_period: 40s flight-booking-agent: image: flight-booking-agent:latest build: context: ./src/flight-booking-agent dockerfile: Dockerfile args: TARGETPLATFORM: linux/arm64 container_name: flight-booking-agent ports: - "9002:9000" # Map host port 9002 to associated container port 9000 for local testing environment: - DB_PATH=/app/data/bookings.db - AWS_REGION=us-east-1 - AWS_DEFAULT_REGION=us-east-1 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-} - AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-} - AGENT_NAME=flight-booking - AGENTCORE_RUNTIME_URL=http://flight-booking-agent:9000/ - KEYCLOAK_URL=${KEYCLOAK_URL:-} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-} - MCP_REGISTRY_URL=${MCP_REGISTRY_URL:-http://registry} - REGISTRY_JWT_TOKEN=${REGISTRY_JWT_TOKEN:-} - M2M_CLIENT_ID=${FLIGHT_BOOKING_M2M_CLIENT_ID:-} - M2M_CLIENT_SECRET=${FLIGHT_BOOKING_M2M_CLIENT_SECRET:-} volumes: - flight_booking_data:/app/data healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9000/ping"] interval: 30s timeout: 10s retries: 3 start_period: 40s volumes: travel_assistant_data: driver: local flight_booking_data: driver: local networks: default: # Use the external network created by the main mcp-gateway-registry docker-compose # "default" is the logical network name used within this compose file # "external: true" means this network already exists and should not be created # This allows agents to communicate with gateway/registry services on the same network name: mcp-gateway-registry_default external: true ================================================ FILE: agents/a2a/docker-compose.local.yml ================================================ services: travel-assistant-agent: image: travel-assistant-agent:latest build: context: ./src/travel-assistant-agent dockerfile: Dockerfile args: TARGETPLATFORM: ${TARGETPLATFORM:-linux/amd64} container_name: travel-assistant-agent ports: - "9001:9000" # Map host port 9001 to associated container port 9000 for local testing environment: - DB_PATH=/app/data/flights.db - AWS_REGION=us-east-1 - AWS_DEFAULT_REGION=us-east-1 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-} - AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-} - AGENT_NAME=travel-assistant - AGENTCORE_RUNTIME_URL=http://travel-assistant-agent:9000/ - KEYCLOAK_URL=${KEYCLOAK_URL:-} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-} - MCP_REGISTRY_URL=${MCP_REGISTRY_URL:-http://registry} - REGISTRY_JWT_TOKEN=${REGISTRY_JWT_TOKEN:-} - M2M_CLIENT_ID=${TRAVEL_AGENT_M2M_CLIENT_ID:-} - M2M_CLIENT_SECRET=${TRAVEL_AGENT_M2M_CLIENT_SECRET:-} volumes: - travel_assistant_data:/app/data healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9000/ping"] interval: 30s timeout: 10s retries: 3 start_period: 40s flight-booking-agent: image: flight-booking-agent:latest build: context: ./src/flight-booking-agent dockerfile: Dockerfile args: TARGETPLATFORM: ${TARGETPLATFORM:-linux/amd64} container_name: flight-booking-agent ports: - "9002:9000" # Map host port 9002 to associated container port 9000 for local testing environment: - DB_PATH=/app/data/bookings.db - AWS_REGION=us-east-1 - AWS_DEFAULT_REGION=us-east-1 - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-} - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-} - AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-} - AGENT_NAME=flight-booking - AGENTCORE_RUNTIME_URL=http://flight-booking-agent:9000/ - KEYCLOAK_URL=${KEYCLOAK_URL:-} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-} - MCP_REGISTRY_URL=${MCP_REGISTRY_URL:-http://registry} - REGISTRY_JWT_TOKEN=${REGISTRY_JWT_TOKEN:-} - M2M_CLIENT_ID=${FLIGHT_BOOKING_M2M_CLIENT_ID:-} - M2M_CLIENT_SECRET=${FLIGHT_BOOKING_M2M_CLIENT_SECRET:-} volumes: - flight_booking_data:/app/data healthcheck: test: ["CMD", "curl", "-f", "http://localhost:9000/ping"] interval: 30s timeout: 10s retries: 3 start_period: 40s volumes: travel_assistant_data: driver: local flight_booking_data: driver: local networks: default: # Use the external network created by the main mcp-gateway-registry docker-compose # "default" is the logical network name used within this compose file # "name" overrides the actual Docker network name (prevents creating a2a_default) # "external: true" means this network already exists and should not be created # This allows agents to communicate with gateway/registry services on the same network name: mcp-gateway-registry_default external: true ================================================ FILE: agents/a2a/pyproject.toml ================================================ [project] name = "a2a" version = "0.1.0" description = "Travel and Flight Booking Agents" requires-python = ">=3.14" dependencies = [ "fastapi>=0.115.12", "uvicorn[standard]>=0.34.2", "strands-agents[a2a]>=0.1.6", "pydantic>=2.11.3", "python-dotenv>=1.2.2", "aiohttp>=3.8.0", ] [project.optional-dependencies] dev = [ "bedrock-agentcore-starter-toolkit>=0.1.0", ] [build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [tool.uv] # Local-only project - never resolve from PyPI package = false ================================================ FILE: agents/a2a/shutdown_local.sh ================================================ #!/bin/bash set -e # Find the agents/a2a directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" A2A_DIR="$SCRIPT_DIR" # If running from repo root, adjust the path if [ ! -f "$A2A_DIR/docker-compose.local.yml" ]; then A2A_DIR="$SCRIPT_DIR/agents/a2a" fi # Verify we found the agents/a2a directory if [ ! -f "$A2A_DIR/docker-compose.local.yml" ]; then echo "❌ Error: docker-compose.local.yml not found" echo "" echo "This script must be run from either:" echo " - The agents/a2a directory: ./shutdown_local.sh" echo " - The repository root: agents/a2a/shutdown_local.sh" exit 1 fi # Change to agents/a2a directory for the shutdown cd "$A2A_DIR" echo "Stopping and removing local agents..." echo "" # Determine which compose file to use based on architecture (same logic as deploy_local.sh) COMPOSE_FILE="docker-compose.local.yml" SYSTEM_ARCH=$(uname -m) if [ "$SYSTEM_ARCH" = "arm64" ] || [ "$SYSTEM_ARCH" = "aarch64" ]; then COMPOSE_FILE="docker-compose.arm.yml" echo "Detected ARM64 architecture" else echo "Detected x86_64 architecture" fi echo "" echo "Using docker-compose file: $COMPOSE_FILE" echo "" # Stop and remove containers, networks, and volumes docker compose -f "$COMPOSE_FILE" down -v echo "" echo "✅ Shutdown complete!" echo "All containers, networks, and volumes have been removed." echo "" echo "To restart the agents, run: ./deploy_local.sh" ================================================ FILE: agents/a2a/src/flight-booking-agent/Dockerfile ================================================ ARG TARGETPLATFORM FROM --platform=${TARGETPLATFORM} public.ecr.aws/docker/library/python:3.14-slim WORKDIR /app # Install system dependencies and uv # build-essential is required to compile asyncpg from source (no py3.14 wheel yet) # apt-get upgrade ensures latest security patches (e.g. openssl ~deb13u2) RUN apt-get update && apt-get upgrade -y && apt-get install -y \ sqlite3 \ curl \ build-essential \ && rm -rf /var/lib/apt/lists/* \ && pip install uv # Copy dependency files (build-images.sh copies these to .tmp/ directory before build) COPY .tmp/pyproject.toml .tmp/uv.lock ./ # Install Python dependencies using uv (as root, before switching users) RUN uv sync --frozen --no-dev # Copy agent code (all files from the context directory) COPY . ./ # Create non-root user RUN useradd -m -u 1000 bedrock_agentcore # Create data directory for SQLite database with proper ownership # Also fix ownership of installed packages RUN mkdir -p /app/data && \ chown -R bedrock_agentcore:bedrock_agentcore /app USER bedrock_agentcore # Set environment variables ENV PYTHONPATH=/app ENV AWS_REGION=us-east-1 ENV AWS_DEFAULT_REGION=us-east-1 # Expose port for A2A communication (port 9000 for A2A protocol) EXPOSE 9000 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:9000/ping || exit 1 # Run the agent with uv (uses the virtual environment created by uv sync) CMD ["uv", "run", "--no-sync", "agent.py"] ================================================ FILE: agents/a2a/src/flight-booking-agent/__init__.py ================================================ """Flight Booking Agent Package.""" import logging from .agent import ( agent, app, ) from .database import BookingDatabaseManager from .env_settings import env_settings from .tools import FLIGHT_BOOKING_TOOLS # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) __all__ = ["app", "agent", "env_settings", "BookingDatabaseManager", "FLIGHT_BOOKING_TOOLS"] ================================================ FILE: agents/a2a/src/flight-booking-agent/agent.py ================================================ """Flight Booking Agent - Main application module.""" import logging from contextlib import asynccontextmanager import uvicorn from dependencies import ( get_db_manager, get_env, ) from fastapi import FastAPI from strands import Agent from strands.multiagent.a2a import A2AServer from tools import ( FLIGHT_BOOKING_TOOLS, check_availability, confirm_booking, manage_reservation, process_payment, reserve_flight, ) # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) strands_agent = Agent( name="Flight Booking Agent", description="Flight booking and reservation management agent", tools=FLIGHT_BOOKING_TOOLS, callback_handler=None, model="global.anthropic.claude-sonnet-4-5-20250929-v1:0", ) env_settings = get_env() a2a_server = A2AServer(agent=strands_agent, http_url=env_settings.agent_url, serve_at_root=True) @asynccontextmanager async def lifespan( app: FastAPI, ): """Application lifespan manager.""" # Setups before server startup get_db_manager() logger.info("Flight Booking Agent starting up") logger.info(f"Agent URL: {env_settings.agent_url}") logger.info(f"Listening on {env_settings.host}:{env_settings.port}") # TODO: register agent with MCP Gateway Registry when path available yield # Triggered after server shutdown logger.info("Flight Booking Agent shutting down") app = FastAPI(title="Flight Booking Agent", lifespan=lifespan) @app.get("/ping") def ping(): """Health check endpoint.""" logger.debug("Ping endpoint called") return {"status": "healthy"} @app.get("/api/health") def health(): """Health status endpoint.""" logger.debug("Health endpoint called") return {"status": "healthy", "agent": "flight_booking"} @app.post("/api/check-availability") def api_check_availability( flight_id: int, ): """Check flight availability API endpoint.""" logger.info(f"Checking availability for flight_id: {flight_id}") result = check_availability(flight_id) logger.debug(f"Availability check result: {result}") return {"result": result} @app.post("/api/reserve-flight") def api_reserve_flight( flight_id: int, passengers: list, requested_seats: list | None = None, ): """Reserve flight API endpoint.""" logger.info(f"Reserving flight_id: {flight_id} for {len(passengers)} passengers") logger.debug(f"Passengers: {passengers}") logger.debug(f"Requested seats: {requested_seats}") result = reserve_flight(flight_id, passengers, requested_seats) logger.debug(f"Reservation result: {result}") return {"result": result} @app.post("/api/confirm-booking") def api_confirm_booking( booking_number: str, ): """Confirm booking API endpoint.""" logger.info(f"Confirming booking: {booking_number}") result = confirm_booking(booking_number) logger.debug(f"Booking confirmation result: {result}") return {"result": result} @app.post("/api/process-payment") def api_process_payment( booking_number: str, payment_method: str, amount: float | None = None, ): """Process payment API endpoint.""" logger.info(f"Processing payment for booking: {booking_number}") logger.debug(f"Payment method: {payment_method}, Amount: {amount}") result = process_payment(booking_number, payment_method, amount) logger.debug(f"Payment processing result: {result}") return {"result": result} @app.get("/api/reservation/{booking_number}") def api_get_reservation( booking_number: str, ): """Get reservation details API endpoint.""" logger.info(f"Retrieving reservation: {booking_number}") result = manage_reservation(booking_number, "view") logger.debug(f"Reservation details: {result}") return {"result": result} @app.delete("/api/reservation/{booking_number}") def api_cancel_reservation( booking_number: str, reason: str = "User requested cancellation", ): """Cancel reservation API endpoint.""" logger.info(f"Canceling reservation: {booking_number}") logger.debug(f"Cancellation reason: {reason}") result = manage_reservation(booking_number, "cancel", reason) logger.debug(f"Cancellation result: {result}") return {"result": result} app.mount("/", a2a_server.to_fastapi_app()) def main() -> None: """Main entry point for the application.""" logger.info("Starting Flight Booking Agent server") uvicorn.run(app, host=env_settings.host, port=env_settings.port) if __name__ == "__main__": main() ================================================ FILE: agents/a2a/src/flight-booking-agent/database.py ================================================ """Database management module for Flight Booking Agent.""" import logging import os import sqlite3 import uuid from datetime import datetime from typing import ( Any, ) # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def _create_tables( conn: sqlite3.Connection, ) -> None: """Create database tables if they don't exist.""" conn.execute(""" CREATE TABLE IF NOT EXISTS flights ( id INTEGER PRIMARY KEY, flight_number TEXT UNIQUE NOT NULL, airline TEXT NOT NULL, departure_city TEXT NOT NULL, arrival_city TEXT NOT NULL, departure_time DATETIME NOT NULL, arrival_time DATETIME NOT NULL, duration_minutes INTEGER, price DECIMAL(10,2), available_seats INTEGER DEFAULT 100, aircraft_type TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS bookings ( id INTEGER PRIMARY KEY AUTOINCREMENT, booking_number TEXT UNIQUE NOT NULL, flight_id INTEGER NOT NULL, total_price DECIMAL(10,2), status TEXT CHECK(status IN ('pending', 'confirmed', 'paid', 'cancelled')) DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, confirmed_at DATETIME, FOREIGN KEY (flight_id) REFERENCES flights(id) ) """) # Booking passengers table conn.execute(""" CREATE TABLE IF NOT EXISTS booking_passengers ( id INTEGER PRIMARY KEY AUTOINCREMENT, booking_id INTEGER NOT NULL, passenger_name TEXT NOT NULL, email TEXT, seat_number TEXT, FOREIGN KEY (booking_id) REFERENCES bookings(id) ) """) # Payments table conn.execute(""" CREATE TABLE IF NOT EXISTS payments ( id INTEGER PRIMARY KEY AUTOINCREMENT, booking_id INTEGER NOT NULL, amount DECIMAL(10,2), status TEXT CHECK(status IN ('pending', 'completed', 'failed')) DEFAULT 'pending', payment_method TEXT, transaction_id TEXT, processed_at DATETIME, FOREIGN KEY (booking_id) REFERENCES bookings(id) ) """) # Seat inventory table conn.execute(""" CREATE TABLE IF NOT EXISTS seat_inventory ( id INTEGER PRIMARY KEY AUTOINCREMENT, flight_id INTEGER NOT NULL, seat_row TEXT, seat_column TEXT, status TEXT CHECK(status IN ('available', 'reserved', 'booked')) DEFAULT 'available', FOREIGN KEY (flight_id) REFERENCES flights(id) ) """) # Cancellations table conn.execute(""" CREATE TABLE IF NOT EXISTS cancellations ( id INTEGER PRIMARY KEY AUTOINCREMENT, booking_id INTEGER NOT NULL, reason TEXT, refund_amount DECIMAL(10,2), cancelled_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (booking_id) REFERENCES bookings(id) ) """) def _insert_seed_data( conn: sqlite3.Connection, ) -> None: """Insert seed data into the database if empty.""" cursor = conn.execute("SELECT COUNT(*) FROM flights") if cursor.fetchone()[0] == 0: flight_data = [ ( 1, "UA101", "United", "SF", "NY", "2025-11-15 08:00", "2025-11-15 16:30", 330, 250.00, 85, "B737", ), ( 2, "AA202", "American", "SF", "NY", "2025-11-15 10:15", "2025-11-15 18:45", 330, 280.00, 45, "A320", ), ( 3, "DL303", "Delta", "SF", "NY", "2025-11-15 14:30", "2025-11-15 23:00", 330, 220.00, 120, "B757", ), ( 4, "UA104", "United", "SF", "LA", "2025-11-16 07:00", "2025-11-16 08:30", 90, 120.00, 95, "B737", ), ] conn.executemany( """ INSERT OR IGNORE INTO flights (id, flight_number, airline, departure_city, arrival_city, departure_time, arrival_time, duration_minutes, price, available_seats, aircraft_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, flight_data, ) cursor = conn.execute("SELECT COUNT(*) FROM bookings") if cursor.fetchone()[0] == 0: booking_data = [ ("BK001", 1, 500.00, "confirmed", "2025-11-01 10:00:00", "2025-11-01 10:15:00"), ("BK002", 1, 250.00, "pending", "2025-11-01 11:00:00", None), ("BK003", 2, 560.00, "confirmed", "2025-11-01 12:00:00", "2025-11-01 12:10:00"), ("BK004", 3, 440.00, "confirmed", "2025-11-01 13:00:00", "2025-11-01 13:05:00"), ] conn.executemany( """ INSERT INTO bookings (booking_number, flight_id, total_price, status, created_at, confirmed_at) VALUES (?, ?, ?, ?, ?, ?) """, booking_data, ) passenger_data = [ (1, "John Smith", "john@example.com", "12A"), (1, "Jane Smith", "jane@example.com", "12B"), (2, "Bob Johnson", "bob@example.com", "14C"), (3, "Alice Williams", "alice@example.com", "1A"), (4, "Charlie Brown", "charlie@example.com", "5B"), ] conn.executemany( """ INSERT INTO booking_passengers (booking_id, passenger_name, email, seat_number) VALUES (?, ?, ?, ?) """, passenger_data, ) payment_data = [ (1, 500.00, "completed", "credit_card", "TXN001", "2025-11-01 10:15:00"), (2, 250.00, "pending", "credit_card", None, None), (3, 560.00, "completed", "credit_card", "TXN003", "2025-11-01 12:10:00"), (4, 440.00, "completed", "paypal", "TXN004", "2025-11-01 13:05:00"), ] conn.executemany( """ INSERT INTO payments (booking_id, amount, status, payment_method, transaction_id, processed_at) VALUES (?, ?, ?, ?, ?, ?) """, payment_data, ) seat_data = [ (1, "1", "A", "booked"), (1, "1", "B", "booked"), (1, "1", "C", "available"), (1, "1", "D", "available"), (1, "12", "A", "booked"), (1, "12", "B", "booked"), (1, "12", "C", "available"), (1, "12", "D", "available"), (1, "14", "C", "booked"), (1, "14", "D", "available"), ] conn.executemany( """ INSERT INTO seat_inventory (flight_id, seat_row, seat_column, status) VALUES (?, ?, ?, ?) """, seat_data, ) conn.commit() class BookingDatabaseManager: """Database manager for flight bookings.""" def __init__( self, db_path: str, ) -> None: """Initialize the database manager.""" self.db_path = db_path logger.info(f"Initializing BookingDatabaseManager with db_path: {db_path}") self.init_database() def init_database(self) -> None: """Initialize the database with tables and seed data.""" os.makedirs(os.path.dirname(self.db_path), exist_ok=True) with sqlite3.connect(self.db_path) as conn: _create_tables(conn) _insert_seed_data(conn) def get_connection(self) -> sqlite3.Connection: """Get a database connection.""" return sqlite3.connect(self.db_path) def get_flight_availability( self, flight_id: int, ) -> dict[str, Any] | None: """Get availability information for a specific flight.""" logger.info(f"Checking availability for flight_id: {flight_id}") with self.get_connection() as conn: cursor = conn.execute( """ SELECT f.flight_number, f.airline, f.departure_city, f.arrival_city, f.departure_time, f.available_seats, f.price FROM flights f WHERE f.id = ? """, (flight_id,), ) row = cursor.fetchone() if not row: logger.warning(f"Flight not found: flight_id={flight_id}") return None logger.info(f"Flight availability retrieved: {row[0]}, available_seats={row[5]}") return { "flight_id": flight_id, "flight_number": row[0], "airline": row[1], "route": f"{row[2]} → {row[3]}", "departure_time": row[4], "available_seats": row[5], "price_per_seat": float(row[6]), "availability_status": "Available" if row[5] > 0 else "Sold Out", } def create_reservation( self, flight_id: int, passengers: list[dict[str, str]], requested_seats: list[str] | None = None, ) -> dict[str, Any]: """Create a new flight reservation.""" logger.info( f"Creating reservation for flight_id: {flight_id}, passengers: {len(passengers)}" ) with self.get_connection() as conn: cursor = conn.execute( "SELECT price, available_seats FROM flights WHERE id = ?", (flight_id,) ) flight_row = cursor.fetchone() if not flight_row: logger.error(f"Flight not found: flight_id={flight_id}") raise ValueError(f"Flight with ID {flight_id} not found") price_per_seat, available_seats = flight_row num_passengers = len(passengers) if available_seats < num_passengers: logger.warning( f"Insufficient seats: requested={num_passengers}, available={available_seats}" ) raise ValueError( f"Not enough seats available. Requested: {num_passengers}, Available: {available_seats}" ) booking_number = f"BK{uuid.uuid4().hex[:6].upper()}" total_price = float(price_per_seat) * num_passengers logger.info(f"Generated booking_number: {booking_number}, total_price: {total_price}") cursor = conn.execute( """ INSERT INTO bookings (booking_number, flight_id, total_price, status) VALUES (?, ?, ?, 'pending') """, (booking_number, flight_id, total_price), ) booking_id = cursor.lastrowid assigned_seats = [] for i, passenger in enumerate(passengers): seat_number = ( requested_seats[i] if requested_seats and i < len(requested_seats) else f"AUTO{i + 1}" ) conn.execute( """ INSERT INTO booking_passengers (booking_id, passenger_name, email, seat_number) VALUES (?, ?, ?, ?) """, (booking_id, passenger["name"], passenger.get("email", ""), seat_number), ) assigned_seats.append(seat_number) conn.execute( """ UPDATE flights SET available_seats = available_seats - ? WHERE id = ? """, (num_passengers, flight_id), ) conn.commit() logger.info( f"Reservation created successfully: booking_number={booking_number}, booking_id={booking_id}" ) return { "booking_number": booking_number, "booking_id": booking_id, "flight_id": flight_id, "status": "reserved", "total_price": total_price, "passengers": passengers, "assigned_seats": assigned_seats, "reservation_expires": "24 hours from creation", "next_steps": ["Confirm booking", "Process payment"], } def confirm_booking( self, booking_number: str, ) -> dict[str, Any]: """Confirm a pending booking.""" logger.info(f"Confirming booking: {booking_number}") with self.get_connection() as conn: # Get booking details cursor = conn.execute( """ SELECT id, flight_id, status, total_price FROM bookings WHERE booking_number = ? """, (booking_number,), ) booking_row = cursor.fetchone() if not booking_row: logger.error(f"Booking not found: {booking_number}") raise ValueError(f"Booking {booking_number} not found") booking_id, flight_id, current_status, total_price = booking_row if current_status != "pending": logger.warning(f"Cannot confirm booking {booking_number}, status: {current_status}") raise ValueError( f"Booking {booking_number} cannot be confirmed. Current status: {current_status}" ) # Update booking status confirmation_time = datetime.now().isoformat() conn.execute( """ UPDATE bookings SET status = 'confirmed', confirmed_at = ? WHERE booking_number = ? """, (confirmation_time, booking_number), ) conn.commit() # Generate confirmation code confirmation_code = f"CONF{uuid.uuid4().hex[:8].upper()}" logger.info( f"Booking confirmed: {booking_number}, confirmation_code: {confirmation_code}" ) return { "booking_number": booking_number, "confirmation_code": confirmation_code, "status": "confirmed", "confirmed_at": confirmation_time, "total_price": float(total_price), "next_steps": ["Process payment to complete booking"], } def process_payment( self, booking_number: str, payment_method: str, amount: float | None = None, ) -> dict[str, Any]: """Process payment for a booking.""" logger.info(f"Processing payment for booking: {booking_number}, method: {payment_method}") with self.get_connection() as conn: # Get booking details cursor = conn.execute( """ SELECT id, total_price, status FROM bookings WHERE booking_number = ? """, (booking_number,), ) booking_row = cursor.fetchone() if not booking_row: logger.error(f"Booking not found: {booking_number}") raise ValueError(f"Booking {booking_number} not found") booking_id, total_price, booking_status = booking_row payment_amount = amount if amount is not None else float(total_price) # Generate transaction ID transaction_id = f"TXN{uuid.uuid4().hex[:8].upper()}" processed_time = datetime.now().isoformat() logger.info(f"Payment transaction created: {transaction_id}, amount: {payment_amount}") # Insert payment record conn.execute( """ INSERT INTO payments (booking_id, amount, status, payment_method, transaction_id, processed_at) VALUES (?, ?, 'completed', ?, ?, ?) """, (booking_id, payment_amount, payment_method, transaction_id, processed_time), ) # Update booking status to paid conn.execute( """ UPDATE bookings SET status = 'paid' WHERE booking_number = ? """, (booking_number,), ) conn.commit() logger.info( f"Payment completed: booking={booking_number}, transaction={transaction_id}" ) return { "booking_number": booking_number, "transaction_id": transaction_id, "payment_status": "completed", "amount_paid": payment_amount, "payment_method": payment_method, "processed_at": processed_time, "booking_status": "paid", "message": "Payment processed successfully. Booking is now complete.", } def get_booking_details( self, booking_number: str, ) -> dict[str, Any]: """Get detailed information about a booking.""" with self.get_connection() as conn: # Get complete booking details cursor = conn.execute( """ SELECT b.id, b.booking_number, b.flight_id, b.total_price, b.status, b.created_at, b.confirmed_at, f.flight_number, f.airline, f.departure_city, f.arrival_city, f.departure_time FROM bookings b JOIN flights f ON b.flight_id = f.id WHERE b.booking_number = ? """, (booking_number,), ) booking_row = cursor.fetchone() if not booking_row: raise ValueError(f"Booking {booking_number} not found") # Get passengers passenger_cursor = conn.execute( """ SELECT passenger_name, email, seat_number FROM booking_passengers WHERE booking_id = ? """, (booking_row[0],), ) passengers = [] for p_row in passenger_cursor.fetchall(): passengers.append({"name": p_row[0], "email": p_row[1], "seat": p_row[2]}) return { "booking_number": booking_number, "flight": { "flight_number": booking_row[7], "airline": booking_row[8], "route": f"{booking_row[9]} → {booking_row[10]}", "departure_time": booking_row[11], }, "booking_details": { "status": booking_row[4], "total_price": float(booking_row[3]), "created_at": booking_row[5], "confirmed_at": booking_row[6], }, "passengers": passengers, } def cancel_booking( self, booking_number: str, reason: str, ) -> dict[str, Any]: """Cancel an existing booking.""" logger.info(f"Cancelling booking: {booking_number}, reason: {reason}") with self.get_connection() as conn: # Get booking details cursor = conn.execute( """ SELECT id, flight_id, status, total_price FROM bookings WHERE booking_number = ? """, (booking_number,), ) booking_row = cursor.fetchone() if not booking_row: logger.error(f"Booking not found: {booking_number}") raise ValueError(f"Booking {booking_number} not found") booking_id, flight_id, current_status, total_price = booking_row if current_status == "cancelled": logger.warning(f"Booking already cancelled: {booking_number}") raise ValueError(f"Booking {booking_number} is already cancelled") # Calculate refund amount (simplified logic) refund_amount = float(total_price) * 0.8 # 80% refund # Insert cancellation record conn.execute( """ INSERT INTO cancellations (booking_id, reason, refund_amount) VALUES (?, ?, ?) """, (booking_id, reason, refund_amount), ) # Update booking status conn.execute( """ UPDATE bookings SET status = 'cancelled' WHERE booking_number = ? """, (booking_number,), ) # Get passenger count to free up seats cursor = conn.execute( """ SELECT COUNT(*) FROM booking_passengers WHERE booking_id = ? """, (booking_id,), ) num_seats = cursor.fetchone()[0] # Update available seats count conn.execute( """ UPDATE flights SET available_seats = available_seats + ? WHERE id = ? """, (num_seats, flight_id), ) conn.commit() logger.info( f"Booking cancelled: {booking_number}, refund_amount: {refund_amount}, seats_freed: {num_seats}" ) return { "booking_number": booking_number, "status": "cancelled", "cancellation_reason": reason, "refund_amount": refund_amount, "cancelled_at": datetime.now().isoformat(), "message": "Booking cancelled successfully. Refund will be processed within 5-7 business days.", } ================================================ FILE: agents/a2a/src/flight-booking-agent/dependencies.py ================================================ """Dependency injection module for Flight Booking Agent.""" import logging from functools import lru_cache from database import BookingDatabaseManager from env_settings import EnvSettings # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Simple singleton providers @lru_cache def get_env() -> EnvSettings: """Get environment settings singleton.""" logger.debug("Getting environment settings") return EnvSettings() @lru_cache def get_db_manager() -> BookingDatabaseManager: """Get database manager singleton.""" env = get_env() logger.debug(f"Getting database manager with db_path: {env.db_path}") return BookingDatabaseManager(env.db_path) ================================================ FILE: agents/a2a/src/flight-booking-agent/env_settings.py ================================================ """Environment settings for Flight Booking Agent.""" import logging import os # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) class EnvSettings: """Environment settings configuration.""" def __init__(self) -> None: """Initialize environment settings.""" self.db_path: str = os.getenv("DB_PATH", "/app/data/bookings.db") self.aws_region: str = os.getenv("AWS_REGION") or os.getenv( "AWS_DEFAULT_REGION", "us-east-1" ) self.agent_name: str = os.getenv("AGENT_NAME", "flight-booking") self.agent_version: str = os.getenv("AGENT_VERSION", "1.0.0") # MCP Gateway Registry URL (TODO: replace later) self.mcp_registry_url: str = os.getenv("MCP_REGISTRY_URL", "http://localhost:7860") # Agent's public URL (AgentCore Runtime injects automatically) self.agent_url: str = os.getenv("AGENTCORE_RUNTIME_URL", "http://127.0.0.1:9000/") # Server configuration (fixed for A2A protocol) # Agent binds to 0.0.0.0 for container/K8s deployment where network isolation # is provided by container runtime. In production, use firewall rules. self.host: str = os.getenv("AGENT_HOST", "0.0.0.0") # nosec B104 - intentional for containerized agent deployment self.port: int = 9000 logger.info( f"EnvSettings initialized: agent_name={self.agent_name}, version={self.agent_version}" ) logger.debug(f"Database path: {self.db_path}") logger.debug(f"Agent URL: {self.agent_url}") ================================================ FILE: agents/a2a/src/flight-booking-agent/tools.py ================================================ """Tools for Flight Booking Agent - Direct SQLite operations for booking management.""" import json import logging from dependencies import get_db_manager from strands import tool # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) @tool def check_availability( flight_id: int, ) -> str: """Check seat availability for a specific flight.""" logger.info(f"Tool called: check_availability(flight_id={flight_id})") try: availability = get_db_manager().get_flight_availability(flight_id) if not availability: error_msg = f"Flight with ID {flight_id} not found" logger.warning(error_msg) return json.dumps({"error": error_msg}) logger.debug(f"Availability result:\n{json.dumps(availability, indent=2)}") return json.dumps(availability, indent=2) except Exception as e: logger.exception(f"Database error in check_availability: {e}") return json.dumps({"error": "An internal database error occurred"}) @tool def reserve_flight( flight_id: int, passengers: list[dict[str, str]], requested_seats: list[str] | None = None, ) -> str: """Reserve seats on a flight for passengers.""" logger.info(f"Tool called: reserve_flight(flight_id={flight_id}, passengers={len(passengers)})") logger.debug(f"Passengers: {passengers}, Requested seats: {requested_seats}") try: reservation = get_db_manager().create_reservation(flight_id, passengers, requested_seats) logger.debug(f"Reservation result:\n{json.dumps(reservation, indent=2)}") return json.dumps(reservation, indent=2) except ValueError as e: logger.warning(f"Validation error in reserve_flight: {e}") return json.dumps({"error": "Invalid reservation parameters"}) except Exception as e: logger.exception(f"Database error in reserve_flight: {e}") return json.dumps({"error": "An internal database error occurred"}) @tool def confirm_booking( booking_number: str, ) -> str: """Confirm and finalize a flight booking.""" logger.info(f"Tool called: confirm_booking(booking_number={booking_number})") try: confirmation = get_db_manager().confirm_booking(booking_number) logger.debug(f"Confirmation result:\n{json.dumps(confirmation, indent=2)}") return json.dumps(confirmation, indent=2) except ValueError as e: logger.warning(f"Validation error in confirm_booking: {e}") return json.dumps({"error": "Invalid booking confirmation parameters"}) except Exception as e: logger.exception(f"Database error in confirm_booking: {e}") return json.dumps({"error": "An internal database error occurred"}) @tool def process_payment( booking_number: str, payment_method: str, amount: float | None = None, ) -> str: """Process payment for a booking (simulated).""" logger.info( f"Tool called: process_payment(booking_number={booking_number}, payment_method={payment_method})" ) logger.debug(f"Payment amount: {amount}") try: payment_result = get_db_manager().process_payment(booking_number, payment_method, amount) logger.debug(f"Payment result:\n{json.dumps(payment_result, indent=2)}") return json.dumps(payment_result, indent=2) except ValueError as e: logger.warning(f"Validation error in process_payment: {e}") return json.dumps({"error": "Invalid payment parameters"}) except Exception as e: logger.exception(f"Database error in process_payment: {e}") return json.dumps({"error": "An internal database error occurred"}) @tool def manage_reservation( booking_number: str, action: str, reason: str | None = None, ) -> str: """Update, view, or cancel existing reservations.""" logger.info( f"Tool called: manage_reservation(booking_number={booking_number}, action={action})" ) logger.debug(f"Reason: {reason}") try: db_manager = get_db_manager() if action == "view": booking_details = db_manager.get_booking_details(booking_number) logger.debug(f"Booking details:\n{json.dumps(booking_details, indent=2)}") return json.dumps(booking_details, indent=2) elif action == "cancel": if not reason: error_msg = "Cancellation reason is required" logger.warning(error_msg) return json.dumps({"error": error_msg}) cancellation_result = db_manager.cancel_booking(booking_number, reason) logger.debug(f"Cancellation result:\n{json.dumps(cancellation_result, indent=2)}") return json.dumps(cancellation_result, indent=2) else: error_msg = f"Unknown action: {action}. Supported actions: view, cancel" logger.warning(error_msg) return json.dumps({"error": error_msg}) except ValueError as e: logger.warning(f"Validation error in manage_reservation: {e}") return json.dumps({"error": "Invalid reservation parameters"}) except Exception as e: logger.exception(f"Database error in manage_reservation: {e}") return json.dumps({"error": "An internal database error occurred"}) # TODO: Create tool that's able to dynamically search agents from MCP Registry # example: # @tool # def delegate_to_agent(agent_capability: str, action: str, params: Dict) -> str: FLIGHT_BOOKING_TOOLS = [ check_availability, reserve_flight, confirm_booking, process_payment, manage_reservation, ] ================================================ FILE: agents/a2a/src/travel-assistant-agent/Dockerfile ================================================ ARG TARGETPLATFORM FROM --platform=${TARGETPLATFORM} public.ecr.aws/docker/library/python:3.14-slim WORKDIR /app # Install system dependencies and uv # build-essential is required to compile asyncpg from source (no py3.14 wheel yet) # apt-get upgrade ensures latest security patches (e.g. openssl ~deb13u2) RUN apt-get update && apt-get upgrade -y && apt-get install -y \ sqlite3 \ curl \ build-essential \ && rm -rf /var/lib/apt/lists/* \ && pip install uv # Copy dependency files (build-images.sh copies these to .tmp/ directory before build) COPY .tmp/pyproject.toml .tmp/uv.lock ./ # Install Python dependencies using uv (as root, before switching users) RUN uv sync --frozen --no-dev # Copy agent code (all files from the context directory) COPY . ./ # Create non-root user RUN useradd -m -u 1000 bedrock_agentcore # Create data directory for SQLite database with proper ownership # Also fix ownership of installed packages RUN mkdir -p /app/data && \ chown -R bedrock_agentcore:bedrock_agentcore /app USER bedrock_agentcore # Set environment variables ENV PYTHONPATH=/app ENV AWS_REGION=us-east-1 ENV AWS_DEFAULT_REGION=us-east-1 # Expose port for A2A communication (port 9000 for A2A protocol) EXPOSE 9000 # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:9000/ping || exit 1 # Run the agent with uv (uses the virtual environment created by uv sync) CMD ["uv", "run", "--no-sync", "server.py"] ================================================ FILE: agents/a2a/src/travel-assistant-agent/__init__.py ================================================ """Travel Assistant Agent Package.""" import logging from .agent import ( agent, app, ) from .database import FlightDatabaseManager from .env_settings import env_settings from .tools import TRAVEL_ASSISTANT_TOOLS logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) __all__ = ["app", "agent", "env_settings", "FlightDatabaseManager", "TRAVEL_ASSISTANT_TOOLS"] ================================================ FILE: agents/a2a/src/travel-assistant-agent/agent.py ================================================ """Tools for Travel Assistant Agent - Flight search and trip planning utilities.""" import json import logging from dependencies import get_db_manager, get_registry_client, get_remote_agent_cache from strands import Agent, tool logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) @tool def search_flights( departure_city: str, arrival_city: str, departure_date: str, ) -> str: """Search for available flights between cities on a specific date.""" logger.info( f"Tool called: search_flights(departure_city={departure_city}, arrival_city={arrival_city}, departure_date={departure_date})" ) try: flights = get_db_manager().search_flights(departure_city, arrival_city, departure_date) result = { "query": { "departure_city": departure_city, "arrival_city": arrival_city, "departure_date": departure_date, }, "flights": flights, "count": len(flights), } logger.debug(f"Flight search result:\n{json.dumps(result, indent=2)}") return json.dumps(result, indent=2) except Exception as e: logger.exception(f"Database error in search_flights: {e}") return json.dumps({"error": "An internal database error occurred"}) @tool def check_prices( flight_id: int, ) -> str: """Get pricing and seat availability for a specific flight.""" logger.info(f"Tool called: check_prices(flight_id={flight_id})") try: flight_details = get_db_manager().get_flight_details(flight_id) if not flight_details: error_msg = f"Flight with ID {flight_id} not found" logger.warning(error_msg) return json.dumps({"error": error_msg}) logger.debug(f"Flight details result:\n{json.dumps(flight_details, indent=2)}") return json.dumps(flight_details, indent=2) except Exception as e: logger.exception(f"Database error in check_prices: {e}") return json.dumps({"error": "An internal database error occurred"}) @tool def get_recommendations( max_price: float, preferred_airlines: list[str] | None = None, ) -> str: """Get flight recommendations based on customer preferences.""" logger.info( f"Tool called: get_recommendations(max_price={max_price}, preferred_airlines={preferred_airlines})" ) try: recommendations = get_db_manager().get_recommendations(max_price, preferred_airlines) result = { "criteria": {"max_price": max_price, "preferred_airlines": preferred_airlines or "Any"}, "recommendations": recommendations, "count": len(recommendations), } logger.debug(f"Recommendations result:\n{json.dumps(result, indent=2)}") return json.dumps(result, indent=2) except Exception as e: logger.exception(f"Database error in get_recommendations: {e}") return json.dumps({"error": "An internal database error occurred"}) @tool def create_trip_plan( departure_city: str, arrival_city: str, departure_date: str, return_date: str | None = None, budget: float | None = None, ) -> str: """Create and save a trip planning record.""" logger.info( f"Tool called: create_trip_plan(departure_city={departure_city}, arrival_city={arrival_city}, departure_date={departure_date})" ) logger.debug(f"Return date: {return_date}, Budget: {budget}") try: db_manager = get_db_manager() trip_plan_id = db_manager.create_trip_plan( departure_city, arrival_city, departure_date, return_date, budget ) # Get available flights for the trip outbound_flights = db_manager.search_flights(departure_city, arrival_city, departure_date) return_flights = [] if return_date: return_flights = db_manager.search_flights(arrival_city, departure_city, return_date) result = { "trip_plan_id": trip_plan_id, "trip_details": { "departure_city": departure_city.upper(), "arrival_city": arrival_city.upper(), "departure_date": departure_date, "return_date": return_date, "budget": budget, "status": "planning", }, "outbound_flights": outbound_flights, "return_flights": return_flights, "next_steps": [ "Review available flights", "Select preferred flights", "Contact Flight Booking Agent for reservation", ], } logger.debug(f"Trip plan result:\n{json.dumps(result, indent=2)}") return json.dumps(result, indent=2) except Exception as e: logger.exception(f"Database error in create_trip_plan: {e}") return json.dumps({"error": "An internal database error occurred"}) @tool async def discover_remote_agents(query: str, max_results: int = 5) -> str: """ Discover remote agents from the mcp-registry with natural language query. Cache them for visibility and invocation for later tool calls from LLM """ logger.info(f"Tool called: discover_remote_agents(query='{query}', max_results={max_results})") try: registry_client = get_registry_client() if not registry_client: return json.dumps( { "error": "Registry discovery not configured", "message": "Set M2M_CLIENT_ID and M2M_CLIENT_SECRET environment variables", } ) # Search registry discovered = await registry_client.discover_by_semantic_search( query=query, max_results=max_results, ) if not discovered: return json.dumps( { "query": query, "agents_found": 0, "message": "No agents found matching your query", } ) # Get auth token and cache the agents auth_token = await registry_client._get_token() cache = get_remote_agent_cache() newly_cached = cache.cache_discovered_agents(discovered, auth_token) result = { "query": query, "agents_found": len(discovered), "newly_cached": len(newly_cached), "total_cached": len(cache), "agents": [ { "id": agent.path, "name": agent.name, "description": agent.description, "url": agent.url, "skills": agent.skill_names, "tags": agent.tags, "relevance_score": agent.relevance_score, "trust_level": agent.trust_level, } for agent in discovered ], "next_steps": [ "Use view_cached_remote_agents() to see all cached agents", "Use invoke_remote_agent(agent_id, message) to call a specific agent", ], } logger.info( f"Discovery successful: found {len(discovered)} agents, cached {len(newly_cached)} new" ) return json.dumps(result, indent=2) except Exception as e: logger.error(f"Discovery error in discover_remote_agents: {e}", exc_info=True) return json.dumps( { "error": "Discovery failed", "message": "An internal error occurred during agent discovery", } ) @tool async def view_cached_remote_agents() -> str: """View all cached remote agents available for invocation.""" logger.info("Tool called: view_cached_remote_agents()") try: cache = get_remote_agent_cache() if len(cache) == 0: return json.dumps( { "total": 0, "message": "No agents cached. Use discover_remote_agents() to find and cache agents.", } ) all_agents = cache.get_all() result = { "total": len(cache), "agents": [ { "id": agent_id, "name": agent_client.agent_name, "url": agent_client.agent_url, "skills": agent_client.skills, } for agent_id, agent_client in all_agents.items() ], "usage": "Use invoke_remote_agent(agent_id, message) to call any of these agents", } logger.info(f"Returning {len(cache)} cached agents") return json.dumps(result, indent=2) except Exception as e: logger.error(f"Error in view_cached_remote_agents: {e}", exc_info=True) return json.dumps( { "error": "Failed to view cached agents", "message": "An internal error occurred while viewing cached agents", } ) @tool async def invoke_remote_agent(agent_id: str, message: str) -> str: """Invoke a cached remote agent by ID with a natural language message.""" logger.info( f"Tool called: invoke_remote_agent(agent_id='{agent_id}', message='{message[:100]}...')" ) try: cache = get_remote_agent_cache() if agent_id not in cache: all_agents = cache.get_all() available_ids = list(all_agents.keys()) return json.dumps( { "error": f"Agent '{agent_id}' not found in cache", "available_agents": available_ids, "hint": "Use discover_remote_agents() to find and cache agents, or view_cached_remote_agents() to see what's available", } ) # Get the cached agent client and invoke it agent_client = cache.get(agent_id) logger.info(f"Invoking agent: {agent_client.agent_name}") response = await agent_client.send_message(message) logger.info(f"Successfully invoked {agent_client.agent_name}") return response except Exception as e: logger.error(f"Error in invoke_remote_agent: {e}", exc_info=True) return json.dumps( { "error": "Failed to invoke remote agent", "agent_id": agent_id, "message": "An internal error occurred while invoking the remote agent", } ) TRAVEL_ASSISTANT_TOOLS = [ search_flights, check_prices, get_recommendations, create_trip_plan, discover_remote_agents, view_cached_remote_agents, invoke_remote_agent, ] strands_agent = Agent( name="Travel Assistant Agent", description="Flight search and trip planning agent with dynamic agent discovery", tools=TRAVEL_ASSISTANT_TOOLS, callback_handler=None, model="global.anthropic.claude-sonnet-4-5-20250929-v1:0", ) def get_agent_instance(): return strands_agent ================================================ FILE: agents/a2a/src/travel-assistant-agent/database.py ================================================ """Database management module for Travel Assistant Agent.""" import logging import os import sqlite3 from typing import ( Any, ) logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def _insert_seed_data( conn: sqlite3.Connection, ) -> None: """Insert seed data into the database.""" seed_data = [ ( 1, "UA101", "United", "SF", "NY", "2025-11-15 08:00", "2025-11-15 16:30", 330, 250.00, 85, "B737", ), ( 2, "AA202", "American", "SF", "NY", "2025-11-15 10:15", "2025-11-15 18:45", 330, 280.00, 45, "A320", ), ( 3, "DL303", "Delta", "SF", "NY", "2025-11-15 14:30", "2025-11-15 23:00", 330, 220.00, 120, "B757", ), ( 4, "UA104", "United", "SF", "LA", "2025-11-16 07:00", "2025-11-16 08:30", 90, 120.00, 95, "B737", ), ( 5, "AA205", "American", "NY", "SF", "2025-11-17 09:00", "2025-11-17 12:30", 330, 260.00, 78, "A321", ), ( 6, "DL306", "Delta", "LA", "NY", "2025-11-18 11:00", "2025-11-18 19:30", 330, 240.00, 92, "B757", ), ] conn.executemany( """ INSERT OR IGNORE INTO flights (id, flight_number, airline, departure_city, arrival_city, departure_time, arrival_time, duration_minutes, price, available_seats, aircraft_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, seed_data, ) conn.commit() class FlightDatabaseManager: """Database manager for flight searches and trip planning.""" def __init__( self, db_path: str, ) -> None: """Initialize the database manager.""" self.db_path = db_path logger.info(f"Initializing FlightDatabaseManager with db_path: {db_path}") self.init_database() def init_database(self) -> None: """Initialize the database with tables and seed data.""" os.makedirs(os.path.dirname(self.db_path), exist_ok=True) with sqlite3.connect(self.db_path) as conn: conn.execute(""" CREATE TABLE IF NOT EXISTS flights ( id INTEGER PRIMARY KEY, flight_number TEXT UNIQUE NOT NULL, airline TEXT NOT NULL, departure_city TEXT NOT NULL, arrival_city TEXT NOT NULL, departure_time DATETIME NOT NULL, arrival_time DATETIME NOT NULL, duration_minutes INTEGER, price DECIMAL(10,2), available_seats INTEGER DEFAULT 100, aircraft_type TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) conn.execute(""" CREATE TABLE IF NOT EXISTS trip_plans ( id INTEGER PRIMARY KEY AUTOINCREMENT, departure_city TEXT NOT NULL, arrival_city TEXT NOT NULL, departure_date TEXT NOT NULL, return_date TEXT, budget DECIMAL(10,2), status TEXT DEFAULT 'planning', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) """) cursor = conn.execute("SELECT COUNT(*) FROM flights") if cursor.fetchone()[0] == 0: _insert_seed_data(conn) def get_connection(self) -> sqlite3.Connection: """Get a database connection.""" return sqlite3.connect(self.db_path) def search_flights( self, departure_city: str, arrival_city: str, departure_date: str, ) -> list[dict[str, Any]]: """Search for available flights between cities on a specific date.""" logger.info( f"Searching flights: {departure_city} -> {arrival_city}, date: {departure_date}" ) with self.get_connection() as conn: cursor = conn.execute( """ SELECT id, flight_number, airline, departure_city, arrival_city, departure_time, arrival_time, duration_minutes, price, available_seats, aircraft_type FROM flights WHERE departure_city = ? AND arrival_city = ? AND DATE(departure_time) = ? ORDER BY price ASC """, (departure_city.upper(), arrival_city.upper(), departure_date), ) flights = [] for row in cursor.fetchall(): flights.append( { "id": row[0], "flight_number": row[1], "airline": row[2], "departure_city": row[3], "arrival_city": row[4], "departure_time": row[5], "arrival_time": row[6], "duration_minutes": row[7], "price": float(row[8]), "available_seats": row[9], "aircraft_type": row[10], } ) logger.info(f"Found {len(flights)} flights") return flights def get_flight_details( self, flight_id: int, ) -> dict[str, Any] | None: """Get detailed information about a specific flight.""" logger.info(f"Getting flight details for flight_id: {flight_id}") with self.get_connection() as conn: cursor = conn.execute( """ SELECT flight_number, airline, departure_city, arrival_city, departure_time, arrival_time, price, available_seats FROM flights WHERE id = ? """, (flight_id,), ) row = cursor.fetchone() if not row: logger.warning(f"Flight not found: flight_id={flight_id}") return None logger.info(f"Flight details retrieved: {row[0]}") return { "flight_id": flight_id, "flight_number": row[0], "airline": row[1], "route": f"{row[2]} → {row[3]}", "departure_time": row[4], "arrival_time": row[5], "price": float(row[6]), "available_seats": row[7], "availability_status": "Available" if row[7] > 0 else "Sold Out", } def get_recommendations( self, max_price: float, preferred_airlines: list[str] | None = None, ) -> list[dict[str, Any]]: """Get flight recommendations based on price and airline preferences.""" logger.info( f"Getting recommendations: max_price={max_price}, airlines={preferred_airlines}" ) with self.get_connection() as conn: query = "SELECT * FROM flights WHERE price <= ? AND available_seats > 0" params: list[Any] = [max_price] if preferred_airlines: placeholders = ",".join(["?" for _ in preferred_airlines]) query += f" AND airline IN ({placeholders})" params.extend(preferred_airlines) query += " ORDER BY price ASC, available_seats DESC" cursor = conn.execute(query, params) recommendations = [] for row in cursor.fetchall(): recommendations.append( { "id": row[0], "flight_number": row[1], "airline": row[2], "route": f"{row[3]} → {row[4]}", "departure_time": row[5], "arrival_time": row[6], "duration_minutes": row[7], "price": float(row[8]), "available_seats": row[9], "aircraft_type": row[10], "recommendation_score": min( 100, int((max_price - float(row[8])) / max_price * 100) ), } ) logger.info(f"Found {len(recommendations)} recommendations") return recommendations def create_trip_plan( self, departure_city: str, arrival_city: str, departure_date: str, return_date: str | None = None, budget: float | None = None, ) -> int: """Create a new trip plan.""" logger.info( f"Creating trip plan: {departure_city} -> {arrival_city}, date: {departure_date}, budget: {budget}" ) with self.get_connection() as conn: cursor = conn.execute( """ INSERT INTO trip_plans (departure_city, arrival_city, departure_date, return_date, budget) VALUES (?, ?, ?, ?, ?) """, (departure_city.upper(), arrival_city.upper(), departure_date, return_date, budget), ) trip_plan_id = cursor.lastrowid conn.commit() logger.info(f"Trip plan created: trip_plan_id={trip_plan_id}") return trip_plan_id ================================================ FILE: agents/a2a/src/travel-assistant-agent/dependencies.py ================================================ """Dependency injection module for Travel Assistant Agent.""" import logging from functools import lru_cache from database import FlightDatabaseManager from env_settings import EnvSettings from registry_discovery_client import RegistryDiscoveryClient from remote_agent_client import RemoteAgentCache logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) @lru_cache def get_env() -> EnvSettings: """Get environment settings singleton.""" logger.debug("Getting environment settings") return EnvSettings() @lru_cache def get_db_manager() -> FlightDatabaseManager: """Get database manager singleton.""" env = get_env() logger.debug(f"Getting database manager with db_path: {env.db_path}") return FlightDatabaseManager(env.db_path) @lru_cache def get_registry_client() -> RegistryDiscoveryClient | None: """Get registry discovery client singleton. Returns: RegistryDiscoveryClient if configured, None otherwise """ env = get_env() # Option 1: Use direct JWT token if provided if env.registry_jwt_token: logger.info("Creating RegistryDiscoveryClient with direct JWT token") return RegistryDiscoveryClient( registry_url=env.mcp_registry_url, jwt_token=env.registry_jwt_token, ) # Option 2: Use M2M client credentials if not env.m2m_client_secret: logger.warning("M2M_CLIENT_SECRET not configured, discovery will not work") return None if not env.m2m_client_id: logger.warning("M2M_CLIENT_ID not configured, discovery will not work") return None logger.info("Creating RegistryDiscoveryClient with M2M credentials") return RegistryDiscoveryClient( registry_url=env.mcp_registry_url, keycloak_url=env.keycloak_url, client_id=env.m2m_client_id, client_secret=env.m2m_client_secret, realm=env.keycloak_realm, ) @lru_cache def get_remote_agent_cache() -> RemoteAgentCache: """Get the remote agent cache singleton. Returns: RemoteAgentCache instance """ logger.debug("Getting remote agent cache") return RemoteAgentCache() ================================================ FILE: agents/a2a/src/travel-assistant-agent/env_settings.py ================================================ """Environment settings for Travel Assistant Agent.""" import logging import os # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) class EnvSettings: """Environment settings configuration.""" def __init__(self) -> None: """Initialize environment settings.""" self.db_path: str = os.getenv("DB_PATH", "/app/data/flights.db") self.aws_region: str = os.getenv("AWS_REGION") or os.getenv( "AWS_DEFAULT_REGION", "us-east-1" ) self.agent_name: str = os.getenv("AGENT_NAME", "travel-assistant") self.agent_version: str = os.getenv("AGENT_VERSION", "1.0.0") # MCP Gateway Registry URL self.mcp_registry_url: str = os.getenv("MCP_REGISTRY_URL", "http://localhost:7860") # Agent's public URL (AgentCore Runtime injects automatically) self.agent_url: str = os.getenv("AGENTCORE_RUNTIME_URL", "http://127.0.0.1:9000/") # Server configuration (fixed for A2A protocol) # Agent binds to 0.0.0.0 for container/K8s deployment where network isolation # is provided by container runtime. In production, use firewall rules. self.host: str = os.getenv("AGENT_HOST", "0.0.0.0") # nosec B104 - intentional for containerized agent deployment self.port: int = 9000 # Keycloak configuration for M2M authentication self.keycloak_url: str = os.getenv("KEYCLOAK_URL", "http://localhost:8080") self.keycloak_realm: str = os.getenv("KEYCLOAK_REALM", "mcp-gateway") self.m2m_client_id: str = os.getenv("M2M_CLIENT_ID", "") self.m2m_client_secret: str = os.getenv("M2M_CLIENT_SECRET", "") # Optional: Direct JWT token (bypasses M2M authentication) # If set, this token is used directly instead of fetching from Keycloak self.registry_jwt_token: str = os.getenv("REGISTRY_JWT_TOKEN", "") logger.info( f"EnvSettings initialized: agent_name={self.agent_name}, version={self.agent_version}" ) if self.registry_jwt_token: logger.info("Using direct JWT token for registry authentication") elif self.m2m_client_id and self.m2m_client_secret: logger.info("Using M2M client credentials for registry authentication") logger.debug(f"Database path: {self.db_path}") logger.debug(f"Agent URL: {self.agent_url}") ================================================ FILE: agents/a2a/src/travel-assistant-agent/models.py ================================================ """Data models for Travel Assistant Agent.""" from typing import Any from pydantic import BaseModel, Field class AgentSkill(BaseModel): """Skill/capability of an agent.""" id: str = Field(..., description="Skill identifier") name: str = Field(..., description="Skill name") description: str | None = Field(None, description="Skill description") tags: list[str] = Field(default_factory=list, description="Skill tags") examples: list[str] | None = Field(None, description="Usage examples") input_modes: list[str] | None = Field(None, description="Supported input modes") output_modes: list[str] | None = Field(None, description="Supported output modes") security: dict[str, Any] | None = Field(None, description="Security requirements") class DiscoveredAgent(BaseModel): """Agent discovered from registry.""" model_config = {"populate_by_name": True, "extra": "ignore"} name: str = Field(..., description="Agent name") description: str = Field(default="", description="Agent description") path: str = Field(..., description="Registry path") url: str | None = Field(None, description="Agent endpoint URL for invocation") tags: list[str] = Field(default_factory=list, description="Categorization tags") skills: list[AgentSkill] = Field(default_factory=list, description="Agent skills") is_enabled: bool = Field(False, description="Whether agent is enabled") trust_level: str = Field("unverified", description="Trust level") visibility: str = Field("public", description="Agent visibility") relevance_score: float | None = Field(None, description="Relevance score from search") @property def agent_name(self) -> str: """Alias for name for backward compatibility.""" return self.name @property def skill_names(self) -> list[str]: """Get list of skill names.""" return [skill.name for skill in self.skills] ================================================ FILE: agents/a2a/src/travel-assistant-agent/registry_discovery_client.py ================================================ """Client for agent discovery through MCP Gateway Registry.""" import logging import time import aiohttp from models import DiscoveredAgent logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) class RegistryDiscoveryClient: """Client for agent discovery through MCP Gateway Registry.""" def __init__( self, registry_url: str, keycloak_url: str | None = None, client_id: str | None = None, client_secret: str | None = None, realm: str = "mcp-gateway", jwt_token: str | None = None, ) -> None: self.registry_url = registry_url.rstrip("/") self.keycloak_url = keycloak_url.rstrip("/") if keycloak_url else None self.client_id = client_id self.client_secret = client_secret self.realm = realm self.token: str | None = None self.token_expires_at: float = 0 # Direct JWT token (bypasses M2M authentication) self.direct_jwt_token = jwt_token if jwt_token: logger.info( f"RegistryDiscoveryClient initialized with direct JWT token for {registry_url}" ) else: logger.info( f"RegistryDiscoveryClient initialized with M2M credentials for {registry_url}" ) async def _get_token(self) -> str: """Get or refresh JWT token from Keycloak using client credentials flow. Returns: JWT access token Raises: Exception: If token acquisition fails """ # If direct JWT token is provided, use it directly if self.direct_jwt_token: logger.debug("Using direct JWT token") return self.direct_jwt_token current_time = time.time() if self.token and current_time < self.token_expires_at - 60: logger.debug("Using cached token") return self.token token_url = f"{self.keycloak_url}/realms/{self.realm}/protocol/openid-connect/token" logger.debug(f"Requesting new token from {token_url}") async with aiohttp.ClientSession() as session: data = { "grant_type": "client_credentials", "client_id": self.client_id, "client_secret": self.client_secret, } try: async with session.post(token_url, data=data) as response: if response.status != 200: error_text = await response.text() logger.error(f"Token request failed: {response.status} - {error_text}") raise Exception(f"Failed to get token: {response.status}") token_data = await response.json() self.token = token_data["access_token"] expires_in = token_data.get("expires_in", 300) self.token_expires_at = current_time + expires_in logger.info(f"Token acquired, expires in {expires_in}s") return self.token except aiohttp.ClientError as e: logger.error(f"Network error getting token: {e}") raise Exception(f"Network error: {e}") async def discover_by_semantic_search( self, query: str, max_results: int = 5, ) -> list[DiscoveredAgent]: """Discover agents using semantic search (natural language query). Args: query: Natural language search query max_results: Maximum number of results to return Returns: List of discovered agents with relevance scores Raises: Exception: If discovery fails """ logger.info(f"Semantic search: '{query}' (max_results={max_results})") token = await self._get_token() discovery_url = f"{self.registry_url}/api/agents/discover/semantic" headers = {"Authorization": f"Bearer {token}", "Host": "localhost"} # This endpoint uses query parameters, not JSON body params = {"query": query, "max_results": max_results} async with aiohttp.ClientSession() as session: try: async with session.post( discovery_url, headers=headers, params=params, ) as response: if response.status != 200: error_text = await response.text() logger.error(f"Discovery failed: {response.status} - {error_text}") raise Exception(f"Discovery failed: {response.status}") result = await response.json() agents_data = result.get("agents", []) agents = [DiscoveredAgent(**agent) for agent in agents_data] logger.info(f"Found {len(agents)} agents") return agents except aiohttp.ClientError as e: logger.error(f"Network error during discovery: {e}") raise Exception(f"Network error: {e}") except aiohttp.ClientError as e: logger.error(f"Network error during discovery: {e}") raise Exception(f"Network error: {e}") async def discover_by_skills( self, skills: list[str], tags: list[str] | None = None, max_results: int = 5, ) -> list[DiscoveredAgent]: """Discover agents by required skills and tags. Args: skills: Required skill names or IDs tags: Optional tag filters max_results: Maximum number of results to return Returns: List of discovered agents with relevance scores Raises: Exception: If discovery fails """ logger.info(f"Skill-based search: skills={skills}, tags={tags}") token = await self._get_token() discovery_url = f"{self.registry_url}/api/agents/discover" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } body = { "skills": skills, "tags": tags or [], "max_results": max_results, } async with aiohttp.ClientSession() as session: try: async with session.post( discovery_url, headers=headers, json=body, ) as response: if response.status != 200: error_text = await response.text() logger.error(f"Discovery failed: {response.status} - {error_text}") raise Exception(f"Discovery failed: {response.status}") result = await response.json() agents_data = result.get("agents", []) agents = [DiscoveredAgent(**agent) for agent in agents_data] logger.info(f"Found {len(agents)} agents") return agents except aiohttp.ClientError as e: logger.error(f"Network error during discovery: {e}") raise Exception(f"Network error: {e}") ================================================ FILE: agents/a2a/src/travel-assistant-agent/remote_agent_client.py ================================================ """Client for communicating with remote A2A agents.""" import logging from uuid import uuid4 import httpx from a2a.client import A2ACardResolver, ClientConfig, ClientFactory from a2a.types import Message, Part, Role, TextPart from models import DiscoveredAgent logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) class RemoteAgentClient: """ Client for communicating with a remote A2A agent. This class wraps an A2A agent discovered from the registry, providing lazy initialization and reusable client connections. Reference: https://strandsagents.com/latest/documentation/docs/user-guide/concepts/multi-agent/agent-to-agent/ """ def __init__( self, agent_url: str, agent_name: str, agent_id: str, skills: list[str] | None = None, auth_token: str | None = None, ): self.agent_url = agent_url self.agent_name = agent_name self.agent_id = agent_id self.skills = skills or [] self.auth_token = auth_token self.agent_card = None self.client = None self.httpx_client = None self._initialized = False logger.info( f"Created RemoteAgentClient for: {agent_name} (ID: {agent_id}, Skills: {len(self.skills)})" ) async def _ensure_initialized(self): if self._initialized: return logger.info(f"Initializing A2A client for {self.agent_name} at {self.agent_url}") headers = {} if self.auth_token: headers["Authorization"] = f"Bearer {self.auth_token}" # Create persistent httpx client (not using context manager) self.httpx_client = httpx.AsyncClient(timeout=300, headers=headers) # Get agent card resolver = A2ACardResolver(httpx_client=self.httpx_client, base_url=self.agent_url) self.agent_card = await resolver.get_agent_card() # Create client with persistent httpx_client config = ClientConfig(httpx_client=self.httpx_client, streaming=False) factory = ClientFactory(config) self.client = factory.create(self.agent_card) self._initialized = True logger.info(f"A2A client initialized for {self.agent_name}") async def send_message(self, message: str) -> str: # Send a natural language message to the remote agent. await self._ensure_initialized() logger.info(f"Sending message to {self.agent_name}: {message[:100]}...") try: # Create A2A message msg = Message( kind="message", role=Role.user, parts=[Part(TextPart(kind="text", text=message))], message_id=uuid4().hex, ) # Send message and get response async for event in self.client.send_message(msg): if isinstance(event, Message): response_text = "" for part in event.parts: if hasattr(part, "text"): response_text += part.text logger.info(f"Message sent successfully to {self.agent_name}") return response_text return f"No response received from {self.agent_name}" except Exception as e: logger.error(f"Message failed: {e}", exc_info=True) return f"Error communicating with {self.agent_name}: an internal error occurred" async def close(self): # Close the httpx client and cleanup resources if self.httpx_client: await self.httpx_client.aclose() logger.info(f"Closed httpx client for {self.agent_name}") class RemoteAgentCache: def __init__(self): self._cache: dict[str, RemoteAgentClient] = {} logger.info("RemoteAgentCache initialized") def get(self, agent_id: str) -> RemoteAgentClient | None: return self._cache.get(agent_id) def get_all(self) -> dict[str, RemoteAgentClient]: return self._cache.copy() def add(self, agent_id: str, agent_client: RemoteAgentClient): self._cache[agent_id] = agent_client logger.info(f"Added agent to cache: {agent_id}") def cache_discovered_agents( self, agents: list[DiscoveredAgent], auth_token: str | None = None ) -> dict[str, RemoteAgentClient]: newly_cached = {} for agent in agents: agent_id = agent.path # Skip if already cached if agent_id in self._cache: logger.info(f"Agent {agent_id} already cached, skipping") continue # Create and cache the remote agent client agent_client = RemoteAgentClient( agent_url=agent.url, agent_name=agent.name, agent_id=agent_id, skills=agent.skill_names, auth_token=auth_token, ) self._cache[agent_id] = agent_client newly_cached[agent_id] = agent_client logger.info(f"Cached agent: {agent.name} (ID: {agent_id})") logger.info(f"Cached {len(newly_cached)} new agents. Total in cache: {len(self._cache)}") return newly_cached async def clear(self): count = len(self._cache) for agent_client in self._cache.values(): await agent_client.close() self._cache.clear() logger.info(f"Cleared {count} agents from cache") def __len__(self) -> int: return len(self._cache) def __contains__(self, agent_id: str) -> bool: return agent_id in self._cache ================================================ FILE: agents/a2a/src/travel-assistant-agent/server.py ================================================ """Travel Assistant Agent - Main application module.""" import logging from contextlib import asynccontextmanager import uvicorn from agent import ( check_prices, create_trip_plan, get_recommendations, search_flights, strands_agent, ) from dependencies import ( get_db_manager, get_env, ) from fastapi import FastAPI from strands.multiagent.a2a import A2AServer # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) env_settings = get_env() # Use agent instance from tools module a2a_server = A2AServer(agent=strands_agent, http_url=env_settings.agent_url, serve_at_root=True) @asynccontextmanager async def lifespan( app: FastAPI, ): """Application lifespan manager.""" # Setups before server startup get_db_manager() logger.info("Travel Assistant Agent starting up") logger.info(f"Agent URL: {env_settings.agent_url}") logger.info(f"Listening on {env_settings.host}:{env_settings.port}") yield # Triggered after server shutdown logger.info("Travel Assistant Agent shutting down") app = FastAPI(title="Travel Assistant Agent", lifespan=lifespan) @app.get("/ping") def ping(): """Health check endpoint.""" logger.debug("Ping endpoint called") return {"status": "healthy"} @app.get("/api/health") def health(): """Health status endpoint.""" logger.debug("Health endpoint called") return {"status": "healthy", "agent": "travel_assistant"} @app.post("/api/search-flights") def api_search_flights( departure_city: str, arrival_city: str, departure_date: str, ): """Search flights API endpoint.""" logger.info(f"Searching flights: {departure_city} to {arrival_city} on {departure_date}") result = search_flights(departure_city, arrival_city, departure_date) logger.debug(f"Flight search result: {result}") return {"result": result} @app.post("/api/check-prices") def api_check_prices( flight_id: int, ): """Check prices API endpoint.""" logger.info(f"Checking prices for flight_id: {flight_id}") result = check_prices(flight_id) logger.debug(f"Price check result: {result}") return {"result": result} @app.get("/api/recommendations") def api_recommendations( max_price: float, preferred_airlines: str | None = None, ): """Get recommendations API endpoint.""" logger.info( f"Getting recommendations: max_price={max_price}, preferred_airlines={preferred_airlines}" ) airlines = preferred_airlines.split(",") if preferred_airlines else None result = get_recommendations(max_price, airlines) logger.debug(f"Recommendations result: {result}") return {"result": result} @app.post("/api/create-trip-plan") def api_create_trip_plan( departure_city: str, arrival_city: str, departure_date: str, return_date: str | None = None, budget: float | None = None, ): """Create trip plan API endpoint.""" logger.info( f"Creating trip plan: {departure_city} to {arrival_city}, dates: {departure_date} - {return_date}" ) logger.debug(f"Budget: {budget}") result = create_trip_plan(departure_city, arrival_city, departure_date, return_date, budget) logger.debug(f"Trip plan result: {result}") return {"result": result} @app.post("/api/discover-agents") async def api_discover_agents(query: str): """Discover agents through registry using semantic search.""" logger.info(f"Agent discovery request: query='{query}'") from dependencies import get_registry_client registry_client = get_registry_client() if not registry_client: return {"error": "Discovery not configured"} try: agents = await registry_client.discover_by_semantic_search( query=query, max_results=5, ) return { "query": query, "agents_found": len(agents), "agents": [agent.model_dump() for agent in agents], } except Exception as e: logger.error(f"Discovery failed: {e}", exc_info=True) return {"error": "An internal error occurred during agent discovery"} app.mount("/", a2a_server.to_fastapi_app()) def main() -> None: """Main entry point for the application.""" logger.info("Starting Travel Assistant Agent server") uvicorn.run(app, host=env_settings.host, port=env_settings.port) if __name__ == "__main__": main() ================================================ FILE: agents/a2a/src/travel-assistant-agent/tools.py ================================================ """Tools for Travel Assistant Agent - Flight search and trip planning utilities.""" import json import logging from dependencies import get_db_manager from strands import tool # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) @tool def search_flights( departure_city: str, arrival_city: str, departure_date: str, ) -> str: """Search for available flights between cities on a specific date.""" logger.info( f"Tool called: search_flights(departure_city={departure_city}, arrival_city={arrival_city}, departure_date={departure_date})" ) try: flights = get_db_manager().search_flights(departure_city, arrival_city, departure_date) result = { "query": { "departure_city": departure_city, "arrival_city": arrival_city, "departure_date": departure_date, }, "flights": flights, "count": len(flights), } logger.debug(f"Flight search result:\n{json.dumps(result, indent=2)}") return json.dumps(result, indent=2) except Exception as e: logger.exception(f"Database error in search_flights: {e}") return json.dumps({"error": "An internal database error occurred"}) @tool def check_prices( flight_id: int, ) -> str: """Get pricing and seat availability for a specific flight.""" logger.info(f"Tool called: check_prices(flight_id={flight_id})") try: flight_details = get_db_manager().get_flight_details(flight_id) if not flight_details: error_msg = f"Flight with ID {flight_id} not found" logger.warning(error_msg) return json.dumps({"error": error_msg}) logger.debug(f"Flight details result:\n{json.dumps(flight_details, indent=2)}") return json.dumps(flight_details, indent=2) except Exception as e: logger.exception(f"Database error in check_prices: {e}") return json.dumps({"error": "An internal database error occurred"}) @tool def get_recommendations( max_price: float, preferred_airlines: list[str] | None = None, ) -> str: """Get flight recommendations based on customer preferences.""" logger.info( f"Tool called: get_recommendations(max_price={max_price}, preferred_airlines={preferred_airlines})" ) try: recommendations = get_db_manager().get_recommendations(max_price, preferred_airlines) result = { "criteria": {"max_price": max_price, "preferred_airlines": preferred_airlines or "Any"}, "recommendations": recommendations, "count": len(recommendations), } logger.debug(f"Recommendations result:\n{json.dumps(result, indent=2)}") return json.dumps(result, indent=2) except Exception as e: logger.exception(f"Database error in get_recommendations: {e}") return json.dumps({"error": "An internal database error occurred"}) @tool def create_trip_plan( departure_city: str, arrival_city: str, departure_date: str, return_date: str | None = None, budget: float | None = None, ) -> str: """Create and save a trip planning record.""" logger.info( f"Tool called: create_trip_plan(departure_city={departure_city}, arrival_city={arrival_city}, departure_date={departure_date})" ) logger.debug(f"Return date: {return_date}, Budget: {budget}") try: db_manager = get_db_manager() trip_plan_id = db_manager.create_trip_plan( departure_city, arrival_city, departure_date, return_date, budget ) # Get available flights for the trip outbound_flights = db_manager.search_flights(departure_city, arrival_city, departure_date) return_flights = [] if return_date: return_flights = db_manager.search_flights(arrival_city, departure_city, return_date) result = { "trip_plan_id": trip_plan_id, "trip_details": { "departure_city": departure_city.upper(), "arrival_city": arrival_city.upper(), "departure_date": departure_date, "return_date": return_date, "budget": budget, "status": "planning", }, "outbound_flights": outbound_flights, "return_flights": return_flights, "next_steps": [ "Review available flights", "Select preferred flights", "Contact Flight Booking Agent for reservation", ], } logger.debug(f"Trip plan result:\n{json.dumps(result, indent=2)}") return json.dumps(result, indent=2) except Exception as e: logger.exception(f"Database error in create_trip_plan: {e}") return json.dumps({"error": "An internal database error occurred"}) # TODO: Create tool that's able to dynamically search agents from MCP Registry # example: # @tool # def delegate_to_agent(agent_capability: str, action: str, params: Dict) -> str: TRAVEL_ASSISTANT_TOOLS = [search_flights, check_prices, get_recommendations, create_trip_plan] ================================================ FILE: agents/a2a/test/agent_discovery_test.py ================================================ #!/usr/bin/env python3 """ Test script for agent discovery and booking workflow. Test 1: Travel agent searches for flights using its own tools Test 2: Travel agent discovers booking agent, checks availability, reserves seats, and completes booking Usage: python agent_discovery_test_v2.py [--endpoint local|live] """ import argparse import logging import sys import requests logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) LOCAL_ENDPOINTS = { "travel_assistant": "http://localhost:9001", } class AgentTester: """Agent testing class.""" def __init__(self, endpoints, is_live=False): self.endpoints = endpoints self.is_live = is_live def send_agent_message(self, agent_type, message): """Send message to agent using A2A protocol.""" endpoint = self.endpoints[agent_type] payload = { "jsonrpc": "2.0", "id": f"test-{message[:10]}", "method": "message/send", "params": { "message": { "role": "user", "parts": [{"kind": "text", "text": message}], "messageId": f"msg-{message[:10]}", } }, } response = requests.post( endpoint, json=payload, headers={"Content-Type": "application/json"}, timeout=60 ) return response.json() def extract_response_text(self, response): """Extract text from A2A response.""" if "result" not in response: return "" artifacts = response["result"].get("artifacts", []) response_text = "" for artifact in artifacts: if "parts" in artifact: for part in artifact["parts"]: if "text" in part: response_text += part["text"] return response_text class AgentDiscoveryTests: """Test suite for agent discovery and booking workflow.""" def __init__(self, tester): self.tester = tester self.agent_type = "travel_assistant" def test_search_flight_solo(self): """Test 1: Travel agent searches for flights using its own tools.""" print("\n1. Testing flight search (travel agent solo)...") message = "Search for flights from New York to Los Angeles on 2025-12-20" response = self.tester.send_agent_message(self.agent_type, message) assert "result" in response, f"No result in response: {response}" response_text = self.tester.extract_response_text(response) # Check if flight search happened assert any( keyword in response_text.lower() for keyword in ["flight", "new york", "los angeles", "nyc", "lax"] ), f"Response doesn't mention flight search. Got: {response_text[:300]}" print(" ✓ Travel agent searched for flights using its own tools") print(f" Response preview: {response_text[:200]}...") return response_text def test_book_flight_with_discovery(self): """Test 2: Travel agent discovers booking agent and delegates booking tasks.""" print("\n2. Testing flight booking with agent discovery and invocation...") message = ( "I want to book flight ID 1. I need you to reserve 2 seats, confirm the reservation, " "and process the payment. You don't have these booking capabilities yourself, so you'll " "need to find and use an agent that can handle flight reservations and confirmations." ) response = self.tester.send_agent_message(self.agent_type, message) response_text = self.tester.extract_response_text(response) # Check if agent discovery and delegation happened assert any( keyword in response_text.lower() for keyword in ["reserve", "book", "confirm", "agent", "discover"] ), f"Booking workflow failed. Got: {response_text[:300]}" print(" ✓ Booking agent discovered and invoked") print(f" Response preview: {response_text[:200]}...") print(" ✓ Complete booking workflow succeeded") return response_text def run_tests(endpoint_type): """Run all discovery tests.""" print( f"Running agent discovery and booking workflow tests against {endpoint_type} endpoints..." ) print("=" * 70) print("Test 1: Travel agent searches for flights (solo)") print("Test 2: Travel agent discovers booking agent and completes booking") print("=" * 70) endpoints = LOCAL_ENDPOINTS is_live = endpoint_type == "live" tester = AgentTester(endpoints, is_live=is_live) try: discovery_tests = AgentDiscoveryTests(tester) # Run tests in sequence discovery_tests.test_search_flight_solo() discovery_tests.test_book_flight_with_discovery() print("\n" + "=" * 70) print("✅ All tests passed!") print("=" * 70) return True except AssertionError as e: logger.error(f"Test assertion failed: {e}") print(f"\n❌ Test failed: {e}") return False except Exception as e: logger.exception("Test failed with exception") print(f"\n❌ Test failed with exception: {e}") return False def main(): """Main entry point for test script.""" parser = argparse.ArgumentParser(description="Test agent discovery and booking workflow") parser.add_argument( "--endpoint", choices=["local", "live"], default="local", help="Test against local or live endpoints (default: local)", ) args = parser.parse_args() success = run_tests(args.endpoint) sys.exit(0 if success else 1) if __name__ == "__main__": main() ================================================ FILE: agents/a2a/test/agent_simple_test.py ================================================ #!/usr/bin/env python3 """ Test script for Travel Assistant and Flight Booking agents Usage: python simple_agents_test.py --endpoint local|live [--debug] """ import argparse import json import logging import sys import time import uuid from typing import ( Any, ) import boto3 import requests # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Endpoint configurations LOCAL_ENDPOINTS = { "travel_assistant": "http://localhost:9001", "flight_booking": "http://localhost:9002", } LIVE_ENDPOINTS = { "travel_assistant": "travel_assistant_agent ARN", "flight_booking": "flight_booking_agent ARN", } AWS_REGION = "us-east-1" class AgentTester: """Agent testing class for both local and live endpoints.""" def __init__( self, endpoints: dict[str, str], is_live: bool = False, ) -> None: self.endpoints = endpoints self.is_live = is_live if is_live: self.bedrock_client = boto3.client("bedrock-agentcore", region_name=AWS_REGION) def send_agent_message( self, agent_type: str, message: str, ) -> dict[str, Any]: """Send message to agent using A2A protocol (local) or boto3 (live).""" endpoint = self.endpoints[agent_type] if not endpoint: raise ValueError(f"No endpoint configured for {agent_type}") request_id = f"test-{uuid.uuid4().hex[:8]}" message_id = f"test-msg-{uuid.uuid4().hex[:8]}" timestamp = time.time() if self.is_live: # Use boto3 for AgentCore Runtime return self._invoke_agentcore_runtime( endpoint, message, request_id, message_id, timestamp ) else: # Use HTTP for local A2A payload = { "jsonrpc": "2.0", "id": request_id, "method": "message/send", "params": { "message": { "role": "user", "parts": [{"kind": "text", "text": message}], "messageId": message_id, } }, } logger.debug(f"[REQUEST] Agent: {agent_type}, Endpoint: {endpoint}") logger.debug(f"[REQUEST] ID: {request_id}, Message ID: {message_id}") logger.debug(f"[REQUEST] Payload:\n{json.dumps(payload, indent=2)}") start_time = time.time() response = requests.post( endpoint, json=payload, headers={"Content-Type": "application/json"}, timeout=60 ) response_time = time.time() - start_time response_json = response.json() logger.debug(f"[RESPONSE] Time: {response_time:.3f}s, Status: {response.status_code}") logger.debug(f"[RESPONSE] Body:\n{json.dumps(response_json, indent=2, default=str)}") return response_json def _invoke_agentcore_runtime( self, runtime_arn: str, message: str, request_id: str, message_id: str, timestamp: float, ) -> dict[str, Any]: """Invoke AgentCore Runtime using boto3.""" # A2A protocol requires JSON-RPC format payload = { "jsonrpc": "2.0", "id": request_id, "method": "message/send", "params": { "message": { "role": "user", "parts": [{"kind": "text", "text": message}], "messageId": message_id, } }, } payload_json = json.dumps(payload) logger.debug(f"[AGENTCORE REQUEST] ARN: {runtime_arn}") logger.debug(f"[AGENTCORE REQUEST] ID: {request_id}, Message ID: {message_id}") logger.debug(f"[AGENTCORE REQUEST] Payload:\n{json.dumps(payload, indent=2)}") # Generate session ID (must be 33+ characters) session_id = f"test-session-{uuid.uuid4().hex}" logger.debug(f"[AGENTCORE REQUEST] Session ID: {session_id}") try: start_time = time.time() response = self.bedrock_client.invoke_agent_runtime( agentRuntimeArn=runtime_arn, runtimeSessionId=session_id, qualifier="DEFAULT", payload=payload_json, ) response_time = time.time() - start_time # Read streaming response if "response" in response: streaming_body = response["response"] all_lines = [] for line in streaming_body.iter_lines(): line_str = line.decode("utf-8") all_lines.append(line_str) logger.debug(f"[AGENTCORE STREAM] Line: {line_str}") # The response is a single JSON-RPC response line if all_lines: try: json_response = json.loads(all_lines[0]) logger.debug(f"[AGENTCORE RESPONSE] Time: {response_time:.3f}s") logger.debug( f"[AGENTCORE RESPONSE] Body:\n{json.dumps(json_response, indent=2, default=str)}" ) # Check for JSON-RPC error if "error" in json_response: return {"error": json_response["error"]} # Return the JSON-RPC result directly return json_response except json.JSONDecodeError as e: logger.error(f"Failed to parse response: {e}") return {"error": f"Failed to parse response: {e}"} return {"error": "Empty response"} return {"error": "No response content"} except Exception as e: logger.error(f"AgentCore invocation failed: {e}") return {"error": str(e)} def call_api_endpoint( self, agent_type: str, endpoint: str, method: str = "POST", **params, ) -> dict[str, Any]: """Call direct API endpoint (only works for local).""" if self.is_live: raise NotImplementedError( "Direct API endpoints not available for live AgentCore Runtime" ) url = f"{self.endpoints[agent_type]}{endpoint}" if not self.endpoints[agent_type]: raise ValueError(f"No endpoint configured for {agent_type}") logger.debug(f"[API REQUEST] Agent: {agent_type}, URL: {url}") logger.debug(f"[API REQUEST] Method: {method}, Params: {params}") start_time = time.time() if method.upper() == "GET": response = requests.get(url, params=params, timeout=60) else: response = requests.post(url, params=params, timeout=60) response_time = time.time() - start_time response_json = response.json() logger.debug(f"[API RESPONSE] Time: {response_time:.3f}s, Status: {response.status_code}") logger.debug(f"[API RESPONSE] Body:\n{json.dumps(response_json, indent=2, default=str)}") return response_json def ping_agent( self, agent_type: str, ) -> bool: """Check if agent is healthy (only works for local).""" if self.is_live: # For live, we can't ping directly, assume healthy if ARN is configured return bool(self.endpoints.get(agent_type)) try: url = f"{self.endpoints[agent_type]}/ping" logger.debug(f"[PING] Agent: {agent_type}, URL: {url}") start_time = time.time() response = requests.get(url, timeout=5) response_time = time.time() - start_time is_healthy = response.status_code == 200 and response.json().get("status") == "healthy" logger.debug(f"[PING RESPONSE] Time: {response_time:.3f}s, Healthy: {is_healthy}") return is_healthy except Exception as e: logger.debug(f"[PING ERROR] Agent: {agent_type}, Error: {e}") return False class TravelAssistantTests: """Test suite for Travel Assistant agent.""" def __init__( self, tester: AgentTester, ) -> None: self.tester = tester self.agent_type = "travel_assistant" def test_ping(self) -> None: """Test agent health check.""" print("Testing Travel Assistant ping...") result = self.tester.ping_agent(self.agent_type) assert result, "Travel Assistant ping failed" print("✓ Travel Assistant is healthy") def test_agent_flight_search(self) -> None: """Test agent flight search via A2A.""" print("Testing Travel Assistant flight search...") message = "Search for flights from SF to NY on 2025-11-15" response = self.tester.send_agent_message(self.agent_type, message) assert "result" in response, f"No result in response: {response}" assert "artifacts" in response["result"], "No artifacts in response" # Check if agent found flights artifacts = response["result"]["artifacts"] assert len(artifacts) > 0, "No artifacts returned" # Extract text from artifact parts response_text = "" for artifact in artifacts: if "parts" in artifact: for part in artifact["parts"]: if "text" in part: response_text += part["text"] assert "flight" in response_text.lower(), ( f"Response doesn't mention flights. Got: {response_text[:100]}" ) print("✓ Travel Assistant flight search working") def test_api_search_flights(self) -> None: """Test direct API endpoint (local only).""" if self.tester.is_live: print( "Skipping /api/search-flights endpoint (only available in local Docker container)" ) return print("Testing Travel Assistant API endpoint...") response = self.tester.call_api_endpoint( self.agent_type, "/api/search-flights", departure_city="SF", arrival_city="NY", departure_date="2025-11-15", ) assert "result" in response, f"No result in API response: {response}" result_data = json.loads(response["result"]) assert "flights" in result_data, "No flights in API response" assert len(result_data["flights"]) > 0, "No flights found" print("✓ Travel Assistant API endpoint working") def test_api_recommendations(self) -> None: """Test recommendations API (local only).""" if self.tester.is_live: print( "Skipping /api/recommendations endpoint (only available in local Docker container)" ) return print("Testing Travel Assistant recommendations...") response = self.tester.call_api_endpoint( self.agent_type, "/api/recommendations", method="GET", max_price=300, preferred_airlines="United,Delta", ) assert "result" in response, "No result in recommendations response" result_data = json.loads(response["result"]) assert "recommendations" in result_data, "No recommendations in response" print("✓ Travel Assistant recommendations working") class FlightBookingTests: """Test suite for Flight Booking agent.""" def __init__( self, tester: AgentTester, ) -> None: self.tester = tester self.agent_type = "flight_booking" def test_ping(self) -> None: """Test agent health check.""" print("Testing Flight Booking ping...") result = self.tester.ping_agent(self.agent_type) assert result, "Flight Booking ping failed" print("✓ Flight Booking is healthy") def test_agent_availability_check(self) -> None: """Test agent availability check via A2A.""" print("Testing Flight Booking availability check...") message = "Check availability for flight ID 1" response = self.tester.send_agent_message(self.agent_type, message) assert "result" in response, f"No result in response: {response}" assert "artifacts" in response["result"], "No artifacts in response" artifacts = response["result"]["artifacts"] assert len(artifacts) > 0, "No artifacts returned" response_text = artifacts[0]["parts"][0]["text"] assert "available" in response_text.lower(), "Response doesn't mention availability" print("✓ Flight Booking availability check working") def test_agent_booking(self) -> None: """Test agent booking via A2A.""" print("Testing Flight Booking reservation...") message = "Book flight ID 1 for Jane Smith, email jane@test.com" response = self.tester.send_agent_message(self.agent_type, message) assert "result" in response, f"No result in response: {response}" artifacts = response["result"]["artifacts"] response_text = artifacts[0]["parts"][0]["text"] assert "booking" in response_text.lower() or "reserved" in response_text.lower(), ( "Response doesn't mention booking/reservation" ) print("✓ Flight Booking reservation working") def test_api_check_availability(self) -> None: """Test direct API endpoint (local only).""" if self.tester.is_live: print( "Skipping /api/check-availability endpoint (only available in local Docker container)" ) return print("Testing Flight Booking API endpoint...") response = self.tester.call_api_endpoint( self.agent_type, "/api/check-availability", flight_id=1 ) assert "result" in response, f"No result in API response: {response}" result_data = json.loads(response["result"]) assert "flight_id" in result_data, "No flight_id in API response" assert "available_seats" in result_data, "No available_seats in response" print("✓ Flight Booking API endpoint working") def run_tests( endpoint_type: str, ) -> bool: """Run all tests for specified endpoint type.""" print(f"Running tests against {endpoint_type} endpoints...") print("=" * 50) # Select endpoints endpoints = LOCAL_ENDPOINTS if endpoint_type == "local" else LIVE_ENDPOINTS # Check if endpoints are configured for agent, url in endpoints.items(): if not url: print(f"❌ No {endpoint_type} endpoint configured for {agent}") return False is_live = endpoint_type == "live" tester = AgentTester(endpoints, is_live=is_live) try: # Test Travel Assistant print("\nTesting Travel Assistant Agent") print("-" * 30) travel_tests = TravelAssistantTests(tester) travel_tests.test_ping() travel_tests.test_agent_flight_search() travel_tests.test_api_search_flights() travel_tests.test_api_recommendations() # Test Flight Booking print("\nTesting Flight Booking Agent") print("-" * 30) booking_tests = FlightBookingTests(tester) booking_tests.test_ping() booking_tests.test_agent_availability_check() booking_tests.test_agent_booking() booking_tests.test_api_check_availability() print("\n" + "=" * 50) print("✅ All tests passed!") return True except Exception as e: logger.exception("Test failed with exception") print(f"\n❌ Test failed: {e}") return False def main() -> None: """Main entry point for test script.""" parser = argparse.ArgumentParser(description="Test Travel Assistant and Flight Booking agents") parser.add_argument( "--endpoint", choices=["local", "live"], required=True, help="Test against local or live endpoints", ) parser.add_argument( "--debug", action="store_true", help="Enable debug logging to see detailed request/response traces", ) parser.add_argument( "--verbose", action="store_true", help="Alias for --debug, enables debug logging", ) args = parser.parse_args() # Enable debug logging if requested if args.debug or args.verbose: logging.getLogger().setLevel(logging.DEBUG) logger.info("Debug logging enabled - detailed traces will be shown") success = run_tests(args.endpoint) sys.exit(0 if success else 1) if __name__ == "__main__": main() ================================================ FILE: agents/a2a/test/check_agent_cards.sh ================================================ #!/bin/bash # Check agent cards for local deployments and save to local files set -e # Get script directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" echo "Checking Agent Cards..." echo "================================" # Check if jq is installed if ! command -v jq &> /dev/null; then echo "Warning: jq not installed. Output will not be formatted." JQ_CMD="cat" else JQ_CMD="jq ." fi echo "" echo "Travel Assistant Agent Card:" echo "--------------------------------" TRAVEL_CARD_FILE="$SCRIPT_DIR/travel_assistant_agent_card.json" TRAVEL_CARD_RESPONSE=$(curl -s http://localhost:9001/.well-known/agent-card.json) if [ -n "$TRAVEL_CARD_RESPONSE" ]; then echo "$TRAVEL_CARD_RESPONSE" | $JQ_CMD if command -v jq &> /dev/null; then echo "$TRAVEL_CARD_RESPONSE" | jq . > "$TRAVEL_CARD_FILE" else echo "$TRAVEL_CARD_RESPONSE" > "$TRAVEL_CARD_FILE" fi echo "✅ Travel Assistant agent card retrieved" echo " Saved to: $TRAVEL_CARD_FILE" else echo "❌ Failed to retrieve Travel Assistant agent card" echo " Is the agent running on port 9001?" fi echo "" echo "Flight Booking Agent Card:" echo "--------------------------------" BOOKING_CARD_FILE="$SCRIPT_DIR/flight_booking_agent_card.json" BOOKING_CARD_RESPONSE=$(curl -s http://localhost:9002/.well-known/agent-card.json) if [ -n "$BOOKING_CARD_RESPONSE" ]; then echo "$BOOKING_CARD_RESPONSE" | $JQ_CMD if command -v jq &> /dev/null; then echo "$BOOKING_CARD_RESPONSE" | jq . > "$BOOKING_CARD_FILE" else echo "$BOOKING_CARD_RESPONSE" > "$BOOKING_CARD_FILE" fi echo "✅ Flight Booking agent card retrieved" echo " Saved to: $BOOKING_CARD_FILE" else echo "❌ Failed to retrieve Flight Booking agent card" echo " Is the agent running on port 9002?" fi echo "" echo "================================" echo "Summary:" if [ -f "$TRAVEL_CARD_FILE" ]; then echo "✅ Travel Assistant agent card saved" fi if [ -f "$BOOKING_CARD_FILE" ]; then echo "✅ Flight Booking agent card saved" fi echo "================================" ================================================ FILE: agents/a2a/test/flight_booking_agent_card.json ================================================ { "capabilities": { "streaming": true }, "defaultInputModes": [ "text" ], "defaultOutputModes": [ "text" ], "description": "Flight booking and reservation management agent", "name": "Flight Booking Agent", "preferredTransport": "JSONRPC", "protocolVersion": "0.3.0", "skills": [ { "description": "Check seat availability for a specific flight.", "id": "check_availability", "name": "check_availability", "tags": [] }, { "description": "Reserve seats on a flight for passengers.", "id": "reserve_flight", "name": "reserve_flight", "tags": [] }, { "description": "Confirm and finalize a flight booking.", "id": "confirm_booking", "name": "confirm_booking", "tags": [] }, { "description": "Process payment for a booking (simulated).", "id": "process_payment", "name": "process_payment", "tags": [] }, { "description": "Update, view, or cancel existing reservations.", "id": "manage_reservation", "name": "manage_reservation", "tags": [] } ], "url": "http://flight-booking-agent:9000/", "version": "0.0.1" } ================================================ FILE: agents/a2a/test/simple_agents_test.py ================================================ #!/usr/bin/env python3 """ Test script for Travel Assistant and Flight Booking agents Usage: python simple_agents_test.py --endpoint local|live [--debug] """ import argparse import json import logging import sys import time import uuid from typing import ( Any, ) import boto3 import requests # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Endpoint configurations LOCAL_ENDPOINTS = { "travel_assistant": "http://localhost:9001", "flight_booking": "http://localhost:9002", } LIVE_ENDPOINTS = { "travel_assistant": "travel_assistant_agent ARN", "flight_booking": "flight_booking_agent ARN", } AWS_REGION = "us-east-1" class AgentTester: """Agent testing class for both local and live endpoints.""" def __init__( self, endpoints: dict[str, str], is_live: bool = False, ) -> None: self.endpoints = endpoints self.is_live = is_live if is_live: self.bedrock_client = boto3.client("bedrock-agentcore", region_name=AWS_REGION) def send_agent_message( self, agent_type: str, message: str, ) -> dict[str, Any]: """Send message to agent using A2A protocol (local) or boto3 (live).""" endpoint = self.endpoints[agent_type] if not endpoint: raise ValueError(f"No endpoint configured for {agent_type}") request_id = f"test-{uuid.uuid4().hex[:8]}" message_id = f"test-msg-{uuid.uuid4().hex[:8]}" timestamp = time.time() if self.is_live: # Use boto3 for AgentCore Runtime return self._invoke_agentcore_runtime( endpoint, message, request_id, message_id, timestamp ) else: # Use HTTP for local A2A payload = { "jsonrpc": "2.0", "id": request_id, "method": "message/send", "params": { "message": { "role": "user", "parts": [{"kind": "text", "text": message}], "messageId": message_id, } }, } logger.debug(f"[REQUEST] Agent: {agent_type}, Endpoint: {endpoint}") logger.debug(f"[REQUEST] ID: {request_id}, Message ID: {message_id}") logger.debug(f"[REQUEST] Payload:\n{json.dumps(payload, indent=2)}") start_time = time.time() response = requests.post( endpoint, json=payload, headers={"Content-Type": "application/json"}, timeout=60 ) response_time = time.time() - start_time response_json = response.json() logger.debug(f"[RESPONSE] Time: {response_time:.3f}s, Status: {response.status_code}") logger.debug(f"[RESPONSE] Body:\n{json.dumps(response_json, indent=2, default=str)}") return response_json def _invoke_agentcore_runtime( self, runtime_arn: str, message: str, request_id: str, message_id: str, timestamp: float, ) -> dict[str, Any]: """Invoke AgentCore Runtime using boto3.""" # A2A protocol requires JSON-RPC format payload = { "jsonrpc": "2.0", "id": request_id, "method": "message/send", "params": { "message": { "role": "user", "parts": [{"kind": "text", "text": message}], "messageId": message_id, } }, } payload_json = json.dumps(payload) logger.debug(f"[AGENTCORE REQUEST] ARN: {runtime_arn}") logger.debug(f"[AGENTCORE REQUEST] ID: {request_id}, Message ID: {message_id}") logger.debug(f"[AGENTCORE REQUEST] Payload:\n{json.dumps(payload, indent=2)}") # Generate session ID (must be 33+ characters) session_id = f"test-session-{uuid.uuid4().hex}" logger.debug(f"[AGENTCORE REQUEST] Session ID: {session_id}") try: start_time = time.time() response = self.bedrock_client.invoke_agent_runtime( agentRuntimeArn=runtime_arn, runtimeSessionId=session_id, qualifier="DEFAULT", payload=payload_json, ) response_time = time.time() - start_time # Read streaming response if "response" in response: streaming_body = response["response"] all_lines = [] for line in streaming_body.iter_lines(): line_str = line.decode("utf-8") all_lines.append(line_str) logger.debug(f"[AGENTCORE STREAM] Line: {line_str}") # The response is a single JSON-RPC response line if all_lines: try: json_response = json.loads(all_lines[0]) logger.debug(f"[AGENTCORE RESPONSE] Time: {response_time:.3f}s") logger.debug( f"[AGENTCORE RESPONSE] Body:\n{json.dumps(json_response, indent=2, default=str)}" ) # Check for JSON-RPC error if "error" in json_response: return {"error": json_response["error"]} # Return the JSON-RPC result directly return json_response except json.JSONDecodeError as e: logger.error(f"Failed to parse response: {e}") return {"error": f"Failed to parse response: {e}"} return {"error": "Empty response"} return {"error": "No response content"} except Exception as e: logger.error(f"AgentCore invocation failed: {e}") return {"error": str(e)} def call_api_endpoint( self, agent_type: str, endpoint: str, method: str = "POST", **params, ) -> dict[str, Any]: """Call direct API endpoint (only works for local).""" if self.is_live: raise NotImplementedError( "Direct API endpoints not available for live AgentCore Runtime" ) url = f"{self.endpoints[agent_type]}{endpoint}" if not self.endpoints[agent_type]: raise ValueError(f"No endpoint configured for {agent_type}") logger.debug(f"[API REQUEST] Agent: {agent_type}, URL: {url}") logger.debug(f"[API REQUEST] Method: {method}, Params: {params}") start_time = time.time() if method.upper() == "GET": response = requests.get(url, params=params, timeout=60) else: response = requests.post(url, params=params, timeout=60) response_time = time.time() - start_time response_json = response.json() logger.debug(f"[API RESPONSE] Time: {response_time:.3f}s, Status: {response.status_code}") logger.debug(f"[API RESPONSE] Body:\n{json.dumps(response_json, indent=2, default=str)}") return response_json def ping_agent( self, agent_type: str, ) -> bool: """Check if agent is healthy (only works for local).""" if self.is_live: # For live, we can't ping directly, assume healthy if ARN is configured return bool(self.endpoints.get(agent_type)) try: url = f"{self.endpoints[agent_type]}/ping" logger.debug(f"[PING] Agent: {agent_type}, URL: {url}") start_time = time.time() response = requests.get(url, timeout=5) response_time = time.time() - start_time is_healthy = response.status_code == 200 and response.json().get("status") == "healthy" logger.debug(f"[PING RESPONSE] Time: {response_time:.3f}s, Healthy: {is_healthy}") return is_healthy except Exception as e: logger.debug(f"[PING ERROR] Agent: {agent_type}, Error: {e}") return False class TravelAssistantTests: """Test suite for Travel Assistant agent.""" def __init__( self, tester: AgentTester, ) -> None: self.tester = tester self.agent_type = "travel_assistant" def test_ping(self) -> None: """Test agent health check.""" print("Testing Travel Assistant ping...") result = self.tester.ping_agent(self.agent_type) assert result, "Travel Assistant ping failed" print("✓ Travel Assistant is healthy") def test_agent_flight_search(self) -> None: """Test agent flight search via A2A.""" print("Testing Travel Assistant flight search...") message = "Search for flights from SF to NY on 2025-11-15" response = self.tester.send_agent_message(self.agent_type, message) assert "result" in response, f"No result in response: {response}" assert "artifacts" in response["result"], "No artifacts in response" # Check if agent found flights artifacts = response["result"]["artifacts"] assert len(artifacts) > 0, "No artifacts returned" # Extract text from artifact parts response_text = "" for artifact in artifacts: if "parts" in artifact: for part in artifact["parts"]: if "text" in part: response_text += part["text"] assert "flight" in response_text.lower(), ( f"Response doesn't mention flights. Got: {response_text[:100]}" ) print("✓ Travel Assistant flight search working") def test_api_search_flights(self) -> None: """Test direct API endpoint (local only).""" if self.tester.is_live: print( "Skipping /api/search-flights endpoint (only available in local Docker container)" ) return print("Testing Travel Assistant API endpoint...") response = self.tester.call_api_endpoint( self.agent_type, "/api/search-flights", departure_city="SF", arrival_city="NY", departure_date="2025-11-15", ) assert "result" in response, f"No result in API response: {response}" result_data = json.loads(response["result"]) assert "flights" in result_data, "No flights in API response" assert len(result_data["flights"]) > 0, "No flights found" print("✓ Travel Assistant API endpoint working") def test_api_recommendations(self) -> None: """Test recommendations API (local only).""" if self.tester.is_live: print( "Skipping /api/recommendations endpoint (only available in local Docker container)" ) return print("Testing Travel Assistant recommendations...") response = self.tester.call_api_endpoint( self.agent_type, "/api/recommendations", method="GET", max_price=300, preferred_airlines="United,Delta", ) assert "result" in response, "No result in recommendations response" result_data = json.loads(response["result"]) assert "recommendations" in result_data, "No recommendations in response" print("✓ Travel Assistant recommendations working") class FlightBookingTests: """Test suite for Flight Booking agent.""" def __init__( self, tester: AgentTester, ) -> None: self.tester = tester self.agent_type = "flight_booking" def test_ping(self) -> None: """Test agent health check.""" print("Testing Flight Booking ping...") result = self.tester.ping_agent(self.agent_type) assert result, "Flight Booking ping failed" print("✓ Flight Booking is healthy") def test_agent_availability_check(self) -> None: """Test agent availability check via A2A.""" print("Testing Flight Booking availability check...") message = "Check availability for flight ID 1" response = self.tester.send_agent_message(self.agent_type, message) assert "result" in response, f"No result in response: {response}" assert "artifacts" in response["result"], "No artifacts in response" artifacts = response["result"]["artifacts"] assert len(artifacts) > 0, "No artifacts returned" response_text = artifacts[0]["parts"][0]["text"] assert "available" in response_text.lower(), "Response doesn't mention availability" print("✓ Flight Booking availability check working") def test_agent_booking(self) -> None: """Test agent booking via A2A.""" print("Testing Flight Booking reservation...") message = "Book flight ID 1 for Jane Smith, email jane@test.com" response = self.tester.send_agent_message(self.agent_type, message) assert "result" in response, f"No result in response: {response}" artifacts = response["result"]["artifacts"] response_text = artifacts[0]["parts"][0]["text"] assert "booking" in response_text.lower() or "reserved" in response_text.lower(), ( "Response doesn't mention booking/reservation" ) print("✓ Flight Booking reservation working") def test_api_check_availability(self) -> None: """Test direct API endpoint (local only).""" if self.tester.is_live: print( "Skipping /api/check-availability endpoint (only available in local Docker container)" ) return print("Testing Flight Booking API endpoint...") response = self.tester.call_api_endpoint( self.agent_type, "/api/check-availability", flight_id=1 ) assert "result" in response, f"No result in API response: {response}" result_data = json.loads(response["result"]) assert "flight_id" in result_data, "No flight_id in API response" assert "available_seats" in result_data, "No available_seats in response" print("✓ Flight Booking API endpoint working") class AgentDiscoveryTests: """Test suite for cross-agent discovery via the MCP Gateway Registry. Tests the full flow: Travel Assistant discovers Flight Booking agent through the registry's semantic search API and delegates a booking task. Requires the MCP Gateway Registry to be running and the Flight Booking agent to be registered in it. """ def __init__( self, tester: AgentTester, registry_url: str = "http://localhost", ) -> None: self.tester = tester self.registry_url = registry_url def _is_registry_available(self) -> bool: """Check if the MCP Gateway Registry is reachable.""" try: response = requests.get(f"{self.registry_url}/health", timeout=5) return response.status_code == 200 except Exception: return False def test_discover_and_delegate_booking(self) -> None: """Test Travel Assistant discovering Flight Booking agent and delegating a booking. Flow: 1. Send booking request to Travel Assistant 2. Travel Assistant calls discover_remote_agents() to find booking agents 3. Travel Assistant calls invoke_remote_agent() to delegate to Flight Booking 4. Flight Booking processes the request and returns confirmation 5. Travel Assistant returns combined response """ if not self._is_registry_available(): print( f" Skipping: registry not available at {self.registry_url}. " "Start the registry and register the Flight Booking agent to run this test." ) return print("Testing cross-agent discovery and delegation flow...") # This message explicitly instructs the LLM to use discovery tools message = ( "I need to book a flight. Please use the discover_remote_agents tool to find " "agents that can handle flight bookings, then use invoke_remote_agent to ask " "that agent to book flight ID 1 for John Smith with email john@test.com" ) logger.debug("[DISCOVERY TEST] Sending booking request to Travel Assistant...") response = self.tester.send_agent_message("travel_assistant", message) assert "result" in response, f"No result in discovery response: {response}" assert "artifacts" in response["result"], "No artifacts in discovery response" # Extract text from all artifact parts artifacts = response["result"]["artifacts"] assert len(artifacts) > 0, "No artifacts returned from discovery flow" response_text = "" for artifact in artifacts: if "parts" in artifact: for part in artifact["parts"]: if "text" in part: response_text += part["text"] logger.debug(f"[DISCOVERY TEST] Full response text:\n{response_text}") response_lower = response_text.lower() # Verify the response indicates discovery happened discovery_keywords = ["discover", "found", "flight booking", "remote agent", "cached"] has_discovery = any(keyword in response_lower for keyword in discovery_keywords) # Verify the response indicates a booking was attempted or completed booking_keywords = ["book", "reserv", "confirm", "john smith"] has_booking = any(keyword in response_lower for keyword in booking_keywords) assert has_discovery or has_booking, ( f"Response doesn't indicate discovery or booking happened. Got: {response_text[:300]}" ) if has_discovery: print(" [OK] Discovery indicators found in response") if has_booking: print(" [OK] Booking indicators found in response") print("[PASS] Cross-agent discovery and delegation flow working") def run_tests( endpoint_type: str, skip_discovery: bool = False, registry_url: str = "http://localhost", ) -> bool: """Run all tests for specified endpoint type.""" print(f"Running tests against {endpoint_type} endpoints...") print("=" * 50) # Select endpoints endpoints = LOCAL_ENDPOINTS if endpoint_type == "local" else LIVE_ENDPOINTS # Check if endpoints are configured for agent, url in endpoints.items(): if not url: print(f"❌ No {endpoint_type} endpoint configured for {agent}") return False is_live = endpoint_type == "live" tester = AgentTester(endpoints, is_live=is_live) try: # Test Travel Assistant print("\nTesting Travel Assistant Agent") print("-" * 30) travel_tests = TravelAssistantTests(tester) travel_tests.test_ping() travel_tests.test_agent_flight_search() travel_tests.test_api_search_flights() travel_tests.test_api_recommendations() # Test Flight Booking print("\nTesting Flight Booking Agent") print("-" * 30) booking_tests = FlightBookingTests(tester) booking_tests.test_ping() booking_tests.test_agent_availability_check() booking_tests.test_agent_booking() booking_tests.test_api_check_availability() # Test Agent-to-Agent Discovery if not skip_discovery: print("\nTesting Agent-to-Agent Discovery") print("-" * 30) discovery_tests = AgentDiscoveryTests(tester, registry_url=registry_url) discovery_tests.test_discover_and_delegate_booking() else: print("\nSkipping Agent-to-Agent Discovery tests (--skip-discovery flag set)") print("\n" + "=" * 50) print("All tests passed!") return True except Exception as e: logger.exception("Test failed with exception") print(f"\n❌ Test failed: {e}") return False def main() -> None: """Main entry point for test script.""" parser = argparse.ArgumentParser(description="Test Travel Assistant and Flight Booking agents") parser.add_argument( "--endpoint", choices=["local", "live"], required=True, help="Test against local or live endpoints", ) parser.add_argument( "--debug", action="store_true", help="Enable debug logging to see detailed request/response traces", ) parser.add_argument( "--verbose", action="store_true", help="Alias for --debug, enables debug logging", ) parser.add_argument( "--skip-discovery", action="store_true", help="Skip agent-to-agent discovery tests (requires registry running)", ) parser.add_argument( "--registry-url", default="http://localhost", help="MCP Gateway Registry URL for discovery tests (default: http://localhost)", ) args = parser.parse_args() # Enable debug logging if requested if args.debug or args.verbose: logging.getLogger().setLevel(logging.DEBUG) logger.info("Debug logging enabled - detailed traces will be shown") success = run_tests( endpoint_type=args.endpoint, skip_discovery=args.skip_discovery, registry_url=args.registry_url, ) sys.exit(0 if success else 1) if __name__ == "__main__": main() ================================================ FILE: agents/a2a/test/travel_assistant_agent_card.json ================================================ { "capabilities": { "streaming": true }, "defaultInputModes": [ "text" ], "defaultOutputModes": [ "text" ], "description": "Flight search and trip planning agent with dynamic agent discovery", "name": "Travel Assistant Agent", "preferredTransport": "JSONRPC", "protocolVersion": "0.3.0", "skills": [ { "description": "Search for available flights between cities on a specific date.", "id": "search_flights", "name": "search_flights", "tags": [] }, { "description": "Get pricing and seat availability for a specific flight.", "id": "check_prices", "name": "check_prices", "tags": [] }, { "description": "Get flight recommendations based on customer preferences.", "id": "get_recommendations", "name": "get_recommendations", "tags": [] }, { "description": "Create and save a trip planning record.", "id": "create_trip_plan", "name": "create_trip_plan", "tags": [] }, { "description": "Discover remote agents from the mcp-registry with natural language query.\nCache them for visibility and invocation for later tool calls from LLM", "id": "discover_remote_agents", "name": "discover_remote_agents", "tags": [] }, { "description": "View all cached remote agents available for invocation.", "id": "view_cached_remote_agents", "name": "view_cached_remote_agents", "tags": [] }, { "description": "Invoke a cached remote agent by ID with a natural language message.", "id": "invoke_remote_agent", "name": "invoke_remote_agent", "tags": [] } ], "url": "http://travel-assistant-agent:9000/", "version": "0.0.1" } ================================================ FILE: agents/agent.py ================================================ #!/usr/bin/env python3 """ Interactive LangGraph Agent with Registry Tool Discovery This agent discovers and invokes MCP tools using semantic search on the registry. It supports multi-turn conversation and maintains conversation history. Authentication: The agent requires a JWT token for authenticating with the MCP Registry. The token can be obtained from different sources depending on your setup. Usage Examples: # Using token from api/.token file (simple cat): python agents/agent.py \ --mcp-registry-url https://mcpgateway.ddns.net/mcpgw/mcp \ --jwt-token "$(cat api/.token)" \ --provider bedrock \ --prompt "What time is it in New York?" # Using token from .oauth-tokens/ingress.json (requires jq): python agents/agent.py \ --mcp-registry-url https://mcpgateway.ddns.net/mcpgw/mcp \ --jwt-token "$(jq -r '.access_token' .oauth-tokens/ingress.json)" \ --provider bedrock \ --prompt "What time is it in New York?" # Interactive mode for multi-turn conversations: python agents/agent.py \ --mcp-registry-url https://mcpgateway.ddns.net/mcpgw/mcp \ --jwt-token "$(cat api/.token)" \ --provider bedrock \ --interactive # With verbose logging for debugging: python agents/agent.py \ --mcp-registry-url https://mcpgateway.ddns.net/mcpgw/mcp \ --jwt-token "$(cat api/.token)" \ --provider bedrock \ --prompt "What time is it in New York?" \ --verbose Available Tools: - calculator: For mathematical calculations - search_registry_tools: Discover MCP tools via semantic search - invoke_mcp_tool: Invoke discovered tools on MCP servers Environment Variables: - ANTHROPIC_API_KEY: Required when using --provider anthropic """ import argparse import ast import asyncio import json import logging import operator as _operator import os import re import sys import threading import time from datetime import ( UTC, datetime, ) from typing import ( Any, ) from urllib.parse import ( urljoin, urlparse, ) import httpx import mcp import yaml from langchain_anthropic import ChatAnthropic from langchain_aws import ChatBedrock from langchain_core.tools import tool from langgraph.prebuilt import create_react_agent from mcp.client.sse import sse_client from mcp.client.streamable_http import streamable_http_client from registry_client import ( RegistryClient, _format_tool_result, ) # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, # Set the log level to INFO # Define log message format format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) # Get logger logger = logging.getLogger(__name__) # Global registry client instance (initialized in main) registry_client: RegistryClient | None = None class ProgressSpinner: """Simple progress spinner for showing activity during operations.""" SPINNER_CHARS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] def __init__(self): self._stop_event = threading.Event() self._thread: threading.Thread | None = None def _spin(self) -> None: idx = 0 while not self._stop_event.is_set(): char = self.SPINNER_CHARS[idx % len(self.SPINNER_CHARS)] sys.stdout.write(f"\r{char}") sys.stdout.flush() idx += 1 time.sleep(0.1) def start(self) -> "ProgressSpinner": self._stop_event.clear() self._thread = threading.Thread(target=self._spin, daemon=True) self._thread.start() return self def stop( self, final_message: str = None, ) -> None: self._stop_event.set() if self._thread: self._thread.join(timeout=0.5) # Clear the spinner character sys.stdout.write("\r \r") sys.stdout.flush() if final_message: print(f" {final_message}") def print_step( step: str, icon: str = "->", ) -> None: """Print a step indicator.""" print(f" {icon} {step}") def load_server_config(config_file: str = "server_config.yml") -> dict[str, Any]: """ Load server configuration from YAML file. Args: config_file: Path to the configuration file Returns: Dict containing server configurations """ try: # Try to find config file in the same directory as this script config_path = os.path.join(os.path.dirname(__file__), config_file) if not os.path.exists(config_path): # Try current working directory config_path = config_file if not os.path.exists(config_path): logger.warning( f"Server config file not found: {config_file}. Using default configuration." ) return {"servers": {}} with open(config_path) as f: config = yaml.safe_load(f) logger.info(f"Loaded server config from: {config_path}") return config or {"servers": {}} except Exception as e: logger.warning(f"Failed to load server config: {e}. Using default configuration.") return {"servers": {}} def resolve_env_vars(value: str, server_name: str = None) -> str: """ Resolve environment variable references in a string. Supports ${VAR_NAME} syntax. Args: value: String that may contain environment variable references server_name: Name of the server (for error context) Returns: String with environment variables resolved Raises: ValueError: If a required environment variable is not found """ import re missing_vars = [] def replace_env_var(match): var_name = match.group(1) env_value = os.environ.get(var_name) if env_value is None: missing_vars.append(var_name) return match.group(0) # Return original if not found return env_value # Find all ${VAR_NAME} patterns and replace them pattern = r"\$\{([^}]+)\}" resolved_value = re.sub(pattern, replace_env_var, value) # If any environment variables were missing, raise an error if missing_vars: server_context = f" for server '{server_name}'" if server_name else "" missing_list = "', '".join(missing_vars) raise ValueError( f"Missing required environment variable(s): '{missing_list}'{server_context}. " f"Please set these environment variables and try again." ) return resolved_value def get_server_headers(server_name: str, config: dict[str, Any]) -> dict[str, str]: """ Get server-specific headers from configuration with environment variable resolution. Args: server_name: Name of the server (e.g., 'sre-gateway', 'atlassian') config: Loaded server configuration Returns: Dictionary of headers for the server Raises: ValueError: If required environment variables for the server are missing """ servers = config.get("servers", {}) server_config = servers.get(server_name, {}) raw_headers = server_config.get("headers", {}) if not raw_headers: logger.debug(f"No custom headers configured for server '{server_name}'") return {} # Resolve environment variables in header values resolved_headers = {} try: for header_name, header_value in raw_headers.items(): resolved_value = resolve_env_vars(header_value, server_name) if resolved_value != header_value: logger.debug(f"Resolved header {header_name} for server {server_name}") resolved_headers[header_name] = resolved_value logger.info(f"Applied {len(resolved_headers)} custom headers for server '{server_name}'") return resolved_headers except ValueError as e: # Re-raise with additional context about which server failed logger.error(f"Failed to configure headers for server '{server_name}': {e}") raise def enable_verbose_logging(): """Enable verbose debug logging for HTTP libraries and main logger.""" # Set main logger to DEBUG level logger.setLevel(logging.DEBUG) # Enable debug logging for httpx to see request/response details httpx_logger = logging.getLogger("httpx") httpx_logger.setLevel(logging.DEBUG) httpx_logger.propagate = True # Enable debug logging for httpcore (underlying HTTP library) httpcore_logger = logging.getLogger("httpcore") httpcore_logger.setLevel(logging.DEBUG) httpcore_logger.propagate = True # Enable debug logging for mcp client libraries mcp_logger = logging.getLogger("mcp") mcp_logger.setLevel(logging.DEBUG) mcp_logger.propagate = True logger.info("Verbose logging enabled for httpx, httpcore, mcp libraries, and main logger") def parse_arguments() -> argparse.Namespace: """ Parse command line arguments for the Interactive LangGraph Agent. Returns: argparse.Namespace: The parsed command line arguments """ parser = argparse.ArgumentParser( description="Interactive LangGraph Agent with Registry Tool Discovery", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Using token from api/.token file: python agents/agent.py --jwt-token "$(cat api/.token)" --prompt "What time is it?" # Using token from .oauth-tokens/ingress.json: python agents/agent.py --jwt-token "$(jq -r '.access_token' .oauth-tokens/ingress.json)" --prompt "What time is it?" # Interactive mode: python agents/agent.py --jwt-token "$(cat api/.token)" --interactive """, ) # Server connection arguments parser.add_argument( "--mcp-registry-url", type=str, default="https://mcpgateway.ddns.net/mcpgw/mcp", help="URL of the MCP Registry (default: https://mcpgateway.ddns.net/mcpgw/mcp)", ) # Authentication - JWT token required parser.add_argument( "--jwt-token", type=str, required=True, help="JWT token for authentication (required)", ) # Model and provider arguments parser.add_argument( "--provider", type=str, choices=["anthropic", "bedrock"], default="bedrock", help="Model provider to use (default: bedrock)", ) parser.add_argument( "--model", type=str, default="us.anthropic.claude-3-7-sonnet-20250219-v1:0", help="Model ID to use", ) # Prompt arguments parser.add_argument( "--prompt", type=str, default=None, help="Initial prompt to send to the agent", ) # Interactive mode argument parser.add_argument( "--interactive", "-i", action="store_true", help="Enable interactive mode for multi-turn conversations", ) # Verbose logging argument parser.add_argument( "--verbose", "-v", action="store_true", help="Enable verbose HTTP debugging output", ) args = parser.parse_args() # Enable verbose logging if requested if args.verbose: enable_verbose_logging() return args _SAFE_OPERATORS: dict = { ast.Add: _operator.add, ast.Sub: _operator.sub, ast.Mult: _operator.mul, ast.Div: _operator.truediv, ast.Pow: _operator.pow, ast.FloorDiv: _operator.floordiv, ast.Mod: _operator.mod, } _SAFE_UNARY_OPERATORS: dict = { ast.UAdd: _operator.pos, ast.USub: _operator.neg, } def _safe_eval_arithmetic(expression: str) -> int | float: """Safely evaluate an arithmetic expression using AST node whitelisting. Only numeric literals and basic arithmetic operators are permitted. Function calls, attribute access, names, and all other non-arithmetic constructs raise ValueError immediately. Args: expression: A pre-validated arithmetic expression string. Returns: The numeric result of the expression. Raises: ValueError: If the expression contains unsupported operations. ZeroDivisionError: If the expression divides by zero. """ def _eval_node(node: ast.AST) -> int | float: if isinstance(node, ast.Expression): return _eval_node(node.body) if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): return node.value if isinstance(node, ast.BinOp): op_func = _SAFE_OPERATORS.get(type(node.op)) if op_func is None: raise ValueError(f"Unsupported operator: {type(node.op).__name__}") # Special handling for exponentiation to prevent DoS if isinstance(node.op, ast.Pow): left_val = _eval_node(node.left) right_val = _eval_node(node.right) if abs(right_val) > 100: raise ValueError("Exponent too large (max 100)") return op_func(left_val, right_val) return op_func(_eval_node(node.left), _eval_node(node.right)) if isinstance(node, ast.UnaryOp): op_func = _SAFE_UNARY_OPERATORS.get(type(node.op)) if op_func is None: raise ValueError(f"Unsupported unary operator: {type(node.op).__name__}") return op_func(_eval_node(node.operand)) raise ValueError(f"Unsupported expression type: {type(node).__name__}") tree = ast.parse(expression, mode="eval") return _eval_node(tree) @tool def calculator(expression: str) -> str: """ Evaluate a mathematical expression and return the result. This tool can perform basic arithmetic operations like addition, subtraction, multiplication, division, and exponentiation. Args: expression (str): The mathematical expression to evaluate (e.g., "2 + 2", "5 * 10", "(3 + 4) / 2") Returns: str: The result of the evaluation as a string Example: calculator("2 + 2") -> "4" calculator("5 * 10") -> "50" calculator("(3 + 4) / 2") -> "3.5" """ # Security check: only allow basic arithmetic operations and numbers # Remove all whitespace expression = expression.replace(" ", "") # Guard against excessively long expressions (DoS via large exponents) if len(expression) > 200: return "Error: Expression too long (max 200 characters)." # Check if the expression contains only allowed characters if not re.match(r"^[0-9+\-*/().^ ]+$", expression): return "Error: Only basic arithmetic operations (+, -, *, /, ^, (), .) are allowed." try: # Replace ^ with ** for exponentiation expression = expression.replace("^", "**") # Safely evaluate using AST node whitelisting (no arbitrary code execution) result = _safe_eval_arithmetic(expression) return str(result) except Exception as e: return f"Error evaluating expression: {str(e)}" @tool async def search_registry_tools( query: str, max_results: int = 10, ) -> str: """ Search for MCP tools using semantic search on the registry. Use this tool to discover available MCP tools that can help accomplish a task. The search uses natural language understanding to find the most relevant tools. Args: query (str): Natural language description of the capability you need (e.g., "get current time", "search jira issues", "manage files") max_results (int): Maximum number of results to return (default: 10) Returns: str: JSON string containing matching tools with their details including: - tool_name: Name of the tool - server_path: Path to invoke the tool on - server_name: Human-readable server name - description: What the tool does - relevance_score: How well it matches your query (0-1) - supported_transports: Transport protocols supported - auth_provider: Authentication provider if needed - tool_schema: Input parameters for the tool Example: search_registry_tools("get the current time in different timezones") search_registry_tools("search for jira issues", max_results=5) """ global registry_client if registry_client is None: return json.dumps( {"error": "Registry client not initialized. Check authentication configuration."} ) try: logger.info(f"Searching registry for tools: '{query}' (max_results={max_results})") # Search for tools using semantic search search_response = await registry_client.search_tools( query=query, max_results=max_results, entity_types=["mcp_server", "tool"], ) results = [] # Process tool results first (most specific) # The search API now returns inputSchema directly, no need for get_server_info for tool_result in search_response.tools: formatted = _format_tool_result(tool_result) results.append(formatted) # Also include matching tools from server results for server_result in search_response.servers: for matching_tool in server_result.matching_tools: # Check if this tool is already in results existing = [ r for r in results if r["tool_name"] == matching_tool.tool_name and r["server_path"] == server_result.path ] if existing: continue tool_data = { "tool_name": matching_tool.tool_name, "server_path": server_result.path, "server_name": server_result.server_name, "description": matching_tool.description or "No description available", "relevance_score": matching_tool.relevance_score, "supported_transports": ["streamable_http"], } # Note: inputSchema is available in the tools[] array, not matching_tools results.append(tool_data) # Sort by relevance score results.sort(key=lambda x: x.get("relevance_score", 0), reverse=True) # Limit to max_results results = results[:max_results] logger.info(f"Found {len(results)} matching tools for query: '{query}'") return json.dumps( { "query": query, "tools": results, "total_found": len(results), }, indent=2, ) except Exception as e: logger.error(f"Error searching registry: {e}", exc_info=True) return json.dumps({"error": f"Search failed: {str(e)}"}) @tool async def invoke_mcp_tool( mcp_registry_url: str, server_name: str, tool_name: str, arguments: dict[str, Any], supported_transports: list[str] = None, auth_provider: str = None, ) -> str: """ Invoke a tool on an MCP server using the MCP Registry URL and server name. Args: mcp_registry_url: The URL of the MCP Registry server_name: The name of the MCP server to connect to tool_name: The name of the tool to invoke arguments: Dictionary containing the arguments for the tool supported_transports: Transport protocols (["streamable_http"] or ["sse"]) auth_provider: Authentication provider (e.g., "atlassian") Returns: The result of the tool invocation as a string """ # Build server URL from registry URL and server name parsed_url = urlparse(mcp_registry_url) base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" # Remove leading slash from server_name if present server_name_clean = server_name.lstrip("/") server_url = urljoin(base_url + "/", server_name_clean) # Build headers with authentication auth_token = agent_settings.auth_token region = agent_settings.region headers = { "X-Authorization": f"Bearer {auth_token}", "X-Region": region, "Authorization": f"Bearer {auth_token}", } # Get server-specific headers from configuration server_headers = get_server_headers(server_name_clean, server_config) headers.update(server_headers) # Handle egress authentication if auth_provider is specified if auth_provider: headers = _add_egress_auth(headers, auth_provider, server_name_clean) # Determine transport (default to streamable_http) use_sse = ( supported_transports and "sse" in supported_transports and "streamable_http" not in supported_transports ) if use_sse: server_url = server_url.rstrip("/") + "/sse" logger.info(f"Invoking {tool_name} on {server_name_clean}") # Try invocation, retry with /mcp suffix on failure try: if use_sse: return await _invoke_via_sse(server_url, headers, tool_name, arguments) else: return await _invoke_via_http(server_url, headers, tool_name, arguments) except Exception as e: # Always retry with /mcp suffix on first failure mcp_url = server_url.rstrip("/") + "/mcp" logger.info(f"First attempt failed, retrying with /mcp suffix: {mcp_url}") try: if use_sse: return await _invoke_via_sse(mcp_url, headers, tool_name, arguments) else: return await _invoke_via_http(mcp_url, headers, tool_name, arguments) except Exception as retry_e: logger.error(f"Error invoking MCP tool (retry): {retry_e}") return f"Error invoking MCP tool: {str(retry_e)}" def _add_egress_auth( headers: dict[str, str], auth_provider: str, server_name: str, ) -> dict[str, str]: """Add egress authentication headers if available.""" oauth_tokens_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".oauth-tokens" ) server_lower = server_name.lower() provider_lower = auth_provider.lower() # Try provider-server specific file, then provider-only file egress_files = [ os.path.join(oauth_tokens_dir, f"{provider_lower}-{server_lower}-egress.json"), os.path.join(oauth_tokens_dir, f"{provider_lower}-egress.json"), ] for egress_file in egress_files: if os.path.exists(egress_file): with open(egress_file) as f: egress_data = json.load(f) egress_token = egress_data.get("access_token") if egress_token: headers["Authorization"] = f"Bearer {egress_token}" logger.info(f"Using egress auth for {auth_provider}") # Provider-specific headers if provider_lower == "atlassian": cloud_id = egress_data.get("cloud_id") if cloud_id: headers["X-Atlassian-Cloud-Id"] = cloud_id break return headers async def _invoke_via_sse( server_url: str, headers: dict[str, str], tool_name: str, arguments: dict[str, Any], ) -> str: """Invoke tool via SSE transport.""" async with sse_client(server_url, headers=headers) as (read, write): async with mcp.ClientSession(read, write) as session: await session.initialize() result = await session.call_tool(tool_name, arguments=arguments) return _format_tool_response(result) async def _invoke_via_http( server_url: str, headers: dict[str, str], tool_name: str, arguments: dict[str, Any], ) -> str: """Invoke tool via streamable HTTP transport.""" async with httpx.AsyncClient(headers=headers) as http_client: async with streamable_http_client( url=server_url, http_client=http_client, ) as (read, write, _): async with mcp.ClientSession(read, write) as session: await session.initialize() result = await session.call_tool(tool_name, arguments=arguments) return _format_tool_response(result) def _format_tool_response(result: Any) -> str: """Format MCP tool result as string.""" response_parts = [] for r in result.content: if hasattr(r, "text"): response_parts.append(r.text) return "\n".join(response_parts).strip() # Get current UTC time (using timezone.utc to avoid deprecation warning) current_utc_time = str(datetime.now(UTC)) # Global agent settings to store authentication details class AgentSettings: """Stores authentication details for MCP tool invocation.""" def __init__(self): self.auth_token: str | None = None self.region: str = "us-east-1" agent_settings = AgentSettings() # Global server configuration server_config = {} def load_system_prompt(): """ Load the system prompt template from the system_prompt.txt file. Returns: str: The system prompt template """ import os try: # Get the directory where this Python file is located current_dir = os.path.dirname(__file__) system_prompt_path = os.path.join(current_dir, "system_prompt.txt") with open(system_prompt_path) as f: return f.read() except Exception as e: print(f"Error loading system prompt: {e}") # Provide a minimal fallback prompt in case the file can't be loaded return """ You are a highly capable AI assistant designed to solve problems for users. Current UTC time: {current_utc_time} MCP Registry URL: {mcp_registry_url} """ def print_agent_response( response_dict: dict[str, Any], verbose: bool = False, ) -> None: """ Print the agent's final response. Args: response_dict: Dictionary containing the agent response with 'messages' key verbose: Whether to show detailed message flow """ if not response_dict or "messages" not in response_dict: return messages = response_dict["messages"] # In verbose mode, show the message flow if verbose: _print_verbose_messages(messages) # Find and print the final AI response for message in reversed(messages): message_type = type(message).__name__ if "AIMessage" in message_type: content = getattr(message, "content", None) if content: print("\n" + str(content), flush=True) break def _print_verbose_messages(messages: list[Any]) -> None: """Print detailed message flow for debugging.""" colors = { "SYSTEM": "\033[1;33m", "HUMAN": "\033[1;32m", "AI": "\033[1;36m", "TOOL": "\033[1;35m", "RESET": "\033[0m", } print(f"\n{colors['AI']}=== Message Flow ({len(messages)} messages) ==={colors['RESET']}\n") for i, message in enumerate(messages, 1): msg_type = type(message).__name__ color = colors.get( "AI" if "AI" in msg_type else "TOOL" if "Tool" in msg_type else "HUMAN", colors["RESET"] ) content = getattr(message, "content", str(message)) preview = content[:100] + "..." if len(str(content)) > 100 else content print(f"{color}[{i}] {msg_type}: {preview}{colors['RESET']}") # Show tool calls if present if hasattr(message, "tool_calls") and message.tool_calls: for tc in message.tool_calls: print(f" -> Tool: {tc.get('name', 'unknown')}") class InteractiveAgent: """Interactive agent that maintains conversation history.""" def __init__( self, agent, system_prompt: str, verbose: bool = False, ): self.agent = agent self.system_prompt = system_prompt self.verbose = verbose self.conversation_history: list[dict[str, str]] = [] async def process_message( self, user_input: str, show_progress: bool = True, ) -> dict[str, Any]: """Process a user message and return the agent's response.""" messages = [{"role": "system", "content": self.system_prompt}] messages.extend(self.conversation_history) messages.append({"role": "user", "content": user_input}) spinner = None if show_progress: spinner = ProgressSpinner().start() try: response = await self.agent.ainvoke({"messages": messages}) finally: if spinner: spinner.stop() # Update history self.conversation_history.append({"role": "user", "content": user_input}) if response and "messages" in response: for message in reversed(response["messages"]): if "AIMessage" in type(message).__name__: ai_content = getattr(message, "content", str(message)) self.conversation_history.append({"role": "assistant", "content": ai_content}) break return response async def run_interactive_session(self) -> None: """Run an interactive conversation session.""" print("\n" + "=" * 60) print("Interactive Agent Session") print("=" * 60) print("Commands: 'exit' to quit, 'clear' to reset, 'history' to view") print("=" * 60 + "\n") while True: try: user_input = input("\nYou: ").strip() if user_input.lower() in ["exit", "quit", "bye"]: print("\nGoodbye!") break if user_input.lower() in ["clear", "reset"]: self.conversation_history = [] print("History cleared.") continue if user_input.lower() == "history": self._print_history() continue if not user_input: continue response = await self.process_message(user_input) print("\nAgent:", end="") print_agent_response(response, self.verbose) except KeyboardInterrupt: print("\n\nInterrupted. Type 'exit' to quit.") except Exception as e: print(f"\nError: {str(e)}") if self.verbose: import traceback traceback.print_exc() def _print_history(self) -> None: """Print conversation history.""" if not self.conversation_history: print("No history yet.") return print("\nConversation History:") print("-" * 40) for i, msg in enumerate(self.conversation_history, 1): role = "You" if msg["role"] == "user" else "Agent" preview = msg["content"][:80] + "..." if len(msg["content"]) > 80 else msg["content"] print(f"{i}. {role}: {preview}") async def main(): """Main function - parses args, sets up model, and runs agent.""" args = parse_arguments() # Set up authentication agent_settings.auth_token = args.jwt_token # Load server configuration global server_config server_config = load_server_config() # Show startup info print_step(f"Registry: {args.mcp_registry_url}") print_step(f"Provider: {args.provider}") print_step(f"Model: {args.model}") # Initialize model model = _create_model(args.provider, args.model) if not model: return try: # Initialize registry client global registry_client parsed_url = urlparse(args.mcp_registry_url) registry_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" registry_client = RegistryClient( registry_url=registry_base_url, jwt_token=args.jwt_token, ) # Create the agent all_tools = [calculator, search_registry_tools, invoke_mcp_tool] agent = create_react_agent(model, all_tools) # Load system prompt system_prompt = load_system_prompt().format( current_utc_time=current_utc_time, mcp_registry_url=args.mcp_registry_url, ) interactive_agent = InteractiveAgent(agent, system_prompt, args.verbose) # Process initial prompt if provided if args.prompt: print_step("Processing prompt...") response = await interactive_agent.process_message(args.prompt) if not args.interactive: print_agent_response(response, args.verbose) return else: print("\nAgent:", end="") print_agent_response(response, args.verbose) # Run interactive session or show usage if args.interactive: await interactive_agent.run_interactive_session() elif not args.prompt: print("\nNo prompt provided. Use --prompt or --interactive") print("\nExamples:") print(' python agent.py --prompt "What time is it?"') print(" python agent.py --interactive") except Exception as e: print(f"Error: {str(e)}") import traceback traceback.print_exc() def _create_model( provider: str, model_id: str, ): """Create the LLM model based on provider.""" if provider == "anthropic": api_key = os.getenv("ANTHROPIC_API_KEY") if not api_key: print("Error: ANTHROPIC_API_KEY not found") return None return ChatAnthropic( model=model_id, api_key=api_key, temperature=0, max_tokens=8192, ) # Default to Bedrock aws_region = os.getenv("AWS_DEFAULT_REGION", os.getenv("AWS_REGION", "us-east-1")) return ChatBedrock( model_id=model_id, region_name=aws_region, temperature=0, max_tokens=8192, ) if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: agents/cli_user_auth.py ================================================ #!/usr/bin/env python3 """ CLI tool for MCP Gateway user authentication via Cognito OAuth. Captures session cookie and saves to local file for agent use. Usage: python cli_auth.py [--cookie-file PATH] Environment variables required: COGNITO_DOMAIN: Cognito domain (e.g., 'mcp-gateway' or full URL) COGNITO_CLIENT_ID: OAuth client ID SECRET_KEY: Must match the registry SECRET_KEY for cookie compatibility AWS_REGION: AWS region (optional, defaults to us-east-1) """ import argparse import base64 import hashlib import json import logging import os import secrets import sys import threading import webbrowser from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from urllib.parse import parse_qs, urlencode import requests from dotenv import load_dotenv from itsdangerous import URLSafeTimedSerializer # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) # Load environment variables from .env file # Look for .env file in the same directory as this script script_dir = Path(__file__).parent env_file = script_dir / ".env.user" if env_file.exists(): load_dotenv(env_file, override=True) logger.info(f"Loaded environment variables from {env_file}") else: logger.warning(f"No .env file found at {env_file}") # Configuration from environment COGNITO_USER_POOL_ID = os.environ.get("COGNITO_USER_POOL_ID") COGNITO_DOMAIN = os.environ.get("COGNITO_DOMAIN") COGNITO_CLIENT_ID = os.environ.get("COGNITO_CLIENT_ID") COGNITO_CLIENT_SECRET = os.environ.get("COGNITO_CLIENT_SECRET") SECRET_KEY = os.environ.get("SECRET_KEY") # Make redirect URI configurable based on environment REGISTRY_URL = os.environ.get("REGISTRY_URL", "http://localhost") USE_DIRECT_CALLBACK = os.environ.get("USE_DIRECT_CALLBACK", "true").lower() == "true" if USE_DIRECT_CALLBACK: logger.info("Using direct callback") # Direct callback to local server (original behavior) COGNITO_REDIRECT_URI = "http://localhost:9090/callback" CALLBACK_PORT = 9090 CALLBACK_PATH = "/callback" else: # Use nginx proxy callback (for Docker environments) COGNITO_REDIRECT_URI = f"{REGISTRY_URL}/oauth2/callback/cognito" CALLBACK_PORT = 8080 # Different port to avoid conflicts CALLBACK_PATH = "/auth_complete" AWS_REGION = os.environ.get("AWS_REGION", "us-east-1") # Validate required environment variables if not all([COGNITO_USER_POOL_ID, COGNITO_CLIENT_ID, SECRET_KEY]): logger.error("Missing required environment variables") logger.error("Required: COGNITO_USER_POOL_ID, COGNITO_CLIENT_ID, SECRET_KEY") sys.exit(1) # Construct the Cognito domain if COGNITO_DOMAIN: # Use custom domain if provided COGNITO_DOMAIN_URL = f"https://{COGNITO_DOMAIN}.auth.{AWS_REGION}.amazoncognito.com" else: # Otherwise use user pool ID without underscores (standard format) user_pool_id_wo_underscore = COGNITO_USER_POOL_ID.replace("_", "") COGNITO_DOMAIN_URL = f"https://{user_pool_id_wo_underscore}.auth.{AWS_REGION}.amazoncognito.com" logger.info(f"Using Cognito domain: {COGNITO_DOMAIN_URL}") logger.info( f"Redirect URI configured: {COGNITO_REDIRECT_URI if 'COGNITO_REDIRECT_URI' in globals() else 'Not yet configured'}" ) # OAuth endpoints AUTHORIZE_URL = f"{COGNITO_DOMAIN_URL}/oauth2/authorize" TOKEN_URL = f"{COGNITO_DOMAIN_URL}/oauth2/token" # Global variables for OAuth flow auth_result = None auth_complete = threading.Event() pkce_verifier = None class OAuthCallbackHandler(BaseHTTPRequestHandler): """HTTP request handler for OAuth callback""" def log_message(self, format, *args): """Override to use logger instead of stderr""" logger.debug(f"Callback server: {format}", *args) def do_GET(self): """Handle OAuth callback""" global auth_result if self.path.startswith(CALLBACK_PATH): # Parse query parameters query_string = self.path.split("?", 1)[1] if "?" in self.path else "" params = parse_qs(query_string) # Check for authorization code if "code" in params: auth_code = params["code"][0] logger.info("Authorization code received") # Exchange code for tokens token_result = self.exchange_code_for_tokens(auth_code) if token_result: # Create session cookie cookie_value = self.create_session_cookie(token_result) if cookie_value: auth_result = { "success": True, "cookie": cookie_value, "user_info": token_result, } self.send_success_response() else: self.send_error_response("Failed to create session cookie") else: self.send_error_response("Failed to exchange authorization code") elif "error" in params: error = params.get("error", ["Unknown error"])[0] error_description = params.get("error_description", [""])[0] logger.error(f"OAuth error: {error} - {error_description}") self.send_error_response(f"Authentication failed: {error}") else: self.send_error_response("Invalid callback parameters") # Signal completion auth_complete.set() else: self.send_404() def exchange_code_for_tokens(self, auth_code): """Exchange authorization code for tokens""" global pkce_verifier try: # Basic auth with client credentials auth_string = f"{COGNITO_CLIENT_ID}:{COGNITO_CLIENT_SECRET}" auth_bytes = auth_string.encode("utf-8") auth_b64 = base64.b64encode(auth_bytes).decode("utf-8") headers = { "Authorization": f"Basic {auth_b64}", "Content-Type": "application/x-www-form-urlencoded", } data = { "grant_type": "authorization_code", "client_id": COGNITO_CLIENT_ID, "code": auth_code, "redirect_uri": COGNITO_REDIRECT_URI, "code_verifier": pkce_verifier, } response = requests.post(TOKEN_URL, headers=headers, data=data, timeout=30) response.raise_for_status() token_data = response.json() logger.info("Successfully exchanged code for tokens") # Decode ID token to get user info id_token = token_data.get("id_token") if id_token: # Simple JWT decode without verification (Cognito already verified) # In production, should verify with Cognito JWKS payload = id_token.split(".")[1] # Add padding if needed payload += "=" * (4 - len(payload) % 4) user_info = json.loads(base64.urlsafe_b64decode(payload)) return { "username": user_info.get("cognito:username", user_info.get("email")), "groups": user_info.get("cognito:groups", []), "email": user_info.get("email"), "sub": user_info.get("sub"), } return None except Exception as e: logger.error(f"Token exchange failed: {e}") return None def create_session_cookie(self, user_info): """Create session cookie matching registry format""" try: signer = URLSafeTimedSerializer(SECRET_KEY) # Create session data matching old implementation format session_data = { "username": user_info["username"], "groups": user_info.get("groups", []), "provider_type": "cognito", "is_oauth": True, "session_id": secrets.token_urlsafe(16), "login_time": None, # Will be set by registry if needed } # Serialize the session data cookie_value = signer.dumps(session_data) logger.info(f"Session cookie created for user: {user_info['username']}") return cookie_value except Exception as e: logger.error(f"Failed to create session cookie: {e}") return None def send_success_response(self): """Send success response to browser""" self.send_response(200) self.send_header("Content-type", "text/html") self.end_headers() html = """ Authentication Successful

✓ Authentication Successful!

Your session cookie has been saved.

You can now close this window and return to the terminal.

""" self.wfile.write(html.encode()) def send_error_response(self, error_message): """Send error response to browser""" self.send_response(400) self.send_header("Content-type", "text/html") self.end_headers() html = f""" Authentication Failed

✗ Authentication Failed

{error_message}

Please close this window and try again.

""" self.wfile.write(html.encode()) def send_404(self): """Send 404 response""" self.send_response(404) self.end_headers() def generate_pkce_challenge(): """Generate PKCE code verifier and challenge""" # Generate code verifier (43-128 characters) code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8").rstrip("=") # Generate code challenge (SHA256 of verifier) challenge_bytes = hashlib.sha256(code_verifier.encode("utf-8")).digest() code_challenge = base64.urlsafe_b64encode(challenge_bytes).decode("utf-8").rstrip("=") return code_verifier, code_challenge def start_callback_server(): """Start the OAuth callback server""" server = HTTPServer(("localhost", CALLBACK_PORT), OAuthCallbackHandler) server_thread = threading.Thread(target=server.serve_forever) server_thread.daemon = True server_thread.start() logger.info(f"Callback server started on http://localhost:{CALLBACK_PORT}") return server def save_cookie_to_file(cookie_value, file_path): """Save cookie to file with secure permissions""" try: # Expand user path and create directory if needed cookie_path = Path(file_path).expanduser() cookie_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) # Write cookie to file cookie_path.write_text(cookie_value) # Set secure permissions (owner read/write only) cookie_path.chmod(0o600) logger.info(f"Session cookie saved to: {cookie_path}") return True except Exception as e: logger.error(f"Failed to save cookie: {e}") return False def main(): """Main authentication flow""" global pkce_verifier parser = argparse.ArgumentParser(description="MCP Gateway CLI Authentication") parser.add_argument( "--cookie-file", default="~/.mcp/session_cookie", help="Path to save session cookie (default: ~/.mcp/session_cookie)", ) parser.add_argument( "--use-proxy", action="store_true", help="Use nginx proxy callback instead of direct callback (for Docker environments)", ) parser.add_argument( "--registry-url", default="http://localhost", help="Registry URL for proxy-based auth (default: http://localhost)", ) args = parser.parse_args() # Override environment variables with CLI arguments if args.use_proxy: global USE_DIRECT_CALLBACK, COGNITO_REDIRECT_URI, CALLBACK_PORT, CALLBACK_PATH, REGISTRY_URL USE_DIRECT_CALLBACK = False REGISTRY_URL = args.registry_url COGNITO_REDIRECT_URI = f"{REGISTRY_URL}/oauth2/callback/cognito" CALLBACK_PORT = 8081 CALLBACK_PATH = "/auth_complete" try: # Generate PKCE challenge pkce_verifier, pkce_challenge = generate_pkce_challenge() # Start callback server server = start_callback_server() # Build authorization URL auth_params = { "response_type": "code", "client_id": COGNITO_CLIENT_ID, "redirect_uri": COGNITO_REDIRECT_URI, "scope": "openid email profile", "code_challenge": pkce_challenge, "code_challenge_method": "S256", } auth_url = f"{AUTHORIZE_URL}?{urlencode(auth_params)}" # Open browser for authentication logger.info("Opening browser for Cognito login...") print("\n" + "=" * 50) print("Opening your browser for authentication...") print("Please complete the login process.") print(f"Redirect URI: {COGNITO_REDIRECT_URI}") print(f"Callback server: http://localhost:{CALLBACK_PORT}") print("=" * 50 + "\n") logger.info(f"Authorization URL: {auth_url}") webbrowser.open(auth_url) # Wait for callback logger.info("Waiting for authentication callback...") auth_complete.wait(timeout=300) # 5 minute timeout # Shutdown callback server server.shutdown() # Check results if auth_result and auth_result.get("success"): cookie_value = auth_result["cookie"] if save_cookie_to_file(cookie_value, args.cookie_file): print("\n" + "=" * 50) print("✓ Authentication successful!") print(f"✓ Session cookie saved to: {Path(args.cookie_file).expanduser()}") print("\nYou can now use this cookie with agents:") print(" python agents/agent.py --use-session-cookie") print("=" * 50 + "\n") return 0 else: print("\n✗ Failed to save session cookie") return 1 else: print("\n✗ Authentication failed") return 1 except KeyboardInterrupt: print("\n\nAuthentication cancelled by user") return 1 except Exception as e: logger.error(f"Unexpected error: {e}") print(f"\n✗ Error: {e}") return 1 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: agents/client.py ================================================ """ Client for the Auth Server REST API. This script demonstrates connecting to the Auth Server with Cognito authentication. Configuration can be provided via command line arguments or environment variables. Command line arguments take precedence over environment variables. Environment Variables: - AUTH_SERVER_URL: URL of the Auth server - COGNITO_CLIENT_ID: Cognito App Client ID - COGNITO_CLIENT_SECRET: Cognito App Client Secret - COGNITO_USER_POOL_ID: Cognito User Pool ID - AWS_REGION: AWS region for Cognito Usage: python client.py --generate-token --scopes "read write" Example with command line arguments: python client.py --server-url http://localhost:8888 \ --client-id [CLIENT_ID] --client-secret [CLIENT_SECRET] \ --user-pool-id [USER_POOL_ID] --region us-east-1 \ --generate-token --scopes "read write" Example with environment variables (create a .env file): AUTH_SERVER_URL=http://localhost:8888 COGNITO_CLIENT_ID=your_client_id COGNITO_CLIENT_SECRET=your_client_secret COGNITO_USER_POOL_ID=your_user_pool_id AWS_REGION=us-east-1 python client.py --generate-token --scopes "read write" """ import argparse import logging import os import requests from cognito_utils import generate_token # Import dotenv for loading environment variables try: from dotenv import load_dotenv DOTENV_AVAILABLE = True except ImportError: DOTENV_AVAILABLE = False print("Warning: python-dotenv not installed. Environment file loading disabled.") # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s|p%(process)d|%(pathname)s:%(lineno)d|%(levelname)s|%(message)s", ) # Create a custom formatter that extracts folder and filename class CustomFormatter(logging.Formatter): def format(self, record): # Get the full path and extract just the folder and filename pathname = record.pathname parts = pathname.split("/") if len(parts) >= 2: folder_and_file = "/".join(parts[-2:]) # Get last two parts (folder/file) else: folder_and_file = parts[-1] # Just filename if no folder # Replace the pathname with our custom format record.pathname = folder_and_file return super().format(record) # Get the root logger and set our custom formatter root_logger = logging.getLogger() for handler in root_logger.handlers: handler.setFormatter( CustomFormatter( "%(asctime)s|p%(process)d|%(pathname)s:%(lineno)d|%(levelname)s|%(message)s" ) ) logger = logging.getLogger(__name__) def load_env_config() -> dict[str, str | None]: """ Load configuration from .env file if available. Returns: Dict[str, Optional[str]]: Dictionary containing environment variables """ env_config = { "client_id": None, "client_secret": None, "region": None, "user_pool_id": None, "server_url": None, } if DOTENV_AVAILABLE: # Try to load from .env file in the current directory env_file = os.path.join(os.path.dirname(__file__), ".env") if os.path.exists(env_file): load_dotenv(env_file) logger.info(f"Loading environment variables from {env_file}") else: # Try to load from .env file in the parent directory env_file = os.path.join(os.path.dirname(__file__), "..", ".env") if os.path.exists(env_file): load_dotenv(env_file) logger.info(f"Loading environment variables from {env_file}") else: # Try to load from current working directory load_dotenv() logger.info("Loading environment variables from current directory") # Get values from environment env_config["client_id"] = os.getenv("COGNITO_CLIENT_ID") env_config["client_secret"] = os.getenv("COGNITO_CLIENT_SECRET") env_config["region"] = os.getenv("AWS_REGION") env_config["user_pool_id"] = os.getenv("COGNITO_USER_POOL_ID") env_config["server_url"] = os.getenv("AUTH_SERVER_URL") return env_config def parse_arguments(): """ Parse command line arguments for the Auth Server REST Client. Command line arguments take precedence over environment variables. Returns: argparse.Namespace: The parsed command line arguments """ # Load environment configuration first env_config = load_env_config() parser = argparse.ArgumentParser(description="Auth Server REST Client") parser.add_argument( "--server-url", type=str, default=env_config["server_url"] or "http://localhost:8888", help="URL of the Auth server (can be set via AUTH_SERVER_URL env var, default: http://localhost:8888)", ) parser.add_argument( "--client-id", type=str, default=env_config["client_id"], help="Cognito App Client ID (can be set via COGNITO_CLIENT_ID env var)", ) parser.add_argument( "--client-secret", type=str, default=env_config["client_secret"], help="Cognito App Client Secret (can be set via COGNITO_CLIENT_SECRET env var, required for token generation)", ) parser.add_argument( "--user-pool-id", type=str, default=env_config["user_pool_id"], help="Cognito User Pool ID (can be set via COGNITO_USER_POOL_ID env var)", ) parser.add_argument( "--region", type=str, default=env_config["region"] or "us-east-1", help="AWS Region (can be set via AWS_REGION env var, default: us-east-1)", ) parser.add_argument( "--token", type=str, help="Provide a token directly", ) parser.add_argument( "--generate-token", action="store_true", help="Generate a valid token using client credentials flow", ) parser.add_argument( "--scopes", type=str, help="Space-separated list of scopes for token generation (e.g., 'read write')", ) args = parser.parse_args() # Validate that required Cognito parameters are available (either from command line or environment) missing_params = [] if not args.client_id: missing_params.append("--client-id (or COGNITO_CLIENT_ID env var)") if not args.client_secret: missing_params.append("--client-secret (or COGNITO_CLIENT_SECRET env var)") if not args.user_pool_id: missing_params.append("--user-pool-id (or COGNITO_USER_POOL_ID env var)") if not args.region: missing_params.append("--region (or AWS_REGION env var)") if missing_params: parser.error(f"Missing required parameters: {', '.join(missing_params)}") return args def main(): """Main function to demonstrate client usage.""" args = parse_arguments() # Example: Check server health try: health_response = requests.get(f"{args.server_url}/health", timeout=30) health_response.raise_for_status() logger.info(f"Server health: {health_response.json()}") except Exception as e: logger.error(f"Error checking server health: {e}") return # Determine which token to use access_token = None # Option 1: Generate a token if requested if args.generate_token: if not args.client_secret: logger.error("Client secret is required for token generation") return try: scopes = args.scopes.split() if args.scopes else None token_response = generate_token( client_id=args.client_id, client_secret=args.client_secret, user_pool_id=args.user_pool_id, region=args.region, scopes=scopes, ) access_token = token_response["access_token"] logger.info(f"Generated token: {access_token[:20]}...") # Print token details logger.info(f"Token type: {token_response.get('token_type', 'N/A')}") logger.info(f"Expires in: {token_response.get('expires_in', 'N/A')} seconds") if "scope" in token_response: logger.info(f"Scopes: {token_response['scope']}") except Exception as e: logger.error(f"Failed to generate token: {e}") return # Option 2: Use provided token elif args.token: access_token = args.token logger.info(f"Using provided token: {access_token[:20]}...") # No token available else: logger.error("No token available. Use --generate-token or --token to provide a token.") return # Include the new required headers headers = { "Authorization": f"Bearer {access_token}", "X-Client-Id": args.client_id, "X-User-Pool-Id": args.user_pool_id, "X-Region": args.region, } logger.info("Sending validation request with headers:") logger.info(f" Authorization: Bearer {access_token[:10]}...") logger.info(f" X-Client-Id: {args.client_id}") logger.info(f" X-User-Pool-Id: {args.user_pool_id}") logger.info(f" X-Region: {args.region}") try: # Call the validate endpoint validate_response = requests.post( f"{args.server_url}/validate", headers=headers, timeout=30 ) validate_response.raise_for_status() result = validate_response.json() # Print the result logger.info("Token validation result:") logger.info(f"Valid: {result['valid']}") logger.info(f"Scopes: {', '.join(result['scopes'])}") logger.info(f"Method: {result.get('method', 'N/A')}") logger.info(f"Client ID: {result.get('client_id', 'N/A')}") if result.get("error"): logger.info(f"Error: {result['error']}") except requests.exceptions.HTTPError as e: if e.response.status_code == 401: logger.error( f"Authentication error: {e.response.json().get('detail', 'Unknown error')}" ) else: logger.error(f"HTTP error: {e}") except Exception as e: logger.error(f"Error validating token: {e}") # Example: Get auth configuration try: config_response = requests.get(f"{args.server_url}/config", timeout=30) config_response.raise_for_status() auth_config = config_response.json() logger.info("Server auth configuration:") for key, value in auth_config.items(): logger.info(f"{key}: {value}") logger.info("\nClient configuration:") logger.info(f"Client ID: {args.client_id}") logger.info(f"User Pool ID: {args.user_pool_id}") logger.info(f"Region: {args.region}") except Exception as e: logger.error(f"Error accessing auth configuration: {e}") if __name__ == "__main__": main() ================================================ FILE: agents/registry_client.py ================================================ """Client for MCP Registry API - tool discovery and search.""" import json import logging import time from typing import ( Any, ) import aiohttp from pydantic import ( BaseModel, Field, ) # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) class MatchingTool(BaseModel): """Tool matching result from semantic search. Note: inputSchema is NOT included here to avoid duplication. Full tool details including inputSchema are in the tools[] array. """ tool_name: str = Field(..., description="Name of the matching tool") description: str | None = Field(None, description="Tool description") relevance_score: float = Field(0.0, ge=0.0, le=1.0, description="Relevance score") match_context: str | None = Field(None, description="Match context") class ServerSearchResult(BaseModel): """MCP Server search result from semantic search.""" path: str = Field(..., description="Server path in registry") server_name: str = Field(..., description="Server name") description: str | None = Field(None, description="Server description") tags: list[str] = Field(default_factory=list, description="Server tags") num_tools: int = Field(0, description="Number of tools") is_enabled: bool = Field(False, description="Whether server is enabled") relevance_score: float = Field(0.0, ge=0.0, le=1.0, description="Relevance score") match_context: str | None = Field(None, description="Match context") matching_tools: list[MatchingTool] = Field( default_factory=list, description="Tools matching the query" ) class ToolSearchResult(BaseModel): """Tool search result from semantic search.""" server_path: str = Field(..., description="Server path in registry") server_name: str = Field(..., description="Server name") tool_name: str = Field(..., description="Tool name") description: str | None = Field(None, description="Tool description") inputSchema: dict[str, Any] | None = Field(None, description="JSON Schema for tool input") relevance_score: float = Field(0.0, ge=0.0, le=1.0, description="Relevance score") match_context: str | None = Field(None, description="Match context") class SearchResponse(BaseModel): """Response from semantic search API.""" query: str = Field(..., description="Original query") servers: list[ServerSearchResult] = Field(default_factory=list, description="Matching servers") tools: list[ToolSearchResult] = Field(default_factory=list, description="Matching tools") total_servers: int = Field(0, description="Total matching servers") total_tools: int = Field(0, description="Total matching tools") class RegistryClient: """Client for MCP Registry API operations.""" def __init__( self, registry_url: str, jwt_token: str | None = None, keycloak_url: str | None = None, client_id: str | None = None, client_secret: str | None = None, realm: str = "mcp-gateway", ) -> None: """ Initialize the Registry Client. Args: registry_url: Base URL of the MCP Registry (e.g., https://mcpgateway.ddns.net) jwt_token: Pre-generated JWT token (bypasses M2M auth) keycloak_url: Keycloak URL for M2M token generation client_id: OAuth client ID client_secret: OAuth client secret realm: Keycloak realm name """ self.registry_url = registry_url.rstrip("/") self.jwt_token = jwt_token self.keycloak_url = keycloak_url.rstrip("/") if keycloak_url else None self.client_id = client_id self.client_secret = client_secret self.realm = realm # Token caching self._cached_token: str | None = None self._token_expires_at: float = 0 if jwt_token: logger.info(f"RegistryClient initialized with JWT token for {registry_url}") elif keycloak_url: logger.info(f"RegistryClient initialized with M2M credentials for {registry_url}") else: logger.warning("RegistryClient initialized without authentication") async def _get_token(self) -> str: """ Get or refresh the authentication token. Returns: JWT access token Raises: Exception: If token acquisition fails """ # Use direct JWT token if provided if self.jwt_token: logger.debug("Using direct JWT token") return self.jwt_token # Check cached token validity (with 60s safety margin) current_time = time.time() if self._cached_token and current_time < self._token_expires_at - 60: logger.debug("Using cached token") return self._cached_token # Need to fetch new token from Keycloak if not self.keycloak_url or not self.client_id or not self.client_secret: raise ValueError("M2M credentials required but not provided") token_url = f"{self.keycloak_url}/realms/{self.realm}/protocol/openid-connect/token" logger.debug(f"Requesting new token from {token_url}") async with aiohttp.ClientSession() as session: data = { "grant_type": "client_credentials", "client_id": self.client_id, "client_secret": self.client_secret, } try: async with session.post(token_url, data=data) as response: if response.status != 200: error_text = await response.text() logger.error(f"Token request failed: {response.status} - {error_text}") raise Exception(f"Failed to get token: {response.status}") token_data = await response.json() self._cached_token = token_data["access_token"] expires_in = token_data.get("expires_in", 300) self._token_expires_at = current_time + expires_in logger.info(f"Token acquired, expires in {expires_in}s") return self._cached_token except aiohttp.ClientError as e: logger.error(f"Network error getting token: {e}") raise Exception(f"Network error: {e}") async def search_tools( self, query: str, max_results: int = 10, entity_types: list[str] | None = None, ) -> SearchResponse: """ Search for MCP tools using semantic search. Args: query: Natural language search query max_results: Maximum number of results to return entity_types: Entity types to search (mcp_server, tool, a2a_agent) Returns: SearchResponse with matching servers and tools Raises: Exception: If search fails """ logger.info(f"Semantic search: '{query}' (max_results={max_results})") token = await self._get_token() search_url = f"{self.registry_url}/api/search/semantic" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json", } body = { "query": query, "max_results": max_results, } if entity_types: body["entity_types"] = entity_types async with aiohttp.ClientSession() as session: try: async with session.post( search_url, headers=headers, json=body, ) as response: if response.status != 200: error_text = await response.text() logger.error(f"Search failed: {response.status} - {error_text}") raise Exception(f"Search failed: {response.status} - {error_text}") result = await response.json() logger.info( f"Search returned {result.get('total_servers', 0)} servers, " f"{result.get('total_tools', 0)} tools" ) # Log full response for debugging logger.info( f"Full search API response:\n{json.dumps(result, indent=2, default=str)}" ) return SearchResponse(**result) except aiohttp.ClientError as e: logger.error(f"Network error during search: {e}") raise Exception(f"Network error: {e}") async def get_server_info( self, server_path: str, ) -> dict[str, Any] | None: """ Get detailed information about a specific MCP server. Uses the /api/servers endpoint with query parameter to find the server. Args: server_path: Path of the server in the registry Returns: Server information dict or None if not found """ logger.info(f"Getting server info for: {server_path}") token = await self._get_token() # Normalize path - remove leading/trailing slashes clean_path = server_path.strip("/") # Use the servers list endpoint with query to find the specific server server_url = f"{self.registry_url}/api/servers" headers = { "Authorization": f"Bearer {token}", } params = { "query": clean_path, } async with aiohttp.ClientSession() as session: try: async with session.get( server_url, headers=headers, params=params, ) as response: if response.status != 200: error_text = await response.text() logger.error(f"Get servers failed: {response.status} - {error_text}") return None result = await response.json() # Find the matching server in the results servers = result if isinstance(result, list) else result.get("servers", []) for server in servers: srv_path = server.get("path", "").strip("/") if srv_path == clean_path: logger.info(f"Got server info for {server_path}") return server logger.warning(f"Server not found in results: {server_path}") return None except aiohttp.ClientError as e: logger.error(f"Network error getting server info: {e}") return None def _format_tool_result( tool: ToolSearchResult, ) -> dict[str, Any]: """ Format a tool search result for display to the agent. The search API returns inputSchema directly, so no additional server lookup is needed. Args: tool: Tool search result Returns: Formatted tool information dict """ result = { "tool_name": tool.tool_name, "server_path": tool.server_path, "server_name": tool.server_name, "description": tool.description or "No description available", "relevance_score": tool.relevance_score, "supported_transports": ["streamable_http"], } # Use inputSchema from search result if available if tool.inputSchema: result["tool_schema"] = tool.inputSchema return result def _format_server_result( server: ServerSearchResult, ) -> dict[str, Any]: """ Format a server search result for display to the agent. Args: server: Server search result Returns: Formatted server information dict """ matching_tools = [] for t in server.matching_tools: tool_info = { "tool_name": t.tool_name, "description": t.description, "relevance_score": t.relevance_score, } # Note: inputSchema is available in the tools[] array, not matching_tools matching_tools.append(tool_info) return { "server_path": server.path, "server_name": server.server_name, "description": server.description or "No description available", "tags": server.tags, "num_tools": server.num_tools, "is_enabled": server.is_enabled, "relevance_score": server.relevance_score, "matching_tools": matching_tools, } ================================================ FILE: agents/system_prompt.txt ================================================ You are a highly capable AI assistant designed to solve a wide range of problems for users. You have access to built-in tools and can discover additional specialized tools as needed. If there is a user question that requires understanding of the current time to answer it, for example it needs to determine a date range then remember that you know the current UTC datetime is {current_utc_time} and determine the date range based on that. MCP Registry URL: {mcp_registry_url} You have direct access to these built-in tools: - calculator: For performing mathematical calculations and arithmetic operations - search_registry_tools: For discovering MCP tools using semantic search on the registry - invoke_mcp_tool: For invoking tools on MCP servers (authentication handled automatically) Tool Discovery and Invocation Workflow: 1. Use search_registry_tools to find tools that match your needs: search_registry_tools("description of needed capability", max_results=10) 2. The search returns tools with these important fields: - tool_name: Name of the tool to invoke - server_path: Path to use as server_name in invoke_mcp_tool - description: What the tool does - tool_schema: Input parameters required - supported_transports: Transport protocol to use - auth_provider: Authentication provider if needed (IMPORTANT!) 3. Use invoke_mcp_tool with the discovered information: Example workflow: # Step 1: Search for tools search_registry_tools("get the current time") # Step 2: Invoke the discovered tool invoke_mcp_tool( mcp_registry_url="{mcp_registry_url}", server_name="/currenttime", tool_name="current_time_by_timezone", arguments={{"tz_name": "America/New_York"}}, supported_transports=["streamable_http"], auth_provider="bedrock-agentcore" ) For Atlassian services (Jira, Confluence): invoke_mcp_tool( mcp_registry_url="{mcp_registry_url}", server_name="/atlassian", tool_name="jira_get_issue", arguments={{"issue_key": "PROJ-123"}}, supported_transports=["streamable_http"], auth_provider="atlassian" ) 1. Understand the user's request completely 2. **First, check if you can handle the request with your existing available tools** 3. **If you need specialized capabilities, use search_registry_tools to discover MCP tools** 4. For calculations, use the calculator tool 5. For discovered tools, use invoke_mcp_tool to call them (authentication is handled automatically) 6. Execute the appropriate tools with proper arguments 7. Present results clearly to the user Always be transparent about what tools you're using. When using MCP tools, explain which tool you're calling. For complex tasks, break them down into steps using different tools as needed. **CRITICAL: When calling invoke_mcp_tool, always check the search_registry_tools results for:** - **server_path**: Use this as the server_name parameter - **auth_provider**: Include this parameter if present - essential for external services like Atlassian, AWS services, etc. - **tool_schema**: Review this to understand required arguments Prioritize security and privacy. Never use tools to access, generate, or share harmful, illegal, or unethical content. ================================================ FILE: api/.gitignore ================================================ # Temporary JSON files created during testing *.json # Token files .token ================================================ FILE: api/README.md ================================================ # MCP Gateway Registry Management API Command-line tools for managing users, groups, servers, and agents in the MCP Gateway Registry. ## API Specification **Live OpenAPI Specification** (Always Up-to-Date): Access the OpenAPI specification directly from your running registry instance: - **Localhost**: `http://localhost/openapi.json` - **Production**: `https://registry.us-east-1.example.com/openapi.json` (replace with your actual registry endpoint) The live OpenAPI spec is auto-generated and always reflects the current API implementation. **Reference OpenAPI Specification** (May Not Be Latest): A reference copy is available at [openapi.json](openapi.json) for offline reference. However, this may not reflect the latest changes. Always use the live endpoint from your running registry for the most current API specification. ## Quick Start ### Local Development Testing ```bash # 1. Start local services docker-compose up -d # 2. Generate credentials for localhost cd credentials-provider ./generate_creds.sh cd .. # 3. Run management commands uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json # Example: Create a user uv run python api/registry_management.py \ --token-file .oauth-tokens/ingress.json \ user-create-human \ --username johndoe \ --email john@example.com \ --first-name John \ --last-name Doe \ --groups mcp-registry-user \ --password MySecurePass123 ``` ### Production (AWS ECS Deployment) ```bash # 1. Get M2M token from Keycloak (requires AWS credentials) ./api/get-m2m-token.sh \ --aws-region us-east-1 \ --keycloak-url https://keycloak.us-east-1.example.com \ --output-file api/.token \ registry-admin-bot # 2. Run management commands against production uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://registry.us-east-1.example.com \ --aws-region us-east-1 \ --keycloak-url https://keycloak.us-east-1.example.com \ # Example: List all users in production uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://registry.us-east-1.example.com \ --aws-region us-east-1 \ --keycloak-url https://keycloak.us-east-1.example.com \ user-list ``` ## Token Generation ### For Localhost Use `credentials-provider/generate_creds.sh` which creates tokens for local Keycloak instance: **Using generate_creds.sh (all services):** ```bash cd credentials-provider && ./generate_creds.sh && cd .. ``` Token saved to: `.oauth-tokens/ingress.json` **Using generate-agent-token.sh (specific M2M bot):** ```bash # Generate token for default bot (mcp-gateway-m2m) ./keycloak/setup/generate-agent-token.sh # Generate token for custom M2M bot ./keycloak/setup/generate-agent-token.sh lob1-bot ``` Tokens saved to: `.oauth-tokens/{agent-name}.json` ### For Production (AWS) Use `api/get-m2m-token.sh` which retrieves tokens from AWS-deployed Keycloak: **Default admin bot:** ```bash ./api/get-m2m-token.sh \ --aws-region us-east-1 \ --keycloak-url https://keycloak.us-east-1.example.com \ --output-file api/.token \ registry-admin-bot ``` **Custom M2M bot account:** ```bash ./api/get-m2m-token.sh \ --aws-region us-east-1 \ --keycloak-url https://keycloak.us-east-1.example.com \ --output-file api/.token \ lob1-bot ``` Token saved to: `api/.token` **Notes:** - `get-m2m-token.sh` is for AWS deployments only and requires AWS credentials - It retrieves secrets from SSM Parameter Store - You can specify any M2M service account name as the last argument - The script automatically handles both `client-name` and `service-account-client-name` formats ## End-to-End Testing ### Test Localhost ```bash ./api/test-management-api-e2e.sh --token-file .oauth-tokens/ingress.json ``` ### Test Production ```bash ./api/test-management-api-e2e.sh \ --token-file api/.token \ --registry-url https://registry.us-east-1.example.com \ --aws-region us-east-1 \ --keycloak-url https://keycloak.us-east-1.example.com ``` ## Common Management Operations ### User Management ```bash # Create human user uv run python api/registry_management.py --token-file \ user-create-human \ --username alice \ --email alice@example.com \ --first-name Alice \ --last-name Smith \ --groups engineering \ --password SecurePass123 # Create M2M service account uv run python api/registry_management.py --token-file \ user-create-m2m \ --name service-bot \ --groups engineering \ --description "Automated service account" # List users uv run python api/registry_management.py --token-file user-list # Delete user uv run python api/registry_management.py --token-file \ user-delete --username alice --force ``` ### Group Management ```bash # Create group uv run python api/registry_management.py --token-file \ group-create \ --name engineering \ --description "Engineering team" # List groups uv run python api/registry_management.py --token-file group-list # Delete group uv run python api/registry_management.py --token-file \ group-delete --name engineering --force ``` ### Server Registration ```bash # Register server from JSON config uv run python api/registry_management.py --token-file \ register --config server-config.json # List servers uv run python api/registry_management.py --token-file list # Remove server uv run python api/registry_management.py --token-file \ remove --path /my-server --force ``` ### Agent Registration ```bash # Register agent from JSON config uv run python api/registry_management.py --token-file \ agent-register --config agent-config.json # List agents uv run python api/registry_management.py --token-file agent-list # Delete agent uv run python api/registry_management.py --token-file \ agent-delete --path /my-agent --force ``` ## Environment Summary | Environment | Token Script | Registry URL | Keycloak URL | |-------------|--------------|--------------|--------------| | **Localhost** | `credentials-provider/generate_creds.sh` or `keycloak/setup/generate-agent-token.sh` | `http://localhost` (default) | `http://localhost:8080` (default) | | **Production** | `api/get-m2m-token.sh --aws-region ... --keycloak-url ...` | `https://registry.us-east-1.example.com` | `https://keycloak.us-east-1.example.com` | ## Files - `registry_management.py` - Main CLI for user/group/server/agent management - `registry_client.py` - Python client library for Registry API - `get-m2m-token.sh` - Get M2M tokens from AWS Keycloak (production only) - `test-management-api-e2e.sh` - End-to-end test suite - `.gitignore` - Excludes token files and temporary JSON files ## Requirements - Python 3.14+ with `uv` package manager - For production: AWS credentials with access to SSM Parameter Store - For localhost: Running `docker-compose` stack with Keycloak ## Authentication All commands require a valid JWT token: - **Localhost**: Session-based tokens from `generate_creds.sh` - **Production**: M2M client credentials from `get-m2m-token.sh` Tokens are passed via `--token-file` parameter and must have appropriate scopes for the operations being performed. ================================================ FILE: api/USER-GROUP-MANAGEMENT.md ================================================ # User and Group Management Guide This guide provides the correct sequence of operations for managing users, groups, and scopes in the MCP Gateway Registry. ## Prerequisites Set up environment variables for easier command execution: ```bash export REGISTRY_URL="https://registry.us-east-1.aroraai.people.aws.dev" export AWS_REGION="us-east-1" export KEYCLOAK_URL="https://kc.us-east-1.aroraai.people.aws.dev" ``` ## Architecture Overview The system has two layers of configuration: 1. **Keycloak IAM Groups**: User membership and authentication (who belongs to which group) 2. **DocumentDB Scopes**: Authorization rules (what each group can access) Both must be configured for users to have proper access. ## Complete Workflow ### Step 1: Import Group Scope Configuration Import the group's authorization rules (scopes) into DocumentDB. This defines what servers/tools the group can access. ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ import-group \ --file cli/examples/currenttime-users.json ``` **What this does:** - Creates scope configuration in DocumentDB - If `"create_in_idp": true` is in the JSON, it also creates the IdP group (Keycloak/Entra) - Defines server access rules, UI permissions, and group mappings **Example JSON structure** (cli/examples/currenttime-users.json): ```json { "scope_name": "currenttime-users", "description": "Users with access to currenttime server", "server_access": [ { "server": "currenttime", "methods": ["initialize", "tools/list", "tools/call"], "tools": ["current_time_by_timezone"] } ], "group_mappings": ["currenttime-users"], "ui_permissions": { "list_service": ["currenttime"], "health_check_service": ["currenttime"] }, "create_in_idp": true } ``` ### Step 2: Create IAM Group (if not auto-created) If the group wasn't auto-created in Step 1 (no `"create_in_idp": true`), create it manually: ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ group-create \ --name currenttime-users \ --description "Users with access to currenttime server" ``` **Note:** If the group already exists, this will fail with "Group already exists" error. You can verify with: ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ group-list ``` ### Step 3: Create Human User Account Create a human user and assign them to the group: ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ user-create-human \ --username ctuser \ --email ctuser@example.com \ --first-name Current \ --last-name Time \ --password riv2025 \ --groups currenttime-users ``` **Important:** - Password is only set during creation - If user already exists, this fails with "User already exists" - Users can be assigned to multiple groups by comma-separating them: `--groups group1,group2` ### Step 4: Create M2M Service Account Create a machine-to-machine service account for programmatic access: ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ user-create-m2m \ --name currenttime-service-bot \ --groups currenttime-users \ --description "Service account for currenttime server automation" ``` **Important:** - Save the client_id and client_secret immediately - the secret is only shown once - Service accounts use OAuth2 client credentials flow ## Verification Commands ### List All Users ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ user-list ``` ### List All Groups ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ group-list ``` ### List Scope Groups (DocumentDB) ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ list-groups ``` ### Describe Specific Group ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ describe-group \ --group-name currenttime-users ``` ## Troubleshooting ### User Already Exists If you get "User already exists" error: 1. Check if user exists: ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ user-list | grep username ``` 2. Either use the existing user or delete and recreate: ```bash # Delete existing user uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ user-delete \ --username ctuser # Then recreate uv run python api/registry_management.py ... user-create-human ... ``` ### Group Already Exists If you get "Group already exists" error, the group is already configured. Verify with: ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ group-list | grep currenttime-users ``` ### Password Reset Currently, password reset must be done through Keycloak admin UI or by deleting and recreating the user with a new password. **Note:** For the existing user `ctuser`, if the password is unknown, you'll need to either: - Delete and recreate the user with a known password - Use Keycloak admin UI to reset the password - Contact an administrator ## Current Status for ctuser The user `ctuser` currently exists with: - **Username:** ctuser - **Email:** ctuser@example.com - **Name:** CT User - **Groups:** currenttime-users - **Status:** Enabled - **Password:** Unknown (was set during initial creation) If you need to use this account and don't know the password, delete and recreate it: ```bash # Delete existing user uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ user-delete \ --username ctuser # Recreate with known password uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ user-create-human \ --username ctuser \ --email ctuser@example.com \ --first-name Current \ --last-name Time \ --password riv2025 \ --groups currenttime-users ``` ## Quick Reference ### Create Everything from Scratch ```bash # 1. Import group scope configuration (creates IdP group if create_in_idp=true) uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ import-group --file cli/examples/currenttime-users.json # 2. Create human user (if group doesn't auto-create users) uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ user-create-human \ --username ctuser \ --email ctuser@example.com \ --first-name Current \ --last-name Time \ --password riv2025 \ --groups currenttime-users # 3. Create M2M service account uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ user-create-m2m \ --name currenttime-service-bot \ --groups currenttime-users \ --description "Service account for currenttime automation" ``` ## Federation Management For importing servers from Anthropic's registry: ```bash # Save federation configuration uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ federation-save \ --config cli/examples/federation-config-example.json # Sync federated servers uv run python api/registry_management.py \ --registry-url $REGISTRY_URL \ --aws-region $AWS_REGION \ --keycloak-url $KEYCLOAK_URL \ federation-sync ``` ================================================ FILE: api/get-m2m-token.sh ================================================ #!/bin/bash # Script to get M2M JWT token for a Keycloak client with smart caching # Usage: ./get-m2m-token.sh [OPTIONS] [client-name] # # Options: # --aws-region REGION AWS region (overrides AWS_REGION env var) # --keycloak-url URL Keycloak base URL (overrides KEYCLOAK_URL env var) # --output-file FILE Save token to file instead of printing to stdout # --help Show this help message # # Environment variables (used if command-line options not provided): # AWS_REGION - AWS region where Keycloak and SSM are deployed (e.g., us-east-1) # KEYCLOAK_URL - Keycloak base URL (e.g., https://kc.us-east-1.mycorp.click) # # This script implements smart token management: # 1. First checks SSM Parameter Store for cached token # 2. Validates token expiration (with 60 second buffer) # 3. Only fetches new token from Keycloak if needed # 4. Stores new tokens in SSM (but NOT in local files by default) # 5. Outputs the token to stdout (or saves to file if --output-file is specified) set -e # Get script directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" TERRAFORM_OUTPUTS="$SCRIPT_DIR/terraform-outputs.json" PARENT_DIR="$(dirname "$SCRIPT_DIR")" # Colors GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # Parse command-line arguments CLIENT_NAME="" CLI_AWS_REGION="" CLI_KEYCLOAK_URL="" OUTPUT_FILE="" while [[ $# -gt 0 ]]; do case $1 in --aws-region) CLI_AWS_REGION="$2" shift 2 ;; --keycloak-url) CLI_KEYCLOAK_URL="$2" shift 2 ;; --output-file) OUTPUT_FILE="$2" shift 2 ;; --help) echo "Usage: $0 [OPTIONS] [client-name]" echo "" echo "Options:" echo " --aws-region REGION AWS region (overrides AWS_REGION env var)" echo " --keycloak-url URL Keycloak base URL (overrides KEYCLOAK_URL env var)" echo " --output-file FILE Save token to file instead of printing to stdout" echo " --help Show this help message" echo "" echo "Environment variables:" echo " AWS_REGION - AWS region where Keycloak and SSM are deployed" echo " KEYCLOAK_URL - Keycloak base URL" exit 0 ;; -*) echo -e "${RED}Error: Unknown option: $1${NC}" >&2 exit 1 ;; *) CLIENT_NAME="$1" shift ;; esac done # Command-line args override environment variables AWS_REGION="${CLI_AWS_REGION:-$AWS_REGION}" KEYCLOAK_URL="${CLI_KEYCLOAK_URL:-$KEYCLOAK_URL}" # Configuration - require mandatory parameters if [ -z "$AWS_REGION" ]; then echo -e "${RED}Error: AWS_REGION is required${NC}" >&2 echo -e "${RED}Set via environment variable or --aws-region option:${NC}" >&2 echo -e "${RED} export AWS_REGION=us-east-1${NC}" >&2 echo -e "${RED} OR${NC}" >&2 echo -e "${RED} $0 --aws-region us-east-1 ${NC}" >&2 exit 1 fi if [ -z "$KEYCLOAK_URL" ]; then echo -e "${RED}Error: KEYCLOAK_URL is required${NC}" >&2 echo -e "${RED}Set via environment variable or --keycloak-url option:${NC}" >&2 echo -e "${RED} export KEYCLOAK_URL=https://kc.us-east-1.mycorp.click${NC}" >&2 echo -e "${RED} OR${NC}" >&2 echo -e "${RED} $0 --keycloak-url https://kc.us-east-1.mycorp.click ${NC}" >&2 exit 1 fi REALM="mcp-gateway" CLIENT_NAME="${CLIENT_NAME:-registry-admin-bot}" ORIGINAL_CLIENT_NAME="${CLIENT_NAME}" SSM_TOKEN_PARAM="/keycloak/clients/${CLIENT_NAME}/jwt_token" EXPIRATION_BUFFER=60 # Refresh token if expires within 60 seconds echo -e "${YELLOW}Getting JWT token for client: $CLIENT_NAME${NC}" >&2 echo -e "${YELLOW}Using AWS region: $AWS_REGION${NC}" >&2 echo -e "${YELLOW}Using Keycloak URL: $KEYCLOAK_URL${NC}" >&2 echo "" >&2 # Function to check if token is expired is_token_expired() { local expires_at=$1 local current_time=$(date +%s) local time_until_expiry=$((expires_at - current_time)) if [ $time_until_expiry -le $EXPIRATION_BUFFER ]; then return 0 # Token is expired or will expire soon else return 1 # Token is still valid fi } # Step 1: Try to get cached token from SSM Parameter Store (skip for local mode) if [ "$AWS_REGION" != "local" ]; then echo -e "${YELLOW}Step 1: Checking SSM Parameter Store for cached token...${NC}" >&2 # Get the SSM parameter value (which is a JSON string) # Try the original client name first SSM_PARAM_VALUE=$(aws ssm get-parameter \ --name "$SSM_TOKEN_PARAM" \ --with-decryption \ --region "$AWS_REGION" 2>/dev/null | jq -r '.Parameter.Value // empty' 2>/dev/null || echo "") else echo -e "${YELLOW}Step 1: Skipping SSM cache check (local mode)${NC}" >&2 SSM_PARAM_VALUE="" fi # If not found, try with service-account- prefix if [ -z "$SSM_PARAM_VALUE" ] || [ "$SSM_PARAM_VALUE" = "null" ]; then SSM_TOKEN_PARAM_ALT="/keycloak/clients/service-account-${ORIGINAL_CLIENT_NAME}/jwt_token" SSM_PARAM_VALUE=$(aws ssm get-parameter \ --name "$SSM_TOKEN_PARAM_ALT" \ --with-decryption \ --region "$AWS_REGION" 2>/dev/null | jq -r '.Parameter.Value // empty' 2>/dev/null || echo "") if [ -n "$SSM_PARAM_VALUE" ] && [ "$SSM_PARAM_VALUE" != "null" ]; then # Use the alternate parameter name for storing the token later SSM_TOKEN_PARAM="$SSM_TOKEN_PARAM_ALT" fi fi if [ -n "$SSM_PARAM_VALUE" ] && [ "$SSM_PARAM_VALUE" != "null" ]; then echo -e "${GREEN}Found cached token in SSM at $SSM_TOKEN_PARAM${NC}" >&2 # Parse the JSON value (Parameter.Value is itself a JSON string) CACHED_ACCESS_TOKEN=$(echo "$SSM_PARAM_VALUE" | jq -r '.access_token // empty' 2>/dev/null) CACHED_EXPIRES_AT=$(echo "$SSM_PARAM_VALUE" | jq -r '.expires_at // empty' 2>/dev/null) CACHED_EXPIRES_IN=$(echo "$SSM_PARAM_VALUE" | jq -r '.expires_in // 300' 2>/dev/null) if [ -n "$CACHED_ACCESS_TOKEN" ] && [ -n "$CACHED_EXPIRES_AT" ]; then # Check if token is still valid if ! is_token_expired "$CACHED_EXPIRES_AT"; then CURRENT_TIME=$(date +%s) TIME_UNTIL_EXPIRY=$((CACHED_EXPIRES_AT - CURRENT_TIME)) echo -e "${GREEN}Cached token is still valid (expires in ${TIME_UNTIL_EXPIRY} seconds)${NC}" >&2 echo -e "${GREEN}Using cached token from SSM${NC}" >&2 echo "" >&2 echo -e "${GREEN}Successfully retrieved cached token!${NC}" >&2 # Output token to file or stdout if [ -n "$OUTPUT_FILE" ]; then echo "$CACHED_ACCESS_TOKEN" > "$OUTPUT_FILE" echo " Token saved to: $OUTPUT_FILE" >&2 else echo "$CACHED_ACCESS_TOKEN" fi exit 0 else echo -e "${YELLOW}Cached token is expired or will expire soon${NC}" >&2 echo -e "${YELLOW}Will fetch new token from Keycloak...${NC}" >&2 fi else echo -e "${YELLOW}Invalid cached token format${NC}" >&2 echo -e "${YELLOW}Will fetch new token from Keycloak...${NC}" >&2 fi else echo -e "${YELLOW}No cached token found in SSM${NC}" >&2 echo -e "${YELLOW}Will fetch new token from Keycloak...${NC}" >&2 fi echo "" >&2 # Step 2: Get new token from Keycloak echo -e "${YELLOW}Step 2: Fetching new token from Keycloak...${NC}" >&2 echo "Keycloak URL: $KEYCLOAK_URL" >&2 # Get Keycloak admin password from environment variable first, then SSM if [ -z "$KEYCLOAK_ADMIN_PASSWORD" ]; then echo "Attempting to retrieve Keycloak admin password from SSM..." >&2 KEYCLOAK_ADMIN_PASSWORD=$(aws ssm get-parameter \ --name "/keycloak/admin_password" \ --with-decryption \ --region "$AWS_REGION" 2>/dev/null | jq -r '.Parameter.Value // empty' 2>/dev/null) fi if [ -z "$KEYCLOAK_ADMIN_PASSWORD" ] || [ "$KEYCLOAK_ADMIN_PASSWORD" = "null" ]; then echo -e "${RED}Error: Could not retrieve Keycloak admin password${NC}" >&2 echo -e "${RED}Set KEYCLOAK_ADMIN_PASSWORD environment variable or ensure SSM parameter exists${NC}" >&2 exit 1 fi # Get admin token echo "Getting admin token..." >&2 ADMIN_TOKEN=$(curl -s -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=admin" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli" 2>/dev/null | jq -r '.access_token // empty' 2>/dev/null) if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" = "null" ]; then echo -e "${RED}Error: Failed to get admin token${NC}" >&2 exit 1 fi echo -e "${GREEN}Admin token obtained${NC}" >&2 # Get client UUID # Try with the provided name first, then try with service-account- prefix echo "Looking up client UUID..." >&2 CLIENT_UUID=$(curl -s -H "Authorization: Bearer ${ADMIN_TOKEN}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=${CLIENT_NAME}" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) # If not found, try with service-account- prefix (Keycloak's naming convention for service accounts) if [ -z "$CLIENT_UUID" ]; then echo "Client '${CLIENT_NAME}' not found, trying 'service-account-${CLIENT_NAME}'..." >&2 CLIENT_NAME="service-account-${CLIENT_NAME}" # Update SSM parameter path to match the actual client name SSM_TOKEN_PARAM="/keycloak/clients/${CLIENT_NAME}/jwt_token" CLIENT_UUID=$(curl -s -H "Authorization: Bearer ${ADMIN_TOKEN}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=${CLIENT_NAME}" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) fi if [ -z "$CLIENT_UUID" ]; then echo -e "${RED}Error: Client '${CLIENT_NAME}' not found${NC}" >&2 exit 1 fi echo -e "${GREEN}Client UUID: ${CLIENT_UUID}${NC}" >&2 # Get client secret echo "Retrieving client secret..." >&2 CLIENT_SECRET=$(curl -s -H "Authorization: Bearer ${ADMIN_TOKEN}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${CLIENT_UUID}/client-secret" 2>/dev/null | \ jq -r '.value // empty' 2>/dev/null) if [ -z "$CLIENT_SECRET" ]; then echo -e "${RED}Error: Could not retrieve client secret${NC}" >&2 exit 1 fi echo -e "${GREEN}Client secret retrieved${NC}" >&2 # Get M2M token using client credentials echo "Requesting M2M access token..." >&2 TOKEN_RESPONSE=$(curl -s -X POST "${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "client_id=${CLIENT_NAME}" \ -d "client_secret=${CLIENT_SECRET}" \ -d "grant_type=client_credentials" 2>/dev/null) ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token // empty' 2>/dev/null) if [ -z "$ACCESS_TOKEN" ]; then echo -e "${RED}Error: Failed to get access token${NC}" >&2 ERROR_MSG=$(echo "$TOKEN_RESPONSE" | jq -r '.error_description // .error // "Unknown error"' 2>/dev/null) echo -e "${RED}Error details: $ERROR_MSG${NC}" >&2 exit 1 fi # Calculate expiration time EXPIRES_IN=$(echo "$TOKEN_RESPONSE" | jq -r '.expires_in // 300' 2>/dev/null) CURRENT_TIME=$(date +%s) EXPIRES_AT=$((CURRENT_TIME + EXPIRES_IN)) echo -e "${GREEN}Successfully obtained new access token!${NC}" >&2 echo "Expires in: ${EXPIRES_IN} seconds" >&2 # Step 3: Store token in SSM Parameter Store (skip for local mode) if [ "$AWS_REGION" != "local" ]; then echo "" >&2 echo -e "${YELLOW}Step 3: Storing token in SSM Parameter Store...${NC}" >&2 TOKEN_JSON=$(cat </dev/null 2>&1 if [ $? -eq 0 ]; then echo -e "${GREEN}Token stored in SSM: $SSM_TOKEN_PARAM${NC}" >&2 else echo -e "${YELLOW}Warning: Failed to store token in SSM (continuing anyway)${NC}" >&2 fi else echo "" >&2 echo -e "${YELLOW}Step 3: Skipping SSM token storage (local mode)${NC}" >&2 fi echo "" >&2 echo -e "${GREEN}=== Token Management Complete ===${NC}" >&2 echo "" >&2 echo "Token details:" >&2 echo " Client: $CLIENT_NAME" >&2 echo " Expires in: ${EXPIRES_IN} seconds" >&2 echo " Expires at: $(date -d @${EXPIRES_AT} 2>/dev/null || date -r ${EXPIRES_AT} 2>/dev/null || echo $EXPIRES_AT)" >&2 echo " SSM location: $SSM_TOKEN_PARAM" >&2 # Output the token to stdout or save to file if [ -n "$OUTPUT_FILE" ]; then echo "$ACCESS_TOKEN" > "$OUTPUT_FILE" echo " Token saved to: $OUTPUT_FILE" >&2 echo "" >&2 else echo "" >&2 # Output the token to stdout for consumption by other scripts echo "$ACCESS_TOKEN" fi # Also save to .token file in the script directory for local convenience if [ "$AWS_REGION" = "local" ]; then TOKEN_FILE="${SCRIPT_DIR}/.token" echo "$ACCESS_TOKEN" > "$TOKEN_FILE" echo " Token also saved to: $TOKEN_FILE" >&2 fi ================================================ FILE: api/populate-registry.sh ================================================ #!/bin/bash # Populate MCP Gateway Registry with example servers and agents # This script registers all example MCP servers, A2A agents, and configures federation set -e # Colors GREEN='\033[0;32m' BLUE='\033[0;34m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # Get script directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" REPO_ROOT="$(dirname "$SCRIPT_DIR")" # Function to show usage show_usage() { echo "Usage: $0 [OPTIONS]" echo "" echo "Options:" echo " --registry-url Registry URL (required if REGISTRY_URL env var not set)" echo " --keycloak-url Keycloak URL (required if KEYCLOAK_URL env var not set)" echo " --aws-region AWS region (default: us-east-1)" echo " --token-file Path to existing token file (optional - will generate if not provided)" echo " --help Show this help message" echo "" echo "Examples:" echo " # Using command-line arguments" echo " $0 \\" echo " --registry-url https://registry.us-east-1.example.com \\" echo " --keycloak-url https://kc.us-east-1.example.com \\" echo " --aws-region us-east-1" echo "" echo " # Using environment variables" echo " export REGISTRY_URL=https://registry.us-east-1.example.com" echo " export KEYCLOAK_URL=https://kc.us-east-1.example.com" echo " export AWS_REGION=us-east-1" echo " $0" echo "" echo " # Using existing token file" echo " $0 \\" echo " --registry-url https://registry.us-east-1.example.com \\" echo " --keycloak-url https://kc.us-east-1.example.com \\" echo " --token-file /path/to/token.json" echo "" } # Parse command-line arguments REGISTRY_URL_ARG="" KEYCLOAK_URL_ARG="" AWS_REGION_ARG="" TOKEN_FILE_ARG="" while [[ $# -gt 0 ]]; do case $1 in --registry-url) REGISTRY_URL_ARG="$2" shift 2 ;; --keycloak-url) KEYCLOAK_URL_ARG="$2" shift 2 ;; --aws-region) AWS_REGION_ARG="$2" shift 2 ;; --token-file) TOKEN_FILE_ARG="$2" shift 2 ;; --help) show_usage exit 0 ;; *) echo -e "${RED}Error: Unknown option: $1${NC}" echo "" show_usage exit 1 ;; esac done echo -e "${BLUE}========================================${NC}" echo -e "${BLUE}MCP Gateway Registry Population Script${NC}" echo -e "${BLUE}========================================${NC}" echo "" # Resolve configuration from arguments or environment variables REGISTRY_URL="${REGISTRY_URL_ARG:-${REGISTRY_URL:-}}" KEYCLOAK_URL="${KEYCLOAK_URL_ARG:-${KEYCLOAK_URL:-}}" AWS_REGION="${AWS_REGION_ARG:-${AWS_REGION:-us-east-1}}" TOKEN_FILE="${TOKEN_FILE_ARG:-${SCRIPT_DIR}/.token}" # Validate required parameters if [[ -z "$REGISTRY_URL" ]]; then echo -e "${RED}Error: REGISTRY_URL is required${NC}" echo "" show_usage exit 1 fi if [[ -z "$KEYCLOAK_URL" ]]; then echo -e "${RED}Error: KEYCLOAK_URL is required${NC}" echo "" show_usage exit 1 fi echo -e "${BLUE}Configuration:${NC}" echo " Registry URL: $REGISTRY_URL" echo " Keycloak URL: $KEYCLOAK_URL" echo " AWS Region: $AWS_REGION" echo " Token File: $TOKEN_FILE" echo "" # Get M2M token if not provided if [[ -n "$TOKEN_FILE_ARG" && -f "$TOKEN_FILE" ]]; then echo -e "${YELLOW}Step 1: Using provided token file...${NC}" echo -e "${GREEN}✓ Token file found: $TOKEN_FILE${NC}" else echo -e "${YELLOW}Step 1: Getting M2M authentication token...${NC}" "${SCRIPT_DIR}/get-m2m-token.sh" \ --aws-region "$AWS_REGION" \ --keycloak-url "$KEYCLOAK_URL" \ --output-file "$TOKEN_FILE" \ registry-admin-bot if [[ ! -f "$TOKEN_FILE" ]]; then echo -e "${RED}Error: Failed to get M2M token${NC}" exit 1 fi echo -e "${GREEN}✓ Token acquired${NC}" fi echo "" # MCP Server configs SERVERS=( "cli/examples/cloudflare-docs-server-config.json" "cli/examples/context7-server-config.json" "cli/examples/currenttime.json" "cli/examples/mcpgw.json" "cli/examples/realserverfaketools.json" ) # A2A Agent configs AGENTS=( "cli/examples/flight_booking_agent_card.json" "cli/examples/travel_assistant_agent_card.json" ) # Register servers echo -e "${YELLOW}Step 2: Registering MCP Servers...${NC}" SUCCESS_COUNT=0 FAIL_COUNT=0 for config in "${SERVERS[@]}"; do config_path="${REPO_ROOT}/${config}" if [[ ! -f "$config_path" ]]; then echo -e "${RED} ✗ Config not found: $config${NC}" ((FAIL_COUNT++)) continue fi echo -e "${BLUE} → Registering: $(basename $config)${NC}" set +e # Temporarily disable exit on error uv run python "${SCRIPT_DIR}/registry_management.py" \ --token-file "$TOKEN_FILE" \ --registry-url "$REGISTRY_URL" \ --aws-region "$AWS_REGION" \ --keycloak-url "$KEYCLOAK_URL" \ register --config "$config_path" --overwrite 2>&1 | grep -q "successfully\|created\|registered\|updated" if [ $? -eq 0 ]; then echo -e "${GREEN} ✓ Registered successfully${NC}" ((SUCCESS_COUNT++)) else echo -e "${YELLOW} ⚠ Failed${NC}" ((FAIL_COUNT++)) fi set -e # Re-enable exit on error done echo "" echo -e "${GREEN}Servers: $SUCCESS_COUNT registered, $FAIL_COUNT skipped/failed${NC}" echo "" # Register agents echo -e "${YELLOW}Step 3: Registering A2A Agents...${NC}" AGENT_SUCCESS=0 AGENT_FAIL=0 for config in "${AGENTS[@]}"; do config_path="${REPO_ROOT}/${config}" if [[ ! -f "$config_path" ]]; then echo -e "${RED} ✗ Config not found: $config${NC}" ((AGENT_FAIL++)) continue fi echo -e "${BLUE} → Registering: $(basename $config)${NC}" set +e # Temporarily disable exit on error uv run python "${SCRIPT_DIR}/registry_management.py" \ --token-file "$TOKEN_FILE" \ --registry-url "$REGISTRY_URL" \ --aws-region "$AWS_REGION" \ --keycloak-url "$KEYCLOAK_URL" \ agent-register --config "$config_path" 2>&1 | grep -q "successfully\|created\|registered\|updated" if [ $? -eq 0 ]; then echo -e "${GREEN} ✓ Registered successfully${NC}" ((AGENT_SUCCESS++)) else echo -e "${YELLOW} ⚠ Failed${NC}" ((AGENT_FAIL++)) fi set -e # Re-enable exit on error done echo "" echo -e "${GREEN}Agents: $AGENT_SUCCESS registered, $AGENT_FAIL skipped/failed${NC}" echo "" # Federation configuration FEDERATION_CONFIG="${REPO_ROOT}/cli/examples/federation-config-example.json" if [[ -f "$FEDERATION_CONFIG" ]]; then echo -e "${YELLOW}Step 4: Configuring Federation with Anthropic Registry...${NC}" echo -e "${BLUE} → Saving federation config...${NC}" if uv run python "${SCRIPT_DIR}/registry_management.py" \ --token-file "$TOKEN_FILE" \ --registry-url "$REGISTRY_URL" \ --aws-region "$AWS_REGION" \ --keycloak-url "$KEYCLOAK_URL" \ federation-save --config "$FEDERATION_CONFIG" ; then echo -e "${GREEN} ✓ Federation config saved${NC}" else echo -e "${RED} ✗ Failed to save federation config${NC}" fi echo -e "${BLUE} → Syncing Anthropic federated servers...${NC}" if uv run python "${SCRIPT_DIR}/registry_management.py" \ --token-file "$TOKEN_FILE" \ --registry-url "$REGISTRY_URL" \ --aws-region "$AWS_REGION" \ --keycloak-url "$KEYCLOAK_URL" \ federation-sync --source anthropic ; then echo -e "${GREEN} ✓ Federated servers imported${NC}" else echo -e "${RED} ✗ Failed to sync federated servers${NC}" fi else echo -e "${YELLOW}Step 4: Skipping federation (config not found)${NC}" fi echo "" echo -e "${GREEN}========================================${NC}" echo -e "${GREEN}Registry Population Complete!${NC}" echo -e "${GREEN}========================================${NC}" echo "" # Show summary commands echo -e "${BLUE}View registered items:${NC}" echo "" echo " # List all servers" echo " uv run python api/registry_management.py \\" echo " --token-file $TOKEN_FILE \\" echo " --registry-url $REGISTRY_URL \\" echo " list" echo "" echo " # List all agents" echo " uv run python api/registry_management.py \\" echo " --token-file $TOKEN_FILE \\" echo " --registry-url $REGISTRY_URL \\" echo " agent-list" echo "" echo -e "${BLUE}Access the Registry UI:${NC}" echo " $REGISTRY_URL" echo "" ================================================ FILE: api/registry_client.py ================================================ #!/usr/bin/env python3 """ MCP Gateway Registry Client - Standalone Pydantic-based client for the Registry API. This client provides a type-safe interface to the MCP Gateway Registry API endpoints documented in: - /home/ubuntu/repos/mcp-gateway-registry/docs/api-specs/server-management.yaml (Server Management) - /home/ubuntu/repos/mcp-gateway-registry/docs/api-specs/a2a-agent-management.yaml (Agent Management) Authentication is handled via JWT tokens retrieved from AWS SSM Parameter Store using the get-m2m-token.sh script. """ import json import logging from datetime import datetime from enum import Enum from typing import Any from urllib.parse import quote from uuid import UUID import requests from pydantic import BaseModel, ConfigDict, Field # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) class HealthStatus(str, Enum): """Health status enumeration for servers.""" HEALTHY = "healthy" UNHEALTHY = "unhealthy" UNKNOWN = "unknown" DISABLED = "disabled" class ServiceRegistration(BaseModel): """Service registration request model (UI-based registration).""" name: str = Field(..., description="Service name") description: str = Field(..., description="Service description") path: str = Field(..., description="Service path") proxy_pass_url: str = Field(..., description="Proxy pass URL") tags: str | None = Field(None, description="Comma-separated tags") num_tools: int | None = Field(None, description="Number of tools") license: str | None = Field(None, description="License type") class InternalServiceRegistration(BaseModel): """Internal service registration model (Admin/M2M registration).""" service_path: str = Field( ..., alias="path", description="Service path (e.g., /cloudflare-docs)" ) name: str | None = Field(None, description="Service name") description: str | None = Field(None, description="Service description") proxy_pass_url: str | None = Field(None, description="Proxy pass URL") version: str | None = Field(None, description="Server version (e.g., v1.0.0, v2.0.0)") status: str | None = Field(None, description="Version status (stable, beta, deprecated)") auth_provider: str | None = Field(None, description="Authentication provider") auth_scheme: str | None = Field( None, description="Authentication scheme (e.g., 'bearer', 'api_key', 'none')" ) supported_transports: list[str] | None = Field(None, description="Supported transports") headers: dict[str, str] | None = Field(None, description="Custom headers") tool_list_json: str | None = Field(None, description="Tool list as JSON string") tags: list[str] | None = Field(None, description="Categorization tags") overwrite: bool | None = Field(False, description="Overwrite if exists") mcp_endpoint: str | None = Field( None, description="Full URL for the MCP streamable-http endpoint (overrides proxy_pass_url + /mcp)", ) sse_endpoint: str | None = Field( None, description="Full URL for the SSE endpoint (overrides proxy_pass_url + /sse)" ) metadata: dict[str, Any] | None = Field( default_factory=dict, description="Additional custom metadata for organization, compliance, or integration purposes", ) provider_organization: str | None = Field(None, description="Provider organization name") provider_url: str | None = Field(None, description="Provider URL") source_created_at: str | None = Field( None, description="Original creation timestamp (ISO format)" ) source_updated_at: str | None = Field(None, description="Last update timestamp (ISO format)") external_tags: list[str] | None = Field(None, description="Tags from external/source system") auth_credential: str | None = Field( None, description="Plaintext auth credential (Bearer token or API key). Encrypted before storage.", ) model_config = ConfigDict(populate_by_name=True) class Server(BaseModel): """Server information model.""" path: str = Field(..., description="Service path") display_name: str = Field(..., description="Service display name") description: str = Field(..., description="Service description") is_enabled: bool = Field(..., description="Whether service is enabled") health_status: HealthStatus = Field(..., description="Health status") status: str = Field( default="active", description="Lifecycle status (active, deprecated, draft, beta)", ) class ServerDetail(BaseModel): """Detailed server information model.""" path: str = Field(..., description="Service path") name: str = Field(..., description="Service name") description: str = Field(..., description="Service description") url: str = Field(..., description="Service URL") is_enabled: bool = Field(..., description="Whether service is enabled") num_tools: int = Field(..., description="Number of tools") health_status: str = Field(..., description="Health status") last_health_check: datetime | None = Field(None, description="Last health check timestamp") status: str = Field( default="active", description="Server status (active, deprecated, draft, beta)" ) provider: dict[str, str] | None = Field( None, description="Provider information (organization, url)" ) source_created_at: str | None = Field(None, description="Creation timestamp in source system") source_updated_at: str | None = Field( None, description="Last update timestamp in source system" ) external_tags: list[str] = Field(default_factory=list, description="Tags from external source") class ServerDetailResponse(BaseModel): """Response model for single server retrieval via GET /api/servers/{path}.""" server_name: str = Field(default="", description="Server display name") description: str = Field(default="", description="Server description") path: str = Field(..., description="Server path (e.g., /my-server)") proxy_pass_url: str | None = Field(None, description="Backend URL") tags: list[str] = Field(default_factory=list, description="Server tags") num_tools: int = Field(default=0, description="Number of tools") tool_list: list[dict[str, Any]] = Field(default_factory=list, description="Tool definitions") is_enabled: bool = Field(default=False, description="Whether server is enabled") health_status: str | None = Field(None, description="Health status") transport: str | None = Field(None, description="Transport type") version: str | None = Field(None, description="Server version") versions: list[dict[str, Any]] | None = Field(None, description="Version list") license: str = Field(default="N/A", description="License") registered_by: str | None = Field(None, description="Who registered") model_config = ConfigDict(extra="allow") class ServerListResponse(BaseModel): """Server list response model.""" servers: list[Server] = Field(..., description="List of servers") total_count: int = Field(..., description="Total count of matching servers (all pages)") limit: int = Field(..., description="Page size applied") offset: int = Field(..., description="Offset applied") has_next: bool = Field(..., description="Whether more pages exist") class ServiceResponse(BaseModel): """Service operation response model.""" path: str = Field(..., description="Service path") name: str = Field(..., description="Service name") message: str = Field(..., description="Response message") class ToggleResponse(BaseModel): """Toggle service response model.""" path: str = Field(..., description="Service path") is_enabled: bool = Field(..., description="Current enabled status") message: str = Field(..., description="Response message") class ErrorResponse(BaseModel): """Error response model.""" detail: str = Field(..., description="Error detail message") error_code: str | None = Field(None, description="Error code") request_id: str | None = Field(None, description="Request ID") class SecurityScanResult(BaseModel): """Security scan result model.""" analysis_results: dict[str, Any] = Field(..., description="Analysis results by analyzer") tool_results: list[dict[str, Any]] = Field(..., description="Detailed tool scan results") class RescanResponse(BaseModel): """Server rescan response model.""" server_url: str = Field(..., description="Server URL that was scanned") server_path: str = Field(..., description="Server path") scan_timestamp: str = Field(..., description="Scan timestamp") is_safe: bool = Field(..., description="Whether server is safe") critical_issues: int = Field(..., description="Number of critical issues") high_severity: int = Field(..., description="Number of high severity issues") medium_severity: int = Field(..., description="Number of medium severity issues") low_severity: int = Field(..., description="Number of low severity issues") analyzers_used: list[str] = Field(..., description="Analyzers used in scan") scan_failed: bool = Field(..., description="Whether scan failed") error_message: str | None = Field(None, description="Error message if scan failed") raw_output: dict[str, Any] | None = Field(None, description="Raw scan output") class AgentSecurityScanResponse(BaseModel): """Agent security scan results response model.""" analysis_results: dict[str, Any] = Field( default_factory=dict, description="Analysis results by analyzer" ) scan_results: dict[str, Any] = Field( default_factory=dict, description="Scan results and metadata" ) class AgentRescanResponse(BaseModel): """Agent rescan response model.""" agent_path: str = Field(..., description="Agent path") agent_url: str = Field(..., description="Agent URL that was scanned") scan_timestamp: str = Field(..., description="Scan timestamp") is_safe: bool = Field(..., description="Whether agent is safe") critical_issues: int = Field(..., description="Number of critical issues") high_severity: int = Field(..., description="Number of high severity issues") medium_severity: int = Field(..., description="Number of medium severity issues") low_severity: int = Field(..., description="Number of low severity issues") analyzers_used: list[str] = Field(..., description="Analyzers used in scan") scan_failed: bool = Field(..., description="Whether scan failed") error_message: str | None = Field(None, description="Error message if scan failed") output_file: str | None = Field(None, description="Path to scan output file") class SkillSecurityScanResponse(BaseModel): """Skill security scan results response model.""" skill_path: str = Field(..., description="Skill path") skill_md_url: str | None = Field(None, description="Skill SKILL.md URL") scan_timestamp: str = Field(..., description="Scan timestamp") is_safe: bool = Field(..., description="Whether skill is safe") critical_issues: int = Field(default=0, description="Number of critical issues") high_severity: int = Field(default=0, description="Number of high severity issues") medium_severity: int = Field(default=0, description="Number of medium severity issues") low_severity: int = Field(default=0, description="Number of low severity issues") analyzers_used: list[str] = Field(default_factory=list, description="Analyzers used in scan") raw_output: dict[str, Any] = Field(default_factory=dict, description="Raw scanner output") scan_failed: bool = Field(default=False, description="Whether scan failed") error_message: str | None = Field(None, description="Error message if scan failed") class SkillRescanResponse(BaseModel): """Skill rescan response model.""" skill_path: str = Field(..., description="Skill path") skill_md_url: str | None = Field(None, description="Skill SKILL.md URL") scan_timestamp: str = Field(..., description="Scan timestamp") is_safe: bool = Field(..., description="Whether skill is safe") critical_issues: int = Field(default=0, description="Number of critical issues") high_severity: int = Field(default=0, description="Number of high severity issues") medium_severity: int = Field(default=0, description="Number of medium severity issues") low_severity: int = Field(default=0, description="Number of low severity issues") analyzers_used: list[str] = Field(default_factory=list, description="Analyzers used in scan") raw_output: dict[str, Any] = Field(default_factory=dict, description="Raw scanner output") scan_failed: bool = Field(default=False, description="Whether scan failed") error_message: str | None = Field(None, description="Error message if scan failed") class GroupListResponse(BaseModel): """Group list response model.""" groups: list[dict[str, Any]] = Field(..., description="List of groups") total: int = Field(..., description="Total number of groups") # Agent Management Models class AgentProvider(str, Enum): """Agent provider enumeration.""" ANTHROPIC = "anthropic" CUSTOM = "custom" OTHER = "other" class AgentVisibility(str, Enum): """Agent visibility enumeration.""" PUBLIC = "public" PRIVATE = "private" GROUP_RESTRICTED = "group-restricted" class Provider(BaseModel): """ A2A Agent Provider information. Represents the service provider of an agent with organization name and website URL. Per A2A specification, if provider is present, both organization and url are required. """ organization: str = Field(..., description="Provider organization name") url: str = Field(..., description="Provider website or documentation URL") class SecuritySchemeType(str, Enum): """Security scheme type enumeration (A2A spec values).""" API_KEY = "apiKey" HTTP = "http" OAUTH2 = "oauth2" OPENID_CONNECT = "openIdConnect" class SecurityScheme(BaseModel): """ Security scheme model. Note: Uses snake_case internally but serializes to camelCase for A2A compliance. """ type: SecuritySchemeType = Field(..., description="Security scheme type") scheme: str | None = Field( None, description="HTTP auth scheme: basic, bearer, digest", ) in_: str | None = Field( None, alias="in", description="API key location: header, query, cookie", ) name: str | None = Field( None, description="Name of header/query/cookie for API key", ) bearer_format: str | None = Field( None, alias="bearerFormat", description="Bearer token format hint (e.g., JWT)", ) flows: dict[str, Any] | None = Field( None, description="OAuth2 flows configuration", ) openid_connect_url: str | None = Field( None, alias="openIdConnectUrl", description="OpenID Connect discovery URL", ) description: str | None = Field(None, description="Security scheme description") class Config: populate_by_name = True # Allow both snake_case and camelCase on input class Skill(BaseModel): """ Agent skill definition per A2A protocol specification. Note: Uses snake_case internally but serializes to camelCase for A2A compliance. """ id: str = Field(..., description="Unique skill identifier") name: str = Field(..., description="Human-readable skill name") description: str = Field(..., description="Detailed skill description") tags: list[str] = Field(default_factory=list, description="Skill categorization tags") examples: list[str] | None = Field(None, description="Usage scenarios and examples") input_modes: list[str] | None = Field( None, alias="inputModes", description="Skill-specific input MIME types" ) output_modes: list[str] | None = Field( None, alias="outputModes", description="Skill-specific output MIME types" ) security: list[dict[str, list[str]]] | None = Field( None, description="Skill-level security requirements" ) class Config: populate_by_name = True # Allow both snake_case and camelCase on input class AgentRegistration(BaseModel): """ Agent registration request model matching server AgentCard schema. This model represents a complete agent card following the A2A protocol specification (v0.3.0), with extensions for MCP Gateway Registry integration. Note: Uses snake_case internally but serializes to camelCase for A2A compliance. """ # Required A2A fields protocol_version: str = Field( "1.0", alias="protocolVersion", description="A2A protocol version (e.g., '1.0')" ) name: str = Field(..., description="Agent name") description: str = Field(..., description="Agent description") url: str = Field(..., description="Agent endpoint URL (HTTP or HTTPS)") version: str = Field(..., description="Agent version") capabilities: dict[str, Any] = Field( default_factory=dict, description="Feature declarations (e.g., {'streaming': true})" ) default_input_modes: list[str] = Field( default_factory=lambda: ["text/plain"], alias="defaultInputModes", description="Supported input MIME types", ) default_output_modes: list[str] = Field( default_factory=lambda: ["text/plain"], alias="defaultOutputModes", description="Supported output MIME types", ) skills: list[Skill] = Field(default_factory=list, description="Agent capabilities (skills)") # Optional A2A fields preferred_transport: str | None = Field( "JSONRPC", alias="preferredTransport", description="Preferred transport protocol: JSONRPC, GRPC, HTTP+JSON", ) provider: Provider | None = Field(None, description="Agent provider information per A2A spec") icon_url: str | None = Field(None, alias="iconUrl", description="Agent icon URL") documentation_url: str | None = Field( None, alias="documentationUrl", description="Documentation URL" ) security_schemes: dict[str, SecurityScheme | dict[str, Any]] = Field( default_factory=dict, alias="securitySchemes", description="Supported authentication methods", ) security: list[dict[str, list[str]]] | None = Field( None, description="Security requirements array" ) supports_authenticated_extended_card: bool | None = Field( None, alias="supportsAuthenticatedExtendedCard", description="Supports extended card with auth", ) metadata: dict[str, Any] = Field(default_factory=dict, description="Additional metadata") # MCP Gateway Registry extensions (optional - not part of A2A spec) path: str | None = Field( None, description="Registry path (e.g., /agents/my-agent). Optional - auto-generated if not provided.", ) tags: list[str] = Field(default_factory=list, description="Categorization tags") is_enabled: bool = Field( False, alias="isEnabled", description="Whether agent is enabled in registry" ) num_stars: int = Field(0, ge=0, alias="numStars", description="Community rating") license: str = Field("N/A", description="License information") registered_at: datetime | None = Field( None, alias="registeredAt", description="Registration timestamp" ) updated_at: datetime | None = Field( None, alias="updatedAt", description="Last update timestamp" ) registered_by: str | None = Field( None, alias="registeredBy", description="Username who registered agent" ) visibility: str = Field("public", description="public, private, or group-restricted") allowed_groups: list[str] = Field( default_factory=list, alias="allowedGroups", description="Groups with access" ) signature: str | None = Field(None, description="JWS signature for card integrity") trust_level: str = Field( "unverified", alias="trustLevel", description="unverified, community, verified, trusted" ) supported_protocol: str | None = Field( None, alias="supportedProtocol", description="Agent protocol: a2a or other" ) class Config: populate_by_name = True # Allow both snake_case and camelCase on input class AgentCard(BaseModel): """Agent card model (summary view).""" name: str = Field(..., description="Agent name") path: str = Field(..., description="Agent path") url: str = Field(..., description="Agent URL") num_skills: int = Field(..., description="Number of skills") registered_at: datetime | None = Field(None, description="Registration timestamp") is_enabled: bool = Field(..., description="Whether agent is enabled") status: str = Field( default="active", description="Agent status (active, deprecated, draft, beta)" ) source_created_at: str | None = Field( None, alias="sourceCreatedAt", description="Creation timestamp in source system" ) source_updated_at: str | None = Field( None, alias="sourceUpdatedAt", description="Last update timestamp in source system" ) external_tags: list[str] = Field( default_factory=list, alias="externalTags", description="Tags from external source" ) supported_protocol: str | None = Field( None, alias="supportedProtocol", description="Agent protocol: 'a2a' or 'other'" ) class Config: populate_by_name = True # Allow both snake_case and camelCase on input class AgentRegistrationResponse(BaseModel): """Agent registration response model.""" message: str = Field(..., description="Response message") agent: AgentCard = Field(..., description="Registered agent card") class SkillDetail(BaseModel): """ Detailed skill model - same as Skill. Note: Uses snake_case internally but serializes to camelCase for A2A compliance. """ id: str = Field(..., description="Unique skill identifier") name: str = Field(..., description="Human-readable skill name") description: str = Field(..., description="Detailed skill description") tags: list[str] = Field(default_factory=list, description="Skill categorization tags") examples: list[str] | None = Field(None, description="Usage scenarios and examples") input_modes: list[str] | None = Field( None, alias="inputModes", description="Skill-specific input MIME types" ) output_modes: list[str] | None = Field( None, alias="outputModes", description="Skill-specific output MIME types" ) security: list[dict[str, list[str]]] | None = Field( None, description="Skill-level security requirements" ) class Config: populate_by_name = True # Allow both snake_case and camelCase on input class AgentDetail(BaseModel): """ Detailed agent model matching server AgentCard schema. This model represents a complete agent card following the A2A protocol specification (v0.3.0), with extensions for MCP Gateway Registry integration. Note: Uses snake_case internally but serializes to camelCase for A2A compliance. """ # Required A2A fields protocol_version: str = Field(..., alias="protocolVersion", description="A2A protocol version") name: str = Field(..., description="Agent name") description: str = Field(..., description="Agent description") url: str = Field(..., description="Agent endpoint URL") version: str = Field(..., description="Agent version") capabilities: dict[str, Any] = Field( default_factory=dict, description="Feature declarations (e.g., {'streaming': true})" ) default_input_modes: list[str] = Field( default_factory=lambda: ["text/plain"], alias="defaultInputModes", description="Supported input MIME types", ) default_output_modes: list[str] = Field( default_factory=lambda: ["text/plain"], alias="defaultOutputModes", description="Supported output MIME types", ) skills: list[SkillDetail] = Field( default_factory=list, description="Agent capabilities (skills)" ) # Optional A2A fields preferred_transport: str | None = Field( "JSONRPC", alias="preferredTransport", description="Preferred transport protocol: JSONRPC, GRPC, HTTP+JSON", ) provider: Provider | None = Field(None, description="Agent provider information per A2A spec") icon_url: str | None = Field(None, alias="iconUrl", description="Agent icon URL") documentation_url: str | None = Field( None, alias="documentationUrl", description="Documentation URL" ) security_schemes: dict[str, SecurityScheme | dict[str, Any]] = Field( default_factory=dict, alias="securitySchemes", description="Supported authentication methods", ) security: list[dict[str, list[str]]] | None = Field( None, description="Security requirements array" ) supports_authenticated_extended_card: bool | None = Field( None, alias="supportsAuthenticatedExtendedCard", description="Supports extended card with auth", ) metadata: dict[str, Any] = Field(default_factory=dict, description="Additional metadata") # MCP Gateway Registry extensions (optional - not part of A2A spec) path: str | None = Field(None, description="Registry path") tags: list[str] = Field(default_factory=list, description="Categorization tags") is_enabled: bool = Field(False, alias="isEnabled", description="Whether agent is enabled") num_stars: int = Field(0, ge=0, alias="numStars", description="Community rating") license: str = Field("N/A", description="License information") registered_at: datetime | None = Field( None, alias="registeredAt", description="Registration timestamp" ) updated_at: datetime | None = Field( None, alias="updatedAt", description="Last update timestamp" ) registered_by: str | None = Field( None, alias="registeredBy", description="Username who registered agent" ) visibility: str = Field("public", description="Visibility level") allowed_groups: list[str] = Field( default_factory=list, alias="allowedGroups", description="Groups with access" ) trust_level: str = Field("community", alias="trustLevel", description="Trust level") ans_metadata: dict[str, Any] | None = Field( default=None, alias="ansMetadata", description="ANS (Agent Name Service) verification metadata", ) signature: str | None = Field(None, description="JWS signature for card integrity") status: str = Field( default="active", description="Agent status (active, deprecated, draft, beta)" ) source_created_at: str | None = Field( None, alias="sourceCreatedAt", description="Creation timestamp in source system" ) source_updated_at: str | None = Field( None, alias="sourceUpdatedAt", description="Last update timestamp in source system" ) external_tags: list[str] = Field( default_factory=list, alias="externalTags", description="Tags from external source" ) supported_protocol: str | None = Field( None, alias="supportedProtocol", description="Agent protocol: 'a2a' or 'other'" ) class Config: populate_by_name = True # Allow both snake_case and camelCase on input class AgentListItem(BaseModel): """ Agent list item model (AgentInfo from server). Note: Uses snake_case internally but serializes to camelCase for A2A compliance. """ name: str = Field(..., description="Agent name") description: str = Field(default="", description="Agent description") path: str = Field(..., description="Agent path") url: str = Field(..., description="Agent URL") tags: list[str] = Field(default_factory=list, description="Categorization tags") skills: list[str] = Field(default_factory=list, description="Skill names") num_skills: int = Field(default=0, alias="numSkills", description="Number of skills") num_stars: float = Field( default=0.0, alias="numStars", description="Average community rating (0.0-5.0)" ) is_enabled: bool = Field( default=False, alias="isEnabled", description="Whether agent is enabled" ) provider: str | None = Field(None, description="Agent provider") streaming: bool = Field(default=False, description="Supports streaming") trust_level: str = Field(default="unverified", alias="trustLevel", description="Trust level") ans_metadata: dict[str, Any] | None = Field( default=None, alias="ansMetadata", description="ANS (Agent Name Service) verification metadata", ) sync_metadata: dict[str, Any] | None = Field( default=None, alias="syncMetadata", description="Federation sync metadata for items from peer registries", ) status: str = Field( default="active", description="Lifecycle status (active, deprecated, draft, beta)", ) class Config: populate_by_name = True # Allow both snake_case and camelCase on input class AgentListResponse(BaseModel): """Agent list response model.""" agents: list[AgentListItem] = Field(..., description="List of agents") total_count: int = Field(..., description="Total count of matching agents (all pages)") limit: int = Field(..., description="Page size applied") offset: int = Field(..., description="Offset applied") has_next: bool = Field(..., description="Whether more pages exist") class AgentToggleResponse(BaseModel): """Agent toggle response model.""" path: str = Field(..., description="Agent path") is_enabled: bool = Field(..., description="Current enabled status") message: str = Field(..., description="Response message") class SkillDiscoveryRequest(BaseModel): """Skill-based discovery request model.""" skills: list[str] = Field(..., description="List of required skills") tags: list[str] | None = Field(None, description="Optional tag filters") class DiscoveredAgent(BaseModel): """Discovered agent model (skill-based).""" path: str = Field(..., description="Agent path") name: str = Field(..., description="Agent name") relevance_score: float = Field(..., description="Matching score (0.0 to 1.0)") matching_skills: list[str] = Field(..., description="Matching skills") class AgentDiscoveryResponse(BaseModel): """Agent discovery response model (skill-based).""" agents: list[DiscoveredAgent] = Field(..., description="Discovered agents") class SemanticDiscoveredAgent(BaseModel): """Semantically discovered agent model with full AgentCard fields.""" # Core identification path: str = Field(..., description="Agent path") name: str = Field(..., description="Agent name") description: str = Field(..., description="Agent description") url: str = Field(..., description="Agent endpoint URL") # Semantic search relevance relevance_score: float = Field(..., description="Semantic similarity score") # Agent metadata tags: list[str] = Field(default_factory=list, description="Agent tags") skills: list[dict[str, Any]] = Field(default_factory=list, description="Agent skills") provider: dict[str, str] | None = Field(None, description="Provider information") capabilities: dict[str, Any] = Field(default_factory=dict, description="Agent capabilities") trust_level: str = Field("unverified", description="Trust level") trust_verified: str | None = Field(None, description="ANS trust verification status") ans_metadata: dict[str, Any] | None = Field(None, description="ANS verification metadata") num_stars: float = Field(0.0, description="Average rating") version: str | None = Field(None, description="Agent version") # Security and authentication security_schemes: dict[str, Any] = Field(default_factory=dict, description="Security schemes") # Timestamps created_at: str | None = Field(None, description="Creation timestamp") updated_at: str | None = Field(None, description="Last update timestamp") class Config: extra = "allow" # Allow additional fields from API class AgentSemanticDiscoveryResponse(BaseModel): """Agent semantic discovery response model.""" agents: list[SemanticDiscoveredAgent] = Field(..., description="Semantically discovered agents") class MatchingToolResult(BaseModel): """Tool matching result with optional schema for display.""" tool_name: str = Field(..., description="Tool name") description: str | None = Field(None, description="Tool description") relevance_score: float = Field(0.0, ge=0.0, le=1.0, description="Relevance score") match_context: str | None = Field(None, description="Why this tool matched") inputSchema: dict[str, Any] | None = Field( None, description="JSON Schema for tool input parameters" ) class SyncMetadata(BaseModel): """Metadata for items synced from peer registries.""" is_federated: bool = Field(False, description="Whether this is from a federated registry") source_peer_id: str | None = Field(None, description="Source peer registry ID") synced_at: str | None = Field(None, description="When item was synced") original_path: str | None = Field(None, description="Original path on source registry") is_orphaned: bool = Field(False, description="Whether item is orphaned") orphaned_at: str | None = Field(None, description="When item became orphaned") is_read_only: bool = Field(True, description="Whether item is read-only") class SemanticDiscoveredServer(BaseModel): """Semantically discovered server model.""" path: str = Field(..., description="Server path") server_name: str = Field(..., description="Server name") relevance_score: float = Field(..., description="Semantic similarity score") description: str | None = Field(None, description="Server description") tags: list[str] = Field(default_factory=list, description="Server tags") num_tools: int = Field(0, description="Number of tools") is_enabled: bool = Field(False, description="Whether server is enabled") match_context: str | None = Field(None, description="Why this matched") matching_tools: list[MatchingToolResult] = Field( default_factory=list, description="Matching tools" ) sync_metadata: SyncMetadata | None = Field( None, description="Sync metadata for federated items" ) # Endpoint URL for agent connectivity (computed based on deployment mode) endpoint_url: str | None = Field( None, description="URL for agents to connect to this MCP server" ) # Raw endpoint fields (for advanced use cases) proxy_pass_url: str | None = Field( None, description="Base URL for the MCP server backend (internal)" ) mcp_endpoint: str | None = Field(None, description="Explicit streamable-http endpoint URL") sse_endpoint: str | None = Field(None, description="Explicit SSE endpoint URL") supported_transports: list[str] = Field( default_factory=list, description="Supported transport types" ) class ToolSearchResult(BaseModel): """Tool search result model.""" server_path: str = Field(..., description="Parent server path") server_name: str = Field(..., description="Parent server name") tool_name: str = Field(..., description="Tool name") description: str | None = Field(None, description="Tool description") inputSchema: dict[str, Any] | None = Field(None, description="JSON Schema for tool input") relevance_score: float = Field(..., ge=0.0, le=1.0, description="Relevance score") match_context: str | None = Field(None, description="Why this tool matched") # Endpoint URL for the parent MCP server endpoint_url: str | None = Field( None, description="URL for agents to connect to the parent MCP server" ) class AgentSearchResult(BaseModel): """Agent search result with minimal top-level fields. Only search-specific fields are at the top level. All agent details (name, description, url, skills, etc.) are in the agent_card. """ path: str = Field(..., description="Agent path for identification") relevance_score: float = Field(..., ge=0.0, le=1.0, description="Relevance score") match_context: str | None = Field(None, description="Why this agent matched") agent_card: dict[str, Any] = Field(..., description="Full agent card with all details") class SkillSearchResult(BaseModel): """Skill search result model.""" path: str = Field(..., description="Skill path") skill_name: str = Field(..., description="Skill name") description: str | None = Field(None, description="Skill description") tags: list[str] = Field(default_factory=list, description="Skill tags") skill_md_url: str | None = Field(None, description="Skill markdown URL") skill_md_raw_url: str | None = Field(None, description="Skill markdown raw URL") version: str | None = Field(None, description="Skill version") author: str | None = Field(None, description="Skill author") visibility: str | None = Field(None, description="Visibility setting") owner: str | None = Field(None, description="Skill owner") is_enabled: bool = Field(False, description="Whether skill is enabled") health_status: str = Field("unknown", description="Health status") last_checked_time: str | None = Field(None, description="Last health check time") relevance_score: float = Field(..., ge=0.0, le=1.0, description="Relevance score") match_context: str | None = Field(None, description="Why this skill matched") class VirtualServerSearchResult(BaseModel): """Virtual server search result model.""" path: str = Field(..., description="Virtual server path") server_name: str = Field(..., description="Virtual server name") description: str | None = Field(None, description="Virtual server description") tags: list[str] = Field(default_factory=list, description="Virtual server tags") num_tools: int = Field(0, description="Number of tools") backend_count: int = Field(0, description="Number of backend servers") backend_paths: list[str] = Field(default_factory=list, description="Backend server paths") is_enabled: bool = Field(False, description="Whether virtual server is enabled") relevance_score: float = Field(..., ge=0.0, le=1.0, description="Relevance score") match_context: str | None = Field(None, description="Why this matched") matching_tools: list[MatchingToolResult] = Field( default_factory=list, description="Matching tools" ) # Endpoint URL for agent connectivity endpoint_url: str | None = Field( None, description="URL for agents to connect to this virtual MCP server" ) class ToolMapping(BaseModel): """Tool mapping for virtual MCP servers.""" tool_name: str = Field(..., description="Original tool name on backend server") alias: str | None = Field(None, description="Renamed tool name in virtual server") backend_server_path: str = Field(..., description="Backend server path (e.g., /github)") backend_version: str | None = Field(None, description="Pin to specific backend version") description_override: str | None = Field(None, description="Override tool description") class ToolScopeOverride(BaseModel): """Per-tool scope override for access control.""" tool_alias: str = Field(..., description="Tool alias or name") required_scopes: list[str] = Field( default_factory=list, description="Required scopes for this tool" ) class VirtualServerCreateRequest(BaseModel): """Request to create a virtual MCP server.""" path: str = Field(..., description="Virtual server path (e.g., /virtual/dev-tools)") server_name: str = Field(..., description="Display name for the virtual server") description: str | None = Field(None, description="Virtual server description") tool_mappings: list[ToolMapping] = Field( ..., min_length=1, description="Tool mappings (at least one)" ) required_scopes: list[str] = Field( default_factory=list, description="Server-level required scopes" ) tool_scope_overrides: list[ToolScopeOverride] = Field( default_factory=list, description="Per-tool scope overrides" ) tags: list[str] = Field(default_factory=list, description="Tags for categorization") supported_transports: list[str] = Field( default_factory=lambda: ["streamable-http"], description="Supported transports" ) is_enabled: bool = Field(True, description="Whether to enable on creation") class VirtualServerConfig(BaseModel): """Full virtual MCP server configuration.""" path: str = Field(..., description="Virtual server path") server_name: str = Field(..., description="Display name") description: str | None = Field(None, description="Description") tool_mappings: list[ToolMapping] = Field(default_factory=list, description="Tool mappings") required_scopes: list[str] = Field(default_factory=list, description="Server-level scopes") tool_scope_overrides: list[ToolScopeOverride] = Field( default_factory=list, description="Per-tool scope overrides" ) tags: list[str] = Field(default_factory=list, description="Tags") supported_transports: list[str] = Field( default_factory=list, description="Supported transports" ) is_enabled: bool = Field(False, description="Whether enabled") num_stars: float = Field(0.0, description="Average rating") rating_details: list[dict[str, Any]] = Field( default_factory=list, description="Individual ratings" ) created_by: str | None = Field(None, description="Creator username") created_at: str | None = Field(None, description="Creation timestamp") updated_at: str | None = Field(None, description="Last update timestamp") class VirtualServerListResponse(BaseModel): """Response for listing virtual servers.""" virtual_servers: list[VirtualServerConfig] = Field( default_factory=list, description="Virtual servers" ) total: int = Field(0, description="Total count") class VirtualServerToggleResponse(BaseModel): """Response from toggling a virtual server.""" path: str = Field(..., description="Virtual server path") is_enabled: bool = Field(..., description="New enabled state") message: str = Field(..., description="Status message") class VirtualServerDeleteResponse(BaseModel): """Response from deleting a virtual server.""" path: str = Field(..., description="Deleted virtual server path") message: str = Field(..., description="Status message") class SemanticSearchResponse(BaseModel): """Comprehensive semantic search response with all entity types.""" query: str = Field(..., description="Search query") search_mode: str = Field("hybrid", description="Search mode: hybrid or lexical-only") servers: list[SemanticDiscoveredServer] = Field( default_factory=list, description="Matching servers" ) tools: list[ToolSearchResult] = Field(default_factory=list, description="Matching tools") agents: list[AgentSearchResult] = Field(default_factory=list, description="Matching agents") skills: list[SkillSearchResult] = Field(default_factory=list, description="Matching skills") virtual_servers: list[VirtualServerSearchResult] = Field( default_factory=list, description="Matching virtual servers" ) total_servers: int = Field(0, description="Total server count") total_tools: int = Field(0, description="Total tool count") total_agents: int = Field(0, description="Total agent count") total_skills: int = Field(0, description="Total skill count") total_virtual_servers: int = Field(0, description="Total virtual server count") class ServerSemanticSearchResponse(BaseModel): """Server semantic search response model (legacy, use SemanticSearchResponse).""" query: str = Field(..., description="Search query") servers: list[SemanticDiscoveredServer] = Field( default_factory=list, description="Matching servers" ) class RatingDetail(BaseModel): """Individual rating detail.""" user: str = Field(..., description="Username who submitted the rating") rating: int = Field(..., ge=1, le=5, description="Rating value (1-5 stars)") class RatingRequest(BaseModel): """Rating submission request.""" rating: int = Field(..., ge=1, le=5, description="Rating value (1-5 stars)") class RatingResponse(BaseModel): """Rating submission response.""" message: str = Field(..., description="Success message") average_rating: float = Field(..., ge=1.0, le=5.0, description="Updated average rating") class RatingInfoResponse(BaseModel): """Rating information response.""" num_stars: float = Field(..., ge=0.0, le=5.0, description="Average rating (0.0 if no ratings)") rating_details: list[RatingDetail] = Field(..., description="Individual ratings (max 100)") # Anthropic Registry API Models (v0.1) class AnthropicRepository(BaseModel): """Repository metadata for MCP server source code (Anthropic Registry API).""" url: str = Field(..., description="Repository URL for browsing source code") source: str = Field(..., description="Repository hosting service identifier (e.g., 'github')") id: str | None = Field(None, description="Repository ID from hosting service") subfolder: str | None = Field(None, description="Path within monorepo") class AnthropicStdioTransport(BaseModel): """Standard I/O transport configuration (Anthropic Registry API).""" type: str = Field(default="stdio") command: str | None = Field(None, description="Command to execute") args: list[str] | None = Field(None, description="Command arguments") env: dict[str, str] | None = Field(None, description="Environment variables") class AnthropicStreamableHttpTransport(BaseModel): """HTTP-based transport configuration (Anthropic Registry API).""" type: str = Field(default="streamable-http") url: str = Field(..., description="HTTP endpoint URL") headers: dict[str, str] | None = Field(None, description="HTTP headers") class AnthropicSseTransport(BaseModel): """Server-Sent Events transport configuration (Anthropic Registry API).""" type: str = Field(default="sse") url: str = Field(..., description="SSE endpoint URL") class AnthropicPackage(BaseModel): """Package information for MCP server distribution (Anthropic Registry API).""" registryType: str = Field(..., description="Registry type (npm, pypi, oci, etc.)") identifier: str = Field(..., description="Package identifier or URL") version: str = Field(..., description="Specific package version") registryBaseUrl: str | None = Field(None, description="Base URL of package registry") transport: dict[str, Any] = Field(..., description="Transport configuration") runtimeHint: str | None = Field(None, description="Runtime hint (npx, uvx, docker, etc.)") class AnthropicServerDetail(BaseModel): """Detailed MCP server information (Anthropic Registry API).""" model_config = ConfigDict(populate_by_name=True) name: str = Field(..., description="Server name in reverse-DNS format") description: str = Field(..., description="Server description") version: str = Field(..., description="Server version") title: str | None = Field(None, description="Human-readable server name") repository: AnthropicRepository | None = Field(None, description="Repository information") websiteUrl: str | None = Field(None, description="Server website URL") packages: list[AnthropicPackage] | None = Field(None, description="Package distributions") meta: dict[str, Any] | None = Field( None, alias="_meta", serialization_alias="_meta", description="Extensible metadata" ) class AnthropicServerResponse(BaseModel): """Response for single server query (Anthropic Registry API).""" model_config = ConfigDict(populate_by_name=True) server: AnthropicServerDetail = Field(..., description="Server details") meta: dict[str, Any] | None = Field( None, alias="_meta", serialization_alias="_meta", description="Registry-managed metadata" ) class AnthropicPaginationMetadata(BaseModel): """Pagination information for server lists (Anthropic Registry API).""" nextCursor: str | None = Field(None, description="Cursor for next page") count: int | None = Field(None, description="Number of items in current page") class AnthropicServerList(BaseModel): """Response for server list queries (Anthropic Registry API).""" servers: list[AnthropicServerResponse] = Field(..., description="List of servers") metadata: AnthropicPaginationMetadata | None = Field(None, description="Pagination info") class AnthropicErrorResponse(BaseModel): """Standard error response (Anthropic Registry API).""" error: str = Field(..., description="Error message") # Registry Card Models class RegistryCapabilitiesResponse(BaseModel): """Registry capabilities response model.""" servers: bool = Field(..., description="Supports MCP server registry") agents: bool = Field(..., description="Supports A2A agent registry") skills: bool = Field(..., description="Supports skill registry") prompts: bool = Field(False, description="Supports prompt registry") security_scans: bool = Field(True, description="Supports security scanning") incremental_sync: bool = Field(False, description="Supports incremental federation sync") webhooks: bool = Field(False, description="Supports webhook notifications") class RegistryAuthConfigResponse(BaseModel): """Registry authentication configuration response model.""" schemes: list[str] = Field(..., description="Supported auth schemes (bearer, oauth2, etc.)") oauth2_issuer: str | None = Field(None, description="OAuth2 issuer URL") oauth2_token_endpoint: str | None = Field(None, description="OAuth2 token endpoint URL") scopes_supported: list[str] = Field(default_factory=list, description="Supported OAuth2 scopes") class RegistryContactResponse(BaseModel): """Registry contact information response model.""" email: str | None = Field(None, description="Contact email address") url: str | None = Field(None, description="Contact URL") class RegistryCardResponse(BaseModel): """Registry Card response model.""" schema_version: str = Field(..., description="Registry card schema version") id: str = Field(..., description="Unique registry identifier (UUID)") name: str = Field(..., description="Registry name") description: str | None = Field(None, description="Registry description") registry_url: str | None = Field(None, description="Base URL of this registry") organization_name: str | None = Field(None, description="Organization operating this registry") federation_api_version: str = Field(..., description="Federation API version") federation_endpoint: str = Field(..., description="Federation endpoint URL") capabilities: RegistryCapabilitiesResponse = Field(..., description="Registry capabilities") authentication: RegistryAuthConfigResponse = Field( ..., description="Authentication configuration" ) visibility_policy: str = Field(..., description="Default visibility policy") contact: RegistryContactResponse | None = Field(None, description="Contact information") metadata: dict[str, Any] = Field(default_factory=dict, description="Additional metadata") created_at: str | None = Field(None, description="Creation timestamp") updated_at: str | None = Field(None, description="Last update timestamp") # Management API Models (IAM/User Management) class M2MAccountRequest(BaseModel): """Request model for creating M2M service account.""" name: str = Field(..., min_length=1, description="Service account name/client ID") groups: list[str] = Field(..., min_length=1, description="List of group names") description: str | None = Field(None, description="Account description") class HumanUserRequest(BaseModel): """Request model for creating human user account.""" username: str = Field(..., min_length=1, description="Username") email: str = Field(..., description="Email address") first_name: str = Field(..., min_length=1, description="First name") last_name: str = Field(..., min_length=1, description="Last name") groups: list[str] = Field(..., min_length=1, description="List of group names") password: str | None = Field(None, description="Initial password") class UserSummary(BaseModel): """User summary model.""" id: str = Field(..., description="User ID") username: str = Field(..., description="Username") email: str | None = Field(None, description="Email address") firstName: str | None = Field(None, description="First name") lastName: str | None = Field(None, description="Last name") enabled: bool = Field(True, description="Whether user is enabled") groups: list[str] = Field(default_factory=list, description="User groups") class UserListResponse(BaseModel): """Response model for list users endpoint.""" users: list[UserSummary] = Field(default_factory=list, description="List of users") total: int = Field(..., description="Total number of users") class UserDeleteResponse(BaseModel): """Response model for delete user endpoint.""" username: str = Field(..., description="Deleted username") deleted: bool = Field(True, description="Deletion status") class M2MAccountResponse(BaseModel): """Response model for M2M account creation.""" client_id: str = Field(..., description="Client ID (app ID in Entra)") client_secret: str = Field(..., description="Client secret") groups: list[str] = Field(default_factory=list, description="Assigned groups") client_uuid: str | None = Field(None, description="Client UUID (Entra app object ID)") service_principal_id: str | None = Field(None, description="Service principal ID (Entra)") class GroupCreateRequest(BaseModel): """Request model for creating a Keycloak group.""" name: str = Field(..., min_length=1, description="Group name") description: str | None = Field(None, description="Group description") class GroupSummary(BaseModel): """Group summary model.""" id: str = Field(..., description="Group ID") name: str = Field(..., description="Group name") path: str = Field(..., description="Group path") attributes: dict[str, Any] | None = Field(None, description="Group attributes") class IdPM2MClient(BaseModel): """M2M client record as stored in idp_m2m_clients. Models the response shape from the /api/iam/m2m-clients direct registration API (issue #851). """ client_id: str = Field(..., description="IdP application client ID") name: str = Field(..., description="Application name") description: str | None = Field(None, description="Application description") groups: list[str] = Field(default_factory=list, description="Groups this client belongs to") enabled: bool = Field(True, description="Whether the client is active") provider: str = Field(..., description="Identity provider (okta, keycloak, entra, manual)") idp_app_id: str | None = Field(None, description="IdP internal app ID") created_by: str | None = Field( None, description="Operator who registered this client (manual records)" ) created_at: datetime = Field(..., description="Creation timestamp") updated_at: datetime = Field(..., description="Last update timestamp") class M2MClientListResponse(BaseModel): """Paginated response for GET /api/iam/m2m-clients.""" total: int = Field(..., description="Total number of matching records") limit: int = Field(..., description="Limit applied to this page") skip: int = Field(..., description="Offset applied to this page") items: list[IdPM2MClient] = Field(default_factory=list, description="Records on this page") class GroupSyncStatusResponse(BaseModel): """Response model for list groups endpoint with sync status.""" keycloak_groups: list[dict[str, Any]] = Field( default_factory=list, description="Groups from Keycloak" ) scopes_groups: dict[str, Any] = Field( default_factory=dict, description="Groups from scopes storage" ) synchronized: list[str] = Field( default_factory=list, description="Groups in both Keycloak and scopes" ) keycloak_only: list[str] = Field(default_factory=list, description="Groups only in Keycloak") scopes_only: list[str] = Field(default_factory=list, description="Groups only in scopes") class GroupDeleteResponse(BaseModel): """Response model for delete group endpoint.""" name: str = Field(..., description="Deleted group name") deleted: bool = Field(True, description="Deletion status") # ========================================== # Agent Skills Models # ========================================== class SkillRegistrationRequest(BaseModel): """Request model for registering a skill.""" name: str = Field(..., description="Skill name (lowercase alphanumeric with hyphens)") skill_md_url: str = Field(..., description="URL to SKILL.md file") description: str | None = Field(None, description="Skill description") repository_url: str | None = Field(None, description="Repository URL") version: str | None = Field(None, description="Skill version (e.g., 1.0.0)") tags: list[str] = Field(default_factory=list, description="Tags for categorization") target_agents: list[str] = Field( default_factory=list, description="Target coding assistants (e.g., claude-code, cursor)" ) metadata: dict[str, Any] | None = Field( None, description="Custom metadata key-value pairs for search and organization" ) visibility: str = Field(default="public", description="Visibility: public, private, group") allowed_groups: list[str] = Field( default_factory=list, description="Groups for group visibility" ) class SkillCard(BaseModel): """Response model for a skill.""" id: UUID = Field(..., description="Unique identifier (UUID) for this skill") name: str = Field(..., description="Skill name") path: str = Field(..., description="Skill path (e.g., /skills/pdf-processing)") description: str | None = Field(None, description="Skill description") skill_md_url: str = Field(..., description="URL to SKILL.md file") skill_md_raw_url: str | None = Field(None, description="Raw content URL") version: str | None = Field(None, description="Skill version") author: str | None = Field(None, description="Skill author") visibility: str = Field(default="public", description="Visibility level") is_enabled: bool = Field(default=True, description="Whether skill is enabled") tags: list[str] = Field(default_factory=list, description="Tags") target_agents: list[str] = Field(default_factory=list, description="Target coding assistants") metadata: dict[str, Any] | None = Field( None, description="Skill metadata (author, version, extra)" ) owner: str | None = Field(None, description="Skill owner") registry_name: str | None = Field(None, description="Source registry") num_stars: float = Field(default=0, description="Average rating") health_status: str = Field(default="unknown", description="Health status") status: str = Field( default="active", description="Lifecycle status (active, deprecated, draft, beta)", ) created_at: str | None = Field(None, description="Creation timestamp") updated_at: str | None = Field(None, description="Last update timestamp") class SkillListResponse(BaseModel): """Response model for listing skills.""" skills: list[SkillCard] = Field(default_factory=list, description="List of skills") total_count: int = Field(0, description="Total number of skills") limit: int = Field(..., description="Page size applied") offset: int = Field(..., description="Offset applied") has_next: bool = Field(..., description="Whether more pages exist") class SkillHealthResponse(BaseModel): """Response model for skill health check.""" path: str = Field(..., description="Skill path") healthy: bool = Field(..., description="Whether SKILL.md is accessible") status_code: int | None = Field(None, description="HTTP status code") error: str | None = Field(None, description="Error message if unhealthy") response_time_ms: float | None = Field(None, description="Response time in ms") class SkillContentResponse(BaseModel): """Response model for skill content.""" content: str = Field(..., description="SKILL.md content") url: str = Field(..., description="URL content was fetched from") class SkillSearchResponse(BaseModel): """Response model for skill search.""" query: str = Field(..., description="Search query") skills: list[dict[str, Any]] = Field(default_factory=list, description="Matching skills") total_count: int = Field(0, description="Total matches") class SkillToggleResponse(BaseModel): """Response model for skill toggle.""" path: str = Field(..., description="Skill path") is_enabled: bool = Field(..., description="New enabled state") class SkillRatingResponse(BaseModel): """Response model for skill rating.""" num_stars: float = Field(..., description="Average rating") rating_details: list[dict[str, Any]] = Field( default_factory=list, description="Individual ratings" ) class AppLogEntry(BaseModel): """Single application log entry.""" timestamp: str = Field(..., description="Log timestamp (ISO-8601)") hostname: str = Field(..., description="Pod/hostname that emitted the log") service: str = Field(..., description="Service name (registry, auth-server)") level: str = Field(..., description="Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)") level_no: int = Field(..., description="Numeric log level") logger: str = Field(..., description="Python logger name") filename: str = Field(..., description="Source filename") lineno: int = Field(..., description="Source line number") process: int = Field(..., description="Process ID") message: str = Field(..., description="Log message") class AppLogResponse(BaseModel): """Response model for application log query.""" entries: list[AppLogEntry] = Field(default_factory=list, description="Log entries") total_count: int = Field(0, description="Total matching entries") limit: int = Field(100, description="Applied page size") offset: int = Field(0, description="Applied offset") has_next: bool = Field(False, description="Whether more entries exist") class AppLogMetadataResponse(BaseModel): """Response model for application log metadata.""" services: list[str] = Field(default_factory=list, description="Available service names") hostnames: list[str] = Field(default_factory=list, description="Available hostnames") levels: list[str] = Field(default_factory=list, description="Available log levels") class RegistryClient: """ MCP Gateway Registry API client. Provides methods for interacting with the Registry API endpoints including: - Server Management: registration, removal, toggling, health checks - Group Management: create, delete, list groups - Agent Management: register, update, delete, discover agents (A2A) - Management API: IAM/user management, M2M accounts, user CRUD operations Authentication is handled via JWT tokens passed to the constructor. """ def __init__(self, registry_url: str, token: str): """ Initialize the Registry Client. Args: registry_url: Base URL of the registry (e.g., https://registry.mycorp.click) token: JWT access token for authentication """ self.registry_url = registry_url.rstrip("/") self._token = token # Redact token in logs - show only first 8 characters redacted_token = f"{token[:8]}..." if len(token) > 8 else "***" logger.info(f"Initialized RegistryClient for {self.registry_url} (token: {redacted_token})") def _get_headers(self) -> dict[str, str]: """ Get request headers with JWT token. Returns: Dictionary of HTTP headers """ return {"Authorization": f"Bearer {self._token}"} def _make_request( self, method: str, endpoint: str, data: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> requests.Response: """ Make HTTP request to the Registry API. Args: method: HTTP method (GET, POST, etc.) endpoint: API endpoint path data: Request body data (sent as form-encoded for POST) params: Query parameters Returns: Response object Raises: requests.HTTPError: If request fails """ url = f"{self.registry_url}{endpoint}" headers = self._get_headers() logger.debug(f"{method} {url}") # Determine content type based on endpoint # Agent, Management, Search, Federation, Skills, Virtual Servers, Registry Card, version, and group import endpoints use JSON # Server registration uses form data if ( endpoint.startswith("/api/agents") or endpoint.startswith("/api/management") or endpoint.startswith("/api/iam") or endpoint.startswith("/api/search") or endpoint.startswith("/api/federation") or endpoint.startswith("/api/peers") or endpoint.startswith("/api/skills") or endpoint.startswith("/api/virtual-servers") or endpoint.startswith("/api/v1/registry") or endpoint.startswith("/api/v1/health") or endpoint == "/api/servers/groups/import" or "/auth-credential" in endpoint or "/versions" in endpoint ): # Send as JSON for agent, management, search, federation, and import endpoints response = requests.request( method=method, url=url, headers=headers, json=data, params=params, timeout=120 ) else: # Send as form-encoded for server registration response = requests.request( method=method, url=url, headers=headers, data=data, params=params, timeout=120 ) try: response.raise_for_status() except requests.HTTPError as e: # For 422 errors, try to extract validation details if response.status_code == 422: try: error_detail = response.json() logger.error(f"Validation error details: {json.dumps(error_detail, indent=2)}") except Exception as e: logger.warning(f"Could not parse 422 error response as JSON: {e}") raise return response def register_service(self, registration: InternalServiceRegistration) -> ServiceResponse: """ Register a new service in the registry. Args: registration: Service registration data Returns: Service response with registration details Raises: requests.HTTPError: If registration fails """ logger.info(f"Registering service: {registration.service_path}") # Convert model to dict data = registration.model_dump(exclude_none=True, by_alias=True) # Convert tags list to comma-separated string for form encoding if "tags" in data and isinstance(data["tags"], list): data["tags"] = ",".join(data["tags"]) # Convert external_tags list to comma-separated string for form encoding if "external_tags" in data and isinstance(data["external_tags"], list): data["external_tags"] = ",".join(data["external_tags"]) # Convert metadata dict to JSON string for form encoding if "metadata" in data and isinstance(data["metadata"], dict): data["metadata"] = json.dumps(data["metadata"]) response = self._make_request(method="POST", endpoint="/api/servers/register", data=data) logger.info(f"Service registered successfully: {registration.service_path}") return ServiceResponse(**response.json()) def remove_service(self, service_path: str) -> dict[str, Any]: """ Remove a service from the registry. Args: service_path: Path of service to remove Returns: Response data Raises: requests.HTTPError: If removal fails """ logger.info(f"Removing service: {service_path}") response = self._make_request( method="POST", endpoint="/api/servers/remove", data={"path": service_path} ) logger.info(f"Service removed successfully: {service_path}") return response.json() def toggle_service(self, service_path: str) -> ToggleResponse: """ Toggle service enabled/disabled status. Args: service_path: Path of service to toggle Returns: Toggle response with current status Raises: requests.HTTPError: If toggle fails """ logger.info(f"Toggling service: {service_path}") response = self._make_request( method="POST", endpoint="/api/servers/toggle", data={"service_path": service_path} ) result = ToggleResponse(**response.json()) logger.info(f"Service toggled: {service_path} -> enabled={result.is_enabled}") return result def update_server_credential( self, service_path: str, auth_scheme: str, auth_credential: str = None, auth_header_name: str = None, ) -> dict[str, Any]: """ Update authentication credentials for a server. Args: service_path: Path of server to update (e.g., /my-server) auth_scheme: Authentication scheme (none, bearer, api_key) auth_credential: New credential (required if auth_scheme is not 'none') auth_header_name: Custom header name (optional, for api_key) Returns: Response dict with message and updated auth details Raises: requests.HTTPError: If update fails """ logger.info(f"Updating auth credential for: {service_path}") # Build payload payload = {"auth_scheme": auth_scheme} if auth_credential: payload["auth_credential"] = auth_credential if auth_header_name: payload["auth_header_name"] = auth_header_name response = self._make_request( method="PATCH", endpoint=f"/api/servers{service_path}/auth-credential", data=payload ) result = response.json() logger.info(f"Credential updated for {service_path}: scheme={result.get('auth_scheme')}") return result def list_services( self, limit: int = 20, offset: int = 0, ) -> ServerListResponse: """ List all services in the registry. Args: limit: Maximum number of services to return per page offset: Number of services to skip for pagination Returns: Server list response Raises: requests.HTTPError: If list operation fails """ logger.info("Listing all services") params = { "limit": limit, "offset": offset, } response = self._make_request(method="GET", endpoint="/api/servers", params=params) response_data = response.json() logger.debug(f"Raw API response: {json.dumps(response_data, indent=2, default=str)}") try: result = ServerListResponse(**response_data) logger.info( f"Retrieved {len(result.servers)} services" f" (total={result.total_count}, offset={result.offset}," f" limit={result.limit}, has_next={result.has_next})" ) return result except Exception as e: logger.error(f"Failed to parse server list response: {e}") logger.error(f"Raw response data: {json.dumps(response_data, indent=2, default=str)}") raise def healthcheck(self) -> dict[str, Any]: """ Perform health check on all services. Returns: Health check response with service statuses Raises: requests.HTTPError: If health check fails """ logger.info("Performing health check on all services") response = self._make_request(method="GET", endpoint="/api/servers/health") result = response.json() logger.info(f"Health check completed: {result.get('status', 'unknown')}") return result def get_config(self) -> dict[str, Any]: """ Get registry configuration including deployment mode and features. Returns: Configuration response with deployment_mode, registry_mode, nginx_updates_enabled, and features dict Raises: requests.HTTPError: If request fails """ logger.info("Fetching registry configuration") response = self._make_request(method="GET", endpoint="/api/config") result = response.json() logger.info( f"Registry config: deployment_mode={result.get('deployment_mode')}, " f"registry_mode={result.get('registry_mode')}" ) return result def get_well_known_registry_card(self) -> RegistryCardResponse: """ Get the Registry Card via .well-known discovery endpoint. This is the standard discovery endpoint for registry federation, following the .well-known convention used for service discovery (similar to .well-known/openid-configuration). Returns: Registry Card response with registry metadata Raises: requests.HTTPError: If request fails or card not initialized """ logger.info("Fetching registry card via .well-known endpoint") response = self._make_request( method="GET", endpoint="/api/v1/registry/.well-known/registry-card" ) result = RegistryCardResponse(**response.json()) logger.info(f"Retrieved registry card: {result.id} (name: {result.name})") return result def get_registry_card(self) -> RegistryCardResponse: """ Get the Registry Card for this registry instance. The Registry Card provides metadata about the registry including: - Capabilities (servers, agents, skills, security scans, etc.) - Authentication configuration - Federation API version and endpoint - Contact information Returns: Registry Card response with registry metadata Raises: requests.HTTPError: If request fails """ logger.info("Fetching registry card") response = self._make_request(method="GET", endpoint="/api/v1/registry/card") result = RegistryCardResponse(**response.json()) logger.info(f"Retrieved registry card: {result.id} (name: {result.name})") return result def update_registry_card(self, card_data: dict[str, Any]) -> dict[str, Any]: """ Update the Registry Card (admin only). This replaces the entire registry card with the provided data. For partial updates, use patch_registry_card() instead. Args: card_data: Complete registry card data Returns: Response with update confirmation Raises: requests.HTTPError: If update fails (e.g., insufficient permissions) """ logger.info("Updating registry card") response = self._make_request( method="POST", endpoint="/api/v1/registry/card", data=card_data ) result = response.json() logger.info("Registry card updated successfully") return result def patch_registry_card(self, updates: dict[str, Any]) -> dict[str, Any]: """ Partially update the Registry Card (admin only). Only the fields provided in updates will be modified. Other fields will remain unchanged. Args: updates: Partial registry card updates Returns: Response with update confirmation Raises: requests.HTTPError: If update fails (e.g., insufficient permissions) """ logger.info(f"Patching registry card with updates: {list(updates.keys())}") response = self._make_request( method="PATCH", endpoint="/api/v1/registry/card", data=updates ) result = response.json() logger.info("Registry card patched successfully") return result def add_server_to_groups(self, server_name: str, group_names: list[str]) -> dict[str, Any]: """ Add a server to user groups. Args: server_name: Name of server group_names: List of group names Returns: Response data Raises: requests.HTTPError: If operation fails """ logger.info(f"Adding server {server_name} to groups: {group_names}") response = self._make_request( method="POST", endpoint="/api/servers/groups/add", data={"server_name": server_name, "group_names": ",".join(group_names)}, ) logger.info("Server added to groups successfully") return response.json() def remove_server_from_groups(self, server_name: str, group_names: list[str]) -> dict[str, Any]: """ Remove a server from user groups. Args: server_name: Name of server group_names: List of group names Returns: Response data Raises: requests.HTTPError: If operation fails """ logger.info(f"Removing server {server_name} from groups: {group_names}") response = self._make_request( method="POST", endpoint="/api/servers/groups/remove", data={"server_name": server_name, "group_names": ",".join(group_names)}, ) logger.info("Server removed from groups successfully") return response.json() def create_group( self, group_name: str, description: str | None = None, create_in_idp: bool = False ) -> dict[str, Any]: """ Create a new user group. Args: group_name: Name of group description: Group description create_in_idp: Whether to create in IdP (Keycloak/Entra) Returns: Response data Raises: requests.HTTPError: If creation fails """ logger.info(f"Creating group: {group_name}") data = {"group_name": group_name} if description: data["description"] = description data["create_in_idp"] = str(create_in_idp).lower() response = self._make_request( method="POST", endpoint="/api/servers/groups/create", data=data ) logger.info(f"Group created successfully: {group_name}") return response.json() def delete_group( self, group_name: str, delete_from_idp: bool = False, force: bool = False ) -> dict[str, Any]: """ Delete a user group. Args: group_name: Name of group delete_from_idp: Whether to delete from IdP (Keycloak/Entra) force: Force deletion of system groups Returns: Response data Raises: requests.HTTPError: If deletion fails """ logger.info(f"Deleting group: {group_name}") data = {"group_name": group_name} if delete_from_idp: data["delete_from_idp"] = True if force: data["force"] = True response = self._make_request( method="POST", endpoint="/api/servers/groups/delete", data=data ) logger.info(f"Group deleted successfully: {group_name}") return response.json() def import_group(self, group_definition: dict[str, Any]) -> dict[str, Any]: """ Import a complete group definition. Args: group_definition: Complete group definition including: - scope_name (required): Name of the scope/group - scope_type (optional): Type of scope (default: "server_scope") - description (optional): Description of the group - server_access (optional): List of server access definitions - group_mappings (optional): List of group mappings - ui_permissions (optional): Dictionary of UI permissions - create_in_idp (optional): Whether to create in IdP (default: false) Returns: Response data Raises: requests.HTTPError: If import fails """ scope_name = group_definition.get("scope_name") if not scope_name: raise ValueError("scope_name is required in group_definition") logger.info(f"Importing group definition: {scope_name}") response = self._make_request( method="POST", endpoint="/api/servers/groups/import", data=group_definition ) logger.info(f"Group imported successfully: {scope_name}") return response.json() def list_groups( self, include_keycloak: bool = True, include_scopes: bool = True ) -> GroupSyncStatusResponse: """ List all user groups. Args: include_keycloak: Include Keycloak information include_scopes: Include scope information Returns: Group list response with sync status Raises: requests.HTTPError: If list operation fails """ logger.info("Listing all groups") params = { "include_keycloak": str(include_keycloak).lower(), "include_scopes": str(include_scopes).lower(), } response = self._make_request(method="GET", endpoint="/api/servers/groups", params=params) result = GroupSyncStatusResponse(**response.json()) total_groups = len(result.scopes_groups) + len(result.keycloak_groups) logger.info( f"Retrieved {total_groups} groups ({len(result.keycloak_groups)} from Keycloak, {len(result.scopes_groups)} from scopes)" ) return result def get_group(self, group_name: str) -> dict[str, Any]: """ Get full details of a specific group. Args: group_name: Name of the group Returns: Complete group definition with server_access, group_mappings, and ui_permissions Raises: requests.HTTPError: If get operation fails (404 if group not found) """ logger.info(f"Getting group details: {group_name}") response = self._make_request(method="GET", endpoint=f"/api/servers/groups/{group_name}") logger.info(f"Retrieved group details for {group_name}") return response.json() # Agent Management Methods def register_agent(self, agent: AgentRegistration) -> AgentRegistrationResponse: """ Register a new A2A agent. Args: agent: Agent registration data Returns: Agent registration response Raises: requests.HTTPError: If registration fails (409 for conflict, 422 for validation error, 403 for permission denied) """ logger.info(f"Registering agent: {agent.path}") agent_data = agent.model_dump(exclude_none=True, by_alias=True) logger.debug(f"Agent data being sent: {json.dumps(agent_data, indent=2, default=str)}") response = self._make_request( method="POST", endpoint="/api/agents/register", data=agent_data ) result = AgentRegistrationResponse(**response.json()) logger.info(f"Agent registered successfully: {agent.path}") return result def list_agents( self, query: str | None = None, enabled_only: bool = False, visibility: str | None = None, allowed_groups: str | None = None, limit: int = 20, offset: int = 0, ) -> AgentListResponse: """ List agents with optional filtering and pagination. Args: query: Search query string enabled_only: Show only enabled agents visibility: Filter by visibility level (public, private, internal) limit: Number of agents to return (1-100, default 20) offset: Number of agents to skip (default 0) Returns: Agent list response with pagination metadata Raises: requests.HTTPError: If list operation fails """ logger.info(f"Listing agents (limit={limit}, offset={offset})") params: dict[str, str | int | bool] = { "limit": limit, "offset": offset, } if query: params["query"] = query if enabled_only: params["enabled_only"] = "true" if visibility: params["visibility"] = visibility if allowed_groups: params["allowed_groups"] = allowed_groups response = self._make_request(method="GET", endpoint="/api/agents", params=params) result = AgentListResponse(**response.json()) logger.info( f"Retrieved {len(result.agents)} agents " f"(total: {result.total_count}, offset: {result.offset}, limit: {result.limit})" ) return result def get_agent(self, path: str) -> AgentDetail: """ Get detailed information about a specific agent. Args: path: Agent path (e.g., /code-reviewer) Returns: Agent detail Raises: requests.HTTPError: If agent not found (404) or unauthorized (403) """ logger.info(f"Getting agent details: {path}") response = self._make_request(method="GET", endpoint=f"/api/agents{path}") result = AgentDetail(**response.json()) logger.info(f"Retrieved agent details: {path}") return result def update_agent(self, path: str, agent: AgentRegistration) -> AgentDetail: """ Update an existing agent. Args: path: Agent path agent: Updated agent data Returns: Updated agent detail Raises: requests.HTTPError: If update fails (404 for not found, 403 for permission denied, 422 for validation error) """ logger.info(f"Updating agent: {path}") response = self._make_request( method="PUT", endpoint=f"/api/agents{path}", data=agent.model_dump(exclude_none=True, by_alias=True), ) result = AgentDetail(**response.json()) logger.info(f"Agent updated successfully: {path}") return result def delete_agent(self, path: str) -> None: """ Delete an agent from the registry. Args: path: Agent path Raises: requests.HTTPError: If deletion fails (404 for not found, 403 for permission denied) """ logger.info(f"Deleting agent: {path}") self._make_request(method="DELETE", endpoint=f"/api/agents{path}") logger.info(f"Agent deleted successfully: {path}") def toggle_agent(self, path: str, enabled: bool) -> AgentToggleResponse: """ Toggle agent enabled/disabled status. Args: path: Agent path enabled: True to enable, False to disable Returns: Agent toggle response Raises: requests.HTTPError: If toggle fails (404 for not found, 403 for permission denied) """ logger.info(f"Toggling agent {path} to {'enabled' if enabled else 'disabled'}") params = {"enabled": str(enabled).lower()} response = self._make_request( method="POST", endpoint=f"/api/agents{path}/toggle", params=params ) result = AgentToggleResponse(**response.json()) logger.info( f"Agent toggled: {path} is now {'enabled' if result.is_enabled else 'disabled'}" ) return result def discover_agents_by_skills( self, skills: list[str], tags: list[str] | None = None, max_results: int = 10 ) -> AgentDiscoveryResponse: """ Discover agents by required skills. Args: skills: List of required skills tags: Optional tag filters max_results: Maximum number of results (default: 10, max: 100) Returns: Agent discovery response Raises: requests.HTTPError: If discovery fails (400 for bad request) """ logger.info(f"Discovering agents by skills: {skills}") request_data = SkillDiscoveryRequest(skills=skills, tags=tags) params = {"max_results": max_results} response = self._make_request( method="POST", endpoint="/api/agents/discover", data=request_data.model_dump(exclude_none=True), params=params, ) result = AgentDiscoveryResponse(**response.json()) logger.info(f"Discovered {len(result.agents)} agents matching skills") return result def discover_agents_semantic( self, query: str, max_results: int = 10 ) -> AgentSemanticDiscoveryResponse: """ Discover agents using semantic search (FAISS vector search). Args: query: Natural language query (e.g., "Find agents that can analyze code") max_results: Maximum number of results (default: 10, max: 100) Returns: Agent semantic discovery response Raises: requests.HTTPError: If discovery fails (400 for bad request, 500 for search error) """ logger.info(f"Discovering agents semantically: {query}") params = {"query": query, "max_results": max_results} response = self._make_request( method="POST", endpoint="/api/agents/discover/semantic", params=params ) result = AgentSemanticDiscoveryResponse(**response.json()) logger.info(f"Discovered {len(result.agents)} agents via semantic search") return result def semantic_search_servers( self, query: str, max_results: int = 10, include_draft: bool = False, include_deprecated: bool = False, include_disabled: bool = False, ) -> ServerSemanticSearchResponse: """ Search for servers using semantic search (vector search). Args: query: Natural language query (e.g., "time and date services") max_results: Maximum number of results (default: 10, max: 100) include_draft: Include draft assets in results (default: False) include_deprecated: Include deprecated assets in results (default: False) include_disabled: Include disabled assets in results (default: False) Returns: Server semantic search response Raises: requests.HTTPError: If search fails (400 for bad request, 500 for search error) """ logger.info(f"Searching servers semantically: {query}") request_data: dict[str, Any] = { "query": query, "entity_types": ["mcp_server"], "max_results": max_results, "include_draft": include_draft, "include_deprecated": include_deprecated, "include_disabled": include_disabled, } response = self._make_request( method="POST", endpoint="/api/search/semantic", data=request_data ) result = ServerSemanticSearchResponse(**response.json()) logger.info(f"Found {len(result.servers)} servers via semantic search") return result def semantic_search( self, query: str, entity_types: list[str] | None = None, max_results: int = 10, include_draft: bool = False, include_deprecated: bool = False, include_disabled: bool = False, ) -> SemanticSearchResponse: """ Comprehensive semantic search across all entity types. Args: query: Natural language query (e.g., "coding assistants") entity_types: Optional list of entity types to search. Valid values: "mcp_server", "tool", "a2a_agent", "skill", "virtual_server" If None, searches all entity types. max_results: Maximum number of results per entity type (default: 10, max: 50) include_draft: Include draft assets in results (default: False) include_deprecated: Include deprecated assets in results (default: False) include_disabled: Include disabled assets in results (default: False) Returns: SemanticSearchResponse with servers, tools, agents, skills, and virtual_servers Raises: requests.HTTPError: If search fails (400 for bad request, 500 for search error) """ logger.info(f"Semantic search: {query} (entity_types={entity_types})") request_data: dict[str, Any] = { "query": query, "max_results": max_results, "include_draft": include_draft, "include_deprecated": include_deprecated, "include_disabled": include_disabled, } if entity_types: request_data["entity_types"] = entity_types response = self._make_request( method="POST", endpoint="/api/search/semantic", data=request_data ) result = SemanticSearchResponse(**response.json()) logger.info( f"Found: {len(result.servers)} servers, {len(result.tools)} tools, " f"{len(result.agents)} agents, {len(result.skills)} skills, " f"{len(result.virtual_servers)} virtual servers" ) return result def rate_agent(self, path: str, rating: int) -> RatingResponse: """ Submit a rating for an agent (1-5 stars). Each user can only have one active rating. If user has already rated, this updates their existing rating. System maintains a rotating buffer of the last 100 ratings. Args: path: Agent path (e.g., /code-reviewer) rating: Rating value (1-5 stars) Returns: Rating response with success message and updated average rating Raises: requests.HTTPError: If rating fails (400 for invalid rating, 403 for unauthorized, 404 for not found) """ logger.info(f"Rating agent '{path}' with {rating} stars") request_data = RatingRequest(rating=rating) response = self._make_request( method="POST", endpoint=f"/api/agents{path}/rate", data=request_data.model_dump() ) result = RatingResponse(**response.json()) logger.info(f"Agent '{path}' rated successfully. New average: {result.average_rating:.2f}") return result def get_agent_rating(self, path: str) -> RatingInfoResponse: """ Get rating information for an agent. Returns average rating and up to 100 most recent individual ratings (maintained as rotating buffer). Args: path: Agent path (e.g., /code-reviewer) Returns: Rating information with average and individual ratings Raises: requests.HTTPError: If retrieval fails (403 for unauthorized, 404 for not found) """ logger.info(f"Getting ratings for agent: {path}") response = self._make_request(method="GET", endpoint=f"/api/agents{path}/rating") result = RatingInfoResponse(**response.json()) logger.info( f"Retrieved ratings for '{path}': {result.num_stars:.2f} stars ({len(result.rating_details)} ratings)" ) return result def rescan_agent(self, path: str) -> AgentRescanResponse: """ Trigger a manual security scan for an agent. Initiates a new security scan for the specified agent and returns the results. This endpoint is useful for re-scanning agents after updates or for on-demand security assessments. Args: path: Agent path (e.g., /code-reviewer) Returns: Newly generated security scan results Raises: requests.HTTPError: If scan fails (403 for unauthorized, 404 for not found) """ logger.info(f"Triggering security scan for agent: {path}") response = self._make_request(method="POST", endpoint=f"/api/agents{path}/rescan") result = AgentRescanResponse(**response.json()) logger.info( f"Security scan completed for '{path}': " f"Safe={result.is_safe}, Critical={result.critical_issues}, " f"High={result.high_severity}, Medium={result.medium_severity}, " f"Low={result.low_severity}" ) return result def get_agent_security_scan(self, path: str) -> AgentSecurityScanResponse: """ Get security scan results for an agent. Returns the latest security scan results including threat analysis, severity levels, and detailed findings from YARA, specification validation, and heuristic analyzers. Args: path: Agent path (e.g., /code-reviewer) Returns: Security scan results with analysis_results and scan_results Raises: requests.HTTPError: If retrieval fails (403 for unauthorized, 404 for not found) """ logger.info(f"Getting security scan results for agent: {path}") response = self._make_request(method="GET", endpoint=f"/api/agents{path}/security-scan") result = AgentSecurityScanResponse(**response.json()) logger.info(f"Retrieved security scan results for '{path}'") return result def agent_ans_link( self, path: str, ans_agent_id: str, ) -> dict[str, Any]: """ Link an ANS Agent ID to an agent. Args: path: Agent path (e.g., /code-reviewer) ans_agent_id: ANS Agent ID (e.g., ans://v1.example.com) Returns: Link result with success status, message, and ans_metadata Raises: requests.HTTPError: If linking fails """ logger.info(f"Linking ANS ID '{ans_agent_id}' to agent: {path}") response = self._make_request( method="POST", endpoint=f"/api/agents{path}/ans/link", data={"ans_agent_id": ans_agent_id}, ) result = response.json() logger.info(f"ANS link result for '{path}': {result.get('message', '')}") return result def agent_ans_status( self, path: str, ) -> dict[str, Any]: """ Get ANS verification status for an agent. Args: path: Agent path (e.g., /code-reviewer) Returns: ANS metadata dict with status, domain, ans_agent_id, etc. Raises: requests.HTTPError: If retrieval fails (404 if no ANS link) """ logger.info(f"Getting ANS status for agent: {path}") response = self._make_request( method="GET", endpoint=f"/api/agents{path}/ans/status", ) result = response.json() logger.info(f"ANS status for '{path}': {result.get('status', 'unknown')}") return result def agent_ans_unlink( self, path: str, ) -> dict[str, Any]: """ Remove ANS link from an agent. Args: path: Agent path (e.g., /code-reviewer) Returns: Unlink result with success status and message Raises: requests.HTTPError: If unlinking fails """ logger.info(f"Unlinking ANS from agent: {path}") response = self._make_request( method="DELETE", endpoint=f"/api/agents{path}/ans/link", ) result = response.json() logger.info(f"ANS unlink result for '{path}': {result.get('message', '')}") return result def rate_server(self, path: str, rating: int) -> RatingResponse: """ Submit a rating for a server (1-5 stars). Each user can only have one active rating. If user has already rated, this updates their existing rating. System maintains a rotating buffer of the last 100 ratings. Args: path: Server path (e.g., /cloudflare-docs) rating: Rating value (1-5 stars) Returns: Rating response with success message and updated average rating Raises: requests.HTTPError: If rating fails (400 for invalid rating, 403 for unauthorized, 404 for not found) """ logger.info(f"Rating server '{path}' with {rating} stars") request_data = RatingRequest(rating=rating) response = self._make_request( method="POST", endpoint=f"/api/servers{path}/rate", data=request_data.model_dump() ) result = RatingResponse(**response.json()) logger.info(f"Server '{path}' rated successfully. New average: {result.average_rating:.2f}") return result def get_server( self, path: str, ) -> ServerDetailResponse: """ Get detailed information about a specific server. Args: path: Server path (e.g., /my-server) Returns: Server detail response Raises: requests.HTTPError: If server not found (404) or unauthorized (403) """ logger.info(f"Getting server details: {path}") response = self._make_request(method="GET", endpoint=f"/api/servers{path}") result = ServerDetailResponse(**response.json()) logger.info(f"Retrieved server details: {path}") return result def get_server_rating(self, path: str) -> RatingInfoResponse: """ Get rating information for a server. Returns average rating and up to 100 most recent individual ratings (maintained as rotating buffer). Args: path: Server path (e.g., /cloudflare-docs) Returns: Rating information with average and individual ratings Raises: requests.HTTPError: If retrieval fails (403 for unauthorized, 404 for not found) """ logger.info(f"Getting ratings for server: {path}") response = self._make_request(method="GET", endpoint=f"/api/servers{path}/rating") result = RatingInfoResponse(**response.json()) logger.info( f"Retrieved ratings for '{path}': {result.num_stars:.2f} stars ({len(result.rating_details)} ratings)" ) return result def get_security_scan(self, path: str) -> SecurityScanResult: """ Get security scan results for a server. Returns the latest security scan results including threat analysis, severity levels, and detailed findings for each tool. Args: path: Server path (e.g., /cloudflare-docs) Returns: Security scan results with analysis_results and tool_results Raises: requests.HTTPError: If retrieval fails (403 for unauthorized, 404 for not found) """ logger.info(f"Getting security scan results for server: {path}") response = self._make_request(method="GET", endpoint=f"/api/servers{path}/security-scan") result = SecurityScanResult(**response.json()) logger.info(f"Retrieved security scan results for '{path}'") return result def rescan_server(self, path: str) -> RescanResponse: """ Trigger a manual security scan for a server. Initiates a new security scan for the specified server and returns the results. This operation is admin-only. Args: path: Server path (e.g., /cloudflare-docs) Returns: Newly generated security scan results Raises: requests.HTTPError: If scan fails (403 for non-admin, 404 for not found, 500 for scan error) """ logger.info(f"Triggering security scan for server: {path}") response = self._make_request(method="POST", endpoint=f"/api/servers{path}/rescan") result = RescanResponse(**response.json()) safety_status = "SAFE" if result.is_safe else "UNSAFE" logger.info( f"Security scan completed for '{path}': {safety_status} " f"(Critical: {result.critical_issues}, High: {result.high_severity}, " f"Medium: {result.medium_severity}, Low: {result.low_severity})" ) return result # Anthropic Registry API Methods (v0.1) def anthropic_list_servers( self, cursor: str | None = None, limit: int | None = None ) -> AnthropicServerList: """ List all MCP servers using the Anthropic Registry API format (v0.1). This endpoint provides pagination support and returns servers in the Anthropic Registry API standard format with reverse-DNS naming. Args: cursor: Pagination cursor (opaque string from previous response) limit: Maximum number of results per page (default: 100, max: 1000) Returns: Anthropic ServerList with servers and pagination metadata Raises: requests.HTTPError: If list operation fails """ logger.info("Listing servers via Anthropic Registry API (v0.1)") params = {} if cursor: params["cursor"] = cursor if limit: params["limit"] = limit response = self._make_request(method="GET", endpoint="/v0.1/servers", params=params) result = AnthropicServerList(**response.json()) logger.info(f"Retrieved {len(result.servers)} servers via Anthropic API") return result def anthropic_list_server_versions(self, server_name: str) -> AnthropicServerList: """ List all versions of a specific server using Anthropic Registry API (v0.1). Currently, the registry maintains only one version per server, so this returns a single-item list. Args: server_name: Server name in reverse-DNS format (e.g., "io.mcpgateway/example-server") Will be URL-encoded automatically. Returns: Anthropic ServerList with single server version Raises: requests.HTTPError: If server not found (404) or user lacks access (403/404) """ logger.info(f"Listing versions for server: {server_name}") # URL-encode the server name encoded_name = quote(server_name, safe="") response = self._make_request( method="GET", endpoint=f"/v0.1/servers/{encoded_name}/versions" ) result = AnthropicServerList(**response.json()) logger.info(f"Retrieved {len(result.servers)} version(s) for {server_name}") return result def anthropic_get_server_version( self, server_name: str, version: str = "latest" ) -> AnthropicServerResponse: """ Get detailed information about a specific server version using Anthropic Registry API (v0.1). Args: server_name: Server name in reverse-DNS format (e.g., "io.mcpgateway/example-server") Will be URL-encoded automatically. version: Version string (e.g., "1.0.0" or "latest"). Default: "latest" Currently only "latest" and "1.0.0" are supported. Returns: Anthropic ServerResponse with full server details Raises: requests.HTTPError: If server not found (404), version not found (404), or user lacks access (403/404) """ logger.info(f"Getting server {server_name} version {version}") # URL-encode both server name and version encoded_name = quote(server_name, safe="") encoded_version = quote(version, safe="") response = self._make_request( method="GET", endpoint=f"/v0.1/servers/{encoded_name}/versions/{encoded_version}" ) result = AnthropicServerResponse(**response.json()) logger.info(f"Retrieved server details for {server_name} v{version}") return result # Local Server Version Management Methods def remove_server_version(self, path: str, version: str) -> dict: """ Remove a version from a server. Args: path: Server path (e.g., "/context7") version: Version to remove Returns: Response dict with status and message Raises: requests.HTTPError: If server not found or cannot remove default """ logger.info(f"Removing version {version} from server {path}") encoded_path = quote(path.lstrip("/"), safe="") encoded_version = quote(version, safe="") response = self._make_request( method="DELETE", endpoint=f"/api/servers/{encoded_path}/versions/{encoded_version}" ) return response.json() def set_default_version(self, path: str, version: str) -> dict: """ Set the default (latest) version for a server. Args: path: Server path (e.g., "/context7") version: Version to set as default Returns: Response dict with status and message Raises: requests.HTTPError: If server or version not found """ logger.info(f"Setting default version to {version} for server {path}") encoded_path = quote(path.lstrip("/"), safe="") response = self._make_request( method="PUT", endpoint=f"/api/servers/{encoded_path}/versions/default", data={"version": version}, ) return response.json() def get_server_versions(self, path: str) -> dict: """ Get all versions for a server. Args: path: Server path (e.g., "/context7") Returns: Dict with path, default_version, and versions list Raises: requests.HTTPError: If server not found """ logger.info(f"Getting versions for server {path}") encoded_path = quote(path.lstrip("/"), safe="") response = self._make_request( method="GET", endpoint=f"/api/servers/{encoded_path}/versions" ) return response.json() # Management API Methods (IAM/User Management) def list_users(self, search: str | None = None, limit: int = 500) -> UserListResponse: """ List Keycloak users (admin only). Args: search: Optional search string to filter users limit: Maximum number of results (default: 500) Returns: UserListResponse with list of users Raises: requests.HTTPError: If not authorized (403) or request fails """ logger.info("Listing Keycloak users") params = {} if search: params["search"] = search if limit != 500: params["limit"] = limit response = self._make_request( method="GET", endpoint="/api/management/iam/users", params=params ) try: response_data = response.json() logger.debug(f"Raw API response: {json.dumps(response_data, indent=2, default=str)}") except json.JSONDecodeError as e: logger.error(f"Failed to decode JSON response: {e}") logger.error(f"Raw response text: {response.text}") logger.error(f"Response status code: {response.status_code}") logger.error(f"Response headers: {dict(response.headers)}") raise try: result = UserListResponse(**response_data) logger.info(f"Retrieved {result.total} users") return result except Exception as e: logger.error(f"Failed to parse user list response: {e}") logger.error(f"Raw response data: {json.dumps(response_data, indent=2, default=str)}") raise def create_m2m_account( self, name: str, groups: list[str], description: str | None = None ) -> M2MAccountResponse: """ Create a machine-to-machine service account. Args: name: Service account name/client ID groups: List of group names for access control description: Optional account description Returns: M2MAccountResponse with client credentials Raises: requests.HTTPError: If not authorized (403), already exists (400), or request fails """ logger.info(f"Creating M2M service account: {name}") data = {"name": name, "groups": groups} if description: data["description"] = description response = self._make_request( method="POST", endpoint="/api/management/iam/users/m2m", data=data ) result = M2MAccountResponse(**response.json()) logger.info(f"M2M account created successfully: {name}") return result def create_human_user( self, username: str, email: str, first_name: str, last_name: str, groups: list[str], password: str | None = None, ) -> UserSummary: """ Create a human user account in Keycloak. Args: username: Username email: Email address first_name: First name last_name: Last name groups: List of group names password: Optional initial password Returns: UserSummary with created user details Raises: requests.HTTPError: If not authorized (403), already exists (400), or request fails """ logger.info(f"Creating human user: {username}") data = { "username": username, "email": email, "firstname": first_name, "lastname": last_name, "groups": groups, } if password: data["password"] = password response = self._make_request( method="POST", endpoint="/api/management/iam/users/human", data=data ) result = UserSummary(**response.json()) logger.info(f"User created successfully: {username}") return result def delete_user(self, username: str) -> UserDeleteResponse: """ Delete a user by username. Args: username: Username to delete Returns: UserDeleteResponse confirming deletion Raises: requests.HTTPError: If not authorized (403), not found (400/404), or request fails """ logger.info(f"Deleting user: {username}") response = self._make_request( method="DELETE", endpoint=f"/api/management/iam/users/{username}" ) result = UserDeleteResponse(**response.json()) logger.info(f"User deleted successfully: {username}") return result def list_keycloak_iam_groups(self) -> GroupListResponse: """ List Keycloak IAM groups (admin only). This is different from list_groups() which returns groups with server associations. This method returns raw Keycloak group data without scopes. Returns: GroupListResponse with list of groups Raises: requests.HTTPError: If not authorized (403) or request fails """ logger.info("Listing Keycloak IAM groups") response = self._make_request(method="GET", endpoint="/api/management/iam/groups") result = GroupListResponse(**response.json()) logger.info(f"Retrieved {result.total} Keycloak groups") return result def create_keycloak_group(self, name: str, description: str | None = None) -> GroupSummary: """ Create a new Keycloak group (admin only). Args: name: Group name description: Optional group description Returns: GroupSummary with created group details Raises: requests.HTTPError: If not authorized (403), already exists (400), or request fails """ logger.info(f"Creating Keycloak group: {name}") data = {"name": name} if description: data["description"] = description response = self._make_request( method="POST", endpoint="/api/management/iam/groups", data=data ) result = GroupSummary(**response.json()) logger.info(f"Group created successfully: {name}") return result def delete_keycloak_group(self, name: str) -> GroupDeleteResponse: """ Delete a Keycloak group by name (admin only). Args: name: Group name to delete Returns: GroupDeleteResponse confirming deletion Raises: requests.HTTPError: If not authorized (403), not found (404), or request fails """ logger.info(f"Deleting Keycloak group: {name}") response = self._make_request( method="DELETE", endpoint=f"/api/management/iam/groups/{name}" ) result = GroupDeleteResponse(**response.json()) logger.info(f"Group deleted successfully: {name}") return result def get_federation_config(self, config_id: str = "default") -> dict[str, Any]: """ Get federation configuration by ID. Args: config_id: Configuration ID (default: "default") Returns: Federation configuration dictionary Raises: requests.HTTPError: If not found (404) or request fails """ logger.info(f"Getting federation config: {config_id}") response = self._make_request( method="GET", endpoint="/api/federation/config", params={"config_id": config_id} ) result = response.json() logger.info(f"Retrieved federation config: {config_id}") return result def save_federation_config( self, config: dict[str, Any], config_id: str = "default" ) -> dict[str, Any]: """ Create or update federation configuration. Args: config: Federation configuration dictionary config_id: Configuration ID (default: "default") Returns: Saved configuration response Raises: requests.HTTPError: If validation fails (422) or request fails """ logger.info(f"Saving federation config: {config_id}") response = self._make_request( method="POST", endpoint="/api/federation/config", params={"config_id": config_id}, data=config, ) result = response.json() logger.info(f"Federation config saved successfully: {config_id}") return result def delete_federation_config(self, config_id: str = "default") -> dict[str, str]: """ Delete federation configuration. Args: config_id: Configuration ID to delete Returns: Deletion confirmation message Raises: requests.HTTPError: If not found (404) or request fails """ logger.info(f"Deleting federation config: {config_id}") response = self._make_request( method="DELETE", endpoint=f"/api/federation/config/{config_id}" ) result = response.json() logger.info(f"Federation config deleted successfully: {config_id}") return result def list_federation_configs(self) -> dict[str, Any]: """ List all federation configurations. Returns: Dictionary with configs list and total count Raises: requests.HTTPError: If request fails """ logger.info("Listing federation configs") response = self._make_request(method="GET", endpoint="/api/federation/configs") result = response.json() logger.info(f"Retrieved {result.get('total', 0)} federation configs") return result def add_anthropic_server(self, server_name: str, config_id: str = "default") -> dict[str, Any]: """ Add Anthropic server to federation configuration. Args: server_name: Server name (e.g., "io.github.jgador/websharp") config_id: Configuration ID (default: "default") Returns: Updated configuration Raises: requests.HTTPError: If config not found (404), already exists (400), or request fails """ logger.info(f"Adding Anthropic server '{server_name}' to config: {config_id}") response = self._make_request( method="POST", endpoint=f"/api/federation/config/{config_id}/anthropic/servers", params={"server_name": server_name}, ) result = response.json() logger.info(f"Anthropic server added successfully: {server_name}") return result def remove_anthropic_server( self, server_name: str, config_id: str = "default" ) -> dict[str, Any]: """ Remove Anthropic server from federation configuration. Args: server_name: Server name to remove config_id: Configuration ID (default: "default") Returns: Updated configuration Raises: requests.HTTPError: If config or server not found (404) or request fails """ logger.info(f"Removing Anthropic server '{server_name}' from config: {config_id}") response = self._make_request( method="DELETE", endpoint=f"/api/federation/config/{config_id}/anthropic/servers/{server_name}", ) result = response.json() logger.info(f"Anthropic server removed successfully: {server_name}") return result def add_asor_agent(self, agent_id: str, config_id: str = "default") -> dict[str, Any]: """ Add ASOR agent to federation configuration. Args: agent_id: Agent ID (e.g., "aws_assistant") config_id: Configuration ID (default: "default") Returns: Updated configuration Raises: requests.HTTPError: If config not found (404), already exists (400), or request fails """ logger.info(f"Adding ASOR agent '{agent_id}' to config: {config_id}") response = self._make_request( method="POST", endpoint=f"/api/federation/config/{config_id}/asor/agents", params={"agent_id": agent_id}, ) result = response.json() logger.info(f"ASOR agent added successfully: {agent_id}") return result def remove_asor_agent(self, agent_id: str, config_id: str = "default") -> dict[str, Any]: """ Remove ASOR agent from federation configuration. Args: agent_id: Agent ID to remove config_id: Configuration ID (default: "default") Returns: Updated configuration Raises: requests.HTTPError: If config or agent not found (404) or request fails """ logger.info(f"Removing ASOR agent '{agent_id}' from config: {config_id}") response = self._make_request( method="DELETE", endpoint=f"/api/federation/config/{config_id}/asor/agents/{agent_id}" ) result = response.json() logger.info(f"ASOR agent removed successfully: {agent_id}") return result def sync_federation( self, config_id: str = "default", source: str | None = None ) -> dict[str, Any]: """ Trigger manual federation sync to import servers/agents. Args: config_id: Configuration ID (default: "default") source: Optional source filter ("anthropic" or "asor"). None syncs all enabled sources. Returns: Sync results with counts of synced items Raises: requests.HTTPError: If config not found (404) or request fails """ logger.info(f"Triggering federation sync for config: {config_id}") params = {} if source: params["source"] = source response = self._make_request( method="POST", endpoint="/api/federation/sync", params={"config_id": config_id, **params}, ) result = response.json() logger.info(f"Federation sync completed: {result.get('total_synced', 0)} items synced") return result # ========================================== # Peer Federation Management Methods # ========================================== def list_peers(self, enabled: bool | None = None) -> dict[str, Any]: """ List all configured peer registries. Args: enabled: Optional filter by enabled status Returns: Dictionary with peers list Raises: requests.HTTPError: If request fails """ logger.info("Listing peer registries") params = {} if enabled is not None: params["enabled"] = str(enabled).lower() response = self._make_request( method="GET", endpoint="/api/peers", params=params if params else None ) result = response.json() logger.info(f"Retrieved {len(result) if isinstance(result, list) else 0} peers") return result def add_peer(self, config: dict[str, Any]) -> dict[str, Any]: """ Add a new peer registry. Args: config: Peer configuration dictionary with peer_id, name, endpoint, etc. Returns: Created peer configuration Raises: requests.HTTPError: If peer already exists (409) or request fails """ peer_id = config.get("peer_id", "unknown") logger.info(f"Adding peer registry: {peer_id}") response = self._make_request(method="POST", endpoint="/api/peers", data=config) result = response.json() logger.info(f"Peer registry added successfully: {peer_id}") return result def get_peer(self, peer_id: str) -> dict[str, Any]: """ Get details of a specific peer registry. Args: peer_id: Peer registry identifier Returns: Peer configuration details Raises: requests.HTTPError: If peer not found (404) or request fails """ logger.info(f"Getting peer registry: {peer_id}") response = self._make_request(method="GET", endpoint=f"/api/peers/{peer_id}") result = response.json() logger.info(f"Retrieved peer registry: {peer_id}") return result def update_peer(self, peer_id: str, config: dict[str, Any]) -> dict[str, Any]: """ Update an existing peer registry configuration. Args: peer_id: Peer registry identifier config: Updated peer configuration Returns: Updated peer configuration Raises: requests.HTTPError: If peer not found (404) or request fails """ logger.info(f"Updating peer registry: {peer_id}") response = self._make_request(method="PUT", endpoint=f"/api/peers/{peer_id}", data=config) result = response.json() logger.info(f"Peer registry updated successfully: {peer_id}") return result def update_peer_token(self, peer_id: str, federation_token: str) -> dict[str, Any]: """ Update only the federation token for a peer registry. This is useful for recovering from token loss (issue #561) or rotating tokens without triggering a full peer update. Args: peer_id: Peer registry identifier federation_token: New federation token value Returns: Success message with peer ID Raises: requests.HTTPError: If peer not found (404) or request fails """ logger.info(f"Updating federation token for peer: {peer_id}") response = self._make_request( method="PATCH", endpoint=f"/api/peers/{peer_id}/token", data={"federation_token": federation_token}, ) result = response.json() logger.info(f"Federation token updated successfully for peer: {peer_id}") return result def remove_peer(self, peer_id: str) -> dict[str, Any]: """ Remove a peer registry. Args: peer_id: Peer registry identifier Returns: Deletion confirmation Raises: requests.HTTPError: If peer not found (404) or request fails """ logger.info(f"Removing peer registry: {peer_id}") response = self._make_request(method="DELETE", endpoint=f"/api/peers/{peer_id}") # Handle 204 No Content response if response.status_code == 204: logger.info(f"Peer registry removed successfully: {peer_id}") return {"status": "deleted", "peer_id": peer_id} result = response.json() logger.info(f"Peer registry removed successfully: {peer_id}") return result def sync_peer(self, peer_id: str) -> dict[str, Any]: """ Trigger sync from a specific peer registry. Args: peer_id: Peer registry identifier Returns: Sync result with statistics Raises: requests.HTTPError: If peer not found (404) or request fails """ logger.info(f"Syncing from peer registry: {peer_id}") response = self._make_request(method="POST", endpoint=f"/api/peers/{peer_id}/sync") result = response.json() logger.info(f"Peer sync completed: {peer_id}") return result def sync_all_peers(self) -> dict[str, Any]: """ Trigger sync from all enabled peer registries. Returns: Sync results for all peers Raises: requests.HTTPError: If request fails """ logger.info("Syncing from all peer registries") response = self._make_request(method="POST", endpoint="/api/peers/sync") result = response.json() logger.info("All peer sync completed") return result def get_peer_status(self, peer_id: str) -> dict[str, Any]: """ Get sync status for a specific peer registry. Args: peer_id: Peer registry identifier Returns: Sync status with history Raises: requests.HTTPError: If peer not found (404) or request fails """ logger.info(f"Getting sync status for peer: {peer_id}") response = self._make_request(method="GET", endpoint=f"/api/peers/{peer_id}/status") result = response.json() logger.info(f"Retrieved sync status for peer: {peer_id}") return result def enable_peer(self, peer_id: str) -> dict[str, Any]: """ Enable a peer registry. Args: peer_id: Peer registry identifier Returns: Updated peer configuration Raises: requests.HTTPError: If peer not found (404) or request fails """ logger.info(f"Enabling peer registry: {peer_id}") response = self._make_request(method="POST", endpoint=f"/api/peers/{peer_id}/enable") result = response.json() logger.info(f"Peer registry enabled: {peer_id}") return result def disable_peer(self, peer_id: str) -> dict[str, Any]: """ Disable a peer registry. Args: peer_id: Peer registry identifier Returns: Updated peer configuration Raises: requests.HTTPError: If peer not found (404) or request fails """ logger.info(f"Disabling peer registry: {peer_id}") response = self._make_request(method="POST", endpoint=f"/api/peers/{peer_id}/disable") result = response.json() logger.info(f"Peer registry disabled: {peer_id}") return result def get_peer_connections(self) -> dict[str, Any]: """ Get all federation connections across all peers. Returns: Dictionary with connection details Raises: requests.HTTPError: If request fails """ logger.info("Getting all peer connections") response = self._make_request(method="GET", endpoint="/api/peers/connections/all") result = response.json() logger.info("Retrieved peer connections") return result def get_shared_resources(self) -> dict[str, Any]: """ Get resource sharing summary across all peers. Returns: Dictionary with shared resource details Raises: requests.HTTPError: If request fails """ logger.info("Getting shared resources summary") response = self._make_request(method="GET", endpoint="/api/peers/shared-resources") result = response.json() logger.info("Retrieved shared resources summary") return result # ========================================== # Agent Skills Management Methods # ========================================== def register_skill(self, request: SkillRegistrationRequest) -> SkillCard: """ Register a new Agent Skill. Args: request: Skill registration request Returns: SkillCard with registered skill details Raises: requests.HTTPError: If skill already exists (409) or validation fails (400/422) """ logger.info(f"Registering skill: {request.name}") response = self._make_request( method="POST", endpoint="/api/skills", data=request.model_dump(exclude_none=True) ) result = response.json() logger.info(f"Skill registered successfully: {result.get('name')} at {result.get('path')}") return SkillCard(**result) def list_skills( self, include_disabled: bool = False, tag: str | None = None, limit: int = 20, offset: int = 0, ) -> SkillListResponse: """ List all Agent Skills. Args: include_disabled: Include disabled skills tag: Filter by tag limit: Maximum number of skills to return per page offset: Number of skills to skip for pagination Returns: SkillListResponse with list of skills Raises: requests.HTTPError: If request fails """ logger.info("Listing skills") params: dict[str, str | int] = { "limit": limit, "offset": offset, } if include_disabled: params["include_disabled"] = "true" if tag: params["tag"] = tag response = self._make_request(method="GET", endpoint="/api/skills", params=params) result = response.json() skills = [SkillCard(**s) for s in result.get("skills", [])] total_count = result.get("total_count", len(skills)) resp_limit = result.get("limit", limit) resp_offset = result.get("offset", offset) has_next = result.get("has_next", False) logger.info( f"Retrieved {len(skills)} skills" f" (total={total_count}, offset={resp_offset}," f" limit={resp_limit}, has_next={has_next})" ) return SkillListResponse( skills=skills, total_count=total_count, limit=resp_limit, offset=resp_offset, has_next=has_next, ) def get_skill(self, path: str) -> SkillCard: """ Get details for a specific skill. Args: path: Skill path or name Returns: SkillCard with skill details Raises: requests.HTTPError: If skill not found (404) """ # Normalize path - remove /skills/ prefix if present api_path = path.replace("/skills/", "/") if path.startswith("/skills/") else f"/{path}" logger.info(f"Getting skill: {api_path}") response = self._make_request(method="GET", endpoint=f"/api/skills{api_path}") result = response.json() logger.info(f"Retrieved skill: {result.get('name')}") return SkillCard(**result) def update_skill(self, path: str, request: SkillRegistrationRequest) -> SkillCard: """ Update an existing skill. Args: path: Skill path or name request: Updated skill data Returns: Updated SkillCard Raises: requests.HTTPError: If skill not found (404) or validation fails """ api_path = path.replace("/skills/", "/") if path.startswith("/skills/") else f"/{path}" logger.info(f"Updating skill: {api_path}") response = self._make_request( method="PUT", endpoint=f"/api/skills{api_path}", data=request.model_dump(exclude_none=True), ) result = response.json() logger.info(f"Skill updated: {result.get('name')}") return SkillCard(**result) def delete_skill(self, path: str) -> bool: """ Delete a skill. Args: path: Skill path or name Returns: True if deleted successfully Raises: requests.HTTPError: If skill not found (404) or permission denied (403) """ api_path = path.replace("/skills/", "/") if path.startswith("/skills/") else f"/{path}" logger.info(f"Deleting skill: {api_path}") self._make_request(method="DELETE", endpoint=f"/api/skills{api_path}") logger.info(f"Skill deleted: {api_path}") return True def toggle_skill(self, path: str, enabled: bool) -> SkillToggleResponse: """ Toggle skill enabled/disabled state. Args: path: Skill path or name enabled: New enabled state Returns: SkillToggleResponse with new state Raises: requests.HTTPError: If skill not found (404) """ api_path = path.replace("/skills/", "/") if path.startswith("/skills/") else f"/{path}" logger.info(f"Toggling skill {api_path} to enabled={enabled}") response = self._make_request( method="POST", endpoint=f"/api/skills{api_path}/toggle", data={"enabled": enabled} ) result = response.json() logger.info(f"Skill toggled: {result.get('path')} -> enabled={result.get('is_enabled')}") return SkillToggleResponse(**result) def check_skill_health(self, path: str) -> SkillHealthResponse: """ Check skill health (SKILL.md accessibility). Args: path: Skill path or name Returns: SkillHealthResponse with health status Raises: requests.HTTPError: If skill not found (404) """ api_path = path.replace("/skills/", "/") if path.startswith("/skills/") else f"/{path}" logger.info(f"Checking health for skill: {api_path}") response = self._make_request(method="GET", endpoint=f"/api/skills{api_path}/health") result = response.json() logger.info(f"Skill health: {result.get('path')} -> healthy={result.get('healthy')}") return SkillHealthResponse(**result) def get_skill_content(self, path: str) -> SkillContentResponse: """ Get SKILL.md content for a skill. Args: path: Skill path or name Returns: SkillContentResponse with content Raises: requests.HTTPError: If skill not found (404) or content unavailable """ api_path = path.replace("/skills/", "/") if path.startswith("/skills/") else f"/{path}" logger.info(f"Getting content for skill: {api_path}") response = self._make_request(method="GET", endpoint=f"/api/skills{api_path}/content") result = response.json() content_len = len(result.get("content", "")) logger.info(f"Retrieved skill content: {content_len} characters") return SkillContentResponse(**result) def search_skills(self, query: str, tags: str | None = None) -> SkillSearchResponse: """ Search for skills by query. Args: query: Search query tags: Optional comma-separated tags filter Returns: SkillSearchResponse with matching skills Raises: requests.HTTPError: If request fails """ logger.info(f"Searching skills: query='{query}', tags={tags}") params = {"q": query} if tags: params["tags"] = tags response = self._make_request(method="GET", endpoint="/api/skills/search", params=params) result = response.json() logger.info(f"Found {result.get('total_count', 0)} skills matching '{query}'") return SkillSearchResponse(**result) def rate_skill(self, path: str, rating: int) -> dict[str, Any]: """ Rate a skill (1-5 stars). Args: path: Skill path or name rating: Rating value (1-5) Returns: Rating response with average rating Raises: requests.HTTPError: If skill not found (404) or invalid rating (400) """ if not 1 <= rating <= 5: raise ValueError("Rating must be between 1 and 5") api_path = path.replace("/skills/", "/") if path.startswith("/skills/") else f"/{path}" logger.info(f"Rating skill {api_path}: {rating} stars") response = self._make_request( method="POST", endpoint=f"/api/skills{api_path}/rate", data={"rating": rating} ) result = response.json() logger.info(f"Skill rated: avg={result.get('average_rating')}") return result def get_skill_rating(self, path: str) -> SkillRatingResponse: """ Get rating information for a skill. Args: path: Skill path or name Returns: SkillRatingResponse with rating details Raises: requests.HTTPError: If skill not found (404) """ api_path = path.replace("/skills/", "/") if path.startswith("/skills/") else f"/{path}" logger.info(f"Getting rating for skill: {api_path}") response = self._make_request(method="GET", endpoint=f"/api/skills{api_path}/rating") result = response.json() logger.info(f"Skill rating: {result.get('num_stars')} stars") return SkillRatingResponse(**result) def get_skill_security_scan(self, path: str) -> SkillSecurityScanResponse: """ Get security scan results for a skill. Returns the latest security scan results including threat analysis, findings by analyzer, and overall safety status. Args: path: Skill path or name Returns: Security scan results with analysis_results and scan_results """ api_path = path.replace("/skills/", "/") if path.startswith("/skills/") else f"/{path}" logger.info(f"Getting security scan results for skill: {api_path}") response = self._make_request(method="GET", endpoint=f"/api/skills{api_path}/security-scan") result = SkillSecurityScanResponse(**response.json()) logger.info(f"Retrieved security scan results for skill '{api_path}'") return result def rescan_skill(self, path: str) -> SkillRescanResponse: """ Trigger a manual security scan for a skill. Initiates a new security scan for the specified skill and returns the scan results. Requires admin privileges. Args: path: Skill path or name Returns: Newly generated security scan results """ api_path = path.replace("/skills/", "/") if path.startswith("/skills/") else f"/{path}" logger.info(f"Triggering security scan for skill: {api_path}") response = self._make_request(method="POST", endpoint=f"/api/skills{api_path}/rescan") result = SkillRescanResponse(**response.json()) safety_status = "SAFE" if result.is_safe else "UNSAFE" logger.info( f"Security scan completed for skill '{api_path}': {safety_status} " f"(C:{result.critical_issues} H:{result.high_severity} " f"M:{result.medium_severity} L:{result.low_severity})" ) return result # ========================================================================= # Virtual MCP Server Operations # ========================================================================= def create_virtual_server(self, request: VirtualServerCreateRequest) -> VirtualServerConfig: """ Create a new virtual MCP server. Args: request: Virtual server creation request with tool mappings Returns: VirtualServerConfig with created server details Raises: requests.HTTPError: If creation fails (400 invalid, 409 conflict) """ logger.info(f"Creating virtual server: {request.path}") logger.debug(f"Virtual server config:\n{json.dumps(request.model_dump(), indent=2)}") response = self._make_request( method="POST", endpoint="/api/virtual-servers", data=request.model_dump() ) result = response.json() logger.info(f"Virtual server created: {result.get('path')}") return VirtualServerConfig(**result) def list_virtual_servers( self, enabled_only: bool = False, tag: str | None = None, limit: int = 100, offset: int = 0, ) -> VirtualServerListResponse: """ List virtual MCP servers. Args: enabled_only: If True, only return enabled servers tag: Filter by tag limit: Maximum number of results offset: Pagination offset Returns: VirtualServerListResponse with list of servers """ params = {"limit": limit, "offset": offset} if enabled_only: params["enabled_only"] = "true" if tag: params["tag"] = tag logger.info(f"Listing virtual servers (enabled_only={enabled_only}, tag={tag})") response = self._make_request(method="GET", endpoint="/api/virtual-servers", params=params) result = response.json() logger.info(f"Found {result.get('total', 0)} virtual servers") return VirtualServerListResponse(**result) def get_virtual_server(self, path: str) -> VirtualServerConfig: """ Get details of a virtual MCP server. Args: path: Virtual server path (e.g., /virtual/dev-tools) Returns: VirtualServerConfig with server details Raises: requests.HTTPError: If server not found (404) """ api_path = path if path.startswith("/") else f"/{path}" logger.info(f"Getting virtual server: {api_path}") response = self._make_request(method="GET", endpoint=f"/api/virtual-servers{api_path}") result = response.json() logger.info(f"Virtual server: {result.get('server_name')}") return VirtualServerConfig(**result) def update_virtual_server( self, path: str, request: VirtualServerCreateRequest ) -> VirtualServerConfig: """ Update an existing virtual MCP server. Args: path: Virtual server path request: Updated configuration Returns: VirtualServerConfig with updated server details Raises: requests.HTTPError: If server not found (404) or invalid (400) """ api_path = path if path.startswith("/") else f"/{path}" logger.info(f"Updating virtual server: {api_path}") logger.debug(f"Updated config:\n{json.dumps(request.model_dump(), indent=2)}") response = self._make_request( method="PUT", endpoint=f"/api/virtual-servers{api_path}", data=request.model_dump() ) result = response.json() logger.info(f"Virtual server updated: {result.get('path')}") return VirtualServerConfig(**result) def delete_virtual_server(self, path: str) -> VirtualServerDeleteResponse: """ Delete a virtual MCP server. Args: path: Virtual server path Returns: VirtualServerDeleteResponse with confirmation Raises: requests.HTTPError: If server not found (404) """ api_path = path if path.startswith("/") else f"/{path}" logger.info(f"Deleting virtual server: {api_path}") response = self._make_request(method="DELETE", endpoint=f"/api/virtual-servers{api_path}") result = response.json() logger.info(f"Virtual server deleted: {api_path}") return VirtualServerDeleteResponse(**result) def toggle_virtual_server(self, path: str, enable: bool) -> VirtualServerToggleResponse: """ Enable or disable a virtual MCP server. Args: path: Virtual server path enable: True to enable, False to disable Returns: VirtualServerToggleResponse with new state Raises: requests.HTTPError: If server not found (404) """ api_path = path if path.startswith("/") else f"/{path}" action = "enable" if enable else "disable" logger.info(f"Toggling virtual server {api_path}: {action}") response = self._make_request( method="POST", endpoint=f"/api/virtual-servers{api_path}/{action}" ) result = response.json() logger.info(f"Virtual server {action}d: {result.get('is_enabled')}") return VirtualServerToggleResponse(**result) def rate_virtual_server(self, path: str, rating: int) -> dict[str, Any]: """ Rate a virtual MCP server (1-5 stars). Args: path: Virtual server path rating: Rating value (1-5) Returns: Rating response with average rating Raises: requests.HTTPError: If server not found (404) or invalid rating (400) """ if not 1 <= rating <= 5: raise ValueError("Rating must be between 1 and 5") api_path = path if path.startswith("/") else f"/{path}" logger.info(f"Rating virtual server {api_path}: {rating} stars") response = self._make_request( method="POST", endpoint=f"/api/virtual-servers{api_path}/rate", data={"rating": rating} ) result = response.json() logger.info(f"Virtual server rated: avg={result.get('average_rating')}") return result def get_virtual_server_rating(self, path: str) -> dict[str, Any]: """ Get rating information for a virtual MCP server. Args: path: Virtual server path Returns: Dict with rating details (num_stars, rating_count, etc.) Raises: requests.HTTPError: If server not found (404) """ api_path = path if path.startswith("/") else f"/{path}" logger.info(f"Getting rating for virtual server: {api_path}") response = self._make_request( method="GET", endpoint=f"/api/virtual-servers{api_path}/rating" ) result = response.json() logger.info(f"Virtual server rating: {result.get('num_stars')} stars") return result def force_heartbeat(self) -> dict[str, Any]: """Force an immediate heartbeat telemetry event (admin only). Bypasses the 24-hour lock and sends a heartbeat event immediately. Returns: Dict with status and payload summary. Raises: requests.HTTPError: If not authorized (403) or telemetry disabled (409) """ logger.info("Forcing heartbeat telemetry event") response = self._make_request( method="POST", endpoint="/api/registry-management/telemetry/heartbeat", ) result = response.json() logger.info(f"Heartbeat result: {result.get('status')}") return result def force_startup_ping(self) -> dict[str, Any]: """Force an immediate startup telemetry event (admin only). Bypasses the 60-second lock and sends a startup ping immediately. Returns: Dict with status and payload summary. Raises: requests.HTTPError: If not authorized (403) or telemetry disabled (409) """ logger.info("Forcing startup telemetry event") response = self._make_request( method="POST", endpoint="/api/registry-management/telemetry/startup", ) result = response.json() logger.info(f"Startup ping result: {result.get('status')}") return result # ------------------------------------------------------------------------- # Direct M2M client registration (issue #851, /api/iam/m2m-clients) # # These endpoints write directly to idp_m2m_clients without calling any # IdP Admin API. Useful when OKTA_API_TOKEN / equivalent is unavailable. # ------------------------------------------------------------------------- def create_m2m_client( self, client_id: str, client_name: str, groups: list[str] | None = None, description: str | None = None, ) -> IdPM2MClient: """Register an M2M client directly (admin only). Args: client_id: IdP application client ID to register. client_name: Human-readable name for the client. groups: Group mappings for authorization. description: Optional description. Returns: The persisted M2M client record. Raises: requests.HTTPError: 401/403 on auth, 409 if client_id already exists, 422 for invalid payload. """ logger.info(f"Registering M2M client: {client_id}") payload: dict[str, Any] = { "client_id": client_id, "client_name": client_name, "groups": list(groups) if groups else [], } if description is not None: payload["description"] = description response = self._make_request( method="POST", endpoint="/api/iam/m2m-clients", data=payload, ) return IdPM2MClient(**response.json()) def list_m2m_clients( self, provider: str | None = None, limit: int = 500, skip: int = 0, ) -> M2MClientListResponse: """List M2M clients with pagination. Args: provider: Optional provider filter (e.g. "manual", "okta"). limit: Max records to return (1-1000). skip: Offset for pagination. Returns: Paginated envelope with total, limit, skip, items. Raises: requests.HTTPError: 401 if unauthenticated. """ logger.info(f"Listing M2M clients (provider={provider}, limit={limit}, skip={skip})") params: dict[str, Any] = {"limit": limit, "skip": skip} if provider is not None: params["provider"] = provider response = self._make_request( method="GET", endpoint="/api/iam/m2m-clients", params=params, ) return M2MClientListResponse(**response.json()) def get_m2m_client(self, client_id: str) -> IdPM2MClient: """Get a single M2M client by client_id. Args: client_id: IdP application client ID. Returns: The M2M client record. Raises: requests.HTTPError: 401 if unauthenticated, 404 if not found. """ logger.info(f"Getting M2M client: {client_id}") response = self._make_request( method="GET", endpoint=f"/api/iam/m2m-clients/{quote(client_id, safe='')}", ) return IdPM2MClient(**response.json()) def patch_m2m_client( self, client_id: str, client_name: str | None = None, groups: list[str] | None = None, description: str | None = None, enabled: bool | None = None, ) -> IdPM2MClient: """Partially update an M2M client (admin only). Only manual records (provider == "manual") can be updated. IdP-synced records return 403. Fields left as None are NOT sent to the server (unchanged). To clear groups, pass an empty list explicitly. Args: client_id: IdP application client ID to update. client_name: New name, or None to leave unchanged. groups: New groups list (empty list clears), or None to leave unchanged. description: New description, or None to leave unchanged. enabled: New enabled flag, or None to leave unchanged. Returns: The updated M2M client record. Raises: requests.HTTPError: 401/403 on auth, 404 if not found, 403 if record was IdP-synced. """ logger.info(f"Updating M2M client: {client_id}") payload: dict[str, Any] = {} if client_name is not None: payload["client_name"] = client_name if groups is not None: payload["groups"] = list(groups) if description is not None: payload["description"] = description if enabled is not None: payload["enabled"] = enabled response = self._make_request( method="PATCH", endpoint=f"/api/iam/m2m-clients/{quote(client_id, safe='')}", data=payload, ) return IdPM2MClient(**response.json()) def delete_m2m_client(self, client_id: str) -> None: """Delete a manual M2M client (admin only). Only manual records (provider == "manual") can be deleted. Args: client_id: IdP application client ID to delete. Raises: requests.HTTPError: 401/403 on auth, 404 if not found, 403 if record was IdP-synced. """ logger.info(f"Deleting M2M client: {client_id}") self._make_request( method="DELETE", endpoint=f"/api/iam/m2m-clients/{quote(client_id, safe='')}", ) # ------------------------------------------------------------------------- # Application Logs (admin-only, issue #886) # ------------------------------------------------------------------------- def get_logs( self, service: str | None = None, level: str | None = None, hostname: str | None = None, search: str | None = None, start: str | None = None, end: str | None = None, limit: int = 100, offset: int = 0, ) -> AppLogResponse: """Query application logs (admin only). Args: service: Filter by service name. level: Minimum log level (DEBUG, INFO, WARNING, ERROR, CRITICAL). hostname: Filter by hostname/pod. search: Substring search in log messages. start: Start timestamp (ISO-8601). end: End timestamp (ISO-8601). limit: Page size (1-10000). offset: Offset for pagination. Returns: AppLogResponse with matching log entries. """ params: dict[str, Any] = {"limit": limit, "offset": offset} if service: params["service"] = service if level: params["level"] = level if hostname: params["hostname"] = hostname if search: params["search"] = search if start: params["start"] = start if end: params["end"] = end response = self._make_request( method="GET", endpoint="/api/admin/logs", params=params, ) return AppLogResponse(**response.json()) def get_log_metadata(self) -> AppLogMetadataResponse: """Get available filter values for application logs (admin only). Returns: AppLogMetadataResponse with services, hostnames, and levels. """ response = self._make_request( method="GET", endpoint="/api/admin/logs/metadata", ) return AppLogMetadataResponse(**response.json()) def get_log_services(self) -> list[str]: """Get list of distinct service names from application logs (admin only). Returns: List of service name strings. """ metadata = self.get_log_metadata() return metadata.services def _format_tool_result( tool: ToolSearchResult, ) -> dict[str, Any]: """ Format a tool search result for display to the agent. The search API returns inputSchema directly, so no additional server lookup is needed. Args: tool: Tool search result Returns: Formatted tool information dict """ result = { "tool_name": tool.tool_name, "server_path": tool.server_path, "server_name": tool.server_name, "description": tool.description or "No description available", "relevance_score": tool.relevance_score, "supported_transports": ["streamable_http"], } # Use inputSchema from search result if available if tool.inputSchema: result["tool_schema"] = tool.inputSchema return result ================================================ FILE: api/registry_management.py ================================================ #!/usr/bin/env python3 """ MCP Gateway Registry Management CLI. High-level wrapper for the RegistryClient providing command-line interface for server registration, management, group operations, and A2A agent management. Server Management: # Register a server from JSON config uv run python registry_management.py register --config /path/to/config.json # List all servers uv run python registry_management.py list # Toggle server status uv run python registry_management.py toggle --path /cloudflare-docs # Remove server uv run python registry_management.py remove --path /cloudflare-docs # Health check uv run python registry_management.py healthcheck # Get registry configuration (deployment mode, features) uv run python registry_management.py config # Get registry configuration as JSON uv run python registry_management.py config --json # Rate a server (1-5 stars) uv run python registry_management.py server-rate --path /cloudflare-docs --rating 5 # Get server rating information uv run python registry_management.py server-rating --path /cloudflare-docs # Get security scan results for a server uv run python registry_management.py security-scan --path /cloudflare-docs # Trigger manual security scan (admin only) uv run python registry_management.py rescan --path /cloudflare-docs Group Management: # Add server to groups uv run python registry_management.py add-to-groups --server my-server --groups group1,group2 # List all groups uv run python registry_management.py list-groups Agent Management (A2A): # Register an agent uv run python registry_management.py agent-register --config /path/to/agent.json # List all agents uv run python registry_management.py agent-list # Get agent details uv run python registry_management.py agent-get --path /code-reviewer # Toggle agent status uv run python registry_management.py agent-toggle --path /code-reviewer --enabled true # Delete agent uv run python registry_management.py agent-delete --path /code-reviewer # Rate an agent (1-5 stars) uv run python registry_management.py agent-rate --path /code-reviewer --rating 5 # Get agent rating information uv run python registry_management.py agent-rating --path /code-reviewer # Discover agents by skills uv run python registry_management.py agent-discover --skills code_analysis,bug_detection # Semantic agent search uv run python registry_management.py agent-search --query "agents that analyze code" Anthropic Registry API (v0.1): # List all servers uv run python registry_management.py anthropic-list # List all servers with raw JSON output uv run python registry_management.py anthropic-list --raw # List versions for a specific server uv run python registry_management.py anthropic-versions --server-name "io.mcpgateway/example-server" # Get server details uv run python registry_management.py anthropic-get --server-name "io.mcpgateway/example-server" --version latest User Management (IAM): # List all Keycloak users uv run python registry_management.py user-list # Search for specific users uv run python registry_management.py user-list --search admin # Create M2M service account uv run python registry_management.py user-create-m2m --name my-service --groups registry-admins # Create human user uv run python registry_management.py user-create-human --username john.doe --email john@example.com --first-name John --last-name Doe --groups registry-admins # Delete user uv run python registry_management.py user-delete --username john.doe Group Management (IAM): # List IAM groups uv run python registry_management.py group-list # Create a new IAM group uv run python registry_management.py group-create --name developers --description "Developer team group" # Delete an IAM group uv run python registry_management.py group-delete --name developers --force Federation Management: # Get federation configuration uv run python registry_management.py federation-get # Save federation configuration from JSON file uv run python registry_management.py federation-save --config federation-config.json # List all federation configurations uv run python registry_management.py federation-list # Add Anthropic server to federation config uv run python registry_management.py federation-add-anthropic-server --server-name io.github.jgador/websharp # Remove Anthropic server from federation config uv run python registry_management.py federation-remove-anthropic-server --server-name io.github.jgador/websharp # Add ASOR agent to federation config uv run python registry_management.py federation-add-asor-agent --agent-id aws_assistant # Remove ASOR agent from federation config uv run python registry_management.py federation-remove-asor-agent --agent-id aws_assistant # Delete federation configuration uv run python registry_management.py federation-delete --config-id default --force Virtual MCP Server Management: # Create a virtual server from JSON config uv run python registry_management.py vs-create --config /path/to/virtual-server.json # List all virtual servers uv run python registry_management.py vs-list # List only enabled virtual servers uv run python registry_management.py vs-list --enabled-only # Get virtual server details uv run python registry_management.py vs-get --path /virtual/dev-tools # Update a virtual server from JSON config uv run python registry_management.py vs-update --path /virtual/dev-tools --config updated-config.json # Enable/disable a virtual server uv run python registry_management.py vs-toggle --path /virtual/dev-tools --enabled true # Delete a virtual server uv run python registry_management.py vs-delete --path /virtual/dev-tools --force # Rate a virtual server (1-5 stars) uv run python registry_management.py vs-rate --path /virtual/dev-tools --rating 5 # Get virtual server rating uv run python registry_management.py vs-rating --path /virtual/dev-tools Registry Card Management: # Get registry card uv run python registry_management.py registry-card-get # Update registry card uv run python registry_management.py registry-card-update --name "My Registry" --description "Production registry" # Update contact information uv run python registry_management.py registry-card-update --contact-email admin@example.com --contact-url https://example.com # Get health status uv run python registry_management.py health Global Options (can be set via environment variables or command-line arguments): --registry-url URL Registry base URL (overrides REGISTRY_URL env var) --aws-region REGION AWS region (overrides AWS_REGION env var) --keycloak-url URL Keycloak base URL (overrides KEYCLOAK_URL env var) --token-file PATH Path to file containing JWT token (bypasses token script) Environment Variables (used if command-line options not provided): REGISTRY_URL: Registry base URL (e.g., https://registry.mycorp.click) AWS_REGION: AWS region where Keycloak and SSM are deployed (e.g., us-east-1) KEYCLOAK_URL: Keycloak base URL (e.g., https://kc.us-east-1.mycorp.click) Environment Variables (Optional): CLIENT_NAME: Keycloak client name (default: registry-admin-bot) GET_TOKEN_SCRIPT: Path to get-m2m-token.sh script Local Development (running against local Docker Compose setup): When running the solution locally with Docker Compose, you can use the --token-file option to provide a pre-generated JWT token instead of dynamically fetching one. Step 1: Generate credentials using the credentials provider script: cd credentials-provider ./generate_creds.sh Step 2: Use the generated token file with the CLI: uv run python api/registry_management.py --debug \\ --registry-url http://localhost \\ --token-file .oauth-tokens/ingress.json \\ list 2>&1 | tee debug.log The credentials-provider/generate_creds.sh script creates tokens in .oauth-tokens/ directory. The ingress.json token file contains the admin JWT token that can be used with the registry management CLI. Other examples for local development: # List users uv run python api/registry_management.py --debug \\ --registry-url http://localhost \\ --token-file .oauth-tokens/ingress.json \\ user-list # Health check uv run python api/registry_management.py --debug \\ --registry-url http://localhost \\ --token-file .oauth-tokens/ingress.json \\ healthcheck # Create M2M account uv run python api/registry_management.py --debug \\ --registry-url http://localhost \\ --token-file .oauth-tokens/ingress.json \\ user-create-m2m --name test-bot --groups developers """ import argparse import json import logging import os import subprocess # nosec B404 import sys from pathlib import Path from typing import Any from registry_client import ( AgentProvider, AgentRegistration, AgentRescanResponse, AgentSecurityScanResponse, AgentVisibility, AnthropicServerList, AnthropicServerResponse, InternalServiceRegistration, RatingInfoResponse, RatingResponse, RegistryClient, Skill, SkillRegistrationRequest, ToolMapping, ToolScopeOverride, VirtualServerCreateRequest, ) # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def _serialize_security_schemes( schemes: dict[str, Any], ) -> dict[str, Any]: """Serialize security schemes to plain dicts for JSON output. Handles both SecurityScheme Pydantic objects and raw dicts (e.g. Bedrock AgentCore httpAuthSecurityScheme format). Args: schemes: Dictionary of security scheme name to scheme data Returns: Dictionary safe for json.dumps """ result: dict[str, Any] = {} for name, scheme in schemes.items(): if isinstance(scheme, dict): result[name] = scheme elif hasattr(scheme, "model_dump"): result[name] = scheme.model_dump(exclude_none=True) else: result[name] = scheme return result def _get_registry_url(cli_value: str | None = None) -> str: """ Get registry URL from command-line argument or environment variable. Args: cli_value: Command-line argument value (overrides environment variable) Returns: Registry base URL Raises: ValueError: If REGISTRY_URL is not provided """ registry_url = cli_value or os.getenv("REGISTRY_URL") if not registry_url: raise ValueError( "REGISTRY_URL is required.\n" "Set via environment variable or --registry-url option:\n" " export REGISTRY_URL=https://registry.mycorp.click\n" " OR\n" " --registry-url https://registry.mycorp.click" ) logger.debug(f"Using registry URL: {registry_url}") return registry_url def _mask_sensitive_fields( data: Any, fields_to_mask: list[str] | None = None, ) -> Any: """ Mask sensitive fields in response data for safe logging/printing. Args: data: Response data (dict, list, or other) fields_to_mask: List of field names to mask (default: federation_token) Returns: Data with sensitive fields masked """ if fields_to_mask is None: fields_to_mask = ["federation_token"] if isinstance(data, dict): masked = {} for key, value in data.items(): if key in fields_to_mask and value: # Show first 3 chars followed by ... if isinstance(value, str) and len(value) > 3: masked[key] = f"{value[:3]}..." else: masked[key] = "***" else: masked[key] = _mask_sensitive_fields(value, fields_to_mask) return masked elif isinstance(data, list): return [_mask_sensitive_fields(item, fields_to_mask) for item in data] else: return data def _get_client_name() -> str: """ Get Keycloak client name from environment variable or default. Returns: Client name """ client_name = os.getenv("CLIENT_NAME", "registry-admin-bot") logger.debug(f"Using client name: {client_name}") return client_name def _get_token_script() -> str: """ Get path to get-m2m-token.sh script. Returns: Script path """ # Default to get-m2m-token.sh in the same directory as this script script_dir = Path(__file__).parent default_script = str(script_dir / "get-m2m-token.sh") script_path = os.getenv("GET_TOKEN_SCRIPT", default_script) logger.debug(f"Using token script: {script_path}") return script_path def _get_jwt_token(aws_region: str | None = None, keycloak_url: str | None = None) -> str: """ Retrieve JWT token using get-m2m-token.sh script. Args: aws_region: AWS region (passed to script via --aws-region) keycloak_url: Keycloak URL (passed to script via --keycloak-url) Returns: JWT access token Raises: RuntimeError: If token retrieval fails """ client_name = _get_client_name() script_path = _get_token_script() try: # Redact client name in logs for security logger.debug(f"Retrieving token for client: {client_name}") # Build command with optional arguments cmd = [script_path] if aws_region: cmd.extend(["--aws-region", aws_region]) if keycloak_url: cmd.extend(["--keycloak-url", keycloak_url]) cmd.append(client_name) result = subprocess.run(cmd, capture_output=True, text=True, check=True) token = result.stdout.strip() if not token: raise RuntimeError("Empty token returned from get-m2m-token.sh") # Redact token in logs - show only first 8 characters redacted_token = f"{token[:8]}..." if len(token) > 8 else "***" logger.debug(f"Successfully retrieved JWT token: {redacted_token}") return token except subprocess.CalledProcessError as e: logger.error(f"Failed to retrieve token: {e.stderr}") raise RuntimeError(f"Token retrieval failed: {e.stderr}") from e except Exception as e: logger.error(f"Unexpected error retrieving token: {e}") raise RuntimeError(f"Token retrieval error: {e}") from e def _load_json_config(config_path: str) -> dict[str, Any]: """ Load JSON configuration file. Args: config_path: Path to JSON config file Returns: Configuration dictionary Raises: FileNotFoundError: If config file not found json.JSONDecodeError: If config file is invalid JSON """ config_file = Path(config_path) if not config_file.exists(): raise FileNotFoundError(f"Configuration file not found: {config_path}") with open(config_file) as f: config = json.load(f) logger.debug(f"Loaded configuration from {config_path}") return config def _create_client(args: argparse.Namespace) -> RegistryClient: """ Create and return a configured RegistryClient instance. Args: args: Command arguments containing optional CLI values Returns: RegistryClient instance Raises: RuntimeError: If token retrieval fails FileNotFoundError: If token file not found ValueError: If required configuration is missing """ # Check all required configuration upfront missing_params = [] # Check REGISTRY_URL registry_url = args.registry_url or os.getenv("REGISTRY_URL") if not registry_url: missing_params.append("REGISTRY_URL") # Check if token file is provided if hasattr(args, "token_file") and args.token_file: token_path = Path(args.token_file) if not token_path.exists(): raise FileNotFoundError(f"Token file not found: {args.token_file}") logger.debug(f"Loading token from file: {args.token_file}") # Try to parse as JSON first (token files from generate-agent-token.sh or UI) try: with open(token_path) as f: token_data = json.load(f) # Extract access_token - handle multiple JSON formats: # Format 1: {"access_token": "..."} (from generate-agent-token.sh) # Format 2: {"tokens": {"access_token": "..."}, ...} (from UI "Get JWT Token") # Format 3: {"token_data": {"access_token": "..."}, ...} (alternative UI format) token = token_data.get("access_token") if not token and "tokens" in token_data: token = token_data["tokens"].get("access_token") if not token and "token_data" in token_data: token = token_data["token_data"].get("access_token") if not token: raise RuntimeError( f"No 'access_token' field found in token file: {args.token_file}" ) except json.JSONDecodeError: # Fall back to plain text token file token = token_path.read_text().strip() if not token: raise RuntimeError(f"Empty token in file: {args.token_file}") # Redact token in logs - show only first 8 characters redacted_token = f"{token[:8]}..." if len(token) > 8 else "***" logger.debug(f"Successfully loaded token from file: {redacted_token}") else: # Check parameters needed for token script aws_region = args.aws_region or os.getenv("AWS_REGION") keycloak_url = args.keycloak_url or os.getenv("KEYCLOAK_URL") if not aws_region: missing_params.append("AWS_REGION") if not keycloak_url: missing_params.append("KEYCLOAK_URL") # If any parameters are missing, raise comprehensive error if missing_params: error_msg = "Missing required configuration:\n\n" for param in missing_params: error_msg += f" - {param}\n" error_msg += "\nSet via environment variables or command-line options:\n\n" if "REGISTRY_URL" in missing_params: error_msg += " export REGISTRY_URL=https://registry.example.com\n" error_msg += " OR use --registry-url https://registry.example.com\n\n" if "AWS_REGION" in missing_params: error_msg += " export AWS_REGION=us-east-1\n" error_msg += " OR use --aws-region us-east-1\n\n" if "KEYCLOAK_URL" in missing_params: error_msg += " export KEYCLOAK_URL=https://keycloak.example.com\n" error_msg += " OR use --keycloak-url https://keycloak.example.com\n\n" error_msg += "Alternatively, use --token-file to provide a pre-generated JWT token." raise ValueError(error_msg) token = _get_jwt_token(aws_region=aws_region, keycloak_url=keycloak_url) # Final check for registry URL (in case token file path was provided) if missing_params and "REGISTRY_URL" in missing_params: raise ValueError( "REGISTRY_URL is required.\n" "Set via environment variable or --registry-url option:\n" " export REGISTRY_URL=https://registry.example.com\n" " OR\n" " --registry-url https://registry.example.com" ) return RegistryClient(registry_url=registry_url, token=token) def cmd_register(args: argparse.Namespace) -> int: """ Register a new server from JSON configuration. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: config = _load_json_config(args.config) # Convert config to InternalServiceRegistration # Handle both old and new config formats registration = InternalServiceRegistration( service_path=config.get("path") or config.get("service_path"), name=config.get("server_name") or config.get("name"), description=config.get("description"), proxy_pass_url=config.get("proxy_pass_url"), version=config.get("version"), status=config.get("status"), auth_provider=config.get("auth_provider"), auth_scheme=config.get("auth_scheme", config.get("auth_type")), supported_transports=config.get("supported_transports"), headers=config.get("headers"), tool_list_json=config.get("tool_list_json"), tags=config.get("tags"), overwrite=args.overwrite, mcp_endpoint=config.get("mcp_endpoint"), sse_endpoint=config.get("sse_endpoint"), metadata=config.get("metadata", {}), provider_organization=config.get("provider_organization"), provider_url=config.get("provider_url"), source_created_at=config.get("source_created_at"), source_updated_at=config.get("source_updated_at"), external_tags=config.get("external_tags"), ) client = _create_client(args) response = client.register_service(registration) logger.info(f"Server registered successfully: {response.path}") logger.info(f"Message: {response.message}") return 0 except FileNotFoundError as e: logger.error(f"Configuration file error: {e}") return 1 except json.JSONDecodeError as e: logger.error(f"Invalid JSON configuration: {e}") return 1 except Exception as e: logger.error(f"Registration failed: {e}") return 1 def cmd_list(args: argparse.Namespace) -> int: """ List all registered servers. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) limit = args.limit if hasattr(args, "limit") else 20 offset = args.offset if hasattr(args, "offset") else 0 query = args.query if hasattr(args, "query") else None # Print raw JSON if requested - fetch directly from API to get all fields if hasattr(args, "json") and args.json: import json params: dict[str, str | int] = {"limit": limit, "offset": offset} if query: params["query"] = query raw_response = client._make_request( method="GET", endpoint="/api/servers", params=params ) print(json.dumps(raw_response.json(), indent=2, default=str)) return 0 response = client.list_services( limit=limit, offset=offset, ) if not response.servers: logger.info("No servers registered") return 0 logger.info( f"Found {len(response.servers)} servers " f"(total: {response.total_count}, offset: {response.offset}, limit: {response.limit}):\n" ) for server in response.servers: status_icon = "✓" if server.is_enabled else "✗" health_icon = { "healthy": "🟢", "unhealthy": "🔴", "unknown": "⚪", "disabled": "⚫", }.get(server.health_status.value, "⚪") lifecycle = f" [{server.status}]" if server.status != "active" else "" print(f"{status_icon} {health_icon} {server.path}{lifecycle}") print(f" Name: {server.display_name}") print(f" Description: {server.description}") print(f" Enabled: {server.is_enabled}") print(f" Health: {server.health_status.value}") if server.status != "active": print(f" Lifecycle: {server.status}") print() return 0 except Exception as e: logger.error(f"List operation failed: {e}") return 1 def cmd_toggle(args: argparse.Namespace) -> int: """ Toggle server enabled/disabled status. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.toggle_service(args.path) status = "enabled" if response.is_enabled else "disabled" logger.info(f"Server {response.path} is now {status}") logger.info(f"Message: {response.message}") return 0 except Exception as e: logger.error(f"Toggle operation failed: {e}") return 1 def cmd_remove(args: argparse.Namespace) -> int: """ Remove a server from the registry. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: if not args.force: confirmation = input(f"Remove server {args.path}? (yes/no): ") if confirmation.lower() != "yes": logger.info("Operation cancelled") return 0 client = _create_client(args) response = client.remove_service(args.path) logger.info(f"Server removed successfully: {args.path}") return 0 except Exception as e: logger.error(f"Remove operation failed: {e}") return 1 def cmd_healthcheck(args: argparse.Namespace) -> int: """ Perform health check on all servers. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.healthcheck() logger.info(f"Health check status: {response.get('status', 'unknown')}") logger.info("\nHealth check results:") print(json.dumps(response, indent=2)) return 0 except Exception as e: logger.error(f"Health check failed: {e}") return 1 def cmd_config(args: argparse.Namespace) -> int: """ Get registry configuration including deployment mode and features. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.get_config() logger.info(f"Deployment Mode: {response.get('deployment_mode', 'unknown')}") logger.info(f"Registry Mode: {response.get('registry_mode', 'unknown')}") logger.info(f"Nginx Updates Enabled: {response.get('nginx_updates_enabled', 'unknown')}") if args.json: print(json.dumps(response, indent=2)) else: print("\nRegistry Configuration:") print(f" Deployment Mode: {response.get('deployment_mode')}") print(f" Registry Mode: {response.get('registry_mode')}") print(f" Nginx Updates Enabled: {response.get('nginx_updates_enabled')}") print("\nEnabled Features:") features = response.get("features", {}) for feature, enabled in features.items(): status = "enabled" if enabled else "disabled" print(f" {feature}: {status}") return 0 except Exception as e: logger.error(f"Failed to get config: {e}") return 1 def cmd_add_to_groups(args: argparse.Namespace) -> int: """ Add server to user groups. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: groups = [g.strip() for g in args.groups.split(",")] client = _create_client(args) response = client.add_server_to_groups(args.server, groups) logger.info(f"Server {args.server} added to groups: {', '.join(groups)}") return 0 except Exception as e: logger.error(f"Add to groups failed: {e}") return 1 def cmd_remove_from_groups(args: argparse.Namespace) -> int: """ Remove server from user groups. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: groups = [g.strip() for g in args.groups.split(",")] client = _create_client(args) response = client.remove_server_from_groups(args.server, groups) logger.info(f"Server {args.server} removed from groups: {', '.join(groups)}") return 0 except Exception as e: logger.error(f"Remove from groups failed: {e}") return 1 def cmd_create_group(args: argparse.Namespace) -> int: """ Create a new user group. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.create_group( group_name=args.name, description=args.description, create_in_idp=args.idp ) logger.info(f"Group created successfully: {args.name}") return 0 except Exception as e: logger.error(f"Create group failed: {e}") return 1 def cmd_delete_group(args: argparse.Namespace) -> int: """ Delete a user group. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: if not args.force: confirmation = input(f"Delete group {args.name}? (yes/no): ") if confirmation.lower() != "yes": logger.info("Operation cancelled") return 0 client = _create_client(args) response = client.delete_group( group_name=args.name, delete_from_idp=args.idp, force=args.force ) logger.info(f"Group deleted successfully: {args.name}") return 0 except Exception as e: logger.error(f"Delete group failed: {e}") return 1 def cmd_import_group(args: argparse.Namespace) -> int: """ Import a complete group definition from JSON file. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ import json try: # Read JSON file with open(args.file) as f: group_definition = json.load(f) # Validate required field if "scope_name" not in group_definition: logger.error("JSON file must contain 'scope_name' field") return 1 client = _create_client(args) response = client.import_group(group_definition) logger.info(f"Group imported successfully: {group_definition['scope_name']}") logger.info(f"Response: {json.dumps(response, indent=2)}") return 0 except FileNotFoundError: logger.error(f"File not found: {args.file}") return 1 except json.JSONDecodeError as e: logger.error(f"Invalid JSON in file: {e}") return 1 except Exception as e: logger.error(f"Import group failed: {e}") return 1 def cmd_list_groups(args: argparse.Namespace) -> int: """ List all user groups. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ import json try: client = _create_client(args) response = client.list_groups( include_keycloak=not args.no_keycloak, include_scopes=not args.no_scopes ) # If JSON output requested, print raw response and exit if hasattr(args, "json") and args.json: print(json.dumps(response.model_dump(), indent=2, default=str)) return 0 # Display synchronized groups if response.synchronized: print("\n=== Synchronized Groups (in both Keycloak and Scopes) ===") for group_name in response.synchronized: print(f" - {group_name}") # Show details from scopes if available if group_name in response.scopes_groups: group_info = response.scopes_groups[group_name] if "description" in group_info: print(f" Description: {group_info['description']}") if "server_count" in group_info: print(f" Servers: {group_info['server_count']}") # Display Keycloak-only groups if response.keycloak_only: print("\n=== Keycloak-Only Groups (not in Scopes) ===") for group_name in response.keycloak_only: print(f" - {group_name}") # Display Scopes-only groups if response.scopes_only: print("\n=== Scopes-Only Groups (not in Keycloak) ===") for group_name in response.scopes_only: print(f" - {group_name}") if group_name in response.scopes_groups: group_info = response.scopes_groups[group_name] if "description" in group_info: print(f" Description: {group_info['description']}") # Summary total_keycloak = len(response.keycloak_groups) total_scopes = len(response.scopes_groups) print("\n=== Summary ===") print(f"Total Keycloak groups: {total_keycloak}") print(f"Total Scopes groups: {total_scopes}") print(f"Synchronized: {len(response.synchronized)}") print(f"Keycloak-only: {len(response.keycloak_only)}") print(f"Scopes-only: {len(response.scopes_only)}") return 0 except Exception as e: logger.error(f"List groups failed: {e}") return 1 def cmd_describe_group(args: argparse.Namespace) -> int: """ Describe a specific group with all details. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ import json try: client = _create_client(args) group_name = args.name # Get full group details from scopes storage try: group_data = client.get_group(group_name) except Exception as e: if "404" in str(e): logger.error(f"Group '{group_name}' not found in scopes storage") group_data = None else: raise # If JSON output requested if hasattr(args, "json") and args.json: if group_data: print(json.dumps(group_data, indent=2, default=str)) return 0 else: print(json.dumps({"error": "Group not found", "group_name": group_name}, indent=2)) return 1 # Human-readable output if not group_data: print(f"\nGroup '{group_name}' not found in scopes storage\n") return 1 print(f"\n=== Group: {group_name} ===\n") print(f"Scope Type: {group_data.get('scope_type', 'N/A')}") print(f"Description: {group_data.get('description', 'N/A')}") print(f"Created: {group_data.get('created_at', 'N/A')}") print(f"Updated: {group_data.get('updated_at', 'N/A')}") print("\nServer Access:") server_access = group_data.get("server_access", []) if server_access: for idx, access in enumerate(server_access, 1): print(f" {idx}. Server: {access.get('server', 'N/A')}") if "methods" in access: print(f" Methods: {', '.join(access['methods'])}") if "tools" in access: print(f" Tools: {', '.join(access['tools'])}") if "agents" in access: print(f" Agents: {json.dumps(access['agents'], indent=6)}") else: print(" None") print("\nGroup Mappings:") group_mappings = group_data.get("group_mappings", []) if group_mappings: for mapping in group_mappings: print(f" - {mapping}") else: print(" None") print("\nUI Permissions:") ui_permissions = group_data.get("ui_permissions", {}) if ui_permissions: print(json.dumps(ui_permissions, indent=2)) else: print(" None") return 0 except Exception as e: logger.error(f"Describe group failed: {e}") return 1 def cmd_server_get(args: argparse.Namespace) -> int: """ Get detailed information about a specific server. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) server = client.get_server(args.path) logger.info(f"Retrieved server: {server.server_name}") output = { "server_name": server.server_name, "path": server.path, "description": server.description, "proxy_pass_url": server.proxy_pass_url, "tags": server.tags, "num_tools": server.num_tools, "tool_list": server.tool_list, "is_enabled": server.is_enabled, "health_status": server.health_status, "transport": server.transport, "version": server.version, "versions": server.versions, "license": server.license, } print(json.dumps(output, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Get server failed: {e}") return 1 def cmd_server_rate(args: argparse.Namespace) -> int: """ Rate a server (1-5 stars). Args: args: Command arguments with path and rating Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response: RatingResponse = client.rate_server(path=args.path, rating=args.rating) logger.info(f"✓ {response.message}") logger.info(f"Average rating: {response.average_rating:.2f} stars") return 0 except Exception as e: logger.error(f"Failed to rate server: {e}") return 1 def cmd_server_rating(args: argparse.Namespace) -> int: """ Get rating information for a server. Args: args: Command arguments with path Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response: RatingInfoResponse = client.get_server_rating(path=args.path) logger.info(f"\nRating for server '{args.path}':") logger.info(f" Average: {response.num_stars:.2f} stars") logger.info(f" Total ratings: {len(response.rating_details)}") if response.rating_details: logger.info("\nIndividual ratings (most recent):") # Show first 10 ratings for detail in response.rating_details[:10]: logger.info(f" {detail.user}: {detail.rating} stars") if len(response.rating_details) > 10: logger.info(f" ... and {len(response.rating_details) - 10} more") return 0 except Exception as e: logger.error(f"Failed to get ratings: {e}") return 1 def cmd_security_scan(args: argparse.Namespace) -> int: """ Get security scan results for a server. Args: args: Command arguments with path and optional json flag Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response: SecurityScanResult = client.get_security_scan(path=args.path) if args.json: # Output raw JSON print(json.dumps(response.model_dump(), indent=2, default=str)) else: # Pretty print results logger.info(f"\nSecurity scan results for server '{args.path}':") # Display analysis results by analyzer if response.analysis_results: for analyzer_name, analyzer_data in response.analysis_results.items(): logger.info(f"\n Analyzer: {analyzer_name}") if isinstance(analyzer_data, dict) and "findings" in analyzer_data: findings = analyzer_data["findings"] logger.info(f" Findings: {len(findings)}") for finding in findings[:5]: # Show first 5 severity = finding.get("severity", "UNKNOWN") tool_name = finding.get("tool_name", "unknown") logger.info(f" - {tool_name}: {severity}") if len(findings) > 5: logger.info(f" ... and {len(findings) - 5} more") # Display tool results summary if response.tool_results: logger.info(f"\n Total tools scanned: {len(response.tool_results)}") safe_count = sum(1 for tool in response.tool_results if tool.get("is_safe", False)) unsafe_count = len(response.tool_results) - safe_count logger.info(f" Safe tools: {safe_count}") if unsafe_count > 0: logger.info(f" Unsafe tools: {unsafe_count}") logger.warning("\n WARNING: Some tools flagged as potentially unsafe!") return 0 except Exception as e: logger.error(f"Failed to get security scan results: {e}") return 1 def cmd_rescan(args: argparse.Namespace) -> int: """ Trigger manual security scan for a server (admin only). Args: args: Command arguments with path and optional json flag Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response: RescanResponse = client.rescan_server(path=args.path) if args.json: # Output raw JSON print(json.dumps(response.model_dump(), indent=2, default=str)) else: # Pretty print results safety_status = "SAFE" if response.is_safe else "UNSAFE" logger.info(f"\nSecurity scan completed for server '{args.path}':") logger.info(f" Status: {safety_status}") logger.info(f" Scan timestamp: {response.scan_timestamp}") logger.info(f" Analyzers used: {', '.join(response.analyzers_used)}") logger.info("\n Severity counts:") logger.info(f" Critical: {response.critical_issues}") logger.info(f" High: {response.high_severity}") logger.info(f" Medium: {response.medium_severity}") logger.info(f" Low: {response.low_severity}") if response.scan_failed: logger.error(f"\n Scan failed: {response.error_message}") return 1 if not response.is_safe: logger.warning("\n WARNING: Server flagged as potentially unsafe!") return 0 except Exception as e: logger.error(f"Failed to trigger security scan: {e}") return 1 def cmd_server_update_credential(args: argparse.Namespace) -> int: """ Update authentication credentials for a server. Args: args: Command arguments with path, auth-scheme, credential, etc. Returns: Exit code (0 for success, 1 for failure) """ try: # Validate that credential is provided when auth_scheme is not 'none' if args.auth_scheme != "none" and not args.credential: logger.error("--credential is required when --auth-scheme is not 'none'") return 1 client = _create_client(args) response = client.update_server_credential( service_path=args.path, auth_scheme=args.auth_scheme, auth_credential=args.credential, auth_header_name=args.auth_header_name, ) if args.json: # Output raw JSON print(json.dumps(response, indent=2, default=str)) else: # Pretty print results logger.info(f"\nAuth credential updated successfully for '{args.path}':") logger.info(f" Auth scheme: {response.get('auth_scheme')}") if response.get("auth_header_name"): logger.info(f" Header name: {response.get('auth_header_name')}") logger.info(f" Message: {response.get('message')}") return 0 except Exception as e: logger.error(f"Failed to update server credential: {e}") return 1 def cmd_server_search(args: argparse.Namespace) -> int: """ Perform semantic search across all entity types. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.semantic_search(query=args.query, max_results=args.max_results) if args.json: # Output raw JSON print(json.dumps(response.model_dump(), indent=2, default=str)) return 0 total_results = ( len(response.servers) + len(response.tools) + len(response.agents) + len(response.skills) + len(response.virtual_servers) ) if total_results == 0: logger.info("No results found matching the query") return 0 logger.info(f"Search mode: {response.search_mode}") # Display MCP Servers if response.servers: print(f"\n--- MCP Servers ({len(response.servers)}) ---") for server in response.servers: print(f" {server.server_name} ({server.path})") print(f" Relevance: {server.relevance_score:.2%}") if server.tags: print(f" Tags: {', '.join(server.tags[:5])}") if server.description: desc = ( server.description[:100] + "..." if len(server.description) > 100 else server.description ) print(f" {desc}") print() # Display Tools if response.tools: print(f"\n--- Tools ({len(response.tools)}) ---") for tool in response.tools: print(f" {tool.tool_name} (from {tool.server_path})") print(f" Relevance: {tool.relevance_score:.2%}") if tool.description: desc = ( tool.description[:100] + "..." if len(tool.description) > 100 else tool.description ) print(f" {desc}") print() # Display A2A Agents if response.agents: print(f"\n--- A2A Agents ({len(response.agents)}) ---") for agent in response.agents: agent_name = agent.agent_card.get("name", "Unknown") agent_desc = agent.agent_card.get("description", "") agent_skills = agent.agent_card.get("skills", []) print(f" {agent_name} ({agent.path})") print(f" Relevance: {agent.relevance_score:.2%}") if agent_skills: skill_names = [ s.get("name", "") if isinstance(s, dict) else str(s) for s in agent_skills[:5] ] print(f" Skills: {', '.join(skill_names)}") if agent_desc: desc = agent_desc[:100] + "..." if len(agent_desc) > 100 else agent_desc print(f" {desc}") print() # Display Skills if response.skills: print(f"\n--- Skills ({len(response.skills)}) ---") for skill in response.skills: print(f" {skill.skill_name} ({skill.path})") print(f" Relevance: {skill.relevance_score:.2%}") if skill.author: print(f" Author: {skill.author}") if skill.tags: print(f" Tags: {', '.join(skill.tags[:5])}") if skill.description: desc = ( skill.description[:100] + "..." if len(skill.description) > 100 else skill.description ) print(f" {desc}") print() # Display Virtual MCP Servers if response.virtual_servers: print(f"\n--- Virtual MCP Servers ({len(response.virtual_servers)}) ---") for vs in response.virtual_servers: print(f" {vs.server_name} ({vs.path})") print(f" Relevance: {vs.relevance_score:.2%}") print(f" Tools: {vs.num_tools}, Backends: {vs.backend_count}") if vs.backend_paths: print(f" Backend paths: {', '.join(vs.backend_paths)}") if vs.tags: print(f" Tags: {', '.join(vs.tags[:5])}") if vs.description: desc = ( vs.description[:100] + "..." if len(vs.description) > 100 else vs.description ) print(f" {desc}") print() return 0 except Exception as e: logger.error(f"Semantic search failed: {e}") return 1 # Server Version Management Command Handlers def cmd_list_versions(args: argparse.Namespace) -> int: """ List all versions for a server. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.get_server_versions(path=args.path) if args.json: print(json.dumps(response, indent=2, default=str)) return 0 logger.info(f"Versions for server {response['path']}:\n") logger.info(f"Default version: {response['default_version']}\n") for v in response.get("versions", []): default_marker = " (DEFAULT)" if v.get("is_default") else "" status = v.get("status", "stable") print(f" {v['version']}{default_marker}") print(f" Status: {status}") print(f" URL: {v.get('proxy_pass_url', 'N/A')}") print() return 0 except Exception as e: logger.error(f"Failed to list versions: {e}") return 1 def cmd_remove_version(args: argparse.Namespace) -> int: """ Remove a version from a server. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.remove_server_version(path=args.path, version=args.version) if args.json: print(json.dumps(response, indent=2, default=str)) return 0 logger.info(f"Successfully removed version {args.version} from {args.path}") return 0 except Exception as e: logger.error(f"Failed to remove version: {e}") return 1 def cmd_set_default_version(args: argparse.Namespace) -> int: """ Set the default version for a server. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.set_default_version(path=args.path, version=args.version) if args.json: print(json.dumps(response, indent=2, default=str)) return 0 logger.info(f"Successfully set default version to {args.version} for {args.path}") return 0 except Exception as e: logger.error(f"Failed to set default version: {e}") return 1 # Agent Management Command Handlers def cmd_agent_register(args: argparse.Namespace) -> int: """ Register a new A2A agent from JSON configuration. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: config_path = Path(args.config) if not config_path.exists(): logger.error(f"Config file not found: {config_path}") return 1 with open(config_path) as f: config = json.load(f) # Convert skills list of dicts to Skill objects # Handle both 'input_schema' and 'parameters' field names # Also handle 'id' vs 'name' field for skill identifier skills = [] for skill_data in config.get("skills", []): # Get skill identifier - prefer 'id', fall back to 'name' skill_id = skill_data.get("id") or skill_data.get("name", "") skill_name = skill_data.get("name", skill_id) # Normalize field names skill_dict = { "id": skill_id, # Always include id field "name": skill_name, "description": skill_data.get("description", ""), "tags": skill_data.get("tags", []), # Include tags field } # Use 'input_schema' if present, otherwise use 'parameters' if "input_schema" in skill_data: skill_dict["input_schema"] = skill_data["input_schema"] elif "parameters" in skill_data: skill_dict["input_schema"] = skill_data["parameters"] skills.append(Skill(**skill_dict)) config["skills"] = skills # Provider is now a dict object per A2A spec {organization, url} # No conversion needed - pass it through as-is # Normalize and convert visibility string to enum if present if "visibility" in config: # Normalize legacy aliases: "internal" -> "private", "group" -> "group-restricted" _visibility_aliases = {"internal": "private", "group": "group-restricted"} normalized = _visibility_aliases.get( config["visibility"].lower(), config["visibility"].lower() ) try: config["visibility"] = AgentVisibility(normalized) except ValueError: logger.warning(f"Unknown visibility '{config['visibility']}', using 'public'") config["visibility"] = AgentVisibility.PUBLIC # Handle security_schemes conversion # Normalize common security type variations to A2A spec values if "security_schemes" in config: transformed_schemes = {} for scheme_name, scheme_data in config["security_schemes"].items(): scheme_type = scheme_data.get("type", "").lower() # Normalize to A2A spec values: apiKey, http, oauth2, openIdConnect # Keep 'http' as is (for bearer auth), not 'bearer' type_map = { "http": "http", # HTTP auth (including bearer) "bearer": "http", # Bearer is a type of HTTP auth "apikey": "apiKey", "api_key": "apiKey", "oauth2": "oauth2", "openidconnect": "openIdConnect", "openid": "openIdConnect", } mapped_type = type_map.get(scheme_type, "http") # Preserve all fields from the original scheme data transformed_scheme = dict(scheme_data) transformed_scheme["type"] = mapped_type transformed_schemes[scheme_name] = transformed_scheme config["security_schemes"] = transformed_schemes # Remove fields that aren't in AgentRegistration model valid_fields = { "protocol_version", "name", "description", "path", "url", "version", "capabilities", "metadata", "default_input_modes", "default_output_modes", "provider", "security_schemes", "skills", "tags", "visibility", "license", "supported_protocol", "supportedProtocol", "trust_level", "trustLevel", } config = {k: v for k, v in config.items() if k in valid_fields} agent = AgentRegistration(**config) client = _create_client(args) response = client.register_agent(agent) logger.info( f"Agent registered successfully: {response.agent.name} at {response.agent.path}" ) print( json.dumps( { "message": response.message, "agent": { "name": response.agent.name, "path": response.agent.path, "url": response.agent.url, "num_skills": response.agent.num_skills, "is_enabled": response.agent.is_enabled, }, }, indent=2, ) ) return 0 except Exception as e: logger.error(f"Agent registration failed: {e}") logger.debug("Full error details:", exc_info=True) return 1 def cmd_agent_list(args: argparse.Namespace) -> int: """ List all A2A agents. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) limit = args.limit if hasattr(args, "limit") else 20 offset = args.offset if hasattr(args, "offset") else 0 # Print raw JSON if requested - fetch directly from API to get all fields if hasattr(args, "json") and args.json: params: dict[str, str | int] = {"limit": limit, "offset": offset} if hasattr(args, "query") and args.query: params["query"] = args.query if hasattr(args, "enabled_only") and args.enabled_only: params["enabled_only"] = "true" if hasattr(args, "visibility") and args.visibility: params["visibility"] = args.visibility if hasattr(args, "allowed_groups") and args.allowed_groups: params["allowed_groups"] = args.allowed_groups raw_response = client._make_request(method="GET", endpoint="/api/agents", params=params) print(json.dumps(raw_response.json(), indent=2, default=str)) return 0 response = client.list_agents( query=args.query if hasattr(args, "query") else None, enabled_only=args.enabled_only if hasattr(args, "enabled_only") else False, visibility=args.visibility if hasattr(args, "visibility") else None, allowed_groups=args.allowed_groups if hasattr(args, "allowed_groups") else None, limit=limit, offset=offset, ) # Debug mode: print full JSON response if args.debug: logger.debug("Full JSON response from API:") print(json.dumps(response.model_dump(by_alias=True), indent=2, default=str)) print() if not response.agents: logger.info("No agents found") return 0 logger.info( f"Found {len(response.agents)} agents " f"(total: {response.total_count}, offset: {response.offset}, limit: {response.limit}):\n" ) for agent in response.agents: status_icon = "✓" if agent.is_enabled else "✗" lifecycle = f" [{agent.status}]" if agent.status != "active" else "" print(f"{status_icon} {agent.name} ({agent.path}){lifecycle}") print(f" {agent.description}") if agent.status != "active": print(f" Lifecycle: {agent.status}") print() return 0 except Exception as e: logger.error(f"List agents failed: {e}") return 1 def cmd_agent_get(args: argparse.Namespace) -> int: """ Get detailed information about a specific agent. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) agent = client.get_agent(args.path) logger.info(f"Retrieved agent: {agent.name}") output = { "name": agent.name, "path": agent.path, "description": agent.description, "url": agent.url, "version": agent.version, "provider": agent.provider.model_dump() if agent.provider else None, "is_enabled": agent.is_enabled, "visibility": agent.visibility, "trust_level": agent.trust_level, "skills": [ {"name": skill.name, "description": skill.description} for skill in agent.skills ], "security_schemes": _serialize_security_schemes(agent.security_schemes), "default_input_modes": agent.default_input_modes, "default_output_modes": agent.default_output_modes, "supported_protocol": agent.supported_protocol, } if agent.ans_metadata: output["ans_metadata"] = agent.ans_metadata if agent.metadata: output["metadata"] = agent.metadata if agent.capabilities: output["capabilities"] = agent.capabilities print(json.dumps(output, indent=2)) return 0 except Exception as e: logger.error(f"Get agent failed: {e}") return 1 def cmd_agent_update(args: argparse.Namespace) -> int: """ Update an existing agent. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: config_path = Path(args.config) if not config_path.exists(): logger.error(f"Config file not found: {config_path}") return 1 with open(config_path) as f: config = json.load(f) # Convert skills list of dicts to Skill objects # Handle both 'input_schema' and 'parameters' field names skills = [] for skill_data in config.get("skills", []): skill_dict = { "name": skill_data.get("name", skill_data.get("id", "")), "description": skill_data.get("description", ""), } if "input_schema" in skill_data: skill_dict["input_schema"] = skill_data["input_schema"] elif "parameters" in skill_data: skill_dict["input_schema"] = skill_data["parameters"] skills.append(Skill(**skill_dict)) config["skills"] = skills # Convert provider string to enum with validation if "provider" in config: provider_value = config["provider"].lower() provider_map = { "anthropic": AgentProvider.ANTHROPIC, "custom": AgentProvider.CUSTOM, "other": AgentProvider.OTHER, "example corp": AgentProvider.CUSTOM, "example": AgentProvider.CUSTOM, } if provider_value in provider_map: config["provider"] = provider_map[provider_value] else: logger.warning(f"Unknown provider '{config['provider']}', using 'custom'") config["provider"] = AgentProvider.CUSTOM # Normalize and convert visibility string to enum if present if "visibility" in config: # Normalize legacy aliases: "internal" -> "private", "group" -> "group-restricted" _visibility_aliases = {"internal": "private", "group": "group-restricted"} normalized = _visibility_aliases.get( config["visibility"].lower(), config["visibility"].lower() ) try: config["visibility"] = AgentVisibility(normalized) except ValueError: logger.warning(f"Unknown visibility '{config['visibility']}', using 'public'") config["visibility"] = AgentVisibility.PUBLIC # Handle security_schemes conversion if "security_schemes" in config: transformed_schemes = {} for scheme_name, scheme_data in config["security_schemes"].items(): scheme_type = scheme_data.get("type", "").lower() type_map = { "http": "bearer", "bearer": "bearer", "apikey": "api_key", "api_key": "api_key", "oauth2": "oauth2", } mapped_type = type_map.get(scheme_type, "bearer") transformed_schemes[scheme_name] = { "type": mapped_type, "description": scheme_data.get("description", ""), } config["security_schemes"] = transformed_schemes # Remove fields that aren't in AgentRegistration model valid_fields = { "name", "description", "path", "url", "version", "capabilities", "metadata", "provider", "security_schemes", "skills", "tags", "visibility", "license", "supported_protocol", "supportedProtocol", "trust_level", "trustLevel", } config = {k: v for k, v in config.items() if k in valid_fields} agent = AgentRegistration(**config) client = _create_client(args) response = client.update_agent(args.path, agent) logger.info(f"Agent updated successfully: {response.name}") return 0 except Exception as e: logger.error(f"Agent update failed: {e}") logger.debug("Full error details:", exc_info=True) return 1 def cmd_agent_delete(args: argparse.Namespace) -> int: """ Delete an agent from the registry. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: if not args.force: confirmation = input(f"Delete agent {args.path}? (yes/no): ") if confirmation.lower() != "yes": logger.info("Operation cancelled") return 0 client = _create_client(args) client.delete_agent(args.path) logger.info(f"Agent deleted successfully: {args.path}") return 0 except Exception as e: logger.error(f"Agent deletion failed: {e}") return 1 def cmd_agent_toggle(args: argparse.Namespace) -> int: """ Toggle agent enabled/disabled status. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.toggle_agent(args.path, args.enabled) logger.info( f"Agent {response.path} is now {'enabled' if response.is_enabled else 'disabled'}" ) return 0 except Exception as e: logger.error(f"Agent toggle failed: {e}") return 1 def cmd_agent_discover(args: argparse.Namespace) -> int: """ Discover agents by required skills. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: skills = [s.strip() for s in args.skills.split(",")] tags = [t.strip() for t in args.tags.split(",")] if args.tags else None client = _create_client(args) response = client.discover_agents_by_skills( skills=skills, tags=tags, max_results=args.max_results ) if not response.agents: logger.info("No agents found matching the required skills") return 0 logger.info(f"Found {len(response.agents)} matching agents:\n") for agent in response.agents: print(f"{agent.name} ({agent.path})") print(f" Relevance: {agent.relevance_score:.2%}") print(f" Matching skills: {', '.join(agent.matching_skills)}") print() return 0 except Exception as e: logger.error(f"Agent discovery failed: {e}") return 1 def cmd_agent_search(args: argparse.Namespace) -> int: """ Perform semantic search for agents. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.discover_agents_semantic(query=args.query, max_results=args.max_results) if not response.agents: if args.json: print(json.dumps({"agents": [], "query": args.query}, indent=2)) else: logger.info("No agents found matching the query") return 0 if args.json: # Output full JSON response output = { "query": args.query, "agents": [agent.model_dump() for agent in response.agents], } print(json.dumps(output, indent=2, default=str)) else: # Human-readable output logger.info(f"Found {len(response.agents)} matching agents:\n") for agent in response.agents: print(f"{agent.name} ({agent.path})") print(f" Relevance: {agent.relevance_score:.2%}") if agent.trust_verified: print(f" ANS Trust: {agent.trust_verified}") print(f" {agent.description[:100]}...") print() return 0 except Exception as e: logger.error(f"Semantic search failed: {e}") return 1 def cmd_agent_rate(args: argparse.Namespace) -> int: """ Rate an agent (1-5 stars). Args: args: Command arguments with path and rating Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response: RatingResponse = client.rate_agent(path=args.path, rating=args.rating) logger.info(f"✓ {response.message}") logger.info(f"Average rating: {response.average_rating:.2f} stars") return 0 except Exception as e: logger.error(f"Failed to rate agent: {e}") return 1 def cmd_agent_rating(args: argparse.Namespace) -> int: """ Get rating information for an agent. Args: args: Command arguments with path Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response: RatingInfoResponse = client.get_agent_rating(path=args.path) logger.info(f"\nRating for agent '{args.path}':") logger.info(f" Average: {response.num_stars:.2f} stars") logger.info(f" Total ratings: {len(response.rating_details)}") if response.rating_details: logger.info("\nIndividual ratings (most recent):") # Show first 10 ratings for detail in response.rating_details[:10]: logger.info(f" {detail.user}: {detail.rating} stars") if len(response.rating_details) > 10: logger.info(f" ... and {len(response.rating_details) - 10} more") return 0 except Exception as e: logger.error(f"Failed to get ratings: {e}") return 1 def cmd_agent_security_scan(args: argparse.Namespace) -> int: """ Get security scan results for an agent. Args: args: Command arguments with path and optional json flag Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response: AgentSecurityScanResponse = client.get_agent_security_scan(path=args.path) # Always output as JSON since the response structure is complex print(json.dumps(response.model_dump(), indent=2, default=str)) return 0 except Exception as e: logger.error(f"Failed to get security scan results: {e}") return 1 def cmd_agent_rescan(args: argparse.Namespace) -> int: """ Trigger manual security scan for an agent (admin only). Args: args: Command arguments with path and optional json flag Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response: AgentRescanResponse = client.rescan_agent(path=args.path) if hasattr(args, "json") and args.json: # Output raw JSON print(json.dumps(response.model_dump(), indent=2, default=str)) else: # Pretty print results safety_status = "SAFE" if response.is_safe else "UNSAFE" logger.info(f"\nSecurity scan completed for agent '{args.path}':") logger.info(f" Status: {safety_status}") logger.info(f" Scan timestamp: {response.scan_timestamp}") logger.info(f" Analyzers used: {', '.join(response.analyzers_used)}") logger.info("\n Severity counts:") logger.info(f" Critical: {response.critical_issues}") logger.info(f" High: {response.high_severity}") logger.info(f" Medium: {response.medium_severity}") logger.info(f" Low: {response.low_severity}") if response.output_file: logger.info(f"\n Output file: {response.output_file}") if response.scan_failed: logger.error(f"\n Scan failed: {response.error_message}") return 1 if not response.is_safe: logger.warning("\n WARNING: Agent flagged as potentially unsafe!") return 0 except Exception as e: logger.error(f"Failed to trigger security scan: {e}") return 1 # ========================================== # Agent ANS (Agent Name Service) Command Handlers # ========================================== def cmd_agent_ans_link(args: argparse.Namespace) -> int: """ Link an ANS Agent ID to an agent. Args: args: Command arguments with path and ans_agent_id Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) result = client.agent_ans_link( path=args.path, ans_agent_id=args.ans_agent_id, ) if result.get("success"): logger.info(f"Successfully linked ANS ID to agent '{args.path}'") if result.get("ans_metadata"): print(json.dumps(result["ans_metadata"], indent=2, default=str)) else: logger.error(f"Failed to link ANS ID: {result.get('message', 'Unknown error')}") return 1 return 0 except Exception as e: logger.error(f"ANS link failed: {e}") return 1 def cmd_agent_ans_status(args: argparse.Namespace) -> int: """ Get ANS verification status for an agent. Args: args: Command arguments with path Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) result = client.agent_ans_status(path=args.path) if args.json: print(json.dumps(result, indent=2, default=str)) else: logger.info(f"\nANS status for agent '{args.path}':") logger.info(f" Status: {result.get('status', 'unknown')}") logger.info(f" Domain: {result.get('domain', 'N/A')}") logger.info(f" ANS Agent ID: {result.get('ans_agent_id', 'N/A')}") if result.get("verified_at"): logger.info(f" Verified at: {result.get('verified_at')}") if result.get("last_checked"): logger.info(f" Last checked: {result.get('last_checked')}") return 0 except Exception as e: logger.error(f"ANS status check failed: {e}") return 1 def cmd_agent_ans_unlink(args: argparse.Namespace) -> int: """ Remove ANS link from an agent. Args: args: Command arguments with path Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) result = client.agent_ans_unlink(path=args.path) if result.get("success"): logger.info(f"Successfully unlinked ANS from agent '{args.path}'") else: logger.error(f"Failed to unlink ANS: {result.get('message', 'Unknown error')}") return 1 return 0 except Exception as e: logger.error(f"ANS unlink failed: {e}") return 1 # ========================================== # Agent Skills Command Handlers # ========================================== def cmd_skill_register(args: argparse.Namespace) -> int: """ Register a new Agent Skill. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: # Parse metadata JSON if provided metadata = None if hasattr(args, "metadata") and args.metadata: metadata = json.loads(args.metadata) request = SkillRegistrationRequest( name=args.name, skill_md_url=args.url, description=args.description if hasattr(args, "description") else None, version=args.version if hasattr(args, "version") else None, tags=args.tags.split(",") if hasattr(args, "tags") and args.tags else [], target_agents=args.target_agents.split(",") if hasattr(args, "target_agents") and args.target_agents else [], metadata=metadata, visibility=args.visibility if hasattr(args, "visibility") else "public", ) client = _create_client(args) skill = client.register_skill(request) logger.info(f"Skill registered successfully: {skill.name} at {skill.path}") print( json.dumps( { "message": "Skill registered successfully", "skill": { "name": skill.name, "path": skill.path, "description": skill.description, "skill_md_url": skill.skill_md_url, "is_enabled": skill.is_enabled, }, }, indent=2, ) ) return 0 except Exception as e: logger.error(f"Skill registration failed: {e}") return 1 def cmd_skill_list(args: argparse.Namespace) -> int: """ List all Agent Skills. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) limit = args.limit if hasattr(args, "limit") else 20 offset = args.offset if hasattr(args, "offset") else 0 response = client.list_skills( include_disabled=args.include_disabled if hasattr(args, "include_disabled") else False, tag=args.tag if hasattr(args, "tag") else None, limit=limit, offset=offset, ) if hasattr(args, "json") and args.json: print(json.dumps([s.model_dump() for s in response.skills], indent=2, default=str)) return 0 if not response.skills: logger.info("No skills found") return 0 logger.info( f"Found {len(response.skills)} skills " f"(total: {response.total_count}, offset: {response.offset}, limit: {response.limit}):\n" ) for skill in response.skills: status_icon = "[+]" if skill.is_enabled else "[-]" health = f"({skill.health_status})" if skill.health_status else "" lifecycle = f" [{skill.status}]" if skill.status != "active" else "" print(f"{status_icon} {skill.name} {health}{lifecycle}") print(f" Path: {skill.path}") if skill.description: print(f" {skill.description[:80]}...") if skill.status != "active": print(f" Lifecycle: {skill.status}") if skill.tags: print(f" Tags: {', '.join(skill.tags)}") print() return 0 except Exception as e: logger.error(f"List skills failed: {e}") return 1 def cmd_skill_get(args: argparse.Namespace) -> int: """ Get details for a specific skill. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) skill = client.get_skill(args.path) logger.info(f"Retrieved skill: {skill.name}") print( json.dumps( { "name": skill.name, "path": skill.path, "description": skill.description, "skill_md_url": skill.skill_md_url, "skill_md_raw_url": skill.skill_md_raw_url, "version": skill.version, "author": skill.author, "visibility": skill.visibility, "is_enabled": skill.is_enabled, "tags": skill.tags, "owner": skill.owner, "num_stars": skill.num_stars, "health_status": skill.health_status, "created_at": skill.created_at, "updated_at": skill.updated_at, }, indent=2, default=str, ) ) return 0 except Exception as e: logger.error(f"Get skill failed: {e}") return 1 def cmd_skill_delete(args: argparse.Namespace) -> int: """ Delete a skill. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) client.delete_skill(args.path) logger.info(f"Skill deleted: {args.path}") print(json.dumps({"message": "Skill deleted successfully", "path": args.path}, indent=2)) return 0 except Exception as e: logger.error(f"Delete skill failed: {e}") return 1 def cmd_skill_toggle(args: argparse.Namespace) -> int: """ Toggle skill enabled/disabled state. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.toggle_skill(args.path, args.enable) state = "enabled" if response.is_enabled else "disabled" logger.info(f"Skill {state}: {response.path}") print(json.dumps({"path": response.path, "is_enabled": response.is_enabled}, indent=2)) return 0 except Exception as e: logger.error(f"Toggle skill failed: {e}") return 1 def cmd_skill_health(args: argparse.Namespace) -> int: """ Check skill health (SKILL.md accessibility). Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.check_skill_health(args.path) status = "HEALTHY" if response.healthy else "UNHEALTHY" logger.info(f"Skill health: {status}") print( json.dumps( { "path": response.path, "healthy": response.healthy, "status_code": response.status_code, "error": response.error, "response_time_ms": response.response_time_ms, }, indent=2, ) ) return 0 if response.healthy else 1 except Exception as e: logger.error(f"Health check failed: {e}") return 1 def cmd_skill_content(args: argparse.Namespace) -> int: """ Get SKILL.md content for a skill. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.get_skill_content(args.path) if hasattr(args, "raw") and args.raw: # Output raw content only print(response.content) else: logger.info(f"Retrieved content from: {response.url}") print(f"--- SKILL.md ({len(response.content)} chars) ---") print(response.content) print("--- END ---") return 0 except Exception as e: logger.error(f"Get content failed: {e}") return 1 def cmd_skill_search(args: argparse.Namespace) -> int: """ Search for skills. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.search_skills( query=args.query, tags=args.tags if hasattr(args, "tags") else None ) if args.debug: print(json.dumps(response.model_dump(), indent=2, default=str)) return 0 logger.info(f"Found {response.total_count} skills matching '{args.query}':\n") for skill in response.skills: print(f" {skill.get('name')} ({skill.get('path')})") if skill.get("description"): print(f" {skill.get('description')[:60]}...") print(f" Score: {skill.get('relevance_score', 0):.2f}") print() return 0 except Exception as e: logger.error(f"Search skills failed: {e}") return 1 def cmd_skill_rate(args: argparse.Namespace) -> int: """ Rate a skill (1-5 stars). Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: if not 1 <= args.rating <= 5: logger.error("Rating must be between 1 and 5") return 1 client = _create_client(args) response = client.rate_skill(args.path, args.rating) logger.info(f"Skill rated: {args.rating} stars") print( json.dumps( { "message": response.get("message"), "average_rating": response.get("average_rating"), }, indent=2, ) ) return 0 except Exception as e: logger.error(f"Rate skill failed: {e}") return 1 def cmd_skill_rating(args: argparse.Namespace) -> int: """ Get rating information for a skill. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.get_skill_rating(args.path) logger.info(f"Skill rating: {response.num_stars} stars") print( json.dumps( { "num_stars": response.num_stars, "rating_details": response.rating_details, }, indent=2, default=str, ) ) return 0 except Exception as e: logger.error(f"Get rating failed: {e}") return 1 def cmd_skill_security_scan(args: argparse.Namespace) -> int: """ Get security scan results for a skill. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.get_skill_security_scan(path=args.path) print(json.dumps(response.model_dump(), indent=2, default=str)) return 0 except Exception as e: logger.error(f"Failed to get security scan results: {e}") return 1 def cmd_skill_rescan(args: argparse.Namespace) -> int: """ Trigger manual security scan for a skill (admin only). Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.rescan_skill(path=args.path) if not args.json_output: safety_status = "SAFE" if response.is_safe else "UNSAFE" logger.info(f"\nSecurity scan completed for skill '{args.path}':") logger.info(f" Status: {safety_status}") logger.info(f" Critical: {response.critical_issues}") logger.info(f" High: {response.high_severity}") logger.info(f" Medium: {response.medium_severity}") logger.info(f" Low: {response.low_severity}") logger.info(f" Analyzers: {', '.join(response.analyzers_used)}") print(json.dumps(response.model_dump(), indent=2, default=str)) return 0 except Exception as e: logger.error(f"Failed to trigger security scan: {e}") return 1 def cmd_anthropic_list_servers(args: argparse.Namespace) -> int: """ List all servers using Anthropic Registry API v0.1. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) result: AnthropicServerList = client.anthropic_list_servers(limit=args.limit) # Print raw JSON if requested if args.raw: print(json.dumps(result.model_dump(), indent=2, default=str)) return 0 logger.info(f"Retrieved {len(result.servers)} servers\n") if result.metadata: logger.info(f"Next cursor: {result.metadata.nextCursor}") logger.info(f"Count: {result.metadata.count}\n") # Print server details for idx, server_response in enumerate(result.servers, 1): server = server_response.server print(f"{idx}. {server.name}") print(f" Title: {server.title or 'N/A'}") print(f" Description: {server.description[:100]}...") print(f" Version: {server.version}") print(f" Website: {server.websiteUrl or 'N/A'}") if server.repository: print(f" Repository: {server.repository.url}") if server.packages: print(f" Packages: {len(server.packages)} package(s)") print() return 0 except Exception as e: logger.error(f"Failed to list servers: {e}") return 1 def cmd_anthropic_list_versions(args: argparse.Namespace) -> int: """ List versions for a specific server using Anthropic Registry API v0.1. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) result: AnthropicServerList = client.anthropic_list_server_versions( server_name=args.server_name ) # Print raw JSON if requested if args.raw: print(json.dumps(result.model_dump(), indent=2, default=str)) return 0 logger.info(f"Found {len(result.servers)} version(s) for {args.server_name}\n") for idx, server_response in enumerate(result.servers, 1): server = server_response.server print(f"{idx}. Version {server.version}") print(f" Name: {server.name}") print(f" Description: {server.description[:100]}...") print() return 0 except Exception as e: logger.error(f"Failed to list server versions: {e}") return 1 def cmd_anthropic_get_server(args: argparse.Namespace) -> int: """ Get detailed information about a specific server version using Anthropic Registry API v0.1. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) result: AnthropicServerResponse = client.anthropic_get_server_version( server_name=args.server_name, version=args.version, ) # Print raw JSON if requested if args.raw: print(json.dumps(result.model_dump(), indent=2, default=str)) return 0 server = result.server print(f"\nServer: {server.name}") print(f"Title: {server.title or 'N/A'}") print(f"Version: {server.version}") print(f"Description: {server.description}") print(f"Website: {server.websiteUrl or 'N/A'}") if server.repository: print("\nRepository:") print(f" URL: {server.repository.url}") print(f" Source: {server.repository.source}") if server.repository.id: print(f" ID: {server.repository.id}") if server.repository.subfolder: print(f" Subfolder: {server.repository.subfolder}") if server.packages: print(f"\nPackages ({len(server.packages)}):") for idx, package in enumerate(server.packages, 1): print(f" {idx}. {package.registryType}: {package.identifier}") print(f" Version: {package.version}") if package.runtimeHint: print(f" Runtime: {package.runtimeHint}") if server.meta: print("\nMetadata:") print(json.dumps(server.meta, indent=2)) if result.meta: print("\nRegistry Metadata:") print(json.dumps(result.meta, indent=2)) return 0 except Exception as e: logger.error(f"Failed to get server version: {e}") return 1 # User Management Command Handlers (Management API) def cmd_user_list(args: argparse.Namespace) -> int: """ List Keycloak users. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.list_users( search=args.search if hasattr(args, "search") and args.search else None, limit=args.limit if hasattr(args, "limit") else 500, ) if not response.users: logger.info("No users found") return 0 logger.info(f"Found {response.total} users\n") for user in response.users: enabled_icon = "✓" if user.enabled else "✗" print(f"{enabled_icon} {user.username} (ID: {user.id})") print(f" Email: {user.email or 'N/A'}") if user.firstName or user.lastName: name = f"{user.firstName or ''} {user.lastName or ''}".strip() print(f" Name: {name}") print(f" Groups: {', '.join(user.groups) if user.groups else 'None'}") print(f" Enabled: {user.enabled}") print() return 0 except Exception as e: logger.error(f"List users failed: {e}") return 1 def cmd_user_create_m2m(args: argparse.Namespace) -> int: """ Create M2M service account. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: groups = [g.strip() for g in args.groups.split(",")] client = _create_client(args) result = client.create_m2m_account( name=args.name, groups=groups, description=args.description if hasattr(args, "description") and args.description else None, ) logger.info("M2M account created successfully\n") print(f"Client ID: {result.client_id}") print(f"Client Secret: {result.client_secret[:8]}...{result.client_secret[-4:]}") print(f"Groups: {', '.join(result.groups)}") if result.service_principal_id: print(f"Service Principal ID: {result.service_principal_id}") print() print("IMPORTANT: Save the client secret securely - it cannot be retrieved later.") return 0 except Exception as e: logger.error(f"Create M2M account failed: {e}") return 1 def cmd_user_create_human(args: argparse.Namespace) -> int: """ Create human user account. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: groups = [g.strip() for g in args.groups.split(",")] client = _create_client(args) result = client.create_human_user( username=args.username, email=args.email, first_name=args.first_name, last_name=args.last_name, groups=groups, password=args.password if hasattr(args, "password") and args.password else None, ) logger.info("User created successfully\n") print(f"Username: {result.username}") print(f"User ID: {result.id}") print(f"Email: {result.email or 'N/A'}") if result.firstName or result.lastName: name = f"{result.firstName or ''} {result.lastName or ''}".strip() print(f"Name: {name}") print(f"Groups: {', '.join(result.groups)}") print(f"Enabled: {result.enabled}") return 0 except Exception as e: logger.error(f"Create user failed: {e}") return 1 def cmd_user_delete(args: argparse.Namespace) -> int: """ Delete a user. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: if not args.force: confirmation = input(f"Delete user '{args.username}'? (yes/no): ") if confirmation.lower() != "yes": logger.info("Operation cancelled") return 0 client = _create_client(args) result = client.delete_user(args.username) logger.info(f"User '{result.username}' deleted successfully") return 0 except Exception as e: logger.error(f"Delete user failed: {e}") return 1 def _print_m2m_client(client: Any) -> None: """Print an IdPM2MClient record in a readable format.""" print(f"Client ID: {client.client_id}") print(f"Name: {client.name}") print(f"Provider: {client.provider}") print(f"Enabled: {client.enabled}") print(f"Groups: {', '.join(client.groups) if client.groups else '(none)'}") print(f"Description: {client.description or '(none)'}") print(f"Created by: {client.created_by or '(not set)'}") print(f"Created at: {client.created_at}") print(f"Updated at: {client.updated_at}") def cmd_m2m_client_create(args: argparse.Namespace) -> int: """Register an M2M client directly (admin only). Args: args: Command arguments. Returns: Exit code (0 for success, 1 for failure). """ try: groups = [g.strip() for g in args.groups.split(",") if g.strip()] if args.groups else [] client = _create_client(args) result = client.create_m2m_client( client_id=args.client_id, client_name=args.client_name, groups=groups, description=args.description, ) logger.info("M2M client registered successfully\n") _print_m2m_client(result) return 0 except Exception as e: logger.error(f"Register M2M client failed: {e}") return 1 def cmd_m2m_client_list(args: argparse.Namespace) -> int: """List M2M clients with pagination. Args: args: Command arguments. Returns: Exit code (0 for success, 1 for failure). """ try: client = _create_client(args) result = client.list_m2m_clients( provider=args.provider, limit=args.limit, skip=args.skip, ) if args.json: print(result.model_dump_json(indent=2)) return 0 print( f"Total: {result.total} (showing {len(result.items)} at skip={result.skip}, limit={result.limit})\n" ) for item in result.items: _print_m2m_client(item) print("-" * 60) return 0 except Exception as e: logger.error(f"List M2M clients failed: {e}") return 1 def cmd_m2m_client_get(args: argparse.Namespace) -> int: """Get a single M2M client by client_id. Args: args: Command arguments. Returns: Exit code (0 for success, 1 for failure). """ try: client = _create_client(args) result = client.get_m2m_client(args.client_id) if args.json: print(result.model_dump_json(indent=2)) return 0 _print_m2m_client(result) return 0 except Exception as e: logger.error(f"Get M2M client failed: {e}") return 1 def cmd_m2m_client_update(args: argparse.Namespace) -> int: """Partially update an M2M client (admin only). Args: args: Command arguments. Returns: Exit code (0 for success, 1 for failure). """ try: # groups is optional; empty-string input means "clear groups". groups: list[str] | None = None if args.groups is not None: groups = [g.strip() for g in args.groups.split(",") if g.strip()] enabled: bool | None = None if args.enabled is not None: enabled = args.enabled.lower() == "true" client = _create_client(args) result = client.patch_m2m_client( client_id=args.client_id, client_name=args.client_name, groups=groups, description=args.description, enabled=enabled, ) logger.info("M2M client updated successfully\n") _print_m2m_client(result) return 0 except Exception as e: logger.error(f"Update M2M client failed: {e}") return 1 def cmd_m2m_client_delete(args: argparse.Namespace) -> int: """Delete a manual M2M client (admin only). Args: args: Command arguments. Returns: Exit code (0 for success, 1 for failure). """ try: if not args.force: confirmation = input(f"Delete M2M client '{args.client_id}'? (yes/no): ") if confirmation.lower() != "yes": logger.info("Operation cancelled") return 0 client = _create_client(args) client.delete_m2m_client(args.client_id) logger.info(f"M2M client '{args.client_id}' deleted successfully") return 0 except Exception as e: logger.error(f"Delete M2M client failed: {e}") return 1 def cmd_group_create(args: argparse.Namespace) -> int: """ Create a new IAM group. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) result = client.create_keycloak_group(name=args.name, description=args.description) logger.info(f"IAM group created successfully: {result.name}") print(f"\nGroup: {result.name}") print(f" ID: {result.id}") print(f" Path: {result.path}") if result.attributes: print(f" Attributes: {json.dumps(result.attributes, indent=4)}") return 0 except Exception as e: logger.error(f"Create IAM group failed: {e}") return 1 def cmd_group_delete(args: argparse.Namespace) -> int: """ Delete an IAM group. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: if not args.force: confirmation = input(f"Delete IAM group '{args.name}'? (yes/no): ") if confirmation.lower() != "yes": logger.info("Operation cancelled") return 0 client = _create_client(args) result = client.delete_keycloak_group(name=args.name) logger.info(f"IAM group deleted successfully: {result.name}") return 0 except Exception as e: logger.error(f"Delete IAM group failed: {e}") return 1 def cmd_group_list(args: argparse.Namespace) -> int: """ List IAM groups. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.list_keycloak_iam_groups() if not response.groups: logger.info("No IAM groups found") return 0 logger.info(f"Found {response.total} IAM groups:\n") for group in response.groups: print(f"Group: {group['name']}") print(f" ID: {group['id']}") print(f" Path: {group['path']}") if group.get("attributes"): print(f" Attributes: {json.dumps(group['attributes'], indent=4)}") print() return 0 except Exception as e: logger.error(f"List IAM groups failed: {e}") return 1 def cmd_federation_get(args: argparse.Namespace) -> int: """ Get federation configuration. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) config = client.get_federation_config(config_id=args.config_id) print(json.dumps(config, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Get federation config failed: {e}") return 1 def cmd_federation_save(args: argparse.Namespace) -> int: """ Save federation configuration from JSON file. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) # Load config from file with open(args.config) as f: config_data = json.load(f) response = client.save_federation_config(config=config_data, config_id=args.config_id) logger.info(f"Federation config saved successfully: {args.config_id}") print(json.dumps(response, indent=2, default=str)) return 0 except FileNotFoundError: logger.error(f"Config file not found: {args.config}") return 1 except Exception as e: logger.error(f"Save federation config failed: {e}") return 1 def cmd_federation_delete(args: argparse.Namespace) -> int: """ Delete federation configuration. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) if not args.force: confirm = input(f"Delete federation config '{args.config_id}'? (y/N): ") if confirm.lower() != "y": logger.info("Cancelled") return 0 response = client.delete_federation_config(config_id=args.config_id) logger.info(f"Federation config deleted: {args.config_id}") print(json.dumps(response, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Delete federation config failed: {e}") return 1 def cmd_federation_list(args: argparse.Namespace) -> int: """ List all federation configurations. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.list_federation_configs() if args.json: # Output raw JSON print(json.dumps(response, indent=2, default=str)) return 0 if not response.get("configs"): logger.info("No federation configs found") return 0 logger.info(f"Found {response.get('total', 0)} federation configs:\n") for config in response["configs"]: print(f"Config ID: {config.get('id')}") print(f" Created: {config.get('created_at')}") print(f" Updated: {config.get('updated_at')}") print() return 0 except Exception as e: logger.error(f"List federation configs failed: {e}") return 1 def cmd_federation_add_anthropic_server(args: argparse.Namespace) -> int: """ Add Anthropic server to federation config. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.add_anthropic_server( server_name=args.server_name, config_id=args.config_id ) logger.info(f"Anthropic server added: {args.server_name}") print(json.dumps(response, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Add Anthropic server failed: {e}") return 1 def cmd_federation_remove_anthropic_server(args: argparse.Namespace) -> int: """ Remove Anthropic server from federation config. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.remove_anthropic_server( server_name=args.server_name, config_id=args.config_id ) logger.info(f"Anthropic server removed: {args.server_name}") print(json.dumps(response, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Remove Anthropic server failed: {e}") return 1 def cmd_federation_add_asor_agent(args: argparse.Namespace) -> int: """ Add ASOR agent to federation config. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.add_asor_agent(agent_id=args.agent_id, config_id=args.config_id) logger.info(f"ASOR agent added: {args.agent_id}") print(json.dumps(response, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Add ASOR agent failed: {e}") return 1 def cmd_federation_remove_asor_agent(args: argparse.Namespace) -> int: """ Remove ASOR agent from federation config. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.remove_asor_agent(agent_id=args.agent_id, config_id=args.config_id) logger.info(f"ASOR agent removed: {args.agent_id}") print(json.dumps(response, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Remove ASOR agent failed: {e}") return 1 def cmd_federation_sync(args: argparse.Namespace) -> int: """ Trigger manual federation sync to import servers/agents. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.sync_federation(config_id=args.config_id, source=args.source) if args.json: # Output raw JSON print(json.dumps(response, indent=2, default=str)) else: # Formatted output logger.info(f"Federation sync completed: {response.get('message')}") print("\nSync Results:") print(f" Config ID: {response.get('config_id')}") print(f" Total Synced: {response.get('total_synced', 0)}") results = response.get("results", {}) if results.get("anthropic", {}).get("count", 0) > 0: print(f"\n Anthropic Servers ({results['anthropic']['count']}):") for server in results["anthropic"].get("servers", []): print(f" - {server}") if results.get("asor", {}).get("count", 0) > 0: print(f"\n ASOR Agents ({results['asor']['count']}):") for agent in results["asor"].get("agents", []): print(f" - {agent}") if results.get("aws_registry", {}).get("count", 0) > 0: aws_reg = results["aws_registry"] print(f"\n AWS Agent Registry ({aws_reg['count']}):") if aws_reg.get("servers"): print(f" Servers ({len(aws_reg['servers'])}):") for server in aws_reg["servers"]: print(f" - {server}") if aws_reg.get("agents"): print(f" Agents ({len(aws_reg['agents'])}):") for agent in aws_reg["agents"]: print(f" - {agent}") if aws_reg.get("skills"): print(f" Skills ({len(aws_reg['skills'])}):") for skill in aws_reg["skills"]: print(f" - {skill}") return 0 except Exception as e: logger.error(f"Federation sync failed: {e}") return 1 def cmd_peer_list(args: argparse.Namespace) -> int: """ List all configured peer registries. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) enabled_filter = None if hasattr(args, "enabled_only") and args.enabled_only: enabled_filter = True response = client.list_peers(enabled=enabled_filter) if args.json: masked_response = _mask_sensitive_fields(response) print(json.dumps(masked_response, indent=2, default=str)) return 0 peers = response if isinstance(response, list) else response.get("peers", []) if not peers: logger.info("No peer registries configured") return 0 logger.info(f"Found {len(peers)} peer registries:\n") for peer in peers: status = "enabled" if peer.get("enabled") else "disabled" print(f" Peer ID: {peer.get('peer_id')}") print(f" Name: {peer.get('name')}") print(f" Endpoint: {peer.get('endpoint')}") print(f" Status: {status}") print(f" Sync Mode: {peer.get('sync_mode', 'all')}") print() return 0 except Exception as e: logger.error(f"List peers failed: {e}") return 1 def cmd_peer_add(args: argparse.Namespace) -> int: """ Add a new peer registry from a JSON config file. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) with open(args.config) as f: config_data = json.load(f) # Override federation_token from CLI arg if provided if hasattr(args, "federation_token") and args.federation_token: config_data["federation_token"] = args.federation_token response = client.add_peer(config=config_data) logger.info(f"Peer registry added successfully: {config_data.get('peer_id')}") masked_response = _mask_sensitive_fields(response) print(json.dumps(masked_response, indent=2, default=str)) return 0 except FileNotFoundError: logger.error(f"Config file not found: {args.config}") return 1 except Exception as e: logger.error(f"Add peer failed: {e}") return 1 def cmd_peer_get(args: argparse.Namespace) -> int: """ Get details of a specific peer registry. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.get_peer(peer_id=args.peer_id) if args.json: masked_response = _mask_sensitive_fields(response) print(json.dumps(masked_response, indent=2, default=str)) return 0 print(f"Peer ID: {response.get('peer_id')}") print(f"Name: {response.get('name')}") print(f"Endpoint: {response.get('endpoint')}") print(f"Enabled: {response.get('enabled')}") print(f"Sync Mode: {response.get('sync_mode', 'all')}") print(f"Created: {response.get('created_at')}") print(f"Updated: {response.get('updated_at')}") # Mask federation token in non-JSON output fed_token = response.get("federation_token") if fed_token: masked_token = f"{fed_token[:3]}..." if len(fed_token) > 3 else "***" print(f"Fed Token: {masked_token}") whitelist_servers = response.get("whitelist_servers", []) if whitelist_servers: print(f"Whitelist: {', '.join(whitelist_servers)}") tag_filter = response.get("tag_filter", []) if tag_filter: print(f"Tag Filter: {', '.join(tag_filter)}") return 0 except Exception as e: logger.error(f"Get peer failed: {e}") return 1 def cmd_peer_update(args: argparse.Namespace) -> int: """ Update an existing peer registry from a JSON config file. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) with open(args.config) as f: config_data = json.load(f) # Override federation_token from CLI arg if provided if hasattr(args, "federation_token") and args.federation_token: config_data["federation_token"] = args.federation_token response = client.update_peer(peer_id=args.peer_id, config=config_data) logger.info(f"Peer registry updated successfully: {args.peer_id}") masked_response = _mask_sensitive_fields(response) print(json.dumps(masked_response, indent=2, default=str)) return 0 except FileNotFoundError: logger.error(f"Config file not found: {args.config}") return 1 except Exception as e: logger.error(f"Update peer failed: {e}") return 1 def cmd_peer_update_token(args: argparse.Namespace) -> int: """ Update only the federation token for a peer registry. This command is useful for: - Recovering from token loss (issue #561) - Rotating federation tokens without modifying other peer config - Fixing authentication issues after peer updates Args: args: Command arguments with peer_id and federation_token Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.update_peer_token( peer_id=args.peer_id, federation_token=args.federation_token ) logger.info(f"Federation token updated successfully for peer: {args.peer_id}") print(json.dumps(response, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Update peer token failed: {e}") return 1 def cmd_peer_remove(args: argparse.Namespace) -> int: """ Remove a peer registry. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: if not args.force: confirm = input(f"Remove peer registry '{args.peer_id}'? (y/N): ") if confirm.lower() != "y": logger.info("Cancelled") return 0 client = _create_client(args) response = client.remove_peer(peer_id=args.peer_id) logger.info(f"Peer registry removed: {args.peer_id}") masked_response = _mask_sensitive_fields(response) print(json.dumps(masked_response, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Remove peer failed: {e}") return 1 def cmd_peer_sync(args: argparse.Namespace) -> int: """ Trigger sync from a specific peer registry. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.sync_peer(peer_id=args.peer_id) if args.json: print(json.dumps(response, indent=2, default=str)) return 0 # Check success field from SyncResult model success = response.get("success", False) status_text = "SUCCESS" if success else "FAILED" print(f"\nSync Results for peer '{args.peer_id}':") print(f" Status: {status_text}") print(f" Servers Synced: {response.get('servers_synced', 0)}") print(f" Agents Synced: {response.get('agents_synced', 0)}") print(f" Servers Orphaned: {response.get('servers_orphaned', 0)}") print(f" Agents Orphaned: {response.get('agents_orphaned', 0)}") # SyncResult has 'error_message' (singular), not 'errors' (plural) error_msg = response.get("error_message") if error_msg: print("\n Error:") print(f" {error_msg}") return 0 if success else 1 except Exception as e: logger.error(f"Peer sync failed: {e}") return 1 def cmd_peer_sync_all(args: argparse.Namespace) -> int: """ Trigger sync from all enabled peer registries. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.sync_all_peers() if args.json: print(json.dumps(response, indent=2, default=str)) return 0 results = response if isinstance(response, list) else response.get("results", []) print("\nSync All Peers Results:") print(f" Total peers synced: {len(results)}") for result in results: peer_id = result.get("peer_id", "unknown") status = result.get("status", "unknown") print(f"\n {peer_id}: {status}") print(f" Servers: {result.get('servers_synced', 0)}") print(f" Agents: {result.get('agents_synced', 0)}") return 0 except Exception as e: logger.error(f"Sync all peers failed: {e}") return 1 def cmd_peer_status(args: argparse.Namespace) -> int: """ Get sync status for a specific peer registry. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.get_peer_status(peer_id=args.peer_id) if args.json: print(json.dumps(response, indent=2, default=str)) return 0 print(f"\nSync Status for peer '{args.peer_id}':") # Determine last sync status from history or health history = response.get("sync_history", []) if history: last_entry = history[0] last_status = "success" if last_entry.get("success") else "failed" last_time = last_entry.get("completed_at") or last_entry.get("started_at") else: last_status = "never" last_time = response.get("last_successful_sync") or response.get("last_sync_attempt") print(f" Last Sync Status: {last_status}") print(f" Last Sync Time: {last_time or 'never'}") print(f" Last Generation: {response.get('current_generation', 0)}") print(f" Servers Synced: {response.get('total_servers_synced', 0)}") print(f" Agents Synced: {response.get('total_agents_synced', 0)}") print(f" Is Healthy: {response.get('is_healthy', False)}") if history: print(f"\n Recent Sync History ({len(history)} entries):") for entry in history[:5]: entry_status = "success" if entry.get("success") else "failed" entry_time = entry.get("completed_at") or entry.get("started_at") print(f" {entry_time} - {entry_status}") print( f" Servers: {entry.get('servers_synced', 0)}, " f"Agents: {entry.get('agents_synced', 0)}" ) return 0 except Exception as e: logger.error(f"Get peer status failed: {e}") return 1 def cmd_peer_enable(args: argparse.Namespace) -> int: """ Enable a peer registry. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.enable_peer(peer_id=args.peer_id) logger.info(f"Peer registry enabled: {args.peer_id}") masked_response = _mask_sensitive_fields(response) print(json.dumps(masked_response, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Enable peer failed: {e}") return 1 def cmd_peer_disable(args: argparse.Namespace) -> int: """ Disable a peer registry. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.disable_peer(peer_id=args.peer_id) logger.info(f"Peer registry disabled: {args.peer_id}") masked_response = _mask_sensitive_fields(response) print(json.dumps(masked_response, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Disable peer failed: {e}") return 1 def cmd_peer_connections(args: argparse.Namespace) -> int: """ Get all federation connections across all peers. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.get_peer_connections() if args.json: print(json.dumps(response, indent=2, default=str)) return 0 connections = response if isinstance(response, list) else response.get("connections", []) if not connections: logger.info("No federation connections found") return 0 logger.info(f"Found {len(connections)} federation connections:\n") for conn in connections: print(f" Peer: {conn.get('peer_id')}") print(f" Direction: {conn.get('direction', 'unknown')}") print(f" Status: {conn.get('status', 'unknown')}") print() return 0 except Exception as e: logger.error(f"Get peer connections failed: {e}") return 1 def cmd_peer_shared_resources(args: argparse.Namespace) -> int: """ Get resource sharing summary across all peers. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.get_shared_resources() if args.json: print(json.dumps(response, indent=2, default=str)) return 0 print("\nShared Resources Summary:") print(json.dumps(response, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Get shared resources failed: {e}") return 1 # ========================================== # Virtual MCP Server Command Handlers # ========================================== def cmd_vs_create(args: argparse.Namespace) -> int: """ Create a virtual MCP server from JSON config. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) # Load config from file with open(args.config) as f: config_data = json.load(f) # Build tool mappings tool_mappings = [] for mapping in config_data.get("tool_mappings", []): tool_mappings.append( ToolMapping( tool_name=mapping["tool_name"], alias=mapping.get("alias"), backend_server_path=mapping["backend_server_path"], backend_version=mapping.get("backend_version"), description_override=mapping.get("description_override"), ) ) # Build tool scope overrides tool_scope_overrides = [] for override in config_data.get("tool_scope_overrides", []): tool_scope_overrides.append( ToolScopeOverride( tool_alias=override["tool_alias"], required_scopes=override.get("required_scopes", []), ) ) request = VirtualServerCreateRequest( path=config_data["path"], server_name=config_data["server_name"], description=config_data.get("description"), tool_mappings=tool_mappings, required_scopes=config_data.get("required_scopes", []), tool_scope_overrides=tool_scope_overrides, tags=config_data.get("tags", []), supported_transports=config_data.get("supported_transports", ["streamable-http"]), is_enabled=config_data.get("is_enabled", True), ) result = client.create_virtual_server(request) logger.info(f"Virtual server created: {result.path}") print( json.dumps( { "message": "Virtual server created successfully", "virtual_server": { "path": result.path, "server_name": result.server_name, "description": result.description, "is_enabled": result.is_enabled, "tool_count": len(result.tool_mappings), }, }, indent=2, ) ) return 0 except FileNotFoundError: logger.error(f"Config file not found: {args.config}") return 1 except KeyError as e: logger.error(f"Missing required field in config: {e}") return 1 except Exception as e: logger.error(f"Create virtual server failed: {e}") return 1 def cmd_vs_list(args: argparse.Namespace) -> int: """ List virtual MCP servers. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) response = client.list_virtual_servers( enabled_only=args.enabled_only if hasattr(args, "enabled_only") else False, tag=args.tag if hasattr(args, "tag") else None, ) if args.json: print(json.dumps(response.model_dump(), indent=2, default=str)) return 0 print(f"\nVirtual MCP Servers ({response.total} total):") print("-" * 80) for vs in response.virtual_servers: status = "enabled" if vs.is_enabled else "disabled" tool_count = len(vs.tool_mappings) print(f" {vs.path}") print(f" Name: {vs.server_name}") print(f" Status: {status}") print(f" Tools: {tool_count}") if vs.description: print(f" Description: {vs.description[:60]}...") if vs.tags: print(f" Tags: {', '.join(vs.tags)}") print() return 0 except Exception as e: logger.error(f"List virtual servers failed: {e}") return 1 def cmd_vs_get(args: argparse.Namespace) -> int: """ Get virtual MCP server details. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) result = client.get_virtual_server(args.path) if args.json: print(json.dumps(result.model_dump(), indent=2, default=str)) return 0 print(f"\nVirtual MCP Server: {result.path}") print("-" * 60) print(f" Name: {result.server_name}") print(f" Status: {'enabled' if result.is_enabled else 'disabled'}") print(f" Description: {result.description or 'N/A'}") print(f" Rating: {result.num_stars} stars") print(f" Tags: {', '.join(result.tags) if result.tags else 'None'}") print(f" Transports: {', '.join(result.supported_transports)}") print( f" Required Scopes: {', '.join(result.required_scopes) if result.required_scopes else 'None'}" ) print(f"\n Tool Mappings ({len(result.tool_mappings)}):") for mapping in result.tool_mappings: alias_info = f" -> {mapping.alias}" if mapping.alias else "" version_info = f" @{mapping.backend_version}" if mapping.backend_version else "" print(f" - {mapping.tool_name}{alias_info}") print(f" Backend: {mapping.backend_server_path}{version_info}") if result.tool_scope_overrides: print("\n Tool Scope Overrides:") for override in result.tool_scope_overrides: print(f" - {override.tool_alias}: {', '.join(override.required_scopes)}") print(f"\n Created: {result.created_at or 'N/A'}") print(f" Updated: {result.updated_at or 'N/A'}") print(f" Created By: {result.created_by or 'N/A'}") return 0 except Exception as e: logger.error(f"Get virtual server failed: {e}") return 1 def cmd_vs_update(args: argparse.Namespace) -> int: """ Update a virtual MCP server from JSON config. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) # Load config from file with open(args.config) as f: config_data = json.load(f) # Build tool mappings tool_mappings = [] for mapping in config_data.get("tool_mappings", []): tool_mappings.append( ToolMapping( tool_name=mapping["tool_name"], alias=mapping.get("alias"), backend_server_path=mapping["backend_server_path"], backend_version=mapping.get("backend_version"), description_override=mapping.get("description_override"), ) ) # Build tool scope overrides tool_scope_overrides = [] for override in config_data.get("tool_scope_overrides", []): tool_scope_overrides.append( ToolScopeOverride( tool_alias=override["tool_alias"], required_scopes=override.get("required_scopes", []), ) ) request = VirtualServerCreateRequest( path=config_data["path"], server_name=config_data["server_name"], description=config_data.get("description"), tool_mappings=tool_mappings, required_scopes=config_data.get("required_scopes", []), tool_scope_overrides=tool_scope_overrides, tags=config_data.get("tags", []), supported_transports=config_data.get("supported_transports", ["streamable-http"]), is_enabled=config_data.get("is_enabled", True), ) result = client.update_virtual_server(args.path, request) logger.info(f"Virtual server updated: {result.path}") print( json.dumps( { "message": "Virtual server updated successfully", "virtual_server": { "path": result.path, "server_name": result.server_name, "is_enabled": result.is_enabled, }, }, indent=2, ) ) return 0 except FileNotFoundError: logger.error(f"Config file not found: {args.config}") return 1 except Exception as e: logger.error(f"Update virtual server failed: {e}") return 1 def cmd_vs_delete(args: argparse.Namespace) -> int: """ Delete a virtual MCP server. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: if not args.force: confirm = input(f"Delete virtual server '{args.path}'? [y/N]: ") if confirm.lower() != "y": print("Cancelled") return 0 client = _create_client(args) result = client.delete_virtual_server(args.path) logger.info(f"Virtual server deleted: {args.path}") print( json.dumps( { "message": result.message, "path": result.path, }, indent=2, ) ) return 0 except Exception as e: logger.error(f"Delete virtual server failed: {e}") return 1 def cmd_vs_toggle(args: argparse.Namespace) -> int: """ Enable or disable a virtual MCP server. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) enable = args.enabled.lower() == "true" result = client.toggle_virtual_server(args.path, enable) action = "enabled" if result.is_enabled else "disabled" logger.info(f"Virtual server {action}: {args.path}") print( json.dumps( { "message": result.message, "path": result.path, "is_enabled": result.is_enabled, }, indent=2, ) ) return 0 except Exception as e: logger.error(f"Toggle virtual server failed: {e}") return 1 def cmd_vs_rate(args: argparse.Namespace) -> int: """ Rate a virtual MCP server. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: if not 1 <= args.rating <= 5: logger.error("Rating must be between 1 and 5") return 1 client = _create_client(args) result = client.rate_virtual_server(args.path, args.rating) logger.info(f"Virtual server rated: {args.path}") print(json.dumps(result, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Rate virtual server failed: {e}") return 1 def cmd_vs_rating(args: argparse.Namespace) -> int: """ Get rating information for a virtual MCP server. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) result = client.get_virtual_server_rating(args.path) print(json.dumps(result, indent=2, default=str)) return 0 except Exception as e: logger.error(f"Get virtual server rating failed: {e}") return 1 def cmd_registry_card_get(args: argparse.Namespace) -> int: """ Get the registry card. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) card = client.get_registry_card() print(json.dumps(card.model_dump(), indent=2)) return 0 except Exception as e: logger.error(f"Get registry card failed: {e}") print(f"Error: {e}", file=sys.stderr) return 1 def cmd_registry_card_discover(args: argparse.Namespace) -> int: """ Discover registry card via .well-known endpoint. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) card = client.get_well_known_registry_card() print(json.dumps(card.model_dump(), indent=2)) return 0 except Exception as e: logger.error(f"Registry card discovery failed: {e}") print(f"Error: {e}", file=sys.stderr) return 1 def cmd_registry_card_update(args: argparse.Namespace) -> int: """ Update the registry card. Args: args: Command arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) updates = {} if args.name: updates["name"] = args.name if args.description: updates["description"] = args.description if args.contact_email: updates["contact"] = updates.get("contact", {}) updates["contact"]["email"] = args.contact_email if args.contact_url: updates["contact"] = updates.get("contact", {}) updates["contact"]["url"] = args.contact_url result = client.patch_registry_card(updates) print(f"Success: {result['message']}") print(json.dumps(result["registry_card"], indent=2)) return 0 except Exception as e: logger.error(f"Update registry card failed: {e}") print(f"Error: {e}", file=sys.stderr) return 1 def cmd_telemetry_heartbeat(args: argparse.Namespace) -> int: """Force an immediate heartbeat telemetry event. Args: args: Parsed command line arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) result = client.force_heartbeat() print(json.dumps(result, indent=2)) if result.get("status") == "sent": logger.info("Heartbeat sent successfully") return 0 else: logger.warning(f"Heartbeat status: {result.get('status')}") return 1 except Exception as e: logger.error(f"Force heartbeat failed: {e}") print(f"Error: {e}", file=sys.stderr) return 1 def cmd_telemetry_startup(args: argparse.Namespace) -> int: """Force an immediate startup telemetry event. Args: args: Parsed command line arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) result = client.force_startup_ping() print(json.dumps(result, indent=2)) if result.get("status") == "sent": logger.info("Startup ping sent successfully") return 0 else: logger.warning(f"Startup ping status: {result.get('status')}") return 1 except Exception as e: logger.error(f"Force startup ping failed: {e}") print(f"Error: {e}", file=sys.stderr) return 1 def cmd_logs(args: argparse.Namespace) -> int: """Query application logs (admin only). Args: args: Parsed command line arguments Returns: Exit code (0 for success, 1 for failure) """ try: client = _create_client(args) if getattr(args, "metadata", False): metadata = client.get_log_metadata() print(json.dumps(metadata.model_dump(), indent=2)) return 0 result = client.get_logs( service=getattr(args, "service", None), level=getattr(args, "level", None), hostname=getattr(args, "hostname", None), search=getattr(args, "search", None), start=getattr(args, "start", None), end=getattr(args, "end", None), limit=getattr(args, "limit", 100), offset=getattr(args, "offset", 0), ) if getattr(args, "json", False): print(json.dumps(result.model_dump(), indent=2)) else: print(f"Total: {result.total_count} (showing {len(result.entries)}, " f"offset={result.offset}, has_next={result.has_next})") print("-" * 100) for entry in result.entries: print(f"[{entry.timestamp}] {entry.level:<8} {entry.service}/{entry.hostname} " f"{entry.logger}:{entry.lineno} {entry.message}") return 0 except Exception as e: logger.error(f"Log query failed: {e}") print(f"Error: {e}", file=sys.stderr) return 1 def main() -> int: """ Main entry point for the CLI. Returns: Exit code """ parser = argparse.ArgumentParser( description="MCP Gateway Registry Management CLI", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Environment Variables (used if command-line options not provided): REGISTRY_URL Registry base URL AWS_REGION AWS region where Keycloak and SSM are deployed KEYCLOAK_URL Keycloak base URL CLIENT_NAME Keycloak client name (default: registry-admin-bot) GET_TOKEN_SCRIPT Path to get-m2m-token.sh script Examples: # Register a server (using environment variables) export REGISTRY_URL=https://registry.us-east-1.mycorp.click export AWS_REGION=us-east-1 export KEYCLOAK_URL=https://kc.us-east-1.mycorp.click uv run python registry_management.py register --config server-config.json # Register a server (using command-line arguments) uv run python registry_management.py \\ --registry-url https://registry.us-east-1.mycorp.click \\ --aws-region us-east-1 \\ --keycloak-url https://kc.us-east-1.mycorp.click \\ register --config server-config.json # Register a server (using token file) uv run python registry_management.py \\ --registry-url https://registry.us-east-1.mycorp.click \\ --token-file /path/to/token.txt \\ register --config server-config.json # List all servers uv run python registry_management.py list # Toggle server status uv run python registry_management.py toggle --path /cloudflare-docs # Add server to groups uv run python registry_management.py add-to-groups --server my-server --groups finance,analytics """, ) parser.add_argument("--registry-url", help="Registry base URL (overrides REGISTRY_URL env var)") parser.add_argument("--aws-region", help="AWS region (overrides AWS_REGION env var)") parser.add_argument("--keycloak-url", help="Keycloak base URL (overrides KEYCLOAK_URL env var)") parser.add_argument( "--token-file", help="Path to file containing JWT token (bypasses token script)" ) parser.add_argument("--debug", action="store_true", help="Enable debug logging") subparsers = parser.add_subparsers(dest="command", help="Command to execute") # Register command register_parser = subparsers.add_parser("register", help="Register a new server") register_parser.add_argument( "--config", required=True, help="Path to server configuration JSON file" ) register_parser.add_argument( "--overwrite", action="store_true", help="Overwrite if server already exists" ) # List command list_parser = subparsers.add_parser("list", help="List all servers") list_parser.add_argument("--query", help="Search query string") list_parser.add_argument( "--limit", type=int, default=20, help="Number of servers to return (1-100, default 20)" ) list_parser.add_argument( "--offset", type=int, default=0, help="Number of servers to skip (default 0)" ) list_parser.add_argument("--json", action="store_true", help="Print raw JSON response") # Toggle command toggle_parser = subparsers.add_parser("toggle", help="Toggle server status") toggle_parser.add_argument("--path", required=True, help="Server path to toggle") # Remove command remove_parser = subparsers.add_parser("remove", help="Remove a server") remove_parser.add_argument("--path", required=True, help="Server path to remove") remove_parser.add_argument("--force", action="store_true", help="Skip confirmation prompt") # Healthcheck command healthcheck_parser = subparsers.add_parser("healthcheck", help="Health check all servers") # Config command config_parser = subparsers.add_parser( "config", help="Get registry configuration (deployment mode, features)" ) config_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # Add to groups command add_groups_parser = subparsers.add_parser("add-to-groups", help="Add server to groups") add_groups_parser.add_argument("--server", required=True, help="Server name") add_groups_parser.add_argument("--groups", required=True, help="Comma-separated group names") # Remove from groups command remove_groups_parser = subparsers.add_parser( "remove-from-groups", help="Remove server from groups" ) remove_groups_parser.add_argument("--server", required=True, help="Server name") remove_groups_parser.add_argument("--groups", required=True, help="Comma-separated group names") # Create group command create_group_parser = subparsers.add_parser("create-group", help="Create a new group") create_group_parser.add_argument("--name", required=True, help="Group name") create_group_parser.add_argument("--description", help="Group description") create_group_parser.add_argument( "--idp", action="store_true", help="Also create in IdP (Keycloak/Entra)" ) # Delete group command delete_group_parser = subparsers.add_parser("delete-group", help="Delete a group") delete_group_parser.add_argument("--name", required=True, help="Group name") delete_group_parser.add_argument( "--idp", action="store_true", help="Also delete from IdP (Keycloak/Entra)" ) delete_group_parser.add_argument( "--force", action="store_true", help="Force deletion of system groups and skip confirmation" ) # Import group command import_group_parser = subparsers.add_parser( "import-group", help="Import a complete group definition from JSON file" ) import_group_parser.add_argument( "--file", required=True, help="Path to JSON file containing group definition" ) # List groups command list_groups_parser = subparsers.add_parser("list-groups", help="List all groups") list_groups_parser.add_argument( "--no-keycloak", action="store_true", help="Exclude Keycloak information" ) list_groups_parser.add_argument( "--no-scopes", action="store_true", help="Exclude scope information" ) list_groups_parser.add_argument("--json", action="store_true", help="Output raw JSON response") # Describe group command describe_group_parser = subparsers.add_parser( "describe-group", help="Show detailed information about a specific group" ) describe_group_parser.add_argument("--name", required=True, help="Group name to describe") describe_group_parser.add_argument( "--json", action="store_true", help="Output raw JSON response" ) # Server get command server_get_parser = subparsers.add_parser("server-get", help="Get details of a specific server") server_get_parser.add_argument("--path", required=True, help="Server path (e.g., /my-server)") # Server rate command server_rate_parser = subparsers.add_parser("server-rate", help="Rate a server (1-5 stars)") server_rate_parser.add_argument( "--path", required=True, help="Server path (e.g., /cloudflare-docs)" ) server_rate_parser.add_argument( "--rating", required=True, type=int, choices=[1, 2, 3, 4, 5], help="Rating value (1-5 stars)", ) # Server rating command server_rating_parser = subparsers.add_parser( "server-rating", help="Get rating information for a server" ) server_rating_parser.add_argument( "--path", required=True, help="Server path (e.g., /cloudflare-docs)" ) # Server security scan command security_scan_parser = subparsers.add_parser( "security-scan", help="Get security scan results for a server" ) security_scan_parser.add_argument( "--path", required=True, help="Server path (e.g., /cloudflare-docs)" ) security_scan_parser.add_argument("--json", action="store_true", help="Output raw JSON") # Server rescan command rescan_parser = subparsers.add_parser( "rescan", help="Trigger manual security scan for a server (admin only)" ) rescan_parser.add_argument("--path", required=True, help="Server path (e.g., /cloudflare-docs)") rescan_parser.add_argument("--json", action="store_true", help="Output raw JSON") # Server credential update command server_update_cred_parser = subparsers.add_parser( "server-update-credential", help="Update authentication credentials for a server" ) server_update_cred_parser.add_argument( "--path", required=True, help="Server path (e.g., /cloudflare-api)" ) server_update_cred_parser.add_argument( "--auth-scheme", required=True, choices=["none", "bearer", "api_key"], help="Authentication scheme", ) server_update_cred_parser.add_argument( "--credential", help="New credential value (required if auth-scheme is not 'none')" ) server_update_cred_parser.add_argument( "--auth-header-name", help="Custom header name (optional, for api_key scheme)" ) server_update_cred_parser.add_argument("--json", action="store_true", help="Output raw JSON") # Server search command server_search_parser = subparsers.add_parser( "server-search", help="Semantic search across all entity types (servers, tools, agents, skills, virtual servers)", ) server_search_parser.add_argument( "--query", required=True, help="Natural language search query (e.g., 'coding assistants')" ) server_search_parser.add_argument( "--max-results", type=int, default=10, help="Maximum number of results per entity type (default: 10)", ) server_search_parser.add_argument( "--json", action="store_true", help="Output raw JSON with all entity types" ) # Server Version Management Commands # List versions command list_versions_parser = subparsers.add_parser( "list-versions", help="List all versions for a server" ) list_versions_parser.add_argument("--path", required=True, help="Server path (e.g., /context7)") list_versions_parser.add_argument("--json", action="store_true", help="Output raw JSON") # Remove version command remove_version_parser = subparsers.add_parser( "remove-version", help="Remove a version from a server" ) remove_version_parser.add_argument( "--path", required=True, help="Server path (e.g., /context7)" ) remove_version_parser.add_argument("--version", required=True, help="Version to remove") remove_version_parser.add_argument("--json", action="store_true", help="Output raw JSON") # Set default version command set_default_version_parser = subparsers.add_parser( "set-default-version", help="Set the default version for a server" ) set_default_version_parser.add_argument( "--path", required=True, help="Server path (e.g., /context7)" ) set_default_version_parser.add_argument( "--version", required=True, help="Version to set as default" ) set_default_version_parser.add_argument("--json", action="store_true", help="Output raw JSON") # Agent Management Commands # Agent register command agent_register_parser = subparsers.add_parser("agent-register", help="Register a new A2A agent") agent_register_parser.add_argument( "--config", required=True, help="Path to agent configuration JSON file" ) # Agent list command agent_list_parser = subparsers.add_parser("agent-list", help="List all A2A agents") agent_list_parser.add_argument("--query", help="Search query string") agent_list_parser.add_argument( "--enabled-only", action="store_true", help="Show only enabled agents" ) agent_list_parser.add_argument( "--visibility", choices=["public", "private", "group-restricted"], help="Filter by visibility level", ) agent_list_parser.add_argument( "--limit", type=int, default=20, help="Number of agents to return (1-100, default 20)" ) agent_list_parser.add_argument( "--offset", type=int, default=0, help="Number of agents to skip (default 0)" ) agent_list_parser.add_argument( "--allowed-groups", help="Filter by allowed_groups (comma-separated). Returns only group-restricted agents matching these groups.", ) agent_list_parser.add_argument("--json", action="store_true", help="Output raw JSON response") # Agent get command agent_get_parser = subparsers.add_parser("agent-get", help="Get agent details") agent_get_parser.add_argument("--path", required=True, help="Agent path (e.g., /code-reviewer)") # Agent update command agent_update_parser = subparsers.add_parser("agent-update", help="Update an existing agent") agent_update_parser.add_argument("--path", required=True, help="Agent path") agent_update_parser.add_argument( "--config", required=True, help="Path to updated agent configuration JSON file" ) # Agent delete command agent_delete_parser = subparsers.add_parser("agent-delete", help="Delete an agent") agent_delete_parser.add_argument("--path", required=True, help="Agent path") agent_delete_parser.add_argument( "--force", action="store_true", help="Skip confirmation prompt" ) # Agent toggle command agent_toggle_parser = subparsers.add_parser( "agent-toggle", help="Toggle agent enabled/disabled status" ) agent_toggle_parser.add_argument("--path", required=True, help="Agent path") agent_toggle_parser.add_argument( "--enabled", required=True, type=lambda x: x.lower() == "true", help="True to enable, false to disable", ) # Agent discover command agent_discover_parser = subparsers.add_parser( "agent-discover", help="Discover agents by skills" ) agent_discover_parser.add_argument( "--skills", required=True, help="Comma-separated list of required skills" ) agent_discover_parser.add_argument("--tags", help="Comma-separated list of tag filters") agent_discover_parser.add_argument( "--max-results", type=int, default=10, help="Maximum number of results (default: 10)" ) # Agent search command agent_search_parser = subparsers.add_parser("agent-search", help="Semantic search for agents") agent_search_parser.add_argument("--query", required=True, help="Natural language search query") agent_search_parser.add_argument( "--max-results", type=int, default=10, help="Maximum number of results (default: 10)" ) agent_search_parser.add_argument("--json", action="store_true", help="Output results as JSON") # Agent rate command agent_rate_parser = subparsers.add_parser("agent-rate", help="Rate an agent (1-5 stars)") agent_rate_parser.add_argument( "--path", required=True, help="Agent path (e.g., /code-reviewer)" ) agent_rate_parser.add_argument( "--rating", required=True, type=int, choices=[1, 2, 3, 4, 5], help="Rating value (1-5 stars)", ) # Agent rating command agent_rating_parser = subparsers.add_parser( "agent-rating", help="Get rating information for an agent" ) agent_rating_parser.add_argument( "--path", required=True, help="Agent path (e.g., /code-reviewer)" ) # Agent security scan command agent_security_scan_parser = subparsers.add_parser( "agent-security-scan", help="Get security scan results for an agent" ) agent_security_scan_parser.add_argument( "--path", required=True, help="Agent path (e.g., /code-reviewer)" ) # Agent rescan command agent_rescan_parser = subparsers.add_parser( "agent-rescan", help="Trigger manual security scan for an agent (admin only)" ) agent_rescan_parser.add_argument( "--path", required=True, help="Agent path (e.g., /code-reviewer)" ) agent_rescan_parser.add_argument("--json", action="store_true", help="Output raw JSON") # Agent ANS (Agent Name Service) commands agent_ans_link_parser = subparsers.add_parser( "agent-ans-link", help="Link an ANS Agent ID to an agent" ) agent_ans_link_parser.add_argument( "--path", required=True, help="Agent path (e.g., /code-reviewer)" ) agent_ans_link_parser.add_argument( "--ans-agent-id", required=True, help="ANS Agent ID (e.g., ans://v1.example.com)", ) agent_ans_status_parser = subparsers.add_parser( "agent-ans-status", help="Get ANS verification status for an agent" ) agent_ans_status_parser.add_argument( "--path", required=True, help="Agent path (e.g., /code-reviewer)" ) agent_ans_status_parser.add_argument("--json", action="store_true", help="Output raw JSON") agent_ans_unlink_parser = subparsers.add_parser( "agent-ans-unlink", help="Remove ANS link from an agent" ) agent_ans_unlink_parser.add_argument( "--path", required=True, help="Agent path (e.g., /code-reviewer)" ) # ========================================== # Agent Skills Commands # ========================================== # Skill register command skill_register_parser = subparsers.add_parser( "skill-register", help="Register a new Agent Skill" ) skill_register_parser.add_argument( "--name", required=True, help="Skill name (lowercase alphanumeric with hyphens)" ) skill_register_parser.add_argument("--url", required=True, help="URL to SKILL.md file") skill_register_parser.add_argument("--description", help="Skill description") skill_register_parser.add_argument("--version", help="Skill version (e.g., 1.0.0)") skill_register_parser.add_argument("--tags", help="Comma-separated tags") skill_register_parser.add_argument( "--target-agents", help="Comma-separated target coding assistants (e.g., claude-code,cursor)", ) skill_register_parser.add_argument( "--metadata", help='Custom metadata as JSON string (e.g., \'{"category": "data-processing"}\')', ) skill_register_parser.add_argument( "--visibility", choices=["public", "private", "group"], default="public", help="Visibility level (default: public)", ) # Skill list command skill_list_parser = subparsers.add_parser("skill-list", help="List all Agent Skills") skill_list_parser.add_argument( "--include-disabled", action="store_true", help="Include disabled skills" ) skill_list_parser.add_argument("--tag", help="Filter by tag") skill_list_parser.add_argument( "--limit", type=int, default=20, help="Number of skills to return (1-100, default 20)" ) skill_list_parser.add_argument( "--offset", type=int, default=0, help="Number of skills to skip (default 0)" ) skill_list_parser.add_argument("--json", action="store_true", help="Output raw JSON response") # Skill get command skill_get_parser = subparsers.add_parser("skill-get", help="Get skill details") skill_get_parser.add_argument( "--path", required=True, help="Skill path or name (e.g., pdf-processing)" ) # Skill delete command skill_delete_parser = subparsers.add_parser("skill-delete", help="Delete a skill") skill_delete_parser.add_argument("--path", required=True, help="Skill path or name") # Skill toggle command skill_toggle_parser = subparsers.add_parser( "skill-toggle", help="Toggle skill enabled/disabled state" ) skill_toggle_parser.add_argument("--path", required=True, help="Skill path or name") skill_toggle_parser.add_argument( "--enable", type=lambda x: x.lower() == "true", required=True, help="Enable (true) or disable (false)", ) # Skill health command skill_health_parser = subparsers.add_parser( "skill-health", help="Check skill health (SKILL.md accessibility)" ) skill_health_parser.add_argument("--path", required=True, help="Skill path or name") # Skill content command skill_content_parser = subparsers.add_parser( "skill-content", help="Get SKILL.md content for a skill" ) skill_content_parser.add_argument("--path", required=True, help="Skill path or name") skill_content_parser.add_argument("--raw", action="store_true", help="Output raw content only") # Skill search command skill_search_parser = subparsers.add_parser("skill-search", help="Search for skills") skill_search_parser.add_argument("--query", required=True, help="Search query") skill_search_parser.add_argument("--tags", help="Comma-separated tags filter") # Skill rate command skill_rate_parser = subparsers.add_parser("skill-rate", help="Rate a skill (1-5 stars)") skill_rate_parser.add_argument("--path", required=True, help="Skill path or name") skill_rate_parser.add_argument( "--rating", type=int, required=True, choices=[1, 2, 3, 4, 5], help="Rating (1-5 stars)" ) # Skill rating command skill_rating_parser = subparsers.add_parser( "skill-rating", help="Get rating information for a skill" ) skill_rating_parser.add_argument("--path", required=True, help="Skill path or name") # Skill security scan command skill_security_scan_parser = subparsers.add_parser( "skill-security-scan", help="Get security scan results for a skill" ) skill_security_scan_parser.add_argument("--path", required=True, help="Skill path or name") # Skill rescan command skill_rescan_parser = subparsers.add_parser( "skill-rescan", help="Trigger manual security scan for a skill (admin only)" ) skill_rescan_parser.add_argument("--path", required=True, help="Skill path or name") skill_rescan_parser.add_argument( "--json", dest="json_output", action="store_true", help="Output raw JSON only" ) # Anthropic Registry API Commands # Anthropic list servers command anthropic_list_parser = subparsers.add_parser( "anthropic-list", help="List all servers (Anthropic Registry API v0.1)" ) anthropic_list_parser.add_argument("--limit", type=int, help="Maximum results per page") anthropic_list_parser.add_argument( "--raw", action="store_true", help="Output raw JSON response" ) # Anthropic list versions command anthropic_versions_parser = subparsers.add_parser( "anthropic-versions", help="List versions for a server (Anthropic Registry API v0.1)" ) anthropic_versions_parser.add_argument( "--server-name", required=True, help="Server name in reverse-DNS format (e.g., 'io.mcpgateway/example-server')", ) anthropic_versions_parser.add_argument( "--raw", action="store_true", help="Output raw JSON response" ) # Anthropic get server command anthropic_get_parser = subparsers.add_parser( "anthropic-get", help="Get server details (Anthropic Registry API v0.1)" ) anthropic_get_parser.add_argument( "--server-name", required=True, help="Server name in reverse-DNS format" ) anthropic_get_parser.add_argument( "--version", default="latest", help="Server version (default: latest)" ) anthropic_get_parser.add_argument("--raw", action="store_true", help="Output raw JSON response") # User Management Commands (Management API) # List users command user_list_parser = subparsers.add_parser("user-list", help="List Keycloak users") user_list_parser.add_argument("--search", help="Search string to filter users") user_list_parser.add_argument( "--limit", type=int, default=500, help="Maximum number of results (default: 500)" ) # Create M2M account command user_m2m_parser = subparsers.add_parser("user-create-m2m", help="Create M2M service account") user_m2m_parser.add_argument("--name", required=True, help="Service account name/client ID") user_m2m_parser.add_argument( "--groups", required=True, help="Comma-separated list of group names" ) user_m2m_parser.add_argument("--description", help="Account description") # Create human user command user_human_parser = subparsers.add_parser("user-create-human", help="Create human user account") user_human_parser.add_argument("--username", required=True, help="Username") user_human_parser.add_argument("--email", required=True, help="Email address") user_human_parser.add_argument("--first-name", required=True, help="First name") user_human_parser.add_argument("--last-name", required=True, help="Last name") user_human_parser.add_argument( "--groups", required=True, help="Comma-separated list of group names" ) user_human_parser.add_argument("--password", help="Initial password (optional)") # Delete user command user_delete_parser = subparsers.add_parser("user-delete", help="Delete a user") user_delete_parser.add_argument("--username", required=True, help="Username to delete") user_delete_parser.add_argument("--force", action="store_true", help="Skip confirmation prompt") # ------------------------------------------------------------------------- # M2M direct registration commands (issue #851) # Write to idp_m2m_clients without IdP Admin API. Admin only for mutations. # ------------------------------------------------------------------------- m2m_create_parser = subparsers.add_parser( "m2m-client-create", help="Register an M2M client directly (no IdP Admin API required)", ) m2m_create_parser.add_argument( "--client-id", required=True, help="IdP application client ID to register" ) m2m_create_parser.add_argument( "--client-name", required=True, help="Human-readable name for the client" ) m2m_create_parser.add_argument( "--groups", default="", help="Comma-separated group names (empty string = no groups)", ) m2m_create_parser.add_argument("--description", help="Optional description") m2m_list_parser = subparsers.add_parser( "m2m-client-list", help="List registered M2M clients (paginated)" ) m2m_list_parser.add_argument("--provider", help="Filter by provider (e.g. manual, okta, auth0)") m2m_list_parser.add_argument( "--limit", type=int, default=500, help="Max records per page (1-1000, default 500)" ) m2m_list_parser.add_argument( "--skip", type=int, default=0, help="Offset for pagination (default 0)" ) m2m_list_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) m2m_get_parser = subparsers.add_parser( "m2m-client-get", help="Get a single M2M client by client_id" ) m2m_get_parser.add_argument("--client-id", required=True, help="IdP client ID") m2m_get_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) m2m_update_parser = subparsers.add_parser( "m2m-client-update", help="Partially update an M2M client (manual records only)", ) m2m_update_parser.add_argument("--client-id", required=True, help="IdP client ID") m2m_update_parser.add_argument( "--client-name", help="New client name (omit to leave unchanged)" ) m2m_update_parser.add_argument( "--groups", help="Comma-separated new groups list; empty string clears groups; omit to leave unchanged", ) m2m_update_parser.add_argument( "--description", help="New description (omit to leave unchanged)" ) m2m_update_parser.add_argument( "--enabled", choices=["true", "false"], help="Set enabled flag (omit to leave unchanged)", ) m2m_delete_parser = subparsers.add_parser( "m2m-client-delete", help="Delete an M2M client (manual records only)", ) m2m_delete_parser.add_argument("--client-id", required=True, help="IdP client ID") m2m_delete_parser.add_argument("--force", action="store_true", help="Skip confirmation prompt") # Create IAM group command group_create_parser = subparsers.add_parser("group-create", help="Create a new IAM group") group_create_parser.add_argument("--name", required=True, help="Group name") group_create_parser.add_argument("--description", help="Group description") # Delete IAM group command group_delete_parser = subparsers.add_parser("group-delete", help="Delete an IAM group") group_delete_parser.add_argument("--name", required=True, help="Group name to delete") group_delete_parser.add_argument( "--force", action="store_true", help="Skip confirmation prompt" ) # List IAM groups command group_list_parser = subparsers.add_parser("group-list", help="List IAM groups") # Federation Management Commands # Get federation config command federation_get_parser = subparsers.add_parser( "federation-get", help="Get federation configuration" ) federation_get_parser.add_argument( "--config-id", default="default", help="Configuration ID (default: default)" ) federation_get_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # Save federation config command federation_save_parser = subparsers.add_parser( "federation-save", help="Save federation configuration from JSON file" ) federation_save_parser.add_argument( "--config", required=True, help="Path to federation config JSON file" ) federation_save_parser.add_argument( "--config-id", default="default", help="Configuration ID (default: default)" ) # Delete federation config command federation_delete_parser = subparsers.add_parser( "federation-delete", help="Delete federation configuration" ) federation_delete_parser.add_argument( "--config-id", default="default", help="Configuration ID to delete (default: default)" ) federation_delete_parser.add_argument( "--force", action="store_true", help="Skip confirmation prompt" ) # List federation configs command federation_list_parser = subparsers.add_parser( "federation-list", help="List all federation configurations" ) federation_list_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # Add Anthropic server command federation_add_anthropic_parser = subparsers.add_parser( "federation-add-anthropic-server", help="Add Anthropic server to federation config" ) federation_add_anthropic_parser.add_argument( "--server-name", required=True, help="Anthropic server name (e.g., io.github.jgador/websharp)", ) federation_add_anthropic_parser.add_argument( "--config-id", default="default", help="Configuration ID (default: default)" ) # Remove Anthropic server command federation_remove_anthropic_parser = subparsers.add_parser( "federation-remove-anthropic-server", help="Remove Anthropic server from federation config" ) federation_remove_anthropic_parser.add_argument( "--server-name", required=True, help="Anthropic server name to remove" ) federation_remove_anthropic_parser.add_argument( "--config-id", default="default", help="Configuration ID (default: default)" ) # Add ASOR agent command federation_add_asor_parser = subparsers.add_parser( "federation-add-asor-agent", help="Add ASOR agent to federation config" ) federation_add_asor_parser.add_argument( "--agent-id", required=True, help="ASOR agent ID (e.g., aws_assistant)" ) federation_add_asor_parser.add_argument( "--config-id", default="default", help="Configuration ID (default: default)" ) # Remove ASOR agent command federation_remove_asor_parser = subparsers.add_parser( "federation-remove-asor-agent", help="Remove ASOR agent from federation config" ) federation_remove_asor_parser.add_argument( "--agent-id", required=True, help="ASOR agent ID to remove" ) federation_remove_asor_parser.add_argument( "--config-id", default="default", help="Configuration ID (default: default)" ) # Federation sync command federation_sync_parser = subparsers.add_parser( "federation-sync", help="Trigger manual federation sync to import servers/agents" ) federation_sync_parser.add_argument( "--config-id", default="default", help="Configuration ID (default: default)" ) federation_sync_parser.add_argument( "--source", choices=["anthropic", "asor", "aws_registry"], help="Optional source filter (anthropic, asor, or aws_registry). Syncs all enabled sources if not specified.", ) federation_sync_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # ========================================== # Peer Registry Management Commands # ========================================== # List peers command peer_list_parser = subparsers.add_parser( "peer-list", help="List all configured peer registries" ) peer_list_parser.add_argument( "--enabled-only", action="store_true", help="Show only enabled peers" ) peer_list_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # Add peer command peer_add_parser = subparsers.add_parser( "peer-add", help="Add a new peer registry from JSON config" ) peer_add_parser.add_argument( "--config", required=True, help="Path to peer configuration JSON file" ) peer_add_parser.add_argument( "--federation-token", required=False, help="Federation static token from the remote peer registry. " "Overrides federation_token in the JSON config file if both are provided.", ) # Get peer command peer_get_parser = subparsers.add_parser( "peer-get", help="Get details of a specific peer registry" ) peer_get_parser.add_argument("--peer-id", required=True, help="Peer registry identifier") peer_get_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # Update peer command peer_update_parser = subparsers.add_parser( "peer-update", help="Update an existing peer registry" ) peer_update_parser.add_argument("--peer-id", required=True, help="Peer registry identifier") peer_update_parser.add_argument( "--config", required=True, help="Path to updated peer configuration JSON file" ) peer_update_parser.add_argument( "--federation-token", required=False, help="Federation static token from the remote peer registry. " "Overrides federation_token in the JSON config file if both are provided.", ) # Update peer token command peer_update_token_parser = subparsers.add_parser( "peer-update-token", help="Update only the federation token for a peer registry" ) peer_update_token_parser.add_argument( "--peer-id", required=True, help="Peer registry identifier" ) peer_update_token_parser.add_argument( "--federation-token", required=True, help="New federation static token from the remote peer registry. " "Use this to recover from token loss (issue #561) or rotate tokens.", ) # Remove peer command peer_remove_parser = subparsers.add_parser("peer-remove", help="Remove a peer registry") peer_remove_parser.add_argument("--peer-id", required=True, help="Peer registry identifier") peer_remove_parser.add_argument("--force", action="store_true", help="Skip confirmation prompt") # Sync from specific peer command peer_sync_parser = subparsers.add_parser( "peer-sync", help="Trigger sync from a specific peer registry" ) peer_sync_parser.add_argument( "--peer-id", required=True, help="Peer registry identifier to sync from" ) peer_sync_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # Sync from all peers command peer_sync_all_parser = subparsers.add_parser( "peer-sync-all", help="Trigger sync from all enabled peer registries" ) peer_sync_all_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # Get peer sync status command peer_status_parser = subparsers.add_parser( "peer-status", help="Get sync status for a specific peer registry" ) peer_status_parser.add_argument("--peer-id", required=True, help="Peer registry identifier") peer_status_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # Enable peer command peer_enable_parser = subparsers.add_parser("peer-enable", help="Enable a peer registry") peer_enable_parser.add_argument("--peer-id", required=True, help="Peer registry identifier") # Disable peer command peer_disable_parser = subparsers.add_parser("peer-disable", help="Disable a peer registry") peer_disable_parser.add_argument("--peer-id", required=True, help="Peer registry identifier") # Get peer connections command peer_connections_parser = subparsers.add_parser( "peer-connections", help="Get all federation connections across all peers" ) peer_connections_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # Get shared resources command peer_shared_resources_parser = subparsers.add_parser( "peer-shared-resources", help="Get resource sharing summary across all peers" ) peer_shared_resources_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # ========================================== # Virtual MCP Server Commands # ========================================== # Create virtual server command vs_create_parser = subparsers.add_parser( "vs-create", help="Create a virtual MCP server from JSON config" ) vs_create_parser.add_argument( "--config", required=True, help="Path to virtual server configuration JSON file" ) # List virtual servers command vs_list_parser = subparsers.add_parser("vs-list", help="List all virtual MCP servers") vs_list_parser.add_argument( "--enabled-only", action="store_true", help="Show only enabled virtual servers" ) vs_list_parser.add_argument("--tag", help="Filter by tag") vs_list_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # Get virtual server command vs_get_parser = subparsers.add_parser("vs-get", help="Get virtual MCP server details") vs_get_parser.add_argument( "--path", required=True, help="Virtual server path (e.g., /virtual/dev-tools)" ) vs_get_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) # Update virtual server command vs_update_parser = subparsers.add_parser( "vs-update", help="Update a virtual MCP server from JSON config" ) vs_update_parser.add_argument("--path", required=True, help="Virtual server path to update") vs_update_parser.add_argument( "--config", required=True, help="Path to updated configuration JSON file" ) # Delete virtual server command vs_delete_parser = subparsers.add_parser("vs-delete", help="Delete a virtual MCP server") vs_delete_parser.add_argument("--path", required=True, help="Virtual server path to delete") vs_delete_parser.add_argument("--force", action="store_true", help="Skip confirmation prompt") # Toggle virtual server command vs_toggle_parser = subparsers.add_parser( "vs-toggle", help="Enable or disable a virtual MCP server" ) vs_toggle_parser.add_argument("--path", required=True, help="Virtual server path") vs_toggle_parser.add_argument( "--enabled", required=True, choices=["true", "false"], help="Enable (true) or disable (false)", ) # Rate virtual server command vs_rate_parser = subparsers.add_parser("vs-rate", help="Rate a virtual MCP server (1-5 stars)") vs_rate_parser.add_argument("--path", required=True, help="Virtual server path") vs_rate_parser.add_argument( "--rating", required=True, type=int, choices=[1, 2, 3, 4, 5], help="Rating (1-5 stars)" ) # Get virtual server rating command vs_rating_parser = subparsers.add_parser( "vs-rating", help="Get rating information for a virtual MCP server" ) vs_rating_parser.add_argument("--path", required=True, help="Virtual server path") # ========================================== # Registry Card Management Commands # ========================================== # Get registry card command registry_card_get_parser = subparsers.add_parser( "registry-card-get", help="Get the registry card" ) # Discover registry card via .well-known endpoint registry_card_discover_parser = subparsers.add_parser( "registry-card-discover", help="Discover registry card via .well-known endpoint" ) # Update registry card command registry_card_update_parser = subparsers.add_parser( "registry-card-update", help="Update the registry card" ) registry_card_update_parser.add_argument("--name", help="Registry name") registry_card_update_parser.add_argument("--description", help="Registry description") registry_card_update_parser.add_argument("--contact-email", help="Contact email address") registry_card_update_parser.add_argument("--contact-url", help="Contact URL") # Telemetry management commands subparsers.add_parser( "telemetry-heartbeat", help="Force an immediate heartbeat telemetry event (admin only)", ) subparsers.add_parser( "telemetry-startup", help="Force an immediate startup telemetry event (admin only)", ) # ========================================== # Application Log Commands (issue #886) # ========================================== logs_parser = subparsers.add_parser( "logs", help="Query application logs (admin only)" ) logs_parser.add_argument("--service", help="Filter by service name") logs_parser.add_argument( "--level", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], help="Minimum log level", ) logs_parser.add_argument("--hostname", help="Filter by hostname/pod") logs_parser.add_argument("--search", help="Substring search in messages") logs_parser.add_argument("--start", help="Start timestamp (ISO-8601)") logs_parser.add_argument("--end", help="End timestamp (ISO-8601)") logs_parser.add_argument("--limit", type=int, default=100, help="Page size (default: 100)") logs_parser.add_argument("--offset", type=int, default=0, help="Offset for pagination") logs_parser.add_argument( "--metadata", action="store_true", help="Show available filter values instead of logs" ) logs_parser.add_argument( "--json", action="store_true", help="Output raw JSON instead of formatted text" ) args = parser.parse_args() # Enable debug logging if requested if args.debug: logging.getLogger().setLevel(logging.DEBUG) # Dispatch to command handler if not args.command: parser.print_help() return 1 command_handlers = { "register": cmd_register, "list": cmd_list, "toggle": cmd_toggle, "remove": cmd_remove, "healthcheck": cmd_healthcheck, "config": cmd_config, "add-to-groups": cmd_add_to_groups, "remove-from-groups": cmd_remove_from_groups, "create-group": cmd_create_group, "delete-group": cmd_delete_group, "import-group": cmd_import_group, "list-groups": cmd_list_groups, "describe-group": cmd_describe_group, "server-get": cmd_server_get, "server-rate": cmd_server_rate, "server-rating": cmd_server_rating, "security-scan": cmd_security_scan, "rescan": cmd_rescan, "server-update-credential": cmd_server_update_credential, "server-search": cmd_server_search, "list-versions": cmd_list_versions, "remove-version": cmd_remove_version, "set-default-version": cmd_set_default_version, "agent-register": cmd_agent_register, "agent-list": cmd_agent_list, "agent-get": cmd_agent_get, "agent-update": cmd_agent_update, "agent-delete": cmd_agent_delete, "agent-toggle": cmd_agent_toggle, "agent-discover": cmd_agent_discover, "agent-search": cmd_agent_search, "agent-rate": cmd_agent_rate, "agent-rating": cmd_agent_rating, "agent-security-scan": cmd_agent_security_scan, "agent-rescan": cmd_agent_rescan, "agent-ans-link": cmd_agent_ans_link, "agent-ans-status": cmd_agent_ans_status, "agent-ans-unlink": cmd_agent_ans_unlink, # Skill commands "skill-register": cmd_skill_register, "skill-list": cmd_skill_list, "skill-get": cmd_skill_get, "skill-delete": cmd_skill_delete, "skill-toggle": cmd_skill_toggle, "skill-health": cmd_skill_health, "skill-content": cmd_skill_content, "skill-search": cmd_skill_search, "skill-rate": cmd_skill_rate, "skill-rating": cmd_skill_rating, "skill-security-scan": cmd_skill_security_scan, "skill-rescan": cmd_skill_rescan, "anthropic-list": cmd_anthropic_list_servers, "anthropic-versions": cmd_anthropic_list_versions, "anthropic-get": cmd_anthropic_get_server, "user-list": cmd_user_list, "user-create-m2m": cmd_user_create_m2m, "user-create-human": cmd_user_create_human, "user-delete": cmd_user_delete, # Direct M2M client registration (issue #851) "m2m-client-create": cmd_m2m_client_create, "m2m-client-list": cmd_m2m_client_list, "m2m-client-get": cmd_m2m_client_get, "m2m-client-update": cmd_m2m_client_update, "m2m-client-delete": cmd_m2m_client_delete, "group-create": cmd_group_create, "group-delete": cmd_group_delete, "group-list": cmd_group_list, "federation-get": cmd_federation_get, "federation-save": cmd_federation_save, "federation-delete": cmd_federation_delete, "federation-list": cmd_federation_list, "federation-add-anthropic-server": cmd_federation_add_anthropic_server, "federation-remove-anthropic-server": cmd_federation_remove_anthropic_server, "federation-add-asor-agent": cmd_federation_add_asor_agent, "federation-remove-asor-agent": cmd_federation_remove_asor_agent, "federation-sync": cmd_federation_sync, "peer-list": cmd_peer_list, "peer-add": cmd_peer_add, "peer-get": cmd_peer_get, "peer-update": cmd_peer_update, "peer-update-token": cmd_peer_update_token, "peer-remove": cmd_peer_remove, "peer-sync": cmd_peer_sync, "peer-sync-all": cmd_peer_sync_all, "peer-status": cmd_peer_status, "peer-enable": cmd_peer_enable, "peer-disable": cmd_peer_disable, "peer-connections": cmd_peer_connections, "peer-shared-resources": cmd_peer_shared_resources, # Virtual server commands "vs-create": cmd_vs_create, "vs-list": cmd_vs_list, "vs-get": cmd_vs_get, "vs-update": cmd_vs_update, "vs-delete": cmd_vs_delete, "vs-toggle": cmd_vs_toggle, "vs-rate": cmd_vs_rate, "vs-rating": cmd_vs_rating, # Registry card commands "registry-card-get": cmd_registry_card_get, "registry-card-discover": cmd_registry_card_discover, "registry-card-update": cmd_registry_card_update, # Telemetry management commands "telemetry-heartbeat": cmd_telemetry_heartbeat, "telemetry-startup": cmd_telemetry_startup, "logs": cmd_logs, } handler = command_handlers.get(args.command) if not handler: logger.error(f"Unknown command: {args.command}") return 1 return handler(args) if __name__ == "__main__": sys.exit(main()) ================================================ FILE: api/test-management-api-e2e.md ================================================ # Management API End-to-End Test Guide **Date:** 2025-12-12 **Purpose:** Comprehensive end-to-end test of the Management API functionality **Location:** `api/test-management-api-e2e.sh` ## Overview This guide demonstrates the complete lifecycle of IAM and resource management using the Management API. The test script creates a group, users (both human and M2M), registers servers and agents, verifies the configuration, and then cleans up all resources. ## Prerequisites ### For Local Testing (Docker Compose) 1. Ensure the registry and Keycloak services are running: ```bash docker-compose up -d ``` 2. Generate authentication tokens: ```bash cd credentials-provider ./generate_creds.sh cd .. ``` 3. Verify token file exists: ```bash ls -la .oauth-tokens/ingress.json ``` **Note:** The script automatically validates that: - The token file exists and is readable - The token contains a valid `access_token` field - The token has not expired (checks JWT expiration time) ### For Remote Testing (AWS Deployment) Set the required environment variables: ```bash export REGISTRY_URL="https://registry.us-east-1.aroraai.people.aws.dev" export AWS_REGION="us-east-1" export KEYCLOAK_URL="https://kc.us-east-1.aroraai.people.aws.dev" ``` ## Test Workflow The script performs the following operations in sequence: ### Phase 1: Resource Creation 1. **Create IAM Group** - Creates a new group with a timestamped name (e.g., `test-team-1702405678`) - Description: "Test group for end-to-end testing" - Command: `group-create --name --description ` 2. **Create Human User** - Creates a human user account with: - Username: `test.user.` - Email: `test.user.@example.com` - First name: "Test" - Last name: "User" - Group membership: The newly created group - Password: "TempPassword123!" - Command: `user-create-human --username --email --first-name --last-name --groups --password ` 3. **Create M2M Service Account** - Creates a machine-to-machine service account with: - Name: `test-service-bot-` - Group membership: The newly created group - Description: "Test service account for end-to-end testing" - Returns client credentials (client_id and client_secret) - Command: `user-create-m2m --name --groups --description ` - **Important:** The client secret is only shown once - save it! 4. **Register MCP Server** - Registers the Cloudflare Documentation MCP Server - Uses JSON configuration from `cli/examples/cloudflare-docs-server-config.json` - Server details: - Name: "Cloudflare Documentation MCP Server" - Path: `/cloudflare-docs` - Proxy URL: `https://docs.mcp.cloudflare.com/mcp` - Transport: streamable-http - Command: `register --config ` 5. **Register Agent** - Registers the Flight Booking Agent - Uses JSON configuration from `cli/examples/flight_booking_agent_card.json` - Agent details: - Name: "Flight Booking Agent" - Path: `/flight-booking` - URL: `http://flight-booking-agent:9000/` - Skills: check_availability, reserve_flight, confirm_booking, process_payment, manage_reservation - Command: `agent-register --config ` ### Phase 2: Verification 6. **List All Users** - Lists all users in the system - **Validates** that both the human user and M2M service account are present in the response - Shows user status (enabled/disabled), email, groups - Command: `user-list` - Test fails if created users are not found in the list 7. **List All Groups** - Lists all groups in the system - **Validates** that the test group appears in the response - Shows group ID, name, path, and attributes - Command: `group-list` - Test fails if created group is not found in the list 8. **List All Servers** - Lists all registered servers - **Validates** that the Cloudflare Documentation MCP Server appears in the response - Shows server configuration and group assignment - Command: `list` - Test fails if registered server is not found in the list 9. **List All Agents** - Lists all registered agents - **Validates** that the Flight Booking Agent appears in the response - Shows agent capabilities and group assignment - Command: `agent-list` - Test fails if registered agent is not found in the list 10. **Search for Test Users** - Searches for users with "test" in their username - Demonstrates user search functionality - Command: `user-list --search test --limit 50` ### Phase 3: Cleanup (Automatic) The cleanup phase runs automatically via a trap on script exit, ensuring all resources are deleted even if the script fails: 11. **Delete Agent** - Removes the Flight Booking Agent - Command: `agent-delete --path /flight-booking --force` 12. **Delete Server** - Removes the Cloudflare Documentation MCP Server - Command: `remove --path /cloudflare-docs --force` 13. **Delete M2M Account** - Removes the M2M service account - Command: `user-delete --username --force` 14. **Delete Human User** - Removes the human user account - Command: `user-delete --username --force` 15. **Delete Group** - Removes the test group - Command: `group-delete --name --force` ## Usage The script requires the `--token-file` parameter and optionally accepts `--registry-url`, `--aws-region`, `--keycloak-url`, and `--quiet`. ### Command Syntax ```bash ./test-management-api-e2e.sh --token-file [--registry-url ] [--aws-region ] [--keycloak-url ] [--quiet] ``` **Required Arguments:** - `--token-file ` - Path to the OAuth token file (e.g., `.oauth-tokens/ingress.json`) **Optional Arguments:** - `--registry-url ` - Registry URL (default: `http://localhost`) - `--aws-region ` - AWS region (e.g., `us-east-1`) - `--keycloak-url ` - Keycloak base URL (e.g., `https://kc.us-east-1.aroraai.people.aws.dev`) - `--quiet` - Suppress verbose output (verbose mode is enabled by default to show all intermediate command outputs) ### Local Testing (Docker Compose) ```bash # First, ensure tokens are generated cd credentials-provider ./generate_creds.sh cd .. # Run the test script with token file (verbose by default) cd api ./test-management-api-e2e.sh --token-file ../.oauth-tokens/ingress.json # Or run in quiet mode ./test-management-api-e2e.sh --token-file ../.oauth-tokens/ingress.json --quiet ``` ### Remote Testing (AWS Deployment) ```bash # Generate M2M token and save to file ./api/get-m2m-token.sh \ --aws-region us-east-1 \ --keycloak-url https://kc.us-east-1.aroraai.people.aws.dev \ --output-file api/.token # Run the test script with all AWS parameters (verbose by default) cd api ./test-management-api-e2e.sh \ --token-file api/.token \ --registry-url https://registry.us-east-1.aroraai.people.aws.dev \ --aws-region us-east-1 \ --keycloak-url https://kc.us-east-1.aroraai.people.aws.dev # Or run in quiet mode ./test-management-api-e2e.sh \ --token-file api/.token \ --registry-url https://registry.us-east-1.aroraai.people.aws.dev \ --aws-region us-east-1 \ --keycloak-url https://kc.us-east-1.aroraai.people.aws.dev \ --quiet ``` ### Getting Help ```bash ./test-management-api-e2e.sh --help ``` Output: ``` Usage: ./test-management-api-e2e.sh --token-file [--registry-url ] [--aws-region ] [--keycloak-url ] [--quiet] Required arguments: --token-file Path to the OAuth token file (e.g., .oauth-tokens/ingress.json) Optional arguments: --registry-url Registry URL (default: http://localhost) --aws-region AWS region (e.g., us-east-1) --keycloak-url Keycloak base URL (e.g., https://kc.us-east-1.aroraai.people.aws.dev) --quiet Suppress verbose output (verbose is enabled by default) Examples: # Local testing with verbose output (default) ./test-management-api-e2e.sh --token-file .oauth-tokens/ingress.json # Remote testing with all parameters ./test-management-api-e2e.sh --token-file api/.token --registry-url https://registry.us-east-1.aroraai.people.aws.dev --aws-region us-east-1 --keycloak-url https://kc.us-east-1.aroraai.people.aws.dev ``` ## Expected Output ### Successful Run ``` ======================================== Management API End-to-End Test ======================================== Configuration: Registry URL: http://localhost Token File: ../.oauth-tokens/ingress.json Group Name: test-team-1702405678 Human User: test.user.1702405678 M2M Account: test-service-bot-1702405678 ======================================== Phase 1: Resource Creation ======================================== [Step 1] Creating IAM group: test-team-1702405678 Group created successfully [Step 2] Creating human user: test.user.1702405678 Human user created successfully [Step 3] Creating M2M service account: test-service-bot-1702405678 Client ID: test-service-bot-1702405678 Client Secret: Groups: test-team-1702405678 M2M service account created successfully Note: Save the client secret from the output above - it will not be shown again! [Step 4] Registering server: Cloudflare Documentation MCP Server Server registered successfully [Step 5] Registering agent: Flight Booking Agent Agent registered successfully ======================================== Phase 2: Verification ======================================== [Step 6] Listing all users (should include test.user.1702405678 and test-service-bot-1702405678) Found 8 users: ... [Step 7] Listing all groups (should include test-team-1702405678) Found 13 groups: ... [Step 8] Listing all servers (should include Cloudflare Documentation MCP Server) Servers: ... [Step 9] Listing all agents (should include Flight Booking Agent) Agents: ... [Step 10] Checking server health for: /cloudflare-docs ... [Step 11] Searching for test users Found 2 users matching 'test': ... ======================================== All verification steps completed! ======================================== The cleanup function will now run automatically... ======================================== Cleanup: Deleting resources ======================================== [Step 12] Deleting agent: Flight Booking Agent [Step 13] Deleting server: Cloudflare Documentation MCP Server [Step 14] Deleting M2M account: test-service-bot-1702405678 [Step 15] Deleting human user: test.user.1702405678 [Step 16] Deleting group: test-team-1702405678 ======================================== Cleanup complete! ======================================== ``` ## Troubleshooting ### Error: Missing --token-file argument **Problem:** ``` Error: --token-file is required Usage: ./test-management-api-e2e.sh --token-file [--registry-url ] ``` **Solution:** Provide the required `--token-file` parameter: ```bash ./test-management-api-e2e.sh --token-file ../.oauth-tokens/ingress.json ``` ### Error: Token file not found **Problem:** ``` Error: Token file not found: .oauth-tokens/ingress.json To generate tokens for local testing: cd credentials-provider && ./generate_creds.sh && cd .. ``` **Solution:** Generate tokens first: ```bash cd credentials-provider ./generate_creds.sh cd .. ``` ### Error: 403 Forbidden (Unauthorized) **Problem:** The user executing the script does not have admin privileges. **Solution:** Ensure the token used belongs to an admin user. For local testing, the `ingress.json` token should have admin rights. For remote testing, ensure you're using proper credentials. ### Error: 422 Unprocessable Entity **Problem:** Invalid input data in the request. **Solution:** Check that: - Group names are valid (alphanumeric with hyphens/underscores) - Email addresses are properly formatted - All required fields are provided ### Error: Server or Agent Registration Failed **Problem:** JSON configuration file is missing or invalid. **Solution:** Ensure the JSON files exist: ```bash ls -la cli/examples/cloudflare-docs-server-config.json ls -la cli/examples/flight_booking_agent_card.json ``` If missing, the script will attempt to create them automatically. ### Warning: Health Check Failed **Problem:** Server health check returns an error. **Solution:** This is expected if the actual server is not running. The health check is included to demonstrate the functionality, but the script will continue even if it fails. ### Cleanup Failures **Problem:** Cleanup phase reports errors when deleting resources. **Solution:** This can happen if: - Resources were manually deleted during the test - Network connectivity issues - Permission issues You can manually clean up remaining resources: ```bash # List and delete remaining resources uv run python registry_management.py --registry-url http://localhost --token-file ../.oauth-tokens/ingress.json user-list uv run python registry_management.py --registry-url http://localhost --token-file ../.oauth-tokens/ingress.json group-list uv run python registry_management.py --registry-url http://localhost --token-file ../.oauth-tokens/ingress.json list uv run python registry_management.py --registry-url http://localhost --token-file ../.oauth-tokens/ingress.json agent-list # Delete manually if needed uv run python registry_management.py --registry-url http://localhost --token-file ../.oauth-tokens/ingress.json user-delete --username --force uv run python registry_management.py --registry-url http://localhost --token-file ../.oauth-tokens/ingress.json group-delete --name --force ``` ## API Endpoints Used This test script exercises the following Management API endpoints: ### User Management - `POST /api/management/iam/users/human` - Create human user - `POST /api/management/iam/users/m2m` - Create M2M service account - `GET /api/management/iam/users` - List users - `DELETE /api/management/iam/users/{username}` - Delete user ### Group Management - `POST /api/management/iam/groups` - Create group - `GET /api/management/iam/groups` - List groups - `DELETE /api/management/iam/groups/{group_name}` - Delete group ### Server Management - `POST /api/servers/register` - Register server - `GET /api/servers` - List servers - `GET /api/servers/health` - Check server health - `DELETE /api/servers/{path}` - Remove server ### Agent Management - `POST /api/agents/register` - Register agent - `GET /api/agents` - List agents - `DELETE /api/agents/{path}` - Delete agent ## Integration with CI/CD This script can be integrated into CI/CD pipelines for automated testing: ```yaml # Example GitHub Actions workflow test-management-api: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Start services run: docker-compose up -d - name: Generate tokens run: | cd credentials-provider ./generate_creds.sh cd .. - name: Run end-to-end test run: | cd api ./test-management-api-e2e.sh --token-file ../.oauth-tokens/ingress.json ``` ## Notes - **Timestamped Names:** All resource names include timestamps to avoid conflicts with existing resources - **Automatic Cleanup:** The script uses a bash trap to ensure cleanup runs even if the script fails - **Idempotent:** The script can be run multiple times safely due to unique timestamped names - **Admin Only:** All Management API operations require admin privileges - **Client Secrets:** M2M client secrets are only shown once during creation - save them immediately - **Resource Dependencies:** The cleanup happens in reverse order to respect dependencies (agents/servers before users, users before groups) ## Related Documentation - [PR #267 Implementation Summary](.scratchpad/pr267-implementation-summary.md) - [Management API Complete Testing](.scratchpad/management-api-complete-testing.md) - [Group CRUD Implementation](.scratchpad/group-crud-implementation-summary.md) - [Management API OpenAPI Specification](../docs/api-specs/management-api.yaml) - [Registry Management CLI Tool](./registry_management.py) ## Next Steps After running this test successfully: 1. **Test on AWS Deployment:** Run the script against the remote registry to verify production readiness 2. **Verify Keycloak:** Check Keycloak admin console to confirm users and groups were created correctly 3. **Test Authentication:** Use the M2M credentials to authenticate and access protected resources 4. **Performance Testing:** Run the script multiple times in parallel to test concurrency 5. **Security Testing:** Verify non-admin users cannot execute Management API operations ================================================ FILE: api/test-management-api-e2e.sh ================================================ #!/bin/bash # Continue on error - we want to run all tests and report results at the end # set -e # Disabled to allow test suite to continue after failures # Color codes for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Usage function usage() { echo "Usage: $0 --token-file [--registry-url ] [--aws-region ] [--keycloak-url ] [--quiet]" echo "" echo "Required arguments:" echo " --token-file Path to the OAuth token file (e.g., .oauth-tokens/ingress.json)" echo "" echo "Optional arguments:" echo " --registry-url Registry URL (default: http://localhost)" echo " --aws-region AWS region (e.g., us-east-1)" echo " --keycloak-url Keycloak base URL (e.g., https://kc.your-domain.example.com)" echo " --quiet Suppress verbose output (verbose is enabled by default)" echo "" echo "Examples:" echo " # Local testing with verbose output (default)" echo " $0 --token-file .oauth-tokens/ingress.json" echo "" echo " # Remote testing with all parameters" echo " $0 --token-file api/.token --registry-url https://registry.your-domain.example.com --aws-region us-east-1 --keycloak-url https://kc.your-domain.example.com" exit 1 } # Parse command line arguments TOKEN_FILE="" REGISTRY_URL="http://localhost" AWS_REGION="" KEYCLOAK_URL="" VERBOSE=true # Verbose by default while [[ $# -gt 0 ]]; do case $1 in --token-file) TOKEN_FILE="$2" shift 2 ;; --registry-url) REGISTRY_URL="$2" shift 2 ;; --aws-region) AWS_REGION="$2" shift 2 ;; --keycloak-url) KEYCLOAK_URL="$2" shift 2 ;; --quiet) VERBOSE=false shift ;; -h|--help) usage ;; *) echo "Error: Unknown argument $1" usage ;; esac done # Validate required arguments if [ -z "$TOKEN_FILE" ]; then echo -e "${RED}Error: --token-file is required${NC}" echo "" usage fi # Validate token file exists if [ ! -f "$TOKEN_FILE" ]; then echo -e "${RED}Error: Token file not found: $TOKEN_FILE${NC}" echo "" echo "To generate tokens for local testing:" echo " cd credentials-provider && ./generate_creds.sh && cd .." exit 1 fi # Configuration SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PARENT_DIR="$(dirname "$SCRIPT_DIR")" # Validate token is not expired echo "Validating token..." TOKEN_CONTENT=$(cat "$TOKEN_FILE") # Detect token format and extract access_token # Format 1: JSON with access_token field (from generate_creds.sh) # Format 2: Raw JWT token (from get-m2m-token.sh) # Format 3: JSON with nested tokens.access_token field (from UI sidebar token generation) # Format 4: Plain text token (e.g., network-trusted placeholder) if echo "$TOKEN_CONTENT" | grep -q "^eyJ"; then # Format 2: Raw JWT token (starts with eyJ which is base64 for '{"') ACCESS_TOKEN="$TOKEN_CONTENT" elif echo "$TOKEN_CONTENT" | grep -q "^{"; then # JSON format - try to extract access_token if command -v jq &> /dev/null; then ACCESS_TOKEN=$(echo "$TOKEN_CONTENT" | jq -r '.access_token // .tokens.access_token // empty') else ACCESS_TOKEN=$(echo "$TOKEN_CONTENT" | grep -o '"access_token":"[^"]*"' | head -1 | sed 's/"access_token":"\([^"]*\)"/\1/') fi else # Format 4: Plain text token (use file content verbatim) ACCESS_TOKEN=$(echo "$TOKEN_CONTENT" | tr -d '[:space:]') fi if [ -z "$ACCESS_TOKEN" ]; then echo -e "${RED}Error: Could not extract access_token from token file${NC}" echo "Token file may be corrupted or in wrong format" echo "" echo "Supported formats:" echo " 1. JSON format: {\"access_token\": \"...\"}" echo " 2. Raw JWT token: eyJ..." echo " 3. Nested JSON: {\"tokens\": {\"access_token\": \"...\"}}" echo " 4. Plain text token (used as-is)" echo "" echo "To regenerate tokens:" echo " cd credentials-provider && ./generate_creds.sh && cd .." exit 1 fi # Decode JWT to check expiration (JWT format: header.payload.signature) # Skip expiration check for non-JWT tokens (plain text placeholders) if ! echo "$ACCESS_TOKEN" | grep -q "^eyJ"; then echo -e "${YELLOW}Token is not a JWT - skipping expiration check (plain text token)${NC}" echo "" else # Extract payload (second part) PAYLOAD=$(echo "$ACCESS_TOKEN" | cut -d. -f2) # Add padding if needed for base64 decoding case $((${#PAYLOAD} % 4)) in 2) PAYLOAD="${PAYLOAD}==" ;; 3) PAYLOAD="${PAYLOAD}=" ;; esac # Decode payload if command -v base64 &> /dev/null; then DECODED_PAYLOAD=$(echo "$PAYLOAD" | base64 -d 2>/dev/null || echo "{}") # Extract exp field if command -v jq &> /dev/null; then EXP=$(echo "$DECODED_PAYLOAD" | jq -r '.exp // empty') else EXP=$(echo "$DECODED_PAYLOAD" | grep -o '"exp":[0-9]*' | sed 's/"exp"://') fi if [ -n "$EXP" ]; then CURRENT_TIME=$(date +%s) if [ "$EXP" -lt "$CURRENT_TIME" ]; then echo -e "${RED}Error: Token has expired${NC}" echo "Token expired at: $(date -d @$EXP 2>/dev/null || date -r $EXP 2>/dev/null)" echo "Current time: $(date)" echo "" echo "To regenerate tokens:" echo " cd credentials-provider && ./generate_creds.sh && cd .." exit 1 else TIME_LEFT=$((EXP - CURRENT_TIME)) MINUTES_LEFT=$((TIME_LEFT / 60)) echo -e "${GREEN}Token is valid (expires in $MINUTES_LEFT minutes)${NC}" fi else echo -e "${YELLOW}Warning: Could not verify token expiration${NC}" fi else echo -e "${YELLOW}Warning: base64 command not found, skipping token expiration check${NC}" fi fi # end of JWT expiration check (non-JWT tokens skip this block) echo "" # Test data with timestamp for uniqueness TIMESTAMP="$(date +%s)" GROUP_NAME="test-team-${TIMESTAMP}" HUMAN_USERNAME="test.user.${TIMESTAMP}" HUMAN_EMAIL="${HUMAN_USERNAME}@example.com" M2M_NAME="test-service-bot-${TIMESTAMP}" SERVER_NAME="Cloudflare Documentation MCP Server" AGENT_NAME="Flight Booking Agent" # Generate random password for human user HUMAN_USER_PASSWORD="$(openssl rand -base64 16 | tr -d '/+=' | head -c 20)Aa1!" # Unique paths with timestamp SERVER_PATH="/cloudflare-docs-${TIMESTAMP}" AGENT_PATH="/flight-booking-${TIMESTAMP}" # Temporary files for JSON payloads SERVER_JSON_FILE="${SCRIPT_DIR}/cloudflare-docs-server-config-${TIMESTAMP}.json" AGENT_JSON_FILE="${SCRIPT_DIR}/flight_booking_agent_card-${TIMESTAMP}.json" # Variables to store created resource info M2M_CLIENT_ID="" M2M_CLIENT_SECRET="" # Arrays to track test results declare -a TEST_NAMES declare -a TEST_RESULTS TEST_COUNT=0 # Function to record test result record_result() { local test_name="$1" local result="$2" # PASS or FAIL TEST_NAMES[$TEST_COUNT]="$test_name" TEST_RESULTS[$TEST_COUNT]="$result" TEST_COUNT=$((TEST_COUNT + 1)) } echo -e "${BLUE}========================================${NC}" echo -e "${BLUE}Management API End-to-End Test${NC}" echo -e "${BLUE}========================================${NC}" echo "" echo -e "${YELLOW}Configuration:${NC}" echo " Registry URL: ${REGISTRY_URL}" echo " Token File: ${TOKEN_FILE}" [ -n "$AWS_REGION" ] && echo " AWS Region: ${AWS_REGION}" [ -n "$KEYCLOAK_URL" ] && echo " Keycloak URL: ${KEYCLOAK_URL}" echo " Group Name: ${GROUP_NAME}" echo " Human User: ${HUMAN_USERNAME}" echo " M2M Account: ${M2M_NAME}" echo "" # Set up management command MGMT_CMD="uv run python ${SCRIPT_DIR}/registry_management.py --debug --registry-url ${REGISTRY_URL} --token-file ${TOKEN_FILE}" [ -n "$AWS_REGION" ] && MGMT_CMD="$MGMT_CMD --aws-region ${AWS_REGION}" [ -n "$KEYCLOAK_URL" ] && MGMT_CMD="$MGMT_CMD --keycloak-url ${KEYCLOAK_URL}" # Function to cleanup on exit cleanup() { # Display test results summary first (before cleanup) if [ $TEST_COUNT -gt 0 ]; then echo "" echo -e "${BLUE}========================================${NC}" echo -e "${BLUE}Test Results Summary${NC}" echo -e "${BLUE}========================================${NC}" echo "" # Print table header printf "%-40s | %-10s\n" "Test Name" "Result" printf "%-40s-+-%-10s\n" "----------------------------------------" "----------" # Calculate pass/fail counts PASS_COUNT=0 FAIL_COUNT=0 SKIP_COUNT=0 # Print each result for ((i=0; i /dev/null 2>&1 CREATE_STATUS=$? fi if [ $CREATE_STATUS -eq 0 ]; then echo -e "${GREEN}Group created successfully${NC}" record_result "Create IAM Group" "PASS" # Wait for group to be available in Keycloak echo -e "${YELLOW}Waiting for group to be available in Keycloak...${NC}" GROUP_AVAILABLE=false for i in {1..10}; do # Use a simple command without --debug to avoid confusion # Store output and check separately to avoid set -e issues GROUP_LIST_OUTPUT=$(uv run python ${SCRIPT_DIR}/registry_management.py --registry-url ${REGISTRY_URL} --token-file ${TOKEN_FILE} group-list 2>/dev/null || true) if echo "${GROUP_LIST_OUTPUT}" | grep -q "${GROUP_NAME}"; then echo -e "${GREEN}Group is now available${NC}" GROUP_AVAILABLE=true break else echo -e "${YELLOW}Group not yet available, waiting 10 seconds (attempt $i/10)...${NC}" sleep 10 fi done if [ "$GROUP_AVAILABLE" = false ]; then echo -e "${RED}Group did not become available after 100 seconds${NC}" echo -e "${YELLOW}Continuing with remaining tests...${NC}" fi else echo -e "${RED}Group creation failed${NC}" record_result "Create IAM Group" "FAIL" fi echo "" # Step 2: Create human user echo -e "${BLUE}[Step 2] Creating human user: ${HUMAN_USERNAME}${NC}" if [ "$VERBOSE" = true ]; then ${MGMT_CMD} user-create-human \ --username "${HUMAN_USERNAME}" \ --email "${HUMAN_EMAIL}" \ --first-name "Test" \ --last-name "User" \ --groups "${GROUP_NAME}" \ --password "${HUMAN_USER_PASSWORD}" CREATE_STATUS=$? else ${MGMT_CMD} user-create-human \ --username "${HUMAN_USERNAME}" \ --email "${HUMAN_EMAIL}" \ --first-name "Test" \ --last-name "User" \ --groups "${GROUP_NAME}" \ --password "${HUMAN_USER_PASSWORD}" > /dev/null 2>&1 CREATE_STATUS=$? fi if [ $CREATE_STATUS -eq 0 ]; then echo -e "${GREEN}Human user created successfully${NC}" record_result "Create Human User" "PASS" else echo -e "${RED}Human user creation failed${NC}" record_result "Create Human User" "FAIL" fi echo "" # Step 3: Create M2M service account echo -e "${BLUE}[Step 3] Creating M2M service account: ${M2M_NAME}${NC}" if [ "$VERBOSE" = true ]; then M2M_OUTPUT=$(${MGMT_CMD} user-create-m2m \ --name "${M2M_NAME}" \ --groups "${GROUP_NAME}" \ --description "Test service account for end-to-end testing" 2>&1) CREATE_STATUS=$? else M2M_OUTPUT=$(${MGMT_CMD} user-create-m2m \ --name "${M2M_NAME}" \ --groups "${GROUP_NAME}" \ --description "Test service account for end-to-end testing" 2>&1) CREATE_STATUS=$? fi if [ $CREATE_STATUS -eq 0 ]; then echo "${M2M_OUTPUT}" # Extract client ID and secret (these are shown in the output) M2M_CLIENT_ID="${M2M_NAME}" echo -e "${GREEN}M2M service account created successfully${NC}" echo -e "${YELLOW}Note: Save the client secret from the output above - it will not be shown again!${NC}" record_result "Create M2M Account" "PASS" else echo -e "${RED}M2M account creation failed${NC}" record_result "Create M2M Account" "FAIL" fi echo "" # Step 4: Register server echo -e "${BLUE}[Step 4] Registering server: ${SERVER_NAME} at ${SERVER_PATH}${NC}" # Create the JSON file with timestamped path cat > "${SERVER_JSON_FILE}" < /dev/null 2>&1 CREATE_STATUS=$? fi if [ $CREATE_STATUS -eq 0 ]; then echo -e "${GREEN}Server registered successfully at ${SERVER_PATH}${NC}" record_result "Register Server" "PASS" else echo -e "${RED}Server registration failed${NC}" record_result "Register Server" "FAIL" fi echo "" # Step 5: Register agent echo -e "${BLUE}[Step 5] Registering agent: ${AGENT_NAME} at ${AGENT_PATH}${NC}" # Create the JSON file with timestamped path cat > "${AGENT_JSON_FILE}" < /dev/null 2>&1 CREATE_STATUS=$? fi if [ $CREATE_STATUS -eq 0 ]; then echo -e "${GREEN}Agent registered successfully at ${AGENT_PATH}${NC}" record_result "Register Agent" "PASS" else echo -e "${RED}Agent registration failed${NC}" record_result "Register Agent" "FAIL" fi echo "" echo -e "${BLUE}========================================${NC}" echo -e "${BLUE}Phase 2: Verification${NC}" echo -e "${BLUE}========================================${NC}" echo "" # Step 6: List users echo -e "${BLUE}[Step 6] Listing all users (should include ${HUMAN_USERNAME} and ${M2M_NAME})${NC}" if [ "$VERBOSE" = true ]; then USER_LIST_OUTPUT=$(${MGMT_CMD} user-list 2>&1) CREATE_STATUS=$? echo "$USER_LIST_OUTPUT" else USER_LIST_OUTPUT=$(${MGMT_CMD} user-list 2>&1) CREATE_STATUS=$? fi if [ $CREATE_STATUS -eq 0 ]; then # Verify our created users are in the list if echo "$USER_LIST_OUTPUT" | grep -q "${HUMAN_USERNAME}" && echo "$USER_LIST_OUTPUT" | grep -q "${M2M_NAME}"; then echo -e "${GREEN}User list retrieved successfully - verified both test users present${NC}" record_result "List Users" "PASS" else echo -e "${RED}User list retrieved but test users not found${NC}" echo -e "${RED}Expected users: ${HUMAN_USERNAME}, ${M2M_NAME}${NC}" record_result "List Users" "FAIL" fi else echo -e "${RED}User list failed${NC}" record_result "List Users" "FAIL" fi echo "" # Step 7: List groups echo -e "${BLUE}[Step 7] Listing all groups (should include ${GROUP_NAME})${NC}" if [ "$VERBOSE" = true ]; then GROUP_LIST_OUTPUT=$(${MGMT_CMD} group-list 2>&1) CREATE_STATUS=$? echo "$GROUP_LIST_OUTPUT" else GROUP_LIST_OUTPUT=$(${MGMT_CMD} group-list 2>&1) CREATE_STATUS=$? fi if [ $CREATE_STATUS -eq 0 ]; then # Verify our created group is in the list if echo "$GROUP_LIST_OUTPUT" | grep -q "${GROUP_NAME}"; then echo -e "${GREEN}Group list retrieved successfully - verified test group present${NC}" record_result "List Groups" "PASS" else echo -e "${RED}Group list retrieved but test group not found${NC}" echo -e "${RED}Expected group: ${GROUP_NAME}${NC}" record_result "List Groups" "FAIL" fi else echo -e "${RED}Group list failed${NC}" record_result "List Groups" "FAIL" fi echo "" # Step 8: List servers echo -e "${BLUE}[Step 8] Listing all servers (should include ${SERVER_NAME})${NC}" if [ "$VERBOSE" = true ]; then SERVER_LIST_OUTPUT=$(${MGMT_CMD} list 2>&1) CREATE_STATUS=$? echo "$SERVER_LIST_OUTPUT" else SERVER_LIST_OUTPUT=$(${MGMT_CMD} list 2>&1) CREATE_STATUS=$? fi if [ $CREATE_STATUS -eq 0 ]; then # Verify our registered server is in the list (check for the path) if echo "$SERVER_LIST_OUTPUT" | grep -q "${SERVER_PATH}"; then echo -e "${GREEN}Server list retrieved successfully - verified test server present${NC}" record_result "List Servers" "PASS" else echo -e "${RED}Server list retrieved but test server not found${NC}" echo -e "${RED}Expected server path: ${SERVER_PATH}${NC}" record_result "List Servers" "FAIL" fi else echo -e "${RED}Server list failed${NC}" record_result "List Servers" "FAIL" fi echo "" # Step 9: List agents echo -e "${BLUE}[Step 9] Listing all agents (should include ${AGENT_NAME})${NC}" if [ "$VERBOSE" = true ]; then AGENT_LIST_OUTPUT=$(${MGMT_CMD} agent-list 2>&1) CREATE_STATUS=$? echo "$AGENT_LIST_OUTPUT" else AGENT_LIST_OUTPUT=$(${MGMT_CMD} agent-list 2>&1) CREATE_STATUS=$? fi if [ $CREATE_STATUS -eq 0 ]; then # Verify our registered agent is in the list (check for the path) if echo "$AGENT_LIST_OUTPUT" | grep -q "${AGENT_PATH}"; then echo -e "${GREEN}Agent list retrieved successfully - verified test agent present${NC}" record_result "List Agents" "PASS" else echo -e "${RED}Agent list retrieved but test agent not found${NC}" echo -e "${RED}Expected agent path: ${AGENT_PATH}${NC}" record_result "List Agents" "FAIL" fi else echo -e "${RED}Agent list failed${NC}" record_result "List Agents" "FAIL" fi echo "" # Step 10: Search for test users echo -e "${BLUE}[Step 10] Searching for test users${NC}" if [ "$VERBOSE" = true ]; then ${MGMT_CMD} user-list --search "test" --limit 50 CREATE_STATUS=$? else ${MGMT_CMD} user-list --search "test" --limit 50 > /dev/null 2>&1 CREATE_STATUS=$? fi if [ $CREATE_STATUS -eq 0 ]; then echo -e "${GREEN}User search successful${NC}" record_result "Search Users" "PASS" else echo -e "${RED}User search failed${NC}" record_result "Search Users" "FAIL" fi echo "" echo -e "${GREEN}========================================${NC}" echo -e "${GREEN}All verification steps completed!${NC}" echo -e "${GREEN}========================================${NC}" echo "" # Cleanup will run automatically via trap EXIT and display summary ================================================ FILE: api/test-mcp-client.sh ================================================ #!/bin/bash # Simple MCP client for testing MCP servers # Usage: ./test-mcp-client.sh [--verbose|-v] set -e # Get script directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # Colors GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' _show_usage() { echo "Usage: ./test-mcp-client.sh [--verbose|-v] " echo "" echo "Options:" echo " --verbose, -v Show HTTP status, headers, and raw response" echo "" echo "Required arguments:" echo " method MCP method to call" echo " server-url Full URL to the MCP server endpoint" echo " token-file Path to file containing the access token" echo "" echo "Available methods:" echo " ping - Test server connectivity" echo " initialize - Initialize MCP connection" echo " tools/list - List available tools" echo " resources/list - List available resources" echo " current_time [tz] - Get current time (optional timezone)" echo "" echo "Token file formats supported:" echo " - Plain JWT string" echo " - JSON with .tokens.access_token" echo " - JSON with .token_data.access_token" echo "" echo "Example:" echo " ./test-mcp-client.sh ping https://mcpgateway.ddns.net/currenttime/mcp ./api/.token" echo " ./test-mcp-client.sh --verbose initialize https://example.com/mcp/server/ /path/to/token" echo " ./test-mcp-client.sh current_time https://example.com/mcp/server/ .token America/New_York" } # Parse --verbose flag VERBOSE=false if [ "$1" = "--verbose" ] || [ "$1" = "-v" ]; then VERBOSE=true shift fi # Required parameters (no defaults) METHOD="$1" SERVER_URL="$2" TOKEN_FILE="$3" SESSION_FILE="${SCRIPT_DIR}/.mcp-session" # Validate required parameters if [ -z "$METHOD" ] || [ -z "$SERVER_URL" ] || [ -z "$TOKEN_FILE" ]; then echo -e "${RED}Error: Missing required arguments${NC}" echo "" _show_usage exit 1 fi # Check if token file exists if [ ! -f "$TOKEN_FILE" ]; then echo -e "${RED}Error: Token file not found at $TOKEN_FILE${NC}" echo "Run get-m2m-token.sh first to generate a token" exit 1 fi # Read and parse token from file # Supports: plain JWT string, or JSON with .tokens.access_token or .token_data.access_token TOKEN_CONTENT=$(cat "$TOKEN_FILE") # Try to extract token from JSON structure first ACCESS_TOKEN=$(echo "$TOKEN_CONTENT" | jq -r '.tokens.access_token // .token_data.access_token // empty' 2>/dev/null) # If no JSON token found, assume the file contains a plain JWT string if [ -z "$ACCESS_TOKEN" ]; then ACCESS_TOKEN="$TOKEN_CONTENT" fi # Validate token is not empty if [ -z "$ACCESS_TOKEN" ]; then echo -e "${RED}Error: Could not extract access token from $TOKEN_FILE${NC}" exit 1 fi # Read session ID if exists SESSION_ID="" if [ -f "$SESSION_FILE" ]; then SESSION_ID=$(cat "$SESSION_FILE") fi echo -e "${YELLOW}Calling MCP server...${NC}" echo " Method: $METHOD" echo " Server: $SERVER_URL" if [ -n "$SESSION_ID" ]; then echo " Session: $SESSION_ID" fi echo "" # Build the request based on method case "$METHOD" in ping) REQUEST_DATA='{ "jsonrpc": "2.0", "id": 1, "method": "ping" }' ;; initialize) REQUEST_DATA='{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": { "name": "test-client", "version": "1.0.0" } } }' ;; tools/list) REQUEST_DATA='{ "jsonrpc": "2.0", "id": 1, "method": "tools/list" }' ;; resources/list) REQUEST_DATA='{ "jsonrpc": "2.0", "id": 1, "method": "resources/list" }' ;; current_time) TIMEZONE="${4:-America/New_York}" REQUEST_DATA="{ \"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"tools/call\", \"params\": { \"name\": \"current_time_by_timezone\", \"arguments\": { \"timezone\": \"$TIMEZONE\" } } }" ;; *) echo -e "${RED}Unknown method: $METHOD${NC}" echo "" _show_usage exit 1 ;; esac # Make the request with proper headers for SSE support # Include session ID in mcp-session-id header if available # Use temporary file to capture response headers HEADERS_FILE=$(mktemp) RESPONSE="" HTTP_CODE="" if [ -n "$SESSION_ID" ]; then RESPONSE=$(curl -D "$HEADERS_FILE" -s -w "\n__HTTP_CODE__:%{http_code}" -X POST "$SERVER_URL" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "mcp-session-id: ${SESSION_ID}" \ -d "$REQUEST_DATA") else RESPONSE=$(curl -D "$HEADERS_FILE" -s -w "\n__HTTP_CODE__:%{http_code}" -X POST "$SERVER_URL" \ -H "Authorization: Bearer ${ACCESS_TOKEN}" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d "$REQUEST_DATA") fi # Extract HTTP status code from response HTTP_CODE=$(echo "$RESPONSE" | grep "^__HTTP_CODE__:" | sed 's/^__HTTP_CODE__://') RESPONSE=$(echo "$RESPONSE" | grep -v "^__HTTP_CODE__:") # Verbose output if [ "$VERBOSE" = true ]; then echo -e "${YELLOW}--- HTTP Status Code ---${NC}" echo "$HTTP_CODE" echo "" echo -e "${YELLOW}--- Response Headers ---${NC}" cat "$HEADERS_FILE" echo "" echo -e "${YELLOW}--- Raw Response Body ---${NC}" echo "$RESPONSE" echo "" echo -e "${YELLOW}--- Parsed JSON ---${NC}" fi # Parse SSE response - extract JSON from "data:" lines # SSE format is: "event: message\ndata: {json}" JSON_RESPONSE=$(echo "$RESPONSE" | grep "^data: " | sed 's/^data: //' | head -1) if [ -z "$JSON_RESPONSE" ]; then # No SSE format, assume plain JSON JSON_RESPONSE="$RESPONSE" fi # Display response - handle jq errors gracefully if ! echo "$JSON_RESPONSE" | jq . 2>/dev/null; then echo -e "${RED}Error: Response is not valid JSON (HTTP $HTTP_CODE)${NC}" echo "$JSON_RESPONSE" fi # Extract session ID from response headers (mcp-session-id header) NEW_SESSION_ID=$(grep -i "^mcp-session-id:" "$HEADERS_FILE" | sed 's/^mcp-session-id: *//i' | tr -d '\r\n') # Save session ID if present if [ -n "$NEW_SESSION_ID" ]; then echo "$NEW_SESSION_ID" > "$SESSION_FILE" echo -e "${GREEN}Session ID saved to $SESSION_FILE: $NEW_SESSION_ID${NC}" fi # Clean up temporary headers file rm -f "$HEADERS_FILE" echo "" echo -e "${GREEN}Done!${NC}" ================================================ FILE: auth_server/__init__.py ================================================ """ Auth server package for MCP Gateway Registry. """ ================================================ FILE: auth_server/cognito_utils.py ================================================ """ Cognito utilities for token generation and AWS Cognito operations. """ import logging import requests logger = logging.getLogger(__name__) def generate_token( client_id: str, client_secret: str, user_pool_id: str, region: str, scopes: list[str] = None, domain: str = None, ) -> dict: """ Generate a token using the client credentials flow Args: client_id: Cognito App Client ID client_secret: Cognito App Client Secret user_pool_id: Cognito User Pool ID region: AWS region scopes: List of scopes to request (optional) domain: Optional custom domain name (e.g., 'mcp-gateway') Returns: Dict containing access token and metadata """ try: # Construct the Cognito domain if domain: # Use custom domain if provided cognito_domain = f"https://{domain}.auth.{region}.amazoncognito.com" else: # Otherwise use user pool ID without underscores (standard format) user_pool_id_wo_underscore = user_pool_id.replace("_", "") cognito_domain = f"https://{user_pool_id_wo_underscore}.auth.{region}.amazoncognito.com" headers = {"Content-Type": "application/x-www-form-urlencoded"} data = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, } if scopes: data["scope"] = " ".join(scopes) token_url = f"{cognito_domain}/oauth2/token" logger.info(f"Requesting token from {token_url}") response = requests.post(token_url, headers=headers, data=data, timeout=10) response.raise_for_status() token_data = response.json() logger.info("Successfully obtained client credentials token") return token_data except Exception as e: logger.error(f"Failed to get client credentials token: {e}") raise ValueError(f"Cannot obtain token: {e}") ================================================ FILE: auth_server/metrics_middleware.py ================================================ """ FastAPI middleware for comprehensive metrics collection in the auth server. This middleware automatically tracks detailed authentication metrics including: - Validation steps and scope checking - Tool access control decisions - Method/tool usage patterns - Error analysis with specific reasons """ import asyncio import hashlib import json import logging import os import time import uuid from collections.abc import Callable from datetime import datetime from typing import Any # Import metrics client - use HTTP API instead of local import import httpx from fastapi import Request, Response from starlette.middleware.base import BaseHTTPMiddleware logger = logging.getLogger(__name__) class AuthMetricsMiddleware(BaseHTTPMiddleware): """ Comprehensive middleware to collect detailed authentication and tool execution metrics. Tracks: - Authentication flow with detailed validation steps - Scope checking and access control decisions - Tool and method execution patterns - Error analysis with specific failure reasons - User activity patterns (hashed for privacy) """ def __init__(self, app, service_name: str = "auth-server"): super().__init__(app) self.service_name = service_name self.metrics_url = os.getenv("METRICS_SERVICE_URL", "http://localhost:8890") self.api_key = os.getenv("METRICS_API_KEY", "") self.client = httpx.AsyncClient(timeout=5.0) # Track request contexts for detailed metrics self.request_contexts: dict[str, dict[str, Any]] = {} # Track session timings for protocol flow analysis self.session_timings: dict[str, dict[str, float]] = {} # Track session client info for consistent metrics across requests self.session_client_info: dict[str, dict[str, str]] = {} # Scalability configuration self.max_sessions = 1000 # Limit concurrent sessions self.session_ttl = 3600 # 1 hour TTL self.cleanup_interval = 300 # Cleanup every 5 minutes self.last_cleanup = time.time() def hash_username(self, username: str) -> str: """Hash username for privacy in metrics.""" if not username: return "" return hashlib.sha256(username.encode()).hexdigest()[:12] async def _cleanup_sessions_if_needed(self): """Perform periodic cleanup of old sessions to prevent memory leaks.""" current_time = time.time() # Only cleanup every cleanup_interval seconds if current_time - self.last_cleanup < self.cleanup_interval: return self.last_cleanup = current_time # Clean up old session timings sessions_to_remove = [] for session_key, methods in self.session_timings.items(): # Remove if all methods are old if all(current_time - timestamp > self.session_ttl for timestamp in methods.values()): sessions_to_remove.append(session_key) # Also remove oldest sessions if we exceed max_sessions if len(self.session_timings) > self.max_sessions: # Sort by oldest timestamp and remove excess session_ages = [ (session_key, min(methods.values()) if methods else 0) for session_key, methods in self.session_timings.items() ] session_ages.sort(key=lambda x: x[1]) excess_count = len(self.session_timings) - self.max_sessions sessions_to_remove.extend([s[0] for s in session_ages[:excess_count]]) # Remove sessions for session_key in sessions_to_remove: self.session_timings.pop(session_key, None) self.session_client_info.pop(session_key, None) if sessions_to_remove: logger.debug(f"Cleaned up {len(sessions_to_remove)} old sessions") def extract_server_name_from_url(self, original_url: str) -> str: """Extract server name from the original URL.""" if not original_url: return "unknown" try: from urllib.parse import urlparse parsed_url = urlparse(original_url) path = parsed_url.path.strip("/") path_parts = path.split("/") if path else [] return path_parts[0] if path_parts else "unknown" except Exception: return "unknown" async def extract_tool_and_method_info(self, request: Request) -> dict[str, Any]: """Extract detailed tool and method information from headers (X-Body) instead of consuming body.""" tool_info = { "method": "unknown", "tool_name": None, "request_id": None, "protocol_version": None, "client_info": {}, "params": {}, } try: # Get the request body from X-Body header set by Lua script instead of consuming it x_body = request.headers.get("X-Body") if x_body: request_payload = json.loads(x_body) if isinstance(request_payload, dict): tool_info["method"] = request_payload.get("method", "unknown") tool_info["request_id"] = request_payload.get("id") tool_info["jsonrpc"] = request_payload.get("jsonrpc") # Extract parameters params = request_payload.get("params", {}) tool_info["params"] = params # For tools/call, extract the actual tool name from params if tool_info["method"] == "tools/call" and isinstance(params, dict): tool_info["tool_name"] = params.get("name", "") # For initialize, extract client info and capabilities elif tool_info["method"] == "initialize" and isinstance(params, dict): tool_info["protocol_version"] = params.get("protocolVersion") tool_info["client_info"] = params.get("clientInfo", {}) except Exception as e: logger.debug(f"Could not extract tool information from X-Body header: {e}") return tool_info async def dispatch(self, request: Request, call_next: Callable) -> Response: """ Process request and collect comprehensive metrics. """ # Skip metrics collection for non-validation endpoints if not request.url.path.startswith("/validate"): return await call_next(request) # Start timing and generate request ID start_time = time.perf_counter() current_timestamp = time.time() request_id = f"req_{uuid.uuid4().hex[:16]}" # Extract comprehensive request data server_name = "unknown" user_hash = "" auth_method = "unknown" tool_info = {} # Extract server name from original URL header original_url = request.headers.get("X-Original-URL") if original_url: server_name = self.extract_server_name_from_url(original_url) # Extract detailed tool/method information tool_info = await self.extract_tool_and_method_info(request) # Process the request response = None success = False error_code = None try: response = await call_next(request) # Determine success based on response status success = response.status_code == 200 if success: # Extract user info from response headers if available username = response.headers.get("X-Username", "") user_hash = self.hash_username(username) auth_method = response.headers.get("X-Auth-Method", "unknown") # Track session timing for protocol flow analysis session_key = ( f"{server_name}:{user_hash}" if user_hash else f"{server_name}:anonymous" ) method = tool_info.get("method", "unknown") # Perform periodic cleanup to prevent memory leaks await self._cleanup_sessions_if_needed() if session_key not in self.session_timings: self.session_timings[session_key] = {} # Store timestamp for this method self.session_timings[session_key][method] = current_timestamp # Store client info for initialize requests if method == "initialize" and tool_info.get("client_info"): self.session_client_info[session_key] = tool_info["client_info"] else: error_code = str(response.status_code) session_key = f"{server_name}:anonymous" except Exception as e: # Handle exceptions during request processing success = False error_code = type(e).__name__ logger.error(f"Error in auth request: {e}") # Re-raise the exception to maintain normal error handling raise finally: # Calculate duration duration_ms = (time.perf_counter() - start_time) * 1000 # Emit comprehensive metrics asynchronously (fire and forget) # 1. Main auth metric asyncio.create_task( self._emit_auth_metric( success=success, method=auth_method, duration_ms=duration_ms, server_name=server_name, user_hash=user_hash, error_code=error_code, request_id=request_id, ) ) # 2. Tool execution metric (if applicable) if tool_info.get("method") and tool_info["method"] != "unknown": asyncio.create_task( self._emit_tool_execution_metric( tool_info=tool_info, server_name=server_name, success=success, duration_ms=duration_ms, user_hash=user_hash, error_code=error_code, request_id=request_id, auth_method=auth_method, ) ) # 3. Protocol flow latency metric (if we can calculate it) if success and session_key in self.session_timings: asyncio.create_task( self._emit_protocol_latency_metric( session_key=session_key, current_method=method, server_name=server_name, user_hash=user_hash, request_id=request_id, ) ) return response async def _emit_auth_metric( self, success: bool, method: str, duration_ms: float, server_name: str, user_hash: str, error_code: str = None, request_id: str = None, ): """ Emit authentication metric asynchronously. """ try: if not self.api_key: return payload = { "service": self.service_name, "version": "1.0.0", "metrics": [ { "type": "auth_request", "timestamp": datetime.utcnow().isoformat(), "value": 1.0, "duration_ms": duration_ms, "dimensions": { "success": success, "method": method, "server": server_name, "user_hash": user_hash, }, "metadata": { "error_code": error_code, "request_id": request_id or f"req_{uuid.uuid4().hex[:16]}", }, } ], } await self.client.post( f"{self.metrics_url}/metrics", json=payload, headers={"X-API-Key": self.api_key} ) except Exception as e: logger.debug(f"Failed to emit auth metric: {e}") async def _emit_tool_execution_metric( self, tool_info: dict[str, Any], server_name: str, success: bool, duration_ms: float, user_hash: str, error_code: str = None, request_id: str = None, auth_method: str = "unknown", ): """ Emit tool execution metric for the specialized tool_metrics table. """ try: if not self.api_key: return # Extract tool/method details method_name = tool_info.get("method", "unknown") actual_tool_name = tool_info.get("tool_name") client_info = tool_info.get("client_info", {}) # If no client_info in current request, try to get it from session if not client_info or client_info.get("name") == "unknown": session_key = ( f"{server_name}:{user_hash}" if user_hash else f"{server_name}:anonymous" ) stored_client_info = self.session_client_info.get(session_key, {}) if stored_client_info: client_info = stored_client_info # Create tool execution metric payload metric_data = { "type": "tool_execution", "timestamp": datetime.utcnow().isoformat(), "value": 1.0, "duration_ms": duration_ms, "dimensions": { "tool_name": actual_tool_name or method_name, "server_name": server_name, "success": success, "method": method_name, "user_hash": user_hash, "server_path": f"/{server_name}/", "client_name": client_info.get("name", "unknown"), "client_version": client_info.get("version", "unknown"), }, "metadata": { "error_code": error_code, "auth_method": auth_method, "request_id": request_id or f"req_{uuid.uuid4().hex[:16]}", "protocol_version": tool_info.get("protocol_version"), "jsonrpc_id": tool_info.get("request_id"), "actual_tool_name": actual_tool_name, "method_type": method_name, "input_size_bytes": len(json.dumps(tool_info.get("params", {})).encode()), "output_size_bytes": 0, # Will be updated if response available }, } payload = {"service": self.service_name, "version": "1.0.0", "metrics": [metric_data]} await self.client.post( f"{self.metrics_url}/metrics", json=payload, headers={"X-API-Key": self.api_key} ) except Exception as e: logger.debug(f"Failed to emit tool execution metric: {e}") async def _emit_protocol_latency_metric( self, session_key: str, current_method: str, server_name: str, user_hash: str, request_id: str, ): """ Emit protocol flow latency metrics based on session timing data. """ try: if not self.api_key: return session_data = self.session_timings.get(session_key, {}) current_time = time.time() # Calculate latencies between protocol steps latency_metrics = [] # Initialize -> Tools List latency if "initialize" in session_data and "tools/list" in session_data: init_to_list_latency = session_data["tools/list"] - session_data["initialize"] if ( init_to_list_latency > 0 and init_to_list_latency < 300 ): # Max 5 minutes reasonable latency_metrics.append( { "type": "protocol_latency", "timestamp": datetime.utcnow().isoformat(), "value": init_to_list_latency, "dimensions": { "flow_step": "initialize_to_tools_list", "server_name": server_name, "user_hash": user_hash, "session_key": session_key, }, "metadata": { "request_id": request_id, "latency_seconds": init_to_list_latency, "from_method": "initialize", "to_method": "tools/list", }, } ) # Tools List -> Tools Call latency if "tools/list" in session_data and "tools/call" in session_data: list_to_call_latency = session_data["tools/call"] - session_data["tools/list"] if ( list_to_call_latency > 0 and list_to_call_latency < 300 ): # Max 5 minutes reasonable latency_metrics.append( { "type": "protocol_latency", "timestamp": datetime.utcnow().isoformat(), "value": list_to_call_latency, "dimensions": { "flow_step": "tools_list_to_tools_call", "server_name": server_name, "user_hash": user_hash, "session_key": session_key, }, "metadata": { "request_id": request_id, "latency_seconds": list_to_call_latency, "from_method": "tools/list", "to_method": "tools/call", }, } ) # Initialize -> Tools Call (total flow latency) if "initialize" in session_data and "tools/call" in session_data: total_flow_latency = session_data["tools/call"] - session_data["initialize"] if total_flow_latency > 0 and total_flow_latency < 600: # Max 10 minutes reasonable latency_metrics.append( { "type": "protocol_latency", "timestamp": datetime.utcnow().isoformat(), "value": total_flow_latency, "dimensions": { "flow_step": "full_protocol_flow", "server_name": server_name, "user_hash": user_hash, "session_key": session_key, }, "metadata": { "request_id": request_id, "latency_seconds": total_flow_latency, "from_method": "initialize", "to_method": "tools/call", }, } ) # Emit metrics if we have any if latency_metrics: payload = { "service": self.service_name, "version": "1.0.0", "metrics": latency_metrics, } await self.client.post( f"{self.metrics_url}/metrics", json=payload, headers={"X-API-Key": self.api_key} ) # Cleanup is now handled by _cleanup_sessions_if_needed method except Exception as e: logger.debug(f"Failed to emit protocol latency metric: {e}") def add_auth_metrics_middleware(app, service_name: str = "auth-server"): """ Convenience function to add auth metrics middleware to a FastAPI app. Args: app: FastAPI application instance service_name: Name of the service for metrics identification """ app.add_middleware(AuthMetricsMiddleware, service_name=service_name) logger.info(f"Auth metrics middleware added for service: {service_name}") ================================================ FILE: auth_server/mongodb_groups_enrichment.py ================================================ """DocumentDB/MongoDB Groups Enrichment for M2M Tokens. This module provides functionality to enrich M2M tokens with groups from DocumentDB/MongoDB when the IdP token has empty groups claim. This solves the authorization problem for M2M clients across all identity providers (Keycloak, Okta, Entra). Works with both: - AWS DocumentDB (with IAM auth or username/password) - MongoDB Community Edition (local or cloud) """ import logging from motor.motor_asyncio import AsyncIOMotorDatabase logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) _mongodb_database: AsyncIOMotorDatabase | None = None async def _get_mongodb() -> AsyncIOMotorDatabase: """Get MongoDB/DocumentDB database connection singleton. This uses the same connection logic as the registry to ensure compatibility with both MongoDB Community Edition and AWS DocumentDB. Returns: MongoDB/DocumentDB database instance Raises: ValueError: If database connection parameters not configured """ global _mongodb_client, _mongodb_database if _mongodb_database is not None: return _mongodb_database try: # Use the registry's DocumentDB client for compatibility # This handles both MongoDB CE and AWS DocumentDB with proper auth mechanisms import sys from pathlib import Path # Add registry path to sys.path if not already there registry_path = Path(__file__).parent.parent / "registry" if str(registry_path) not in sys.path: sys.path.insert(0, str(registry_path.parent)) from registry.repositories.documentdb.client import get_documentdb_client _mongodb_database = await get_documentdb_client() logger.info("✓ Connected to DocumentDB/MongoDB for groups enrichment") return _mongodb_database except Exception as e: logger.error(f"Failed to connect to DocumentDB/MongoDB: {e}") raise ValueError(f"Database connection failed: {e}") async def enrich_groups_from_mongodb( client_id: str, current_groups: list[str], ) -> list[str]: """Enrich groups from DocumentDB/MongoDB if current groups are empty. This function checks if an M2M client has groups defined in the database and returns them if the current groups list is empty. This provides a fallback authorization mechanism for M2M tokens. Works with both AWS DocumentDB and MongoDB Community Edition. Args: client_id: Client ID from the JWT token current_groups: Current groups from JWT token Returns: Enriched groups list (either from MongoDB or original) """ # If groups already exist in token (non-empty array), use them if current_groups: logger.debug(f"Client {client_id} has groups in token: {current_groups}") return current_groups logger.info(f"Client {client_id} has no groups in token, querying database") # Try to fetch groups from DocumentDB/MongoDB try: db = await _get_mongodb() collection = db["idp_m2m_clients"] doc = await collection.find_one({"client_id": client_id}) if doc: db_groups = doc.get("groups", []) if db_groups: logger.info(f"Enriched groups for client {client_id} from database: {db_groups}") return db_groups else: logger.debug(f"Client {client_id} found in database but has no groups") else: logger.debug(f"Client {client_id} not found in groups database") except Exception as e: logger.warning(f"Failed to query database for groups enrichment: {e}") # Don't fail token validation if database is unavailable # Return original empty groups if no enrichment possible return current_groups def should_enrich_groups(validation_result: dict) -> bool: """Check if groups should be enriched from MongoDB. Args: validation_result: Token validation result dictionary Returns: True if groups enrichment should be attempted """ # Only enrich if: # 1. Token is valid # 2. Groups list is empty (not present or empty array) # 3. Has a client_id is_valid = validation_result.get("valid", False) groups = validation_result.get("groups", []) client_id = validation_result.get("client_id") return is_valid and not groups and client_id is not None ================================================ FILE: auth_server/oauth2_providers.yml ================================================ providers: keycloak: display_name: "Keycloak" client_id: "${KEYCLOAK_CLIENT_ID}" client_secret: "${KEYCLOAK_CLIENT_SECRET}" auth_url: "${KEYCLOAK_EXTERNAL_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth" token_url: "${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token" user_info_url: "${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/userinfo" logout_url: "${KEYCLOAK_EXTERNAL_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/logout" scopes: ["openid", "email", "profile"] response_type: "code" grant_type: "authorization_code" # Claims mapping for user info username_claim: "preferred_username" groups_claim: "groups" email_claim: "email" name_claim: "name" enabled: "${KEYCLOAK_ENABLED}" cognito: display_name: "AWS Cognito" client_id: "${COGNITO_CLIENT_ID}" client_secret: "${COGNITO_CLIENT_SECRET}" # Domain will be auto-derived from user pool ID if COGNITO_DOMAIN is not set auth_url: "https://${COGNITO_DOMAIN}.auth.${AWS_REGION}.amazoncognito.com/oauth2/authorize" token_url: "https://${COGNITO_DOMAIN}.auth.${AWS_REGION}.amazoncognito.com/oauth2/token" user_info_url: "https://${COGNITO_DOMAIN}.auth.${AWS_REGION}.amazoncognito.com/oauth2/userInfo" logout_url: "https://${COGNITO_DOMAIN}.auth.${AWS_REGION}.amazoncognito.com/logout" scopes: ["openid", "email", "profile", "aws.cognito.signin.user.admin"] response_type: "code" grant_type: "authorization_code" # Claims mapping for user info username_claim: "email" groups_claim: "cognito:groups" email_claim: "email" name_claim: "name" enabled: "${COGNITO_ENABLED}" entra: display_name: "Microsoft Entra ID" client_id: "${ENTRA_CLIENT_ID}" client_secret: "${ENTRA_CLIENT_SECRET}" auth_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize" token_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" user_info_url: "https://graph.microsoft.com/oidc/userinfo" logout_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/logout" # Request only OIDC scopes for user authentication # The resulting access token is for Microsoft Graph API # For programmatic API access, users get JWT tokens through the /tokens/generate endpoint scopes: ["openid", "email", "profile"] response_type: "code" grant_type: "authorization_code" # Claims mapping for user info username_claim: "preferred_username" groups_claim: "groups" email_claim: "email" name_claim: "name" enabled: "${ENTRA_ENABLED}" okta: display_name: "Okta" client_id: "${OKTA_CLIENT_ID}" client_secret: "${OKTA_CLIENT_SECRET}" auth_url: "https://${OKTA_DOMAIN}/oauth2/v1/authorize" token_url: "https://${OKTA_DOMAIN}/oauth2/v1/token" user_info_url: "https://${OKTA_DOMAIN}/oauth2/v1/userinfo" logout_url: "https://${OKTA_DOMAIN}/oauth2/v1/logout" scopes: ["openid", "email", "profile", "groups"] response_type: "code" grant_type: "authorization_code" username_claim: "preferred_username" groups_claim: "groups" email_claim: "email" name_claim: "name" enabled: true github: display_name: "GitHub" client_id: "${GITHUB_CLIENT_ID}" client_secret: "${GITHUB_CLIENT_SECRET}" auth_url: "https://github.com/login/oauth/authorize" token_url: "https://github.com/login/oauth/access_token" user_info_url: "https://api.github.com/user" scopes: ["read:user", "user:email"] response_type: "code" grant_type: "authorization_code" # GitHub specific claim mapping username_claim: "login" groups_claim: null # GitHub doesn't provide groups in basic scope email_claim: "email" name_claim: "name" enabled: "${GITHUB_ENABLED}" auth0: display_name: "Auth0" client_id: "${AUTH0_CLIENT_ID}" client_secret: "${AUTH0_CLIENT_SECRET}" auth_url: "https://${AUTH0_DOMAIN}/authorize" token_url: "https://${AUTH0_DOMAIN}/oauth/token" user_info_url: "https://${AUTH0_DOMAIN}/userinfo" logout_url: "https://${AUTH0_DOMAIN}/v2/logout" scopes: ["openid", "email", "profile"] response_type: "code" grant_type: "authorization_code" # Claims mapping for user info # Auth0 uses 'nickname' for display name and requires a custom # Rule/Action to add groups to tokens as a namespaced claim username_claim: "nickname" groups_claim: "${AUTH0_GROUPS_CLAIM}" email_claim: "email" name_claim: "name" enabled: "${AUTH0_ENABLED}" google: display_name: "Google" client_id: "${GOOGLE_CLIENT_ID}" client_secret: "${GOOGLE_CLIENT_SECRET}" auth_url: "https://accounts.google.com/o/oauth2/auth" token_url: "https://oauth2.googleapis.com/token" user_info_url: "https://www.googleapis.com/oauth2/v2/userinfo" scopes: ["openid", "email", "profile"] response_type: "code" grant_type: "authorization_code" # Google specific claim mapping username_claim: "email" groups_claim: null # Google doesn't provide groups in basic scope email_claim: "email" name_claim: "name" enabled: "${GOOGLE_ENABLED}" # Default session settings session: max_age_seconds: 28800 # 8 hours cookie_name: "mcp_oauth_session" secure: true # Set to false for development httponly: true samesite: "lax" domain: "${SESSION_COOKIE_DOMAIN}" # Set to your domain (with leading dot) to share cookies across subdomains, e.g., ".example.com" # Registry integration settings registry: callback_url: "${REGISTRY_URL}/auth/callback" success_redirect: "${REGISTRY_URL}/" error_redirect: "${REGISTRY_URL}/login" ================================================ FILE: auth_server/providers/__init__.py ================================================ """Authentication provider package for MCP Gateway Registry.""" from .auth0 import Auth0Provider from .base import AuthProvider from .cognito import CognitoProvider from .entra import EntraIdProvider from .factory import get_auth_provider from .keycloak import KeycloakProvider from .okta import OktaProvider __all__ = [ "Auth0Provider", "AuthProvider", "CognitoProvider", "EntraIdProvider", "KeycloakProvider", "OktaProvider", "get_auth_provider", ] ================================================ FILE: auth_server/providers/auth0.py ================================================ """Auth0 authentication provider implementation.""" import logging import os import time from typing import Any from urllib.parse import urlencode import jwt import requests from .base import AuthProvider # Constants for self-signed token validation JWT_ISSUER = os.environ.get("JWT_ISSUER", "mcp-auth-server") JWT_AUDIENCE = os.environ.get("JWT_AUDIENCE", "mcp-registry") SECRET_KEY = os.environ.get("SECRET_KEY", "development-secret-key") logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) class Auth0Provider(AuthProvider): """Auth0 authentication provider implementation. This provider implements OAuth2/OIDC authentication using Auth0. It supports: - User authentication via OAuth2 authorization code flow - Machine-to-machine authentication via client credentials flow - JWT token validation using Auth0 JWKS - Group-based authorization via custom claims or Auth0 Organizations """ def __init__( self, domain: str, client_id: str, client_secret: str, audience: str | None = None, m2m_client_id: str | None = None, m2m_client_secret: str | None = None, groups_claim: str = "https://mcp-gateway/groups", ): """Initialize Auth0 provider. Args: domain: Auth0 domain (e.g., 'your-tenant.auth0.com') client_id: OAuth2 client ID for web authentication client_secret: OAuth2 client secret for web authentication audience: API audience identifier for access tokens m2m_client_id: Optional M2M client ID (defaults to client_id) m2m_client_secret: Optional M2M client secret (defaults to client_secret) groups_claim: Custom claim name for groups in the ID/access token. Auth0 requires a namespaced claim via a Rule/Action (e.g., 'https://mcp-gateway/groups'). Defaults to 'https://mcp-gateway/groups'. """ self.domain = domain.rstrip("/") self.client_id = client_id self.client_secret = client_secret self.audience = audience self.m2m_client_id = m2m_client_id or client_id self.m2m_client_secret = m2m_client_secret or client_secret self.groups_claim = groups_claim # JWKS cache self._jwks_cache: dict[str, Any] | None = None self._jwks_cache_time: float = 0 self._jwks_cache_ttl: int = 3600 # 1 hour # Auth0 endpoints base_url = f"https://{self.domain}" self.auth_url = f"{base_url}/authorize" self.token_url = f"{base_url}/oauth/token" self.userinfo_url = f"{base_url}/userinfo" self.jwks_url = f"{base_url}/.well-known/jwks.json" self.logout_url = f"{base_url}/v2/logout" self.issuer = f"{base_url}/" logger.debug(f"Initialized Auth0 provider for domain '{domain}'") def validate_token(self, token: str, **kwargs: Any) -> dict[str, Any]: """Validate Auth0 JWT token. Args: token: The JWT access token to validate **kwargs: Additional provider-specific arguments Returns: Dictionary containing: - valid: True if token is valid - username: User's sub or nickname claim - email: User's email address - groups: List of group memberships from custom claim - scopes: List of token scopes - client_id: Client ID that issued the token - method: 'auth0' - data: Raw token claims Raises: ValueError: If token validation fails """ try: logger.debug("Validating Auth0 JWT token") # First check if this is a self-signed token from our auth server try: unverified_claims = jwt.decode(token, options={"verify_signature": False}) if unverified_claims.get("iss") == JWT_ISSUER: logger.debug("Token appears to be self-signed, validating...") return self._validate_self_signed_token(token) except Exception as e: logger.debug(f"Not a self-signed token: {e}") # Get JWKS for validation jwks = self.get_jwks() # Decode token header to get key ID unverified_header = jwt.get_unverified_header(token) kid = unverified_header.get("kid") if not kid: raise ValueError("Token missing 'kid' in header") # Find matching key signing_key = None for key in jwks.get("keys", []): if key.get("kid") == kid: from jwt import PyJWK signing_key = PyJWK(key).key break if not signing_key: raise ValueError(f"No matching key found for kid: {kid}") # Build audience list for validation valid_audiences = [self.client_id] if self.audience: valid_audiences.append(self.audience) # Validate and decode token claims = jwt.decode( token, signing_key, algorithms=["RS256"], issuer=self.issuer, audience=valid_audiences, options={"verify_exp": True, "verify_iat": True, "verify_aud": True}, ) logger.debug( f"Token validation successful for user: " f"{claims.get('nickname', claims.get('sub', 'unknown'))}" ) # Extract groups from custom namespaced claim groups = claims.get(self.groups_claim, []) if not groups: # Fallback: check permissions claim (Auth0 RBAC) groups = claims.get("permissions", []) return { "valid": True, "username": claims.get("nickname", claims.get("sub")), "email": claims.get("email"), "groups": groups, "scopes": claims.get("scope", "").split() if claims.get("scope") else [], "client_id": claims.get("azp", self.client_id), "method": "auth0", "data": claims, } except jwt.ExpiredSignatureError as e: logger.warning("Token validation failed: Token has expired") raise ValueError("Token has expired") from e except jwt.InvalidTokenError as e: logger.warning(f"Token validation failed: Invalid token - {e}") raise ValueError(f"Invalid token: {e}") from e except Exception as e: logger.error(f"Auth0 token validation error: {e}") raise ValueError(f"Token validation failed: {e}") from e def _validate_self_signed_token(self, token: str) -> dict[str, Any]: """Validate a self-signed JWT token generated by our auth server. Self-signed tokens are generated for OAuth users to use for programmatic API access. They contain the user's identity, groups, and scopes. Args: token: The self-signed JWT token to validate Returns: Dictionary containing validation results Raises: ValueError: If token validation fails """ try: claims = jwt.decode( token, SECRET_KEY, algorithms=["HS256"], audience=JWT_AUDIENCE, issuer=JWT_ISSUER, options={"verify_exp": True, "verify_iat": True, "verify_aud": True}, ) # Check token_use claim token_use = claims.get("token_use") if token_use != "access": # nosec B105 - OAuth2 token type validation per RFC 6749, not a password raise ValueError(f"Invalid token_use: {token_use}") # Extract scopes from claims scopes = [] if "scope" in claims: scope_value = claims["scope"] if isinstance(scope_value, str): scopes = scope_value.split() if scope_value else [] elif isinstance(scope_value, list): scopes = scope_value # Extract groups from claims groups = claims.get("groups", []) if isinstance(groups, str): groups = [groups] logger.info( f"Successfully validated self-signed token for user: {claims.get('sub')}, " f"groups: {groups}, scopes: {scopes}" ) return { "valid": True, "method": "self_signed", "data": claims, "client_id": claims.get("client_id", "user-generated"), "username": claims.get("sub", ""), "email": claims.get("email", ""), "expires_at": claims.get("exp"), "scopes": scopes, "groups": groups, "token_type": "user_generated", } except jwt.ExpiredSignatureError as e: logger.warning("Self-signed token validation failed: Token has expired") raise ValueError("Token has expired") from e except jwt.InvalidTokenError as e: logger.warning(f"Self-signed token validation failed: {e}") raise ValueError(f"Invalid self-signed token: {e}") from e except Exception as e: logger.error(f"Self-signed token validation error: {e}") raise ValueError(f"Self-signed token validation failed: {e}") from e def get_jwks(self) -> dict[str, Any]: """Get JSON Web Key Set from Auth0 with caching. Returns: Dictionary containing the JWKS data Raises: ValueError: If JWKS cannot be retrieved """ current_time = time.time() # Check if cache is still valid if self._jwks_cache and (current_time - self._jwks_cache_time) < self._jwks_cache_ttl: logger.debug("Using cached JWKS") return self._jwks_cache try: logger.debug(f"Fetching JWKS from {self.jwks_url}") response = requests.get(self.jwks_url, timeout=10) response.raise_for_status() self._jwks_cache = response.json() self._jwks_cache_time = current_time logger.debug("JWKS fetched and cached successfully") return self._jwks_cache except Exception as e: logger.error(f"Failed to retrieve JWKS from Auth0: {e}") raise ValueError(f"Cannot retrieve JWKS: {e}") def exchange_code_for_token(self, code: str, redirect_uri: str) -> dict[str, Any]: """Exchange authorization code for access token. Args: code: Authorization code from OAuth2 flow redirect_uri: Redirect URI used in the authorization request Returns: Dictionary containing token response Raises: ValueError: If code exchange fails """ try: logger.debug("Exchanging authorization code for token") data = { "grant_type": "authorization_code", "code": code, "client_id": self.client_id, "client_secret": self.client_secret, "redirect_uri": redirect_uri, } headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(self.token_url, data=data, headers=headers, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("Token exchange successful") return token_data except requests.RequestException as e: logger.error(f"Failed to exchange code for token: {e}") raise ValueError(f"Token exchange failed: {e}") def get_user_info(self, access_token: str) -> dict[str, Any]: """Get user information from Auth0. Args: access_token: Valid access token Returns: Dictionary containing user information Raises: ValueError: If user info cannot be retrieved """ try: logger.debug("Fetching user info from Auth0") headers = {"Authorization": f"Bearer {access_token}"} response = requests.get(self.userinfo_url, headers=headers, timeout=10) response.raise_for_status() user_info = response.json() logger.debug(f"User info retrieved for: {user_info.get('nickname', 'unknown')}") return user_info except requests.RequestException as e: logger.error(f"Failed to get user info: {e}") raise ValueError(f"User info retrieval failed: {e}") def get_auth_url(self, redirect_uri: str, state: str, scope: str | None = None) -> str: """Get Auth0 authorization URL. Args: redirect_uri: URI to redirect to after authorization state: State parameter for CSRF protection scope: Optional scope parameter (defaults to openid email profile) Returns: Full authorization URL """ logger.debug(f"Generating auth URL with redirect_uri: {redirect_uri}") params = { "client_id": self.client_id, "response_type": "code", "scope": scope or "openid email profile", "redirect_uri": redirect_uri, "state": state, } # Include audience if configured (required for API access tokens) if self.audience: params["audience"] = self.audience auth_url = f"{self.auth_url}?{urlencode(params)}" logger.debug(f"Generated auth URL: {auth_url}") return auth_url def get_logout_url(self, redirect_uri: str) -> str: """Get Auth0 logout URL. Auth0 uses 'returnTo' parameter and requires client_id. Args: redirect_uri: URI to redirect to after logout Returns: Full logout URL """ logger.debug(f"Generating logout URL with redirect_uri: {redirect_uri}") params = {"client_id": self.client_id, "returnTo": redirect_uri} logout_url = f"{self.logout_url}?{urlencode(params)}" logger.debug(f"Generated logout URL: {logout_url}") return logout_url def refresh_token(self, refresh_token: str) -> dict[str, Any]: """Refresh an access token using a refresh token. Args: refresh_token: The refresh token Returns: Dictionary containing new token response Raises: ValueError: If token refresh fails """ try: logger.debug("Refreshing access token") data = { "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": self.client_id, "client_secret": self.client_secret, } headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(self.token_url, data=data, headers=headers, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("Token refresh successful") return token_data except requests.RequestException as e: logger.error(f"Failed to refresh token: {e}") raise ValueError(f"Token refresh failed: {e}") def validate_m2m_token(self, token: str) -> dict[str, Any]: """Validate a machine-to-machine token. Args: token: The M2M access token to validate Returns: Dictionary containing validation result Raises: ValueError: If token validation fails """ return self.validate_token(token) def get_m2m_token( self, client_id: str | None = None, client_secret: str | None = None, scope: str | None = None, ) -> dict[str, Any]: """Get machine-to-machine token using client credentials. Auth0 M2M tokens require an audience parameter to specify which API the token is intended for. Args: client_id: Optional client ID (uses M2M default if not provided) client_secret: Optional client secret (uses M2M default if not provided) scope: Optional scope for the token Returns: Dictionary containing token response Raises: ValueError: If token generation fails """ try: logger.debug("Requesting M2M token using client credentials") data: dict[str, str] = { "grant_type": "client_credentials", "client_id": client_id or self.m2m_client_id, "client_secret": client_secret or self.m2m_client_secret, } # Auth0 requires audience for M2M tokens if self.audience: data["audience"] = self.audience if scope: data["scope"] = scope headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(self.token_url, data=data, headers=headers, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("M2M token generation successful") return token_data except requests.RequestException as e: logger.error(f"Failed to get M2M token: {e}") raise ValueError(f"M2M token generation failed: {e}") def extract_user_from_tokens(self, token_data: dict[str, Any]) -> dict[str, Any]: """Extract user information from Auth0 token response. Parses the ID token from the OAuth2 token exchange response to extract user identity and group memberships. The ID token is validated for issuer and audience claims to prevent token forgery. Groups are extracted from a custom namespaced claim (e.g., 'https://mcp-gateway/groups') which must be configured via an Auth0 Action or Rule. If no groups are found, falls back to the 'permissions' claim from Auth0 RBAC. Args: token_data: Token response from Auth0 containing 'id_token' and 'access_token' keys Returns: Dictionary containing: - username: User's nickname, email, or sub claim - email: User's email address - name: User's display name - groups: List of group memberships Raises: ValueError: If ID token is missing or cannot be parsed """ if "id_token" not in token_data: raise ValueError("Missing ID token in Auth0 response") try: # Validate issuer and audience claims on the ID token. # Signature verification is skipped because this token was received # directly from Auth0's token endpoint over TLS (OIDC Core 3.1.3.7). id_token_claims = jwt.decode( token_data["id_token"], options={ "verify_signature": False, "verify_iss": True, "verify_aud": True, "verify_exp": True, }, issuer=self.issuer, audience=self.client_id, ) logger.info(f"Auth0 ID token claims decoded for sub: {id_token_claims.get('sub')}") # Extract groups from custom namespaced claim. # Requires an Auth0 Action or Rule to add groups to the ID token. # Example Action: api.idToken.setCustomClaim("https://mcp-gateway/groups", event.user.groups) groups = id_token_claims.get(self.groups_claim, []) if not groups: # Fallback: check permissions claim (Auth0 RBAC) groups = id_token_claims.get("permissions", []) return { "username": id_token_claims.get("nickname") or id_token_claims.get("email") or id_token_claims.get("sub"), "email": id_token_claims.get("email"), "name": id_token_claims.get("name") or id_token_claims.get("given_name"), "groups": groups, } except jwt.InvalidTokenError as e: logger.warning(f"Auth0 ID token parsing failed: {e}") raise ValueError(f"Failed to parse Auth0 ID token: {e}") from e def get_provider_info(self) -> dict[str, Any]: """Get provider-specific information. Returns: Dictionary containing provider configuration and endpoints """ return { "provider_type": "auth0", "domain": self.domain, "client_id": self.client_id, "audience": self.audience, "endpoints": { "auth": self.auth_url, "token": self.token_url, "userinfo": self.userinfo_url, "jwks": self.jwks_url, "logout": self.logout_url, }, "issuer": self.issuer, } ================================================ FILE: auth_server/providers/base.py ================================================ """Base authentication provider interface.""" import logging from abc import ABC, abstractmethod from typing import Any logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) class AuthProvider(ABC): """Abstract base class for authentication providers.""" @abstractmethod def validate_token(self, token: str, **kwargs: Any) -> dict[str, Any]: """Validate an access token and return user info. Args: token: The access token to validate **kwargs: Additional provider-specific arguments Returns: Dictionary containing: - valid: Boolean indicating if token is valid - username: User's username - email: User's email address - groups: List of group memberships - scopes: List of token scopes - client_id: Client ID that issued the token - method: Authentication method used - data: Raw token claims/data Raises: ValueError: If token validation fails """ pass @abstractmethod def get_jwks(self) -> dict[str, Any]: """Get JSON Web Key Set for token validation. Returns: Dictionary containing the JWKS data Raises: ValueError: If JWKS cannot be retrieved """ pass @abstractmethod def exchange_code_for_token(self, code: str, redirect_uri: str) -> dict[str, Any]: """Exchange authorization code for access token. Args: code: Authorization code from OAuth2 flow redirect_uri: Redirect URI used in the authorization request Returns: Dictionary containing token response: - access_token: The access token - id_token: The ID token (if available) - refresh_token: The refresh token (if available) - token_type: Type of token (usually "Bearer") - expires_in: Token expiration time in seconds Raises: ValueError: If code exchange fails """ pass @abstractmethod def get_user_info(self, access_token: str) -> dict[str, Any]: """Get user information from access token. Args: access_token: Valid access token Returns: Dictionary containing user information: - username: User's username - email: User's email - groups: User's group memberships - Additional provider-specific fields Raises: ValueError: If user info cannot be retrieved """ pass @abstractmethod def get_auth_url(self, redirect_uri: str, state: str, scope: str | None = None) -> str: """Get authorization URL for OAuth2 flow. Args: redirect_uri: URI to redirect to after authorization state: State parameter for CSRF protection scope: Optional scope parameter (defaults to provider's default) Returns: Full authorization URL """ pass @abstractmethod def get_logout_url(self, redirect_uri: str) -> str: """Get logout URL. Args: redirect_uri: URI to redirect to after logout Returns: Full logout URL """ pass @abstractmethod def refresh_token(self, refresh_token: str) -> dict[str, Any]: """Refresh an access token using a refresh token. Args: refresh_token: The refresh token Returns: Dictionary containing new token response Raises: ValueError: If token refresh fails """ pass @abstractmethod def validate_m2m_token(self, token: str) -> dict[str, Any]: """Validate a machine-to-machine token. Args: token: The M2M access token to validate Returns: Dictionary containing validation result Raises: ValueError: If token validation fails """ pass @abstractmethod def get_m2m_token( self, client_id: str | None = None, client_secret: str | None = None, scope: str | None = None, ) -> dict[str, Any]: """Get a machine-to-machine token using client credentials. Args: client_id: Optional client ID (uses default if not provided) client_secret: Optional client secret (uses default if not provided) scope: Optional scope for the token Returns: Dictionary containing token response Raises: ValueError: If token generation fails """ pass ================================================ FILE: auth_server/providers/cognito.py ================================================ """AWS Cognito authentication provider implementation.""" import logging import time from typing import Any from urllib.parse import urlencode import jwt import requests from .base import AuthProvider logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) class CognitoProvider(AuthProvider): """AWS Cognito authentication provider implementation.""" def __init__( self, user_pool_id: str, client_id: str, client_secret: str, region: str, domain: str | None = None, ): """Initialize Cognito provider. Args: user_pool_id: AWS Cognito User Pool ID client_id: OAuth2 client ID client_secret: OAuth2 client secret region: AWS region domain: Optional custom domain name """ self.user_pool_id = user_pool_id self.client_id = client_id self.client_secret = client_secret self.region = region self.domain = domain # Cache for JWKS self._jwks_cache: dict[str, Any] | None = None self._jwks_cache_time: float = 0 self._jwks_cache_ttl: int = 3600 # 1 hour # Cognito endpoints if domain: self.cognito_domain = f"https://{domain}.auth.{region}.amazoncognito.com" else: user_pool_id_clean = user_pool_id.replace("_", "") self.cognito_domain = f"https://{user_pool_id_clean}.auth.{region}.amazoncognito.com" self.token_url = f"{self.cognito_domain}/oauth2/token" self.auth_url = f"{self.cognito_domain}/oauth2/authorize" self.userinfo_url = f"{self.cognito_domain}/oauth2/userInfo" self.jwks_url = ( f"https://cognito-idp.{region}.amazonaws.com/{user_pool_id}/.well-known/jwks.json" ) self.logout_url = f"{self.cognito_domain}/logout" self.issuer = f"https://cognito-idp.{region}.amazonaws.com/{user_pool_id}" logger.debug( f"Initialized Cognito provider for user pool '{user_pool_id}' in region '{region}'" ) def validate_token(self, token: str, **kwargs: Any) -> dict[str, Any]: """Validate Cognito JWT token.""" try: logger.debug("Validating Cognito JWT token") # Get JWKS for validation jwks = self.get_jwks() # Decode token header to get key ID unverified_header = jwt.get_unverified_header(token) kid = unverified_header.get("kid") if not kid: raise ValueError("Token missing 'kid' in header") # Find matching key signing_key = None for key in jwks.get("keys", []): if key.get("kid") == kid: from jwt import PyJWK signing_key = PyJWK(key).key break if not signing_key: raise ValueError(f"No matching key found for kid: {kid}") # Validate and decode token claims = jwt.decode( token, signing_key, algorithms=["RS256"], issuer=self.issuer, audience=self.client_id, options={"verify_exp": True, "verify_iat": True, "verify_aud": True}, ) logger.debug( f"Token validation successful for user: {claims.get('username', 'unknown')}" ) # Extract user info from claims return { "valid": True, "username": claims.get("username", claims.get("sub")), "email": claims.get("email"), "groups": claims.get("cognito:groups", []), "scopes": claims.get("scope", "").split() if claims.get("scope") else [], "client_id": claims.get("client_id", self.client_id), "method": "cognito", "data": claims, } except jwt.ExpiredSignatureError: logger.warning("Token validation failed: Token has expired") raise ValueError("Token has expired") except jwt.InvalidTokenError as e: logger.warning(f"Token validation failed: Invalid token - {e}") raise ValueError(f"Invalid token: {e}") except Exception as e: logger.error(f"Cognito token validation error: {e}") raise ValueError(f"Token validation failed: {e}") def get_jwks(self) -> dict[str, Any]: """Get JSON Web Key Set from Cognito with caching.""" current_time = time.time() # Check if cache is still valid if self._jwks_cache and (current_time - self._jwks_cache_time) < self._jwks_cache_ttl: logger.debug("Using cached JWKS") return self._jwks_cache try: logger.debug(f"Fetching JWKS from {self.jwks_url}") response = requests.get(self.jwks_url, timeout=10) response.raise_for_status() self._jwks_cache = response.json() self._jwks_cache_time = current_time logger.debug("JWKS fetched and cached successfully") return self._jwks_cache except Exception as e: logger.error(f"Failed to retrieve JWKS from Cognito: {e}") raise ValueError(f"Cannot retrieve JWKS: {e}") def exchange_code_for_token(self, code: str, redirect_uri: str) -> dict[str, Any]: """Exchange authorization code for access token.""" try: logger.debug("Exchanging authorization code for token") data = { "grant_type": "authorization_code", "code": code, "client_id": self.client_id, "client_secret": self.client_secret, "redirect_uri": redirect_uri, } headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(self.token_url, data=data, headers=headers, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("Token exchange successful") return token_data except requests.RequestException as e: logger.error(f"Failed to exchange code for token: {e}") raise ValueError(f"Token exchange failed: {e}") def get_user_info(self, access_token: str) -> dict[str, Any]: """Get user information from Cognito.""" try: logger.debug("Fetching user info from Cognito") headers = {"Authorization": f"Bearer {access_token}"} response = requests.get(self.userinfo_url, headers=headers, timeout=10) response.raise_for_status() user_info = response.json() logger.debug(f"User info retrieved for: {user_info.get('username', 'unknown')}") return user_info except requests.RequestException as e: logger.error(f"Failed to get user info: {e}") raise ValueError(f"User info retrieval failed: {e}") def get_auth_url(self, redirect_uri: str, state: str, scope: str | None = None) -> str: """Get Cognito authorization URL.""" logger.debug(f"Generating auth URL with redirect_uri: {redirect_uri}") params = { "client_id": self.client_id, "response_type": "code", "scope": scope or "openid email profile", "redirect_uri": redirect_uri, "state": state, } auth_url = f"{self.auth_url}?{urlencode(params)}" logger.debug(f"Generated auth URL: {auth_url}") return auth_url def get_logout_url(self, redirect_uri: str) -> str: """Get Cognito logout URL.""" logger.debug(f"Generating logout URL with redirect_uri: {redirect_uri}") params = {"client_id": self.client_id, "logout_uri": redirect_uri} logout_url = f"{self.logout_url}?{urlencode(params)}" logger.debug(f"Generated logout URL: {logout_url}") return logout_url def refresh_token(self, refresh_token: str) -> dict[str, Any]: """Refresh an access token using a refresh token.""" try: logger.debug("Refreshing access token") data = { "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": self.client_id, "client_secret": self.client_secret, } headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(self.token_url, data=data, headers=headers, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("Token refresh successful") return token_data except requests.RequestException as e: logger.error(f"Failed to refresh token: {e}") raise ValueError(f"Token refresh failed: {e}") def validate_m2m_token(self, token: str) -> dict[str, Any]: """Validate a machine-to-machine token.""" # M2M tokens use the same validation as regular tokens in Cognito return self.validate_token(token) def get_m2m_token( self, client_id: str | None = None, client_secret: str | None = None, scope: str | None = None, ) -> dict[str, Any]: """Get machine-to-machine token using client credentials.""" try: logger.debug("Requesting M2M token using client credentials") data = { "grant_type": "client_credentials", "client_id": client_id or self.client_id, "client_secret": client_secret or self.client_secret, } if scope: data["scope"] = scope headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(self.token_url, data=data, headers=headers, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("M2M token generation successful") return token_data except requests.RequestException as e: logger.error(f"Failed to get M2M token: {e}") raise ValueError(f"M2M token generation failed: {e}") def get_provider_info(self) -> dict[str, Any]: """Get provider-specific information.""" return { "provider_type": "cognito", "user_pool_id": self.user_pool_id, "region": self.region, "client_id": self.client_id, "endpoints": { "auth": self.auth_url, "token": self.token_url, "userinfo": self.userinfo_url, "jwks": self.jwks_url, "logout": self.logout_url, }, "issuer": self.issuer, } ================================================ FILE: auth_server/providers/entra.py ================================================ """Microsoft Entra ID (Azure AD) authentication provider implementation.""" import logging import os import time from typing import Any from urllib.parse import urlencode import jwt import requests from .base import AuthProvider # Constants for self-signed token validation JWT_ISSUER = os.environ.get("JWT_ISSUER", "mcp-auth-server") JWT_AUDIENCE = os.environ.get("JWT_AUDIENCE", "mcp-registry") SECRET_KEY = os.environ.get("SECRET_KEY", "development-secret-key") logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Default Entra ID login base URL DEFAULT_ENTRA_LOGIN_BASE_URL = "https://login.microsoftonline.com" class EntraIdProvider(AuthProvider): """Microsoft Entra ID (Azure AD) authentication provider. This provider implements OAuth2/OIDC authentication using Microsoft Entra ID (formerly Azure Active Directory). It supports: - User authentication via OAuth2 authorization code flow - Machine-to-machine authentication via client credentials flow - JWT token validation using Azure AD JWKS - Group-based authorization with Azure AD security groups """ def __init__(self, tenant_id: str, client_id: str, client_secret: str): """Initialize Entra ID provider. Args: tenant_id: Azure AD tenant ID (GUID) client_id: App registration client ID (GUID) client_secret: App registration client secret """ self.tenant_id = tenant_id self.client_id = client_id self.client_secret = client_secret # JWKS cache self._jwks_cache: dict[str, Any] | None = None self._jwks_cache_time: float = 0 self._jwks_cache_ttl: int = 3600 # 1 hour # Get login base URL from environment variable or use default login_base_url = os.environ.get("ENTRA_LOGIN_BASE_URL", DEFAULT_ENTRA_LOGIN_BASE_URL) # Entra ID endpoints base_url = f"{login_base_url}/{tenant_id}" self.auth_url = f"{base_url}/oauth2/v2.0/authorize" self.token_url = f"{base_url}/oauth2/v2.0/token" self.userinfo_url = "https://graph.microsoft.com/oidc/userinfo" self.jwks_url = f"{base_url}/discovery/v2.0/keys" self.logout_url = f"{base_url}/oauth2/v2.0/logout" # Entra ID supports two issuer formats: # v2.0 endpoint: https://login.microsoftonline.com/{tenant}/v2.0 # v1.0/M2M endpoint: https://sts.windows.net/{tenant}/ self.issuer_v2 = f"{base_url}/v2.0" self.issuer_v1 = f"https://sts.windows.net/{tenant_id}/" self.valid_issuers = [self.issuer_v2, self.issuer_v1] logger.debug(f"Initialized Entra ID provider for tenant '{tenant_id}'") def validate_token(self, token: str, **kwargs: Any) -> dict[str, Any]: """Validate Entra ID JWT token. Args: token: The JWT access token to validate **kwargs: Additional provider-specific arguments Returns: Dictionary containing: - valid: True if token is valid - username: User's preferred_username or sub claim - email: User's email address - groups: List of Azure AD group Object IDs - scopes: List of token scopes - client_id: Client ID that issued the token - method: 'entra' - data: Raw token claims Raises: ValueError: If token validation fails """ try: logger.debug("Validating Entra ID JWT token") # First check if this is a self-signed token from our auth server try: unverified_claims = jwt.decode(token, options={"verify_signature": False}) if unverified_claims.get("iss") == JWT_ISSUER: logger.debug("Token appears to be self-signed, validating...") return self._validate_self_signed_token(token) except Exception as e: logger.debug(f"Not a self-signed token: {e}") # Get JWKS for validation jwks = self.get_jwks() # Decode token header to get key ID unverified_header = jwt.get_unverified_header(token) kid = unverified_header.get("kid") if not kid: raise ValueError("Token missing 'kid' in header") # Find matching key signing_key = None for key in jwks.get("keys", []): if key.get("kid") == kid: from jwt import PyJWK signing_key = PyJWK(key).key break if not signing_key: raise ValueError(f"No matching key found for kid: {kid}") # First, decode without validation to check issuer unverified_claims = jwt.decode(token, options={"verify_signature": False}) token_issuer = unverified_claims.get("iss") # Check if issuer is valid (v1.0 or v2.0) if token_issuer not in self.valid_issuers: raise ValueError( f"Invalid issuer: {token_issuer}. Expected one of: {self.valid_issuers}" ) # Validate and decode token with the correct issuer claims = jwt.decode( token, signing_key, algorithms=["RS256"], issuer=token_issuer, audience=[self.client_id, f"api://{self.client_id}"], # Accept both formats options={"verify_exp": True, "verify_iat": True, "verify_aud": True}, ) logger.debug( f"Token validation successful for user: {claims.get('preferred_username', 'unknown')}" ) # Extract user info from claims # For M2M tokens, group memberships are in 'roles' claim instead of 'groups' # For user tokens, they're in 'groups' claim groups = claims.get("groups", []) if not groups and "roles" in claims: # M2M token - use roles claim as groups groups = claims.get("roles", []) logger.debug(f"M2M token detected, using roles claim as groups: {groups}") return { "valid": True, "username": claims.get("preferred_username", claims.get("sub")), "email": claims.get("email"), "groups": groups, "scopes": claims.get("scope", "").split() if claims.get("scope") else [], "client_id": claims.get("azp", self.client_id), "method": "entra", "data": claims, } except jwt.ExpiredSignatureError: logger.warning("Token validation failed: Token has expired") raise ValueError("Token has expired") except jwt.InvalidTokenError as e: logger.warning(f"Token validation failed: Invalid token - {e}") raise ValueError(f"Invalid token: {e}") except Exception as e: logger.error(f"Entra ID token validation error: {e}") raise ValueError(f"Token validation failed: {e}") def _validate_self_signed_token(self, token: str) -> dict[str, Any]: """Validate a self-signed JWT token generated by our auth server. Self-signed tokens are generated for OAuth users to use for programmatic API access. They contain the user's identity, groups, and scopes. Args: token: The self-signed JWT token to validate Returns: Dictionary containing validation results Raises: ValueError: If token validation fails """ try: claims = jwt.decode( token, SECRET_KEY, algorithms=["HS256"], audience=JWT_AUDIENCE, issuer=JWT_ISSUER, options={"verify_exp": True, "verify_iat": True, "verify_aud": True}, ) # Check token_use claim token_use = claims.get("token_use") if token_use != "access": # nosec B105 - OAuth2 token type validation per RFC 6749, not a password raise ValueError(f"Invalid token_use: {token_use}") # Extract scopes from claims scopes = [] if "scope" in claims: scope_value = claims["scope"] if isinstance(scope_value, str): scopes = scope_value.split() if scope_value else [] elif isinstance(scope_value, list): scopes = scope_value # Extract groups from claims groups = claims.get("groups", []) if isinstance(groups, str): groups = [groups] logger.info( f"Successfully validated self-signed token for user: {claims.get('sub')}, " f"groups: {groups}, scopes: {scopes}" ) return { "valid": True, "method": "self_signed", "data": claims, "client_id": claims.get("client_id", "user-generated"), "username": claims.get("sub", ""), "email": claims.get("email", ""), "expires_at": claims.get("exp"), "scopes": scopes, "groups": groups, "token_type": "user_generated", } except jwt.ExpiredSignatureError: logger.warning("Self-signed token validation failed: Token has expired") raise ValueError("Token has expired") except jwt.InvalidTokenError as e: logger.warning(f"Self-signed token validation failed: {e}") raise ValueError(f"Invalid self-signed token: {e}") except Exception as e: logger.error(f"Self-signed token validation error: {e}") raise ValueError(f"Self-signed token validation failed: {e}") def get_jwks(self) -> dict[str, Any]: """Get JSON Web Key Set from Entra ID with caching. Returns: Dictionary containing the JWKS data Raises: ValueError: If JWKS cannot be retrieved """ current_time = time.time() # Check if cache is still valid if self._jwks_cache and (current_time - self._jwks_cache_time) < self._jwks_cache_ttl: logger.debug("Using cached JWKS") return self._jwks_cache try: logger.debug(f"Fetching JWKS from {self.jwks_url}") response = requests.get(self.jwks_url, timeout=10) response.raise_for_status() self._jwks_cache = response.json() self._jwks_cache_time = current_time logger.debug("JWKS fetched and cached successfully") return self._jwks_cache except Exception as e: logger.error(f"Failed to retrieve JWKS from Entra ID: {e}") raise ValueError(f"Cannot retrieve JWKS: {e}") def exchange_code_for_token(self, code: str, redirect_uri: str) -> dict[str, Any]: """Exchange authorization code for access token. Args: code: Authorization code from OAuth2 flow redirect_uri: Redirect URI used in the authorization request Returns: Dictionary containing token response: - access_token: The access token - id_token: The ID token - refresh_token: The refresh token (if available) - token_type: "Bearer" - expires_in: Token expiration time in seconds Raises: ValueError: If code exchange fails """ try: logger.debug("Exchanging authorization code for token") data = { "grant_type": "authorization_code", "code": code, "client_id": self.client_id, "client_secret": self.client_secret, "redirect_uri": redirect_uri, } headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(self.token_url, data=data, headers=headers, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("Token exchange successful") return token_data except requests.RequestException as e: logger.error(f"Failed to exchange code for token: {e}") raise ValueError(f"Token exchange failed: {e}") def get_user_info(self, access_token: str) -> dict[str, Any]: """Get user information from Entra ID. Args: access_token: Valid access token Returns: Dictionary containing user information: - username: User's preferred_username - email: User's email - groups: User's group memberships (Object IDs) Raises: ValueError: If user info cannot be retrieved """ try: logger.debug("Fetching user info from Entra ID") headers = {"Authorization": f"Bearer {access_token}"} response = requests.get(self.userinfo_url, headers=headers, timeout=10) response.raise_for_status() user_info = response.json() logger.debug( f"User info retrieved for: {user_info.get('preferred_username', 'unknown')}" ) return user_info except requests.RequestException as e: logger.error(f"Failed to get user info: {e}") raise ValueError(f"User info retrieval failed: {e}") def get_auth_url(self, redirect_uri: str, state: str, scope: str | None = None) -> str: """Get Entra ID authorization URL. Args: redirect_uri: URI to redirect to after authorization state: State parameter for CSRF protection scope: Optional scope parameter (defaults to openid email profile) Returns: Full authorization URL """ logger.debug(f"Generating auth URL with redirect_uri: {redirect_uri}") params = { "client_id": self.client_id, "response_type": "code", "scope": scope or "openid email profile", "redirect_uri": redirect_uri, "state": state, } auth_url = f"{self.auth_url}?{urlencode(params)}" logger.debug(f"Generated auth URL: {auth_url}") return auth_url def get_logout_url(self, redirect_uri: str) -> str: """Get Entra ID logout URL. Args: redirect_uri: URI to redirect to after logout Returns: Full logout URL """ logger.debug(f"Generating logout URL with redirect_uri: {redirect_uri}") params = {"client_id": self.client_id, "post_logout_redirect_uri": redirect_uri} logout_url = f"{self.logout_url}?{urlencode(params)}" logger.debug(f"Generated logout URL: {logout_url}") return logout_url def refresh_token(self, refresh_token: str) -> dict[str, Any]: """Refresh an access token using a refresh token. Args: refresh_token: The refresh token Returns: Dictionary containing new token response Raises: ValueError: If token refresh fails """ try: logger.debug("Refreshing access token") data = { "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": self.client_id, "client_secret": self.client_secret, } headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(self.token_url, data=data, headers=headers, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("Token refresh successful") return token_data except requests.RequestException as e: logger.error(f"Failed to refresh token: {e}") raise ValueError(f"Token refresh failed: {e}") def validate_m2m_token(self, token: str) -> dict[str, Any]: """Validate a machine-to-machine token. Args: token: The M2M access token to validate Returns: Dictionary containing validation result Raises: ValueError: If token validation fails """ return self.validate_token(token) def get_m2m_token( self, client_id: str | None = None, client_secret: str | None = None, scope: str | None = None, ) -> dict[str, Any]: """Get machine-to-machine token using client credentials. This method is used for AI agent authentication using Azure AD service principals. Each AI agent should have its own service principal (app registration) in Azure AD. Args: client_id: Optional client ID (uses default if not provided) client_secret: Optional client secret (uses default if not provided) scope: Optional scope for the token (defaults to .default) Returns: Dictionary containing token response: - access_token: The M2M access token - token_type: "Bearer" - expires_in: Token expiration time in seconds Raises: ValueError: If token generation fails """ try: logger.debug("Requesting M2M token using client credentials") # Default scope for Entra ID M2M tokens if not scope: scope = f"api://{client_id or self.client_id}/.default" data = { "grant_type": "client_credentials", "client_id": client_id or self.client_id, "client_secret": client_secret or self.client_secret, "scope": scope, } headers = {"Content-Type": "application/x-www-form-urlencoded"} response = requests.post(self.token_url, data=data, headers=headers, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("M2M token generation successful") return token_data except requests.RequestException as e: logger.error(f"Failed to get M2M token: {e}") raise ValueError(f"M2M token generation failed: {e}") def initiate_device_code_flow(self, scope: str | None = None) -> dict[str, Any]: """Initiate device code flow for user authentication. This allows CLI applications to authenticate users by displaying a code that the user enters at a browser URL. The user logs in with their credentials and the CLI receives a token on their behalf. Args: scope: OAuth scopes to request (defaults to openid profile email) Returns: Dictionary containing: - device_code: Code for polling - user_code: Code for user to enter - verification_uri: URL for user to visit - expires_in: Seconds until codes expire - interval: Polling interval in seconds - message: User-friendly instruction message Raises: ValueError: If device code request fails """ try: logger.info("Initiating device code flow") # Default scopes for user authentication if not scope: scope = f"api://{self.client_id}/user_impersonation openid profile email" data = {"client_id": self.client_id, "scope": scope} headers = {"Content-Type": "application/x-www-form-urlencoded"} # Device code endpoint device_code_url = self.token_url.replace("/token", "/devicecode") response = requests.post(device_code_url, data=data, headers=headers, timeout=10) response.raise_for_status() result = response.json() logger.info(f"Device code flow initiated, user_code: {result.get('user_code')}") return result except requests.RequestException as e: logger.error(f"Failed to initiate device code flow: {e}") raise ValueError(f"Device code flow initiation failed: {e}") def poll_device_code_token( self, device_code: str, interval: int = 5, timeout: int = 300 ) -> dict[str, Any]: """Poll for token after user completes device code authentication. Args: device_code: The device code from initiate_device_code_flow interval: Polling interval in seconds (default 5) timeout: Maximum time to wait in seconds (default 300) Returns: Dictionary containing token response: - access_token: The user's access token - token_type: "Bearer" - expires_in: Token expiration time in seconds - refresh_token: Token for refreshing access - id_token: OpenID Connect ID token Raises: ValueError: If polling times out or fails """ try: logger.info("Polling for device code token") data = { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "client_id": self.client_id, "device_code": device_code, } headers = {"Content-Type": "application/x-www-form-urlencoded"} start_time = time.time() while (time.time() - start_time) < timeout: response = requests.post(self.token_url, data=data, headers=headers, timeout=10) if response.status_code == 200: token_data = response.json() logger.info("Device code authentication successful") return token_data error_data = response.json() error = error_data.get("error", "") if error == "authorization_pending": # User hasn't completed auth yet, keep polling logger.debug("Authorization pending, continuing to poll") time.sleep(interval) continue elif error == "slow_down": # Polling too fast, increase interval interval += 5 logger.debug(f"Slowing down, new interval: {interval}s") time.sleep(interval) continue elif error == "expired_token": raise ValueError("Device code expired. Please start over.") elif error == "access_denied": raise ValueError("User denied the authorization request.") else: raise ValueError( f"Token request failed: {error_data.get('error_description', error)}" ) raise ValueError("Device code authentication timed out") except requests.RequestException as e: logger.error(f"Failed to poll device code token: {e}") raise ValueError(f"Device code token polling failed: {e}") def get_provider_info(self) -> dict[str, Any]: """Get provider-specific information. Returns: Dictionary containing provider configuration and endpoints """ return { "provider_type": "entra", "tenant_id": self.tenant_id, "client_id": self.client_id, "endpoints": { "auth": self.auth_url, "token": self.token_url, "userinfo": self.userinfo_url, "jwks": self.jwks_url, "logout": self.logout_url, }, "issuers": {"v2": self.issuer_v2, "v1": self.issuer_v1}, } ================================================ FILE: auth_server/providers/factory.py ================================================ """Factory for creating authentication provider instances.""" import logging import os from .auth0 import Auth0Provider from .base import AuthProvider from .cognito import CognitoProvider from .entra import EntraIdProvider from .keycloak import KeycloakProvider from .okta import OktaProvider logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def get_auth_provider(provider_type: str | None = None) -> AuthProvider: """Factory function to get the appropriate auth provider. Args: provider_type: Type of provider to create ('cognito', 'keycloak', or 'entra'). If None, uses AUTH_PROVIDER environment variable. Returns: AuthProvider instance configured for the specified provider Raises: ValueError: If provider type is unknown or required config is missing """ provider_type = provider_type or os.environ.get("AUTH_PROVIDER", "cognito") logger.info(f"Creating authentication provider: {provider_type}") if provider_type == "keycloak": return _create_keycloak_provider() elif provider_type == "cognito": return _create_cognito_provider() elif provider_type == "entra": return _create_entra_provider() elif provider_type == "okta": return _create_okta_provider() elif provider_type == "auth0": return _create_auth0_provider() else: raise ValueError(f"Unknown auth provider: {provider_type}") def _create_keycloak_provider() -> KeycloakProvider: """Create and configure Keycloak provider.""" # Required configuration keycloak_url = os.environ.get("KEYCLOAK_URL") keycloak_external_url = os.environ.get("KEYCLOAK_EXTERNAL_URL", keycloak_url) realm = os.environ.get("KEYCLOAK_REALM", "mcp-gateway") client_id = os.environ.get("KEYCLOAK_CLIENT_ID") client_secret = os.environ.get("KEYCLOAK_CLIENT_SECRET") # Optional M2M configuration m2m_client_id = os.environ.get("KEYCLOAK_M2M_CLIENT_ID") m2m_client_secret = os.environ.get("KEYCLOAK_M2M_CLIENT_SECRET") # Validate required configuration missing_vars = [] if not keycloak_url: missing_vars.append("KEYCLOAK_URL") if not client_id: missing_vars.append("KEYCLOAK_CLIENT_ID") if not client_secret: missing_vars.append("KEYCLOAK_CLIENT_SECRET") if missing_vars: raise ValueError( f"Missing required Keycloak configuration: {', '.join(missing_vars)}. " "Please set these environment variables." ) logger.info( f"Initializing Keycloak provider for realm '{realm}' at {keycloak_url} (external: {keycloak_external_url})" ) return KeycloakProvider( keycloak_url=keycloak_url, keycloak_external_url=keycloak_external_url, realm=realm, client_id=client_id, client_secret=client_secret, m2m_client_id=m2m_client_id, m2m_client_secret=m2m_client_secret, ) def _create_cognito_provider() -> CognitoProvider: """Create and configure Cognito provider.""" # Required configuration user_pool_id = os.environ.get("COGNITO_USER_POOL_ID") client_id = os.environ.get("COGNITO_CLIENT_ID") client_secret = os.environ.get("COGNITO_CLIENT_SECRET") region = os.environ.get("AWS_REGION", "us-east-1") # Optional configuration domain = os.environ.get("COGNITO_DOMAIN") # Validate required configuration missing_vars = [] if not user_pool_id: missing_vars.append("COGNITO_USER_POOL_ID") if not client_id: missing_vars.append("COGNITO_CLIENT_ID") if not client_secret: missing_vars.append("COGNITO_CLIENT_SECRET") if missing_vars: raise ValueError( f"Missing required Cognito configuration: {', '.join(missing_vars)}. " "Please set these environment variables." ) logger.info( f"Initializing Cognito provider for user pool '{user_pool_id}' in region '{region}'" ) return CognitoProvider( user_pool_id=user_pool_id, client_id=client_id, client_secret=client_secret, region=region, domain=domain, ) def _create_entra_provider() -> EntraIdProvider: """Create and configure Entra ID provider.""" # Required configuration tenant_id = os.environ.get("ENTRA_TENANT_ID") client_id = os.environ.get("ENTRA_CLIENT_ID") client_secret = os.environ.get("ENTRA_CLIENT_SECRET") # Validate required configuration missing_vars = [] if not tenant_id: missing_vars.append("ENTRA_TENANT_ID") if not client_id: missing_vars.append("ENTRA_CLIENT_ID") if not client_secret: missing_vars.append("ENTRA_CLIENT_SECRET") if missing_vars: raise ValueError( f"Missing required Entra ID configuration: {', '.join(missing_vars)}. " "Please set these environment variables." ) logger.info(f"Initializing Entra ID provider for tenant '{tenant_id}'") return EntraIdProvider(tenant_id=tenant_id, client_id=client_id, client_secret=client_secret) def _create_okta_provider() -> OktaProvider: """Create and configure Okta provider.""" okta_domain = os.environ.get("OKTA_DOMAIN") client_id = os.environ.get("OKTA_CLIENT_ID") client_secret = os.environ.get("OKTA_CLIENT_SECRET") m2m_client_id = os.environ.get("OKTA_M2M_CLIENT_ID") m2m_client_secret = os.environ.get("OKTA_M2M_CLIENT_SECRET") missing_vars = [] if not okta_domain: missing_vars.append("OKTA_DOMAIN") if not client_id: missing_vars.append("OKTA_CLIENT_ID") if not client_secret: missing_vars.append("OKTA_CLIENT_SECRET") if missing_vars: raise ValueError( f"Missing required Okta configuration: {', '.join(missing_vars)}. " "Please set these environment variables." ) logger.info(f"Initializing Okta provider for domain '{okta_domain}'") return OktaProvider( okta_domain=okta_domain, client_id=client_id, client_secret=client_secret, m2m_client_id=m2m_client_id, m2m_client_secret=m2m_client_secret, ) def _create_auth0_provider() -> Auth0Provider: """Create and configure Auth0 provider.""" # Required configuration domain = os.environ.get("AUTH0_DOMAIN") client_id = os.environ.get("AUTH0_CLIENT_ID") client_secret = os.environ.get("AUTH0_CLIENT_SECRET") # Optional configuration audience = os.environ.get("AUTH0_AUDIENCE") m2m_client_id = os.environ.get("AUTH0_M2M_CLIENT_ID") m2m_client_secret = os.environ.get("AUTH0_M2M_CLIENT_SECRET") groups_claim = os.environ.get("AUTH0_GROUPS_CLAIM", "https://mcp-gateway/groups") # Validate required configuration missing_vars = [] if not domain: missing_vars.append("AUTH0_DOMAIN") if not client_id: missing_vars.append("AUTH0_CLIENT_ID") if not client_secret: missing_vars.append("AUTH0_CLIENT_SECRET") if missing_vars: raise ValueError( f"Missing required Auth0 configuration: {', '.join(missing_vars)}. " "Please set these environment variables." ) logger.info(f"Initializing Auth0 provider for domain '{domain}'") return Auth0Provider( domain=domain, client_id=client_id, client_secret=client_secret, audience=audience, m2m_client_id=m2m_client_id, m2m_client_secret=m2m_client_secret, groups_claim=groups_claim, ) def _get_provider_health_info() -> dict: """Get health information for the current provider.""" try: provider = get_auth_provider() if hasattr(provider, "get_provider_info"): return provider.get_provider_info() else: return { "provider_type": os.environ.get("AUTH_PROVIDER", "cognito"), "status": "unknown", } except Exception as e: logger.error(f"Failed to get provider health info: {e}") return { "provider_type": os.environ.get("AUTH_PROVIDER", "cognito"), "status": "error", "error": str(e), } ================================================ FILE: auth_server/providers/keycloak.py ================================================ """Keycloak authentication provider implementation.""" import logging import os import time from functools import lru_cache from typing import Any from urllib.parse import urlencode import jwt import requests from .base import AuthProvider # Constants for self-signed token validation JWT_ISSUER = os.environ.get("JWT_ISSUER", "mcp-auth-server") JWT_AUDIENCE = os.environ.get("JWT_AUDIENCE", "mcp-registry") SECRET_KEY = os.environ.get("SECRET_KEY", "development-secret-key") logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) class KeycloakProvider(AuthProvider): """Keycloak authentication provider implementation.""" def __init__( self, keycloak_url: str, realm: str, client_id: str, client_secret: str, keycloak_external_url: str | None = None, m2m_client_id: str | None = None, m2m_client_secret: str | None = None, ): """Initialize Keycloak provider. Args: keycloak_url: Base URL of the Keycloak instance for server-to-server communication realm: Keycloak realm name client_id: OAuth2 client ID for web authentication client_secret: OAuth2 client secret for web authentication keycloak_external_url: External URL for browser redirects (defaults to keycloak_url) m2m_client_id: Optional M2M client ID (defaults to client_id) m2m_client_secret: Optional M2M client secret (defaults to client_secret) """ self.keycloak_url = keycloak_url.rstrip("/") self.keycloak_external_url = (keycloak_external_url or keycloak_url).rstrip("/") self.realm = realm self.client_id = client_id self.client_secret = client_secret self.m2m_client_id = m2m_client_id or client_id self.m2m_client_secret = m2m_client_secret or client_secret # Cache for JWKS and configuration self._jwks_cache: dict[str, Any] | None = None self._jwks_cache_time: float = 0 self._jwks_cache_ttl: int = 3600 # 1 hour # Keycloak endpoints - use internal URL for server-to-server, external for browser redirects self.realm_url = f"{self.keycloak_url}/realms/{realm}" self.external_realm_url = f"{self.keycloak_external_url}/realms/{realm}" self.token_url = f"{self.realm_url}/protocol/openid-connect/token" self.auth_url = f"{self.external_realm_url}/protocol/openid-connect/auth" self.userinfo_url = f"{self.realm_url}/protocol/openid-connect/userinfo" self.jwks_url = f"{self.realm_url}/protocol/openid-connect/certs" self.logout_url = f"{self.external_realm_url}/protocol/openid-connect/logout" self.config_url = f"{self.realm_url}/.well-known/openid_configuration" logger.debug( f"Initialized Keycloak provider for realm '{realm}' at {keycloak_url} (external: {self.keycloak_external_url})" ) def validate_token(self, token: str, **kwargs: Any) -> dict[str, Any]: """Validate Keycloak JWT token.""" try: logger.debug("Validating Keycloak JWT token") # First check if this is a self-signed token from our auth server try: unverified_claims = jwt.decode(token, options={"verify_signature": False}) if unverified_claims.get("iss") == JWT_ISSUER: logger.debug("Token appears to be self-signed, validating...") return self._validate_self_signed_token(token) except Exception as e: logger.debug(f"Not a self-signed token: {e}") # Get JWKS for validation jwks = self.get_jwks() # Decode token header to get key ID unverified_header = jwt.get_unverified_header(token) kid = unverified_header.get("kid") if not kid: raise ValueError("Token missing 'kid' in header") # Find matching key signing_key = None for key in jwks.get("keys", []): if key.get("kid") == kid: from jwt import PyJWK signing_key = PyJWK(key).key break if not signing_key: raise ValueError(f"No matching key found for kid: {kid}") # Validate and decode token - accept multiple valid issuers valid_issuers = [ self.external_realm_url, # External URL: https://mcpgateway.ddns.net/realms/mcp-gateway self.realm_url, # Internal URL: http://keycloak:8080/realms/mcp-gateway f"http://localhost:8080/realms/{self.realm}", # Localhost URL for development ] claims = None last_error = None for issuer in valid_issuers: try: claims = jwt.decode( token, signing_key, algorithms=["RS256"], issuer=issuer, audience=["account", self.client_id, self.m2m_client_id], options={"verify_exp": True, "verify_iat": True, "verify_aud": True}, ) logger.debug(f"Token validation successful with issuer: {issuer}") break except jwt.InvalidIssuerError as e: last_error = e continue if claims is None: raise last_error or ValueError("Token validation failed with all valid issuers") logger.debug( f"Token validation successful for user: {claims.get('preferred_username', 'unknown')}" ) # Extract user info from claims return { "valid": True, "username": claims.get("preferred_username", claims.get("sub")), "email": claims.get("email"), "groups": claims.get("groups", []), "scopes": claims.get("scope", "").split() if claims.get("scope") else [], "client_id": claims.get("azp", claims.get("aud", self.client_id)), "method": "keycloak", "data": claims, } except jwt.ExpiredSignatureError: logger.warning("Token validation failed: Token has expired") raise ValueError("Token has expired") except jwt.InvalidTokenError as e: logger.warning(f"Token validation failed: Invalid token - {e}") raise ValueError(f"Invalid token: {e}") except Exception as e: logger.error(f"Keycloak token validation error: {e}") raise ValueError(f"Token validation failed: {e}") def _validate_self_signed_token(self, token: str) -> dict[str, Any]: """Validate a self-signed JWT token generated by our auth server. Self-signed tokens are generated for OAuth users to use for programmatic API access. They contain the user's identity, groups, and scopes. Args: token: The self-signed JWT token to validate Returns: Dictionary containing validation results Raises: ValueError: If token validation fails """ try: claims = jwt.decode( token, SECRET_KEY, algorithms=["HS256"], audience=JWT_AUDIENCE, issuer=JWT_ISSUER, options={"verify_exp": True, "verify_iat": True, "verify_aud": True}, ) # Check token_use claim token_use = claims.get("token_use") if token_use != "access": # nosec B105 - OAuth2 token type validation per RFC 6749, not a password raise ValueError(f"Invalid token_use: {token_use}") # Extract scopes from claims scopes = [] if "scope" in claims: scope_value = claims["scope"] if isinstance(scope_value, str): scopes = scope_value.split() if scope_value else [] elif isinstance(scope_value, list): scopes = scope_value # Extract groups from claims groups = claims.get("groups", []) if isinstance(groups, str): groups = [groups] logger.info( f"Successfully validated self-signed token for user: {claims.get('sub')}, " f"groups: {groups}, scopes: {scopes}" ) return { "valid": True, "method": "self_signed", "data": claims, "client_id": claims.get("client_id", "user-generated"), "username": claims.get("sub", ""), "email": claims.get("email", ""), "expires_at": claims.get("exp"), "scopes": scopes, "groups": groups, "token_type": "user_generated", } except jwt.ExpiredSignatureError: logger.warning("Self-signed token validation failed: Token has expired") raise ValueError("Token has expired") except jwt.InvalidTokenError as e: logger.warning(f"Self-signed token validation failed: {e}") raise ValueError(f"Invalid self-signed token: {e}") except Exception as e: logger.error(f"Self-signed token validation error: {e}") raise ValueError(f"Self-signed token validation failed: {e}") def get_jwks(self) -> dict[str, Any]: """Get JSON Web Key Set from Keycloak with caching.""" current_time = time.time() # Check if cache is still valid if self._jwks_cache and (current_time - self._jwks_cache_time) < self._jwks_cache_ttl: logger.debug("Using cached JWKS") return self._jwks_cache try: logger.debug(f"Fetching JWKS from {self.jwks_url}") response = requests.get(self.jwks_url, timeout=10) response.raise_for_status() self._jwks_cache = response.json() self._jwks_cache_time = current_time logger.debug("JWKS fetched and cached successfully") return self._jwks_cache except Exception as e: logger.error(f"Failed to retrieve JWKS from Keycloak: {e}") raise ValueError(f"Cannot retrieve JWKS: {e}") def exchange_code_for_token(self, code: str, redirect_uri: str) -> dict[str, Any]: """Exchange authorization code for access token.""" try: logger.debug("Exchanging authorization code for token") data = { "grant_type": "authorization_code", "code": code, "client_id": self.client_id, "client_secret": self.client_secret, "redirect_uri": redirect_uri, } response = requests.post(self.token_url, data=data, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("Token exchange successful") return token_data except requests.RequestException as e: logger.error(f"Failed to exchange code for token: {e}") raise ValueError(f"Token exchange failed: {e}") def get_user_info(self, access_token: str) -> dict[str, Any]: """Get user information from Keycloak.""" try: logger.debug("Fetching user info from Keycloak") headers = {"Authorization": f"Bearer {access_token}"} response = requests.get(self.userinfo_url, headers=headers, timeout=10) response.raise_for_status() user_info = response.json() logger.debug( f"User info retrieved for: {user_info.get('preferred_username', 'unknown')}" ) return user_info except requests.RequestException as e: logger.error(f"Failed to get user info: {e}") raise ValueError(f"User info retrieval failed: {e}") def get_auth_url(self, redirect_uri: str, state: str, scope: str | None = None) -> str: """Get Keycloak authorization URL.""" logger.debug(f"Generating auth URL with redirect_uri: {redirect_uri}") params = { "client_id": self.client_id, "response_type": "code", "scope": scope or "openid email profile", "redirect_uri": redirect_uri, "state": state, } auth_url = f"{self.auth_url}?{urlencode(params)}" logger.debug(f"Generated auth URL: {auth_url}") return auth_url def get_logout_url(self, redirect_uri: str) -> str: """Get Keycloak logout URL.""" logger.debug(f"Generating logout URL with redirect_uri: {redirect_uri}") params = {"client_id": self.client_id, "post_logout_redirect_uri": redirect_uri} logout_url = f"{self.logout_url}?{urlencode(params)}" logger.debug(f"Generated logout URL: {logout_url}") return logout_url def refresh_token(self, refresh_token: str) -> dict[str, Any]: """Refresh an access token using a refresh token.""" try: logger.debug("Refreshing access token") data = { "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": self.client_id, "client_secret": self.client_secret, } response = requests.post(self.token_url, data=data, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("Token refresh successful") return token_data except requests.RequestException as e: logger.error(f"Failed to refresh token: {e}") raise ValueError(f"Token refresh failed: {e}") def validate_m2m_token(self, token: str) -> dict[str, Any]: """Validate a machine-to-machine token.""" # M2M tokens use the same validation as regular tokens return self.validate_token(token) def get_m2m_token( self, client_id: str | None = None, client_secret: str | None = None, scope: str | None = None, ) -> dict[str, Any]: """Get machine-to-machine token using client credentials.""" try: logger.debug("Requesting M2M token using client credentials") data = { "grant_type": "client_credentials", "client_id": client_id or self.m2m_client_id, "client_secret": client_secret or self.m2m_client_secret, "scope": scope or "openid", } response = requests.post(self.token_url, data=data, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("M2M token generation successful") return token_data except requests.RequestException as e: logger.error(f"Failed to get M2M token: {e}") raise ValueError(f"M2M token generation failed: {e}") @lru_cache(maxsize=1) def _get_openid_configuration(self) -> dict[str, Any]: """Get OpenID Connect configuration from Keycloak.""" try: logger.debug(f"Fetching OpenID configuration from {self.config_url}") response = requests.get(self.config_url, timeout=10) response.raise_for_status() config = response.json() logger.debug("OpenID configuration retrieved successfully") return config except requests.RequestException as e: logger.error(f"Failed to get OpenID configuration: {e}") raise ValueError(f"OpenID configuration retrieval failed: {e}") def _check_keycloak_health(self) -> bool: """Check if Keycloak is healthy and accessible.""" try: health_url = f"{self.keycloak_url}/health/ready" response = requests.get(health_url, timeout=5) return response.status_code == 200 except Exception: return False def get_provider_info(self) -> dict[str, Any]: """Get provider-specific information.""" return { "provider_type": "keycloak", "keycloak_url": self.keycloak_url, "realm": self.realm, "client_id": self.client_id, "endpoints": { "auth": self.auth_url, "token": self.token_url, "userinfo": self.userinfo_url, "jwks": self.jwks_url, "logout": self.logout_url, "config": self.config_url, }, "healthy": self._check_keycloak_health(), } ================================================ FILE: auth_server/providers/okta.py ================================================ """Okta authentication provider implementation.""" import logging import os import re import time from typing import Any from urllib.parse import urlencode import jwt import requests from .base import AuthProvider # Constants for self-signed token validation JWT_ISSUER = os.environ.get("JWT_ISSUER", "mcp-auth-server") JWT_AUDIENCE = os.environ.get("JWT_AUDIENCE", "mcp-registry") SECRET_KEY = os.environ.get("SECRET_KEY", "development-secret-key") logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) class OktaProvider(AuthProvider): """Okta authentication provider implementation. This provider implements OAuth2/OIDC authentication using Okta. It supports: - User authentication via OAuth2 authorization code flow - Machine-to-machine authentication via client credentials flow - JWT token validation using Okta JWKS - Group-based authorization with Okta groups """ def __init__( self, okta_domain: str, client_id: str, client_secret: str, m2m_client_id: str | None = None, m2m_client_secret: str | None = None, ): """Initialize Okta provider. Args: okta_domain: Okta org domain (e.g., dev-123456.okta.com) client_id: OAuth2 client ID for web authentication client_secret: OAuth2 client secret m2m_client_id: Optional separate M2M client ID m2m_client_secret: Optional separate M2M client secret """ # Normalize domain (remove https:// if present) self.okta_domain = okta_domain.replace("https://", "").rstrip("/") self.client_id = client_id self.client_secret = client_secret self.m2m_client_id = m2m_client_id or client_id self.m2m_client_secret = m2m_client_secret or client_secret # Validate Okta domain format (security: warn on non-standard domains) standard_okta_pattern = r"^[a-zA-Z0-9-]+\.(okta\.com|oktapreview\.com|okta-emea\.com)$" if not re.match(standard_okta_pattern, self.okta_domain): logger.warning( f"Non-standard Okta domain: {self.okta_domain}. " f"Expected format: *.okta.com, *.oktapreview.com, or *.okta-emea.com" ) # JWKS cache self._jwks_cache: dict[str, Any] | None = None self._jwks_cache_time: float = 0 self._jwks_cache_ttl: int = 3600 # 1 hour # Check for custom authorization server auth_server_id = os.environ.get("OKTA_AUTH_SERVER_ID", "") # Okta endpoints (org or custom authorization server) base_url = f"https://{self.okta_domain}" if auth_server_id: # Custom authorization server endpoints oauth2_base = f"{base_url}/oauth2/{auth_server_id}/v1" self.auth_url = f"{oauth2_base}/authorize" self.token_url = f"{oauth2_base}/token" self.userinfo_url = f"{oauth2_base}/userinfo" self.jwks_url = f"{oauth2_base}/keys" self.logout_url = f"{oauth2_base}/logout" self.issuer = f"{base_url}/oauth2/{auth_server_id}" logger.info( f"Initialized Okta provider with custom authorization server '{auth_server_id}'" ) else: # Default org authorization server endpoints self.auth_url = f"{base_url}/oauth2/v1/authorize" self.token_url = f"{base_url}/oauth2/v1/token" self.userinfo_url = f"{base_url}/oauth2/v1/userinfo" self.jwks_url = f"{base_url}/oauth2/v1/keys" self.logout_url = f"{base_url}/oauth2/v1/logout" self.issuer = base_url logger.info(f"Initialized Okta provider for domain '{self.okta_domain}'") def validate_token(self, token: str, **kwargs: Any) -> dict[str, Any]: """Validate Okta JWT token. Checks for self-signed tokens first (iss == mcp-auth-server), then validates against Okta JWKS using RS256. Args: token: The JWT access token to validate **kwargs: Additional provider-specific arguments Returns: Dictionary containing validation results with valid=True, username, email, groups, scopes, client_id, method, and data. Raises: ValueError: If token validation fails """ try: logger.debug("Validating Okta JWT token") # First check if this is a self-signed token from our auth server try: unverified_claims = jwt.decode(token, options={"verify_signature": False}) if unverified_claims.get("iss") == JWT_ISSUER: logger.debug("Token appears to be self-signed, validating...") return self._validate_self_signed_token(token) except Exception as e: logger.debug(f"Not a self-signed token: {e}") # Get JWKS for validation jwks = self.get_jwks() # Decode token header to get key ID unverified_header = jwt.get_unverified_header(token) kid = unverified_header.get("kid") if not kid: raise ValueError("Token missing 'kid' in header") # Find matching key signing_key = None for key in jwks.get("keys", []): if key.get("kid") == kid: from jwt import PyJWK signing_key = PyJWK(key).key break if not signing_key: raise ValueError(f"No matching key found for kid: {kid}") # Accept both web client_id and M2M client_id as valid audiences valid_audiences = [self.client_id] if self.m2m_client_id and self.m2m_client_id != self.client_id: valid_audiences.append(self.m2m_client_id) # For custom authorization servers, M2M tokens use API identifier as audience # Decode without audience validation first to check token type unverified_claims = jwt.decode(token, options={"verify_signature": False}) # Check if this is an M2M token (has cid but audience is not client_id) is_m2m_token = "cid" in unverified_claims aud_claim = unverified_claims.get("aud", "") aud_is_client_id = aud_claim in valid_audiences # For M2M tokens with custom auth server, skip audience validation # since Okta uses API identifier (e.g., "api://ai-registry") as audience verify_audience = not (is_m2m_token and not aud_is_client_id) # Validate and decode token claims = jwt.decode( token, signing_key, algorithms=["RS256"], issuer=self.issuer, audience=valid_audiences if verify_audience else None, options={ "verify_exp": True, "verify_iat": True, "verify_aud": verify_audience, }, ) logger.debug(f"Token validation successful for user: {claims.get('sub', 'unknown')}") # Extract and validate groups claim (must be list of strings) groups = claims.get("groups", []) if not isinstance(groups, list): groups = [groups] if groups else [] if not all(isinstance(g, str) for g in groups): raise ValueError("Invalid groups claim format: must contain only strings") # Extract scopes - Okta uses 'scp' for scopes in access tokens scope_claim = claims.get("scp") or claims.get("scope", "") if isinstance(scope_claim, list): scopes = scope_claim else: scopes = scope_claim.split() if scope_claim else [] return { "valid": True, "username": claims.get("sub", claims.get("preferred_username", "")), "email": claims.get("email", ""), "groups": groups, "scopes": scopes, "client_id": claims.get("cid", self.client_id), "method": "okta", "data": claims, } except jwt.ExpiredSignatureError: logger.warning("Token validation failed: Token has expired") raise ValueError("Token has expired") except jwt.InvalidTokenError as e: logger.warning(f"Token validation failed: Invalid token - {e}") raise ValueError(f"Invalid token: {e}") except Exception as e: logger.error(f"Okta token validation error: {e}") raise ValueError(f"Token validation failed: {e}") def _validate_self_signed_token(self, token: str) -> dict[str, Any]: """Validate a self-signed JWT token generated by our auth server. Self-signed tokens are generated for OAuth users to use for programmatic API access. They contain the user's identity, groups, and scopes. Args: token: The self-signed JWT token to validate Returns: Dictionary containing validation results with method="self_signed" Raises: ValueError: If token validation fails """ try: claims = jwt.decode( token, SECRET_KEY, algorithms=["HS256"], audience=JWT_AUDIENCE, issuer=JWT_ISSUER, options={ "verify_exp": True, "verify_iat": True, "verify_aud": True, }, ) # Check token_use claim token_use = claims.get("token_use") if token_use != "access": # nosec B105 - OAuth2 token type validation per RFC 6749 raise ValueError(f"Invalid token_use: {token_use}") # Extract scopes from claims scopes = [] if "scope" in claims: scope_value = claims["scope"] if isinstance(scope_value, str): scopes = scope_value.split() if scope_value else [] elif isinstance(scope_value, list): scopes = scope_value # Extract groups from claims groups = claims.get("groups", []) if isinstance(groups, str): groups = [groups] logger.info( f"Successfully validated self-signed token for user: {claims.get('sub')}, " f"groups: {groups}, scopes: {scopes}" ) return { "valid": True, "method": "self_signed", "data": claims, "client_id": claims.get("client_id", "user-generated"), "username": claims.get("sub", ""), "email": claims.get("email", ""), "expires_at": claims.get("exp"), "scopes": scopes, "groups": groups, "token_type": "user_generated", } except jwt.ExpiredSignatureError: logger.warning("Self-signed token validation failed: Token has expired") raise ValueError("Token has expired") except jwt.InvalidTokenError as e: logger.warning(f"Self-signed token validation failed: {e}") raise ValueError(f"Invalid self-signed token: {e}") except Exception as e: logger.error(f"Self-signed token validation error: {e}") raise ValueError(f"Self-signed token validation failed: {e}") def get_jwks(self) -> dict[str, Any]: """Get JSON Web Key Set from Okta with caching. Returns cached JWKS if still valid (within TTL), otherwise fetches fresh data from Okta. Retries once on failure and falls back to stale cache if available. Returns: JWKS dictionary containing keys for token verification Raises: ValueError: If JWKS cannot be retrieved and no cache exists """ current_time = time.time() # Check if cache is still valid if self._jwks_cache and (current_time - self._jwks_cache_time) < self._jwks_cache_ttl: logger.debug("Using cached JWKS") return self._jwks_cache # Try to fetch fresh JWKS with retry max_retries = 2 last_error = None for attempt in range(max_retries): try: logger.debug(f"Fetching JWKS (attempt {attempt + 1})") response = requests.get(self.jwks_url, timeout=10) response.raise_for_status() self._jwks_cache = response.json() self._jwks_cache_time = current_time logger.debug("JWKS fetched and cached successfully") return self._jwks_cache except Exception as e: last_error = e logger.warning(f"JWKS fetch attempt {attempt + 1} failed: {e}") if attempt < max_retries - 1: time.sleep(1) # Brief delay before retry # Graceful degradation: use stale cache if available if self._jwks_cache: cache_age = current_time - self._jwks_cache_time logger.warning( f"JWKS fetch failed after {max_retries} attempts, " f"using stale cache (age: {cache_age:.0f}s): {last_error}" ) return self._jwks_cache # No cache available, must fail logger.error(f"Failed to retrieve JWKS from Okta (no cache available): {last_error}") raise ValueError(f"Cannot retrieve JWKS: {last_error}") def exchange_code_for_token(self, code: str, redirect_uri: str) -> dict[str, Any]: """Exchange authorization code for access token. Args: code: Authorization code from Okta callback redirect_uri: The redirect URI used in the authorization request Returns: Token response dictionary containing access_token, id_token, etc. Raises: ValueError: If the token exchange request fails """ try: logger.debug("Exchanging authorization code for token") data = { "grant_type": "authorization_code", "code": code, "client_id": self.client_id, "client_secret": self.client_secret, "redirect_uri": redirect_uri, } headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", } response = requests.post(self.token_url, data=data, headers=headers, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("Token exchange successful") return token_data except requests.RequestException as e: logger.error(f"Failed to exchange code for token: {e}") raise ValueError(f"Token exchange failed: {e}") def get_user_info(self, access_token: str) -> dict[str, Any]: """Get user information from Okta. Args: access_token: Valid Okta access token Returns: User info dictionary from Okta userinfo endpoint Raises: ValueError: If the userinfo request fails """ try: logger.debug("Fetching user info from Okta") headers = {"Authorization": f"Bearer {access_token}"} response = requests.get(self.userinfo_url, headers=headers, timeout=10) response.raise_for_status() user_info = response.json() logger.debug(f"User info retrieved for: {user_info.get('sub', 'unknown')}") return user_info except requests.RequestException as e: logger.error(f"Failed to get user info: {e}") raise ValueError(f"User info retrieval failed: {e}") def get_auth_url(self, redirect_uri: str, state: str, scope: str | None = None) -> str: """Get Okta authorization URL. Args: redirect_uri: The redirect URI after authentication state: CSRF protection state parameter scope: OAuth2 scopes (defaults to 'openid email profile groups') Returns: Authorization URL string """ logger.debug(f"Generating auth URL with redirect_uri: {redirect_uri}") params = { "client_id": self.client_id, "response_type": "code", "scope": scope or "openid email profile groups", "redirect_uri": redirect_uri, "state": state, } auth_url = f"{self.auth_url}?{urlencode(params)}" logger.debug(f"Generated auth URL for endpoint: {self.auth_url}") return auth_url def get_logout_url(self, redirect_uri: str) -> str: """Get Okta logout URL. Args: redirect_uri: URI to redirect to after logout Returns: Full logout URL with client_id and post_logout_redirect_uri params """ logger.debug(f"Generating logout URL with redirect_uri: {redirect_uri}") params = { "client_id": self.client_id, "post_logout_redirect_uri": redirect_uri, } logout_url = f"{self.logout_url}?{urlencode(params)}" logger.debug(f"Generated logout URL for endpoint: {self.logout_url}") return logout_url def refresh_token(self, refresh_token: str) -> dict[str, Any]: """Refresh an access token using a refresh token. Args: refresh_token: The refresh token from a previous token response Returns: Dictionary containing new token response Raises: ValueError: If token refresh fails """ try: logger.debug("Refreshing access token") data = { "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": self.client_id, "client_secret": self.client_secret, } headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", } response = requests.post( self.token_url, data=data, headers=headers, timeout=10, ) response.raise_for_status() token_data = response.json() logger.debug("Token refresh successful") return token_data except requests.RequestException as e: logger.error(f"Failed to refresh token: {e}") raise ValueError(f"Token refresh failed: {e}") def validate_m2m_token(self, token: str) -> dict[str, Any]: """Validate a machine-to-machine token. Delegates to the standard validate_token() method since M2M tokens use the same JWT validation logic as user tokens. Args: token: JWT token string to validate Returns: Validated token information dictionary Raises: ValueError: If token validation fails """ return self.validate_token(token) def get_m2m_token( self, client_id: str | None = None, client_secret: str | None = None, scope: str | None = None, ) -> dict[str, Any]: """Get machine-to-machine token using client credentials. Args: client_id: Optional override client ID (defaults to configured M2M client ID) client_secret: Optional override client secret (defaults to configured M2M client secret) scope: Optional scope string (defaults to 'openid') Returns: Token response dictionary containing access_token, etc. Raises: ValueError: If the M2M token request fails """ try: logger.debug("Requesting M2M token using client credentials") data = { "grant_type": "client_credentials", "client_id": client_id or self.m2m_client_id, "client_secret": client_secret or self.m2m_client_secret, "scope": scope or "openid", } headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", } response = requests.post(self.token_url, data=data, headers=headers, timeout=10) response.raise_for_status() token_data = response.json() logger.debug("M2M token generation successful") return token_data except requests.RequestException as e: logger.error(f"Failed to get M2M token: {e}") raise ValueError(f"M2M token generation failed: {e}") def get_provider_info(self) -> dict[str, Any]: """Get provider-specific information. Returns: Dictionary containing provider configuration and endpoints """ return { "provider_type": "okta", "okta_domain": self.okta_domain, "client_id": self.client_id, "endpoints": { "auth": self.auth_url, "token": self.token_url, "userinfo": self.userinfo_url, "jwks": self.jwks_url, "logout": self.logout_url, }, "issuer": self.issuer, } ================================================ FILE: auth_server/pyproject.toml ================================================ [build-system] requires = ["setuptools>=42.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools] packages = ["auth_server"] [project] name = "auth_server" version = "0.1.0" description = "Authentication server for validating JWT tokens against Amazon Cognito" requires-python = ">=3.14" dependencies = [ "fastapi>=0.115.0", "uvicorn[standard]>=0.34.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0", "requests>=2.28.0", "python-jose>=3.3.0", "python-dotenv>=1.0.0", "boto3>=1.28.0", "pyjwt>=2.6.0", "cryptography>=40.0.0", "pyyaml>=6.0.0", "httpx>=0.25.0", "itsdangerous>=2.1.0", "opensearch-py>=2.4.0", "aiohttp>=3.8.0", "motor>=3.3.0", "pymongo>=4.6.0", "aiofiles>=24.1.0" ] [project.optional-dependencies] dev = [ "pytest>=7.0.0", "black>=23.0.0", "isort>=5.12.0" ] [tool.uv] # Local-only project - never resolve from PyPI package = false ================================================ FILE: auth_server/scopes.yml ================================================ # Scopes Configuration for MCP Gateway Registry # # This file defines three main top-level groups: # 1. UI-Scopes: Agent registry permissions (list, get, publish, modify, delete agents) and MCP service access # 2. group_mappings: Maps IdP groups to scope names (supports both Keycloak group names and Entra ID Object IDs) # 3. Individual group scopes: Detailed MCP server method/tool access for each group # # Each group has two types of permissions: # - Agent permissions: Actions on agent resources (list_agents, get_agent, publish_agent, modify_agent, delete_agent) # - MCP server permissions: Methods and tools accessible on specific MCP servers (currenttime, mcpgw, fininfo, etc.) # # To add a new group, follow these three steps: # 1. Add to UI-Scopes: Define agent and service permissions (what agents/services the group can access) # 2. Add to group_mappings: Map the IdP group identifier to the internal scope name # - For Keycloak: Use the group name (e.g., "registry-admins") # - For Entra ID: Use the Azure AD Group Object ID (e.g., "4c46ec66-a4f7-4b62-9095-b7958662f4b6") # 3. Add individual group scope entry: Define detailed MCP server methods/tools and agent actions for the group # ==================== UI-SCOPES ==================== # Define agent registry permissions and service listing rights for each group UI-Scopes: # Federation service account for peer-to-peer registry sync (read-only) federation-service: list_agents: - all get_agent: - all list_service: - all health_check_service: - all # Admin user for MCP registry (highest privileges) mcp-registry-admin: list_agents: - all get_agent: - all publish_agent: - all modify_agent: - all delete_agent: - all list_service: - all register_service: - all health_check_service: - all toggle_service: - all modify_service: - all # Registry admin group (wildcard access to all agents and services) registry-admins: list_agents: - all get_agent: - all publish_agent: - all modify_agent: - all delete_agent: - all list_service: - all register_service: - all health_check_service: - all toggle_service: - all modify_service: - all # LOB1 (Line of Business 1): Restricted to code-reviewer and test-automation agents registry-users-lob1: list_agents: - /code-reviewer - /test-automation get_agent: - /code-reviewer - /test-automation publish_agent: - /code-reviewer - /test-automation modify_agent: - /code-reviewer - /test-automation delete_agent: - /code-reviewer - /test-automation list_service: - currenttime - mcpgw health_check_service: - currenttime - mcpgw # Public MCP Users: Access to public MCP servers (context7, cloudflare-docs) and flight-booking agent public-mcp-users: list_agents: - /flight-booking get_agent: - /flight-booking list_service: - all health_check_service: - context7 - cloudflare-docs # LOB2 (Line of Business 2): Restricted to data-analysis and security-analyzer agents registry-users-lob2: list_agents: - /data-analysis - /security-analyzer get_agent: - /data-analysis - /security-analyzer publish_agent: - /data-analysis - /security-analyzer modify_agent: - /data-analysis - /security-analyzer delete_agent: - /data-analysis - /security-analyzer list_service: - realserverfaketools - mcpgw - fininfo health_check_service: - realserverfaketools - mcpgw - fininfo # ==================== GROUP MAPPINGS ==================== # Maps IdP groups to internal scope group names # This section supports BOTH Keycloak (group names) and Entra ID (Object IDs) # # Keycloak: Uses group names directly (e.g., "registry-admins") # Entra ID: Uses Azure AD Group Object IDs (GUIDs) from Azure Portal -> Groups -> [group] -> Object Id group_mappings: # ----- Keycloak Group Mappings (group names) ----- federation-service: - federation-service mcp-registry-admin: - mcp-registry-admin - mcp-servers-unrestricted/read - mcp-servers-unrestricted/execute registry-admins: - registry-admins - mcp-servers-unrestricted/read - mcp-servers-unrestricted/execute registry-users-lob1: - registry-users-lob1 registry-users-lob2: - registry-users-lob2 public-mcp-users: - public-mcp-users # ----- Entra ID Group Mappings (Azure AD Object IDs) ----- # registry-admins group Object ID from Azure AD "4c46ec66-a4f7-4b62-9095-b7958662f4b6": - registry-admins - mcp-servers-unrestricted/read - mcp-servers-unrestricted/execute # public-mcp-users group Object ID from Azure AD "5f605d68-06bc-4208-b992-bb378eee12c5": - public-mcp-users # Add additional Entra ID group mappings here as needed: # "your-lob1-group-object-id": # - registry-users-lob1 # # "your-lob2-group-object-id": # - registry-users-lob2 # ==================== MCP SERVER SCOPES ==================== # Unrestricted read access: Wildcard access to all servers with all methods and tools mcp-servers-unrestricted/read: - server: '*' methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list - GET tools: '*' - server: api methods: - tokens - GET # Unrestricted execute access: Full CRUD operations on all servers (POST, PUT, DELETE in addition to read) mcp-servers-unrestricted/execute: - server: '*' methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list - GET - POST - PUT - DELETE tools: '*' - server: api methods: - tokens - GET - POST # Federation Service Scope: Read-only access for peer-to-peer registry sync # This scope is used by peer registries to fetch servers and agents for federation federation-service: - server: api methods: - initialize - GET - agents: actions: - action: list_agents resources: - all - action: get_agent resources: - all # LOB1 Group Scope: Read-only access to API; currenttime and mcpgw servers; code-reviewer and test-automation agents registry-users-lob1: - server: api methods: - initialize - GET - server: currenttime methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list tools: - current_time_by_timezone - server: mcpgw methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list tools: - intelligent_tool_finder - agents: actions: - action: list_agents resources: - /code-reviewer - /test-automation - action: get_agent resources: - /code-reviewer - /test-automation - action: publish_agent resources: - /code-reviewer - /test-automation - action: modify_agent resources: - /code-reviewer - /test-automation - action: delete_agent resources: - /code-reviewer - /test-automation # LOB2 Group Scope: Read-only access to API; realserverfaketools, mcpgw, fininfo servers; data-analysis and security-analyzer agents registry-users-lob2: - server: api methods: - initialize - GET - server: realserverfaketools methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list tools: - quantum_flux_analyzer - neural_pattern_synthesizer - hyper_dimensional_mapper - server: mcpgw methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list tools: - intelligent_tool_finder - server: fininfo methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list tools: - get_stock_aggregates - print_stock_data - agents: actions: - action: list_agents resources: - /data-analysis - /security-analyzer - action: get_agent resources: - /data-analysis - /security-analyzer - action: publish_agent resources: - /data-analysis - /security-analyzer - action: modify_agent resources: - /data-analysis - /security-analyzer - action: delete_agent resources: - /data-analysis - /security-analyzer # Admin Group Scope: Unrestricted access to all servers with wildcard; unrestricted access to all agents registry-admins: - server: '*' methods: - all tools: - all - agents: actions: - action: list_agents resources: - all - action: get_agent resources: - all - action: publish_agent resources: - all - action: modify_agent resources: - all - action: delete_agent resources: - all # Public MCP Users: Access to public MCP servers and flight-booking agent public-mcp-users: - server: api methods: - initialize - GET - POST - servers - agents - search - rating - tools - tokens tools: [] - server: v0.1 methods: - agents - GET - POST tools: [] - server: context7 methods: - initialize - tools/list - tools/call tools: '*' - server: /context7 methods: - initialize - tools/list - tools/call tools: '*' - server: /context7/ methods: - initialize - tools/list - tools/call tools: '*' - server: cloudflare-docs methods: - initialize - tools/list - tools/call tools: '*' - server: /cloudflare-docs methods: - initialize - tools/list - tools/call tools: '*' - server: /cloudflare-docs/ methods: - initialize - tools/list - tools/call tools: '*' - agents: actions: - action: list_agents resources: - /flight-booking - action: get_agent resources: - /flight-booking ================================================ FILE: auth_server/scopes.yml.backup ================================================ # Scopes Configuration for MCP Gateway Registry # # This file defines three main top-level groups: # 1. UI-Scopes: Agent registry permissions (list, get, publish, modify, delete agents) and MCP service access # 2. group_mappings: Maps Keycloak groups to scope names # 3. Individual group scopes: Detailed MCP server method/tool access for each group # # Each group has two types of permissions: # - Agent permissions: Actions on agent resources (list_agents, get_agent, publish_agent, modify_agent, delete_agent) # - MCP server permissions: Methods and tools accessible on specific MCP servers (currenttime, mcpgw, fininfo, etc.) # # To add a new group, follow these three steps: # 1. Add to UI-Scopes: Define agent and service permissions (what agents/services the group can access) # 2. Add to group_mappings: Map the Keycloak group name to the internal scope name # 3. Add individual group scope entry: Define detailed MCP server methods/tools and agent actions for the group # ==================== UI-SCOPES ==================== # Define agent registry permissions and service listing rights for each group UI-Scopes: # Admin user for MCP registry (highest privileges) mcp-registry-admin: list_agents: - all get_agent: - all publish_agent: - all modify_agent: - all delete_agent: - all list_service: - all register_service: - all health_check_service: - all toggle_service: - all modify_service: - all # Registry admin group (wildcard access to all agents and services) registry-admins: list_agents: - all get_agent: - all publish_agent: - all modify_agent: - all delete_agent: - all list_service: - all register_service: - all health_check_service: - all toggle_service: - all modify_service: - all # LOB1 (Line of Business 1): Restricted to code-reviewer and test-automation agents registry-users-lob1: list_agents: - /code-reviewer - /test-automation get_agent: - /code-reviewer - /test-automation publish_agent: - /code-reviewer - /test-automation modify_agent: - /code-reviewer - /test-automation delete_agent: - /code-reviewer - /test-automation list_service: - currenttime - mcpgw health_check_service: - currenttime - mcpgw # LOB2 (Line of Business 2): Restricted to data-analysis and security-analyzer agents registry-users-lob2: list_agents: - /data-analysis - /security-analyzer get_agent: - /data-analysis - /security-analyzer publish_agent: - /data-analysis - /security-analyzer modify_agent: - /data-analysis - /security-analyzer delete_agent: - /data-analysis - /security-analyzer list_service: - realserverfaketools - mcpgw - fininfo health_check_service: - realserverfaketools - mcpgw - fininfo # ==================== GROUP MAPPINGS ==================== # Maps Keycloak groups to internal scope group names group_mappings: mcp-registry-admin: - mcp-registry-admin - mcp-servers-unrestricted/read - mcp-servers-unrestricted/execute registry-admins: - registry-admins registry-users-lob1: - registry-users-lob1 registry-users-lob2: - registry-users-lob2 # ==================== MCP SERVER SCOPES ==================== # Unrestricted read access: Wildcard access to all servers with all methods and tools mcp-servers-unrestricted/read: - server: '*' methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list - GET tools: '*' # Unrestricted execute access: Full CRUD operations on all servers (POST, PUT, DELETE in addition to read) mcp-servers-unrestricted/execute: - server: '*' methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list - GET - POST - PUT - DELETE tools: '*' # LOB1 Group Scope: Read-only access to API; currenttime and mcpgw servers; code-reviewer and test-automation agents registry-users-lob1: - server: api methods: - initialize - GET - server: currenttime methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list tools: - current_time_by_timezone - server: mcpgw methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list tools: - intelligent_tool_finder - agents: actions: - action: list_agents resources: - /code-reviewer - /test-automation - action: get_agent resources: - /code-reviewer - /test-automation - action: publish_agent resources: - /code-reviewer - /test-automation - action: modify_agent resources: - /code-reviewer - /test-automation - action: delete_agent resources: - /code-reviewer - /test-automation # LOB2 Group Scope: Read-only access to API; realserverfaketools, mcpgw, fininfo servers; data-analysis and security-analyzer agents registry-users-lob2: - server: api methods: - initialize - GET - server: realserverfaketools methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list tools: - quantum_flux_analyzer - neural_pattern_synthesizer - hyper_dimensional_mapper - server: mcpgw methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list tools: - intelligent_tool_finder - server: fininfo methods: - initialize - notifications/initialized - ping - tools/list - tools/call - resources/list - resources/templates/list tools: - get_stock_aggregates - print_stock_data - agents: actions: - action: list_agents resources: - /data-analysis - /security-analyzer - action: get_agent resources: - /data-analysis - /security-analyzer - action: publish_agent resources: - /data-analysis - /security-analyzer - action: modify_agent resources: - /data-analysis - /security-analyzer - action: delete_agent resources: - /data-analysis - /security-analyzer # Admin Group Scope: Unrestricted access to all servers with wildcard; unrestricted access to all agents registry-admins: - server: '*' methods: - all tools: - all - agents: actions: - action: list_agents resources: - all - action: get_agent resources: - all - action: publish_agent resources: - all - action: modify_agent resources: - all - action: delete_agent resources: - all ================================================ FILE: auth_server/server.py ================================================ """ Simplified Authentication server that validates JWT tokens against Amazon Cognito. Configuration is passed via headers instead of environment variables. """ import argparse import hashlib import hmac import json import logging import os import re import secrets # Import shared scopes loader and repository factory from registry common module import sys import time import urllib.parse from contextlib import asynccontextmanager from datetime import datetime from pathlib import Path from string import Template from typing import Any from urllib.parse import urlparse import boto3 import httpx import jwt import requests import uvicorn import yaml from botocore.exceptions import ClientError from fastapi import Cookie, FastAPI, Header, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer from jwt.api_jwk import PyJWK # Import metrics middleware from metrics_middleware import add_auth_metrics_middleware # Import provider factory from providers.factory import get_auth_provider from pydantic import ( BaseModel, Field, field_validator, ) sys.path.insert(0, "/app") # Import MCP audit logging components from pathlib import Path as _LogPath from registry.audit.mcp_logger import MCPLogger from registry.audit.models import Identity, MCPServer from registry.audit.service import AuditLogger from registry.common.scopes_loader import reload_scopes_config from registry.core.config import settings from registry.repositories.factory import get_scope_repository # Configure logging using shared module (RotatingFileHandler + optional MongoDB) from registry.utils.logging_setup import setup_logging as _setup_logging from registry.utils.request_utils import get_client_ip _auth_log_file = _setup_logging( service_name="auth-server", log_file=_LogPath("/app/logs/auth-server.log") if _LogPath("/app").exists() else None, ) logger = logging.getLogger(__name__) logger.info(f"Auth-server logging configured. Writing to file: {_auth_log_file}") # Import JWT constants from shared internal auth module from registry.auth.internal import ( _INTERNAL_JWT_AUDIENCE as JWT_AUDIENCE, ) from registry.auth.internal import ( _INTERNAL_JWT_ISSUER as JWT_ISSUER, ) MAX_TOKEN_LIFETIME_HOURS = 24 DEFAULT_TOKEN_LIFETIME_HOURS = 8 # Rate limiting for token generation (simple in-memory counter) user_token_generation_counts = {} MAX_TOKENS_PER_USER_PER_HOUR = int(os.environ.get("MAX_TOKENS_PER_USER_PER_HOUR", "100")) # Global scopes configuration (will be loaded during FastAPI startup) SCOPES_CONFIG = {} # Static token auth: use static API key instead of IdP JWT for Registry API _registry_static_token_requested: bool = ( os.environ.get("REGISTRY_STATIC_TOKEN_AUTH_ENABLED", "false").lower() == "true" ) # Static API key for Registry API (must match Bearer token value when enabled) REGISTRY_API_TOKEN: str = os.environ.get("REGISTRY_API_TOKEN", "") # OAuth token storage in session cookies (disable for IdPs with large tokens) # Default: false - tokens are not used functionally and storing them risks cookie size limits OAUTH_STORE_TOKENS_IN_SESSION: bool = ( os.environ.get("OAUTH_STORE_TOKENS_IN_SESSION", "false").lower() == "true" ) logging.info( f"OAUTH_STORE_TOKENS_IN_SESSION={'enabled' if OAUTH_STORE_TOKENS_IN_SESSION else 'disabled'}" ) # Issue #779: multiple static API keys with per-key groups. _REGISTRY_API_KEYS_RAW: str = os.environ.get("REGISTRY_API_KEYS", "").strip() # Validate configuration: static token auth requires at least one token source if _registry_static_token_requested and not REGISTRY_API_TOKEN and not _REGISTRY_API_KEYS_RAW: logging.error( "REGISTRY_STATIC_TOKEN_AUTH_ENABLED=true but neither REGISTRY_API_TOKEN " "nor REGISTRY_API_KEYS is set. Static token auth is DISABLED. " "Set at least one of these or disable the feature. " "Falling back to standard IdP JWT validation." ) REGISTRY_STATIC_TOKEN_AUTH_ENABLED: bool = False else: REGISTRY_STATIC_TOKEN_AUTH_ENABLED: bool = _registry_static_token_requested # --------------------------------------------------------------------------- # Multi-key static token config model and parser (Issue #779) # --------------------------------------------------------------------------- _KEY_NAME_PATTERN: re.Pattern = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") _RESERVED_KEY_NAMES: frozenset = frozenset( { "legacy", "network-user", "network-trusted", } ) _STATIC_TOKEN_MAP: dict[str, dict] = {} class _RegistryApiKeyEntry(BaseModel): """Config entry parsed from REGISTRY_API_KEYS.""" name: str = Field( ..., description="Key name (log-safe identifier)", ) key: str = Field( ..., min_length=32, description=( "The Bearer token value. Minimum 32 chars matches the default " "output of python3 -c 'import secrets; print(secrets.token_urlsafe(32))'." ), ) groups: list[str] = Field( ..., min_length=1, description="Groups this key is mapped to", ) @field_validator("name") @classmethod def _validate_name( cls, v: str, ) -> str: if not _KEY_NAME_PATTERN.match(v): raise ValueError(f"Invalid key name '{v}': must match ^[a-z0-9][a-z0-9_-]{{0,63}}$") if v in _RESERVED_KEY_NAMES: raise ValueError( f"Key name '{v}' is reserved (legacy/internal). Pick a different name." ) return v def _repair_stripped_json( raw: str, ) -> str: """Re-quote a JSON-like string where docker-compose stripped double quotes. Converts e.g. {name:{key:val,groups:[g1]}} back to valid JSON by adding double quotes around all bare identifiers and values. """ result = [] i = 0 while i < len(raw): ch = raw[i] if ch in "{}[],:": result.append(ch) i += 1 elif ch in " \t\n\r": i += 1 else: # Read a bare token (everything until a structural char) j = i while j < len(raw) and raw[j] not in "{}[],:": j += 1 token = raw[i:j].strip() result.append(f'"{token}"') i = j return "".join(result) def _parse_registry_api_keys( raw: str, ) -> list[_RegistryApiKeyEntry]: """Parse REGISTRY_API_KEYS env var into validated entries. Returns: List of entries. Empty list if raw is empty. Raises: ValueError: on malformed JSON, duplicate name, duplicate key value, reserved name, or validation failure on any entry. """ if not raw: return [] try: doc = json.loads(raw) except json.JSONDecodeError: # Docker Compose strips double quotes from .env values containing JSON. # Attempt to recover by re-quoting bare identifiers: # {name:{key:val,...}} -> {"name":{"key":"val",...}} repaired = _repair_stripped_json(raw) try: doc = json.loads(repaired) logging.warning( "REGISTRY_API_KEYS was not valid JSON (docker-compose may have " "stripped quotes). Auto-repaired successfully." ) except json.JSONDecodeError as e2: raise ValueError(f"REGISTRY_API_KEYS is not valid JSON: {e2}") from e2 if not isinstance(doc, dict): raise ValueError("REGISTRY_API_KEYS must be a JSON object") entries: list[_RegistryApiKeyEntry] = [] seen_names: set[str] = set() seen_keys: set[str] = set() for name, value in doc.items(): if name in seen_names: raise ValueError(f"Duplicate key name in REGISTRY_API_KEYS: {name}") if not isinstance(value, dict): raise ValueError(f"Entry for '{name}' must be an object") try: entry = _RegistryApiKeyEntry(name=name, **value) except Exception as e: raise ValueError(f"Invalid entry '{name}': {e}") from e if entry.key in seen_keys: raise ValueError(f"Duplicate key value across entries (conflicts around name '{name}')") seen_names.add(entry.name) seen_keys.add(entry.key) entries.append(entry) return entries async def _build_static_token_map() -> None: """Build _STATIC_TOKEN_MAP from env config. Fail-closed on any error.""" global REGISTRY_STATIC_TOKEN_AUTH_ENABLED, _STATIC_TOKEN_MAP if not REGISTRY_STATIC_TOKEN_AUTH_ENABLED: return token_map: dict[str, dict] = {} try: parsed = _parse_registry_api_keys(_REGISTRY_API_KEYS_RAW) except ValueError as e: logging.error( "Failed to parse REGISTRY_API_KEYS: %s. Static-token auth DISABLED.", e, ) REGISTRY_STATIC_TOKEN_AUTH_ENABLED = False return for entry in parsed: scopes = await map_groups_to_scopes(entry.groups) if not scopes: logging.warning( "Static key '%s' has no scope mappings for groups %s. " "Requests using this key will get 403 on all protected endpoints.", entry.name, entry.groups, ) token_map[entry.name] = { "key_bytes": entry.key.encode("utf-8"), "groups": list(entry.groups), "scopes": scopes, } if REGISTRY_API_TOKEN: # Legacy entry uses the well-known admin scopes directly to avoid a DB # roundtrip. The list must include the UI scope name "mcp-registry-admin" # so the registry resolves admin UI permissions through the standard path # (the hard-coded admin branch was removed in #779). token_map["legacy"] = { "key_bytes": REGISTRY_API_TOKEN.encode("utf-8"), "groups": ["mcp-registry-admin"], "scopes": [ "mcp-registry-admin", "mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute", ], "username_override": "network-user", "client_id_override": "network-trusted", } _STATIC_TOKEN_MAP = token_map if not _STATIC_TOKEN_MAP: logging.warning( "Static-token auth ENABLED but no keys loaded. " "Check REGISTRY_API_TOKEN / REGISTRY_API_KEYS. " "All bearer tokens will fall through to JWT validation." ) else: logging.info( "Static-token auth: loaded %d key(s): %s", len(_STATIC_TOKEN_MAP), sorted(_STATIC_TOKEN_MAP.keys()), ) # Get ROOT_PATH for path-based routing (auth server's own path, e.g. /auth-server) ROOT_PATH = os.environ.get("ROOT_PATH", "").rstrip("/") # REGISTRY_ROOT_PATH is the registry's base path (e.g. /registry) used for matching # X-Original-URL paths that come from the registry's nginx. Falls back to ROOT_PATH # for backward compatibility when both services share the same root path. REGISTRY_ROOT_PATH = os.environ.get("REGISTRY_ROOT_PATH", ROOT_PATH).rstrip("/") # Registry API path patterns that use static token auth when enabled # REGISTRY_ROOT_PATH is prepended so pattern matching works when hosted on a base path (e.g. /registry/api/) REGISTRY_API_PATTERNS: list = [ f"{REGISTRY_ROOT_PATH}/api/", f"{REGISTRY_ROOT_PATH}/v0.1/", ] # Federation static token auth: scoped token for federation endpoints only _federation_static_token_requested: bool = ( os.environ.get("FEDERATION_STATIC_TOKEN_AUTH_ENABLED", "false").lower() == "true" ) FEDERATION_STATIC_TOKEN: str = os.environ.get("FEDERATION_STATIC_TOKEN", "") if _federation_static_token_requested and not FEDERATION_STATIC_TOKEN: logging.error( "FEDERATION_STATIC_TOKEN_AUTH_ENABLED=true but FEDERATION_STATIC_TOKEN is not set. " "Federation static token auth is DISABLED. Set FEDERATION_STATIC_TOKEN or disable the feature. " "Falling back to standard IdP JWT validation." ) FEDERATION_STATIC_TOKEN_AUTH_ENABLED: bool = False else: FEDERATION_STATIC_TOKEN_AUTH_ENABLED: bool = _federation_static_token_requested # Warn if token is too short (weak entropy) MIN_FEDERATION_TOKEN_LENGTH: int = 32 if ( FEDERATION_STATIC_TOKEN_AUTH_ENABLED and len(FEDERATION_STATIC_TOKEN) < MIN_FEDERATION_TOKEN_LENGTH ): logging.warning( f"FEDERATION_STATIC_TOKEN is only {len(FEDERATION_STATIC_TOKEN)} characters. " f"Recommended minimum is {MIN_FEDERATION_TOKEN_LENGTH} characters. " 'Generate a stronger token with: python3 -c "import secrets; print(secrets.token_urlsafe(32))"' ) # Federation endpoint path patterns (scoped access for federation static token) # REGISTRY_ROOT_PATH is prepended so pattern matching works when hosted on a base path FEDERATION_API_PATTERNS: list = [ f"{REGISTRY_ROOT_PATH}/api/federation/", f"{REGISTRY_ROOT_PATH}/api/peers/", "/api/peers", # exact match for list peers (no trailing slash) ] # Utility functions for GDPR/SOX compliance def is_request_https(request) -> bool: """ Detect if the original request was HTTPS. Priority order: 1. X-Cloudfront-Forwarded-Proto header (CloudFront deployments) 2. x-forwarded-proto header (ALB/custom domain deployments) 3. Request URL scheme (direct access) Args: request: FastAPI Request object Returns: True if the original request was HTTPS """ # Check CloudFront header first (ALB won't overwrite this) cloudfront_proto = request.headers.get("x-cloudfront-forwarded-proto", "") if cloudfront_proto.lower() == "https": return True # Fall back to standard x-forwarded-proto x_forwarded_proto = request.headers.get("x-forwarded-proto", "") if x_forwarded_proto.lower() == "https": return True # Finally check request scheme return request.url.scheme == "https" def mask_sensitive_id(value: str) -> str: """Mask sensitive IDs showing only first and last 4 characters.""" if not value or len(value) <= 8: return "***MASKED***" return f"{value[:4]}...{value[-4:]}" def hash_username(username: str) -> str: """Hash username for privacy compliance.""" if not username: return "anonymous" return f"user_{hashlib.sha256(username.encode()).hexdigest()[:8]}" def anonymize_ip(ip_address: str) -> str: """Anonymize IP address by masking last octet for IPv4.""" if not ip_address or ip_address == "unknown": return ip_address if "." in ip_address: # IPv4 parts = ip_address.split(".") if len(parts) == 4: return f"{'.'.join(parts[:3])}.xxx" elif ":" in ip_address: # IPv6 # Mask last segment parts = ip_address.split(":") if len(parts) > 1: parts[-1] = "xxxx" return ":".join(parts) return ip_address def mask_token(token: str) -> str: """Mask JWT token showing only first 4 characters followed by ellipsis.""" if not token: return "***EMPTY***" if len(token) > 8: return f"{token[:4]}..." return "***MASKED***" def _is_safe_redirect_url( url: str, allowed_hosts: set[str] | None = None, ) -> bool: """Validate that a redirect URL is safe (relative or same-origin). Prevents open redirect attacks by ensuring the URL is either: - A relative path (no scheme or netloc) - An absolute URL with an allowed hostname and safe scheme (http/https) Args: url: The URL to validate. allowed_hosts: Set of allowed hostnames. If None, only relative URLs are allowed. Returns: True if the URL is safe to redirect to, False otherwise. """ if not url: return False parsed = urlparse(url) # Allow relative URLs (no scheme and no netloc) if not parsed.scheme and not parsed.netloc: return True # Block non-http(s) schemes (e.g., javascript:, data:, etc.) if parsed.scheme not in ("http", "https"): return False # If allowed_hosts is provided, check hostname if allowed_hosts is not None: return parsed.hostname in allowed_hosts # No allowed_hosts and URL is absolute — reject by default return False def _mask_sensitive_dict( data: dict, sensitive_keys: tuple = ("access_token", "refresh_token", "token", "secret", "password"), ) -> dict: """ Recursively mask sensitive fields in a dictionary for safe logging. Args: data: Dictionary to process sensitive_keys: Tuple of key names to mask Returns: New dictionary with sensitive fields masked """ if not isinstance(data, dict): return data masked = {} for key, value in data.items(): key_lower = key.lower() if any(sensitive in key_lower for sensitive in sensitive_keys): if isinstance(value, str) and value: masked[key] = mask_token(value) else: masked[key] = "***MASKED***" elif isinstance(value, dict): masked[key] = _mask_sensitive_dict(value, sensitive_keys) elif isinstance(value, list): masked[key] = [ _mask_sensitive_dict(item, sensitive_keys) if isinstance(item, dict) else item for item in value ] else: masked[key] = value return masked def mask_headers(headers: dict) -> dict: """Mask sensitive headers for logging compliance.""" masked = {} for key, value in headers.items(): key_lower = key.lower() if key_lower in ["x-authorization", "authorization", "cookie"]: if "bearer" in str(value).lower(): # Extract token part and mask it parts = str(value).split(" ", 1) if len(parts) == 2: masked[key] = f"Bearer {mask_token(parts[1])}" else: masked[key] = mask_token(value) else: masked[key] = "***MASKED***" elif key_lower in ["x-user-pool-id", "x-client-id"]: masked[key] = mask_sensitive_id(value) else: masked[key] = value return masked async def map_groups_to_scopes(groups: list[str]) -> list[str]: """ Map identity provider groups to MCP scopes by querying DocumentDB directly. Args: groups: List of group names from identity provider (Cognito, Keycloak, etc.) Returns: List of MCP scopes """ scopes = [] # Query DocumentDB directly for group mappings try: scope_repo = get_scope_repository() for group in groups: # Query DocumentDB for this group's scope mappings group_scopes = await scope_repo.get_group_mappings(group) if group_scopes: scopes.extend(group_scopes) logger.debug(f"Mapped group '{group}' to scopes: {group_scopes}") else: logger.debug(f"No scope mapping found for group: {group}") except Exception as e: logger.error(f"Error querying group mappings from DocumentDB: {e}", exc_info=True) # Fall back to in-memory config if DocumentDB query fails group_mappings = SCOPES_CONFIG.get("group_mappings", {}) for group in groups: if group in group_mappings: group_scopes = group_mappings[group] scopes.extend(group_scopes) logger.debug(f"Mapped group '{group}' to scopes (fallback): {group_scopes}") # Remove duplicates while preserving order seen = set() unique_scopes = [] for scope in scopes: if scope not in seen: seen.add(scope) unique_scopes.append(scope) logger.info(f"Final mapped scopes: {unique_scopes}") return unique_scopes async def validate_session_cookie(cookie_value: str) -> dict[str, any]: """ Validate session cookie using itsdangerous serializer. Args: cookie_value: The session cookie value Returns: Dict containing validation results matching JWT validation format: { 'valid': True, 'username': str, 'scopes': List[str], 'method': 'session_cookie', 'groups': List[str] } Raises: ValueError: If cookie is invalid or expired """ # Use global signer initialized at startup global signer if not signer: logger.warning("Global signer not configured for session cookie validation") raise ValueError("Session cookie validation not configured") try: # Decrypt cookie (max_age=28800 for 8 hours) data = signer.loads(cookie_value, max_age=28800) # Extract user info username = data.get("username") groups = data.get("groups", []) # Map groups to scopes (async call to query DocumentDB) scopes = await map_groups_to_scopes(groups) logger.info(f"Session cookie validated for user: {hash_username(username)}") return { "valid": True, "username": username, "scopes": scopes, "method": "session_cookie", "groups": groups, "client_id": "", # Not applicable for session "data": data, # Include full data for consistency } except SignatureExpired: logger.warning("Session cookie has expired") raise ValueError("Session cookie has expired") except BadSignature: logger.warning("Invalid session cookie signature") raise ValueError("Invalid session cookie") except Exception as e: logger.error(f"Session cookie validation error: {e}") raise ValueError(f"Session cookie validation failed: {e}") def parse_server_and_tool_from_url(original_url: str) -> tuple[str | None, str | None]: """ Parse server name and tool name from the original URL and request payload. Args: original_url: The original URL from X-Original-URL header Returns: Tuple of (server_name, tool_name) or (None, None) if parsing fails """ try: # Extract path from URL (remove query parameters and fragments) from urllib.parse import urlparse parsed_url = urlparse(original_url) path = parsed_url.path.strip("/") # The path should be in format: /server_name/... # Extract the first path component as server name path_parts = path.split("/") if path else [] server_name = path_parts[0] if path_parts else None logger.debug(f"Parsed server name '{server_name}' from URL path: {path}") return server_name, None # Tool name would need to be extracted from request payload except Exception as e: logger.error(f"Failed to parse server/tool from URL {original_url}: {e}") return None, None def _normalize_server_name(name: str) -> str: """ Normalize server name by removing leading and trailing slashes for comparison. This handles cases where a server is registered with a leading or trailing slash but accessed without one (or vice versa). Scope configs from the UI store server names with a leading slash (e.g. '/cloudflare-docs') while the URL extraction produces names without one (e.g. 'cloudflare-docs'). Args: name: Server name to normalize Returns: Normalized server name (without leading or trailing slashes) """ return name.strip("/") if name else name def _server_names_match(name1: str, name2: str) -> bool: """ Compare two server names, normalizing for trailing slashes. Supports wildcard matching with '*'. Args: name1: First server name (can be '*' for wildcard) name2: Second server name Returns: True if names match (ignoring trailing slashes) or if name1 is '*', False otherwise """ normalized_name1 = _normalize_server_name(name1) if normalized_name1 == "*": return True return normalized_name1 == _normalize_server_name(name2) async def validate_server_tool_access( server_name: str, method: str, tool_name: str, user_scopes: list[str] ) -> bool: """ Validate if the user has access to the specified server method/tool based on scopes. Args: server_name: Name of the MCP server method: Name of the method being accessed (e.g., 'initialize', 'notifications/initialized', 'tools/list') tool_name: Name of the specific tool being accessed (optional, for tools/call) user_scopes: List of user scopes from token Returns: True if access is allowed, False otherwise """ try: # Verbose logging: Print input parameters logger.info("=== VALIDATE_SERVER_TOOL_ACCESS START ===") logger.info(f"Requested server: '{server_name}'") logger.info(f"Requested method: '{method}'") logger.info(f"Requested tool: '{tool_name}'") logger.info(f"User scopes: {user_scopes}") # Query DocumentDB directly for server access rules scope_repo = get_scope_repository() # Check each user scope to see if it grants access for scope in user_scopes: logger.info(f"--- Checking scope: '{scope}' ---") # Query DocumentDB for this scope's server access rules scope_config = await scope_repo.get_server_scopes(scope) if not scope_config: logger.info(f"Scope '{scope}' not found in DocumentDB") continue logger.info(f"Scope '{scope}' config: {scope_config}") # The scope_config is directly a list of server configurations # since the permission type is already encoded in the scope name for server_config in scope_config: logger.info(f" Examining server config: {server_config}") server_config_name = server_config.get("server") logger.info( f" Server name in config: '{server_config_name}' vs requested: '{server_name}'" ) if _server_names_match(server_config_name, server_name): logger.info(" ✓ Server name matches!") # Check methods first allowed_methods = server_config.get("methods", []) logger.info(f" Allowed methods for server '{server_name}': {allowed_methods}") logger.info(f" Checking if method '{method}' is in allowed methods...") # Check if all methods are allowed (wildcard support) has_wildcard_methods = "all" in allowed_methods or "*" in allowed_methods # for all methods except tools/call we are good if the method is allowed # for tools/call we need to do an extra validation to check if the tool # itself is allowed or not if ( method in allowed_methods or has_wildcard_methods ) and method != "tools/call": logger.info(f" ✓ Method '{method}' found in allowed methods!") logger.info( f"Access granted: scope '{scope}' allows access to {server_name}.{method}" ) logger.info("=== VALIDATE_SERVER_TOOL_ACCESS END: GRANTED ===") return True # Check tools if method not found in methods allowed_tools = server_config.get("tools", []) logger.info(f" Allowed tools for server '{server_name}': {allowed_tools}") # Check if all tools are allowed (wildcard support) has_wildcard_tools = "all" in allowed_tools or "*" in allowed_tools # For tools/call, check if the specific tool is allowed if method == "tools/call" and tool_name: logger.info( f" Checking if tool '{tool_name}' is in allowed tools for tools/call..." ) if tool_name in allowed_tools or has_wildcard_tools: logger.info(f" ✓ Tool '{tool_name}' found in allowed tools!") logger.info( f"Access granted: scope '{scope}' allows access to {server_name}.{method} for tool {tool_name}" ) logger.info("=== VALIDATE_SERVER_TOOL_ACCESS END: GRANTED ===") return True else: logger.info(f" ✗ Tool '{tool_name}' NOT found in allowed tools") else: # For other methods, check if method is in tools list (backward compatibility) logger.info(f" Checking if method '{method}' is in allowed tools...") if method in allowed_tools or has_wildcard_tools: logger.info(f" ✓ Method '{method}' found in allowed tools!") logger.info( f"Access granted: scope '{scope}' allows access to {server_name}.{method}" ) logger.info("=== VALIDATE_SERVER_TOOL_ACCESS END: GRANTED ===") return True else: logger.info(f" ✗ Method '{method}' NOT found in allowed tools") else: logger.info(" ✗ Server name does not match") logger.warning( f"Access denied: no scope allows access to {server_name}.{method} (tool: {tool_name}) for user scopes: {user_scopes}" ) logger.info("=== VALIDATE_SERVER_TOOL_ACCESS END: DENIED ===") return False except Exception as e: logger.error(f"Error validating server/tool access: {e}") logger.info("=== VALIDATE_SERVER_TOOL_ACCESS END: ERROR ===") return False # Deny access on error def validate_scope_subset(user_scopes: list[str], requested_scopes: list[str]) -> bool: """ Validate that requested scopes are a subset of user's current scopes. Args: user_scopes: List of scopes the user currently has requested_scopes: List of scopes being requested for the token Returns: True if requested scopes are valid (subset of user scopes), False otherwise """ if not requested_scopes: return True # Empty request is valid user_scope_set = set(user_scopes) requested_scope_set = set(requested_scopes) is_valid = requested_scope_set.issubset(user_scope_set) if not is_valid: invalid_scopes = requested_scope_set - user_scope_set logger.warning(f"Invalid scopes requested: {invalid_scopes}") return is_valid def check_rate_limit(username: str) -> bool: """ Check if user has exceeded token generation rate limit. Args: username: Username to check Returns: True if under rate limit, False if exceeded """ current_time = int(time.time()) current_hour = current_time // 3600 # Clean up old entries (older than 1 hour) keys_to_remove = [] for key in user_token_generation_counts.keys(): stored_hour = int(key.split(":")[1]) if current_hour - stored_hour > 1: keys_to_remove.append(key) for key in keys_to_remove: del user_token_generation_counts[key] # Check current hour count rate_key = f"{username}:{current_hour}" current_count = user_token_generation_counts.get(rate_key, 0) if current_count >= MAX_TOKENS_PER_USER_PER_HOUR: logger.warning( f"Rate limit exceeded for user {hash_username(username)}: {current_count} tokens this hour" ) return False # Increment counter user_token_generation_counts[rate_key] = current_count + 1 return True @asynccontextmanager async def lifespan(app: FastAPI): """Lifespan context manager for FastAPI application.""" # Startup: Load scopes configuration global SCOPES_CONFIG try: SCOPES_CONFIG = await reload_scopes_config() logger.info( f"Loaded scopes configuration on startup with {len(SCOPES_CONFIG.get('group_mappings', {}))} group mappings" ) except Exception as e: logger.error(f"Failed to load scopes configuration on startup: {e}", exc_info=True) # Fall back to empty config SCOPES_CONFIG = {"group_mappings": {}} # Build multi-key static token map (Issue #779). # Runs after scopes are loaded so map_groups_to_scopes can resolve groups. await _build_static_token_map() yield # Shutdown: Add cleanup code here if needed in the future logger.info("Shutting down auth server") # Create FastAPI app app = FastAPI( title="Simplified Auth Server", description="Authentication server for validating JWT tokens against Amazon Cognito with header-based configuration", version="0.1.0", lifespan=lifespan, root_path=ROOT_PATH, ) @app.on_event("startup") async def startup_event(): """Load scopes configuration on startup.""" global SCOPES_CONFIG try: SCOPES_CONFIG = await reload_scopes_config() logger.info( f"Loaded scopes configuration on startup with {len(SCOPES_CONFIG.get('group_mappings', {}))} group mappings" ) except Exception as e: logger.error(f"Failed to load scopes configuration on startup: {e}", exc_info=True) # Fall back to empty config SCOPES_CONFIG = {"group_mappings": {}} # Add metrics collection middleware add_auth_metrics_middleware(app) class TokenValidationResponse(BaseModel): """Response model for token validation""" valid: bool scopes: list[str] = [] error: str | None = None method: str | None = None client_id: str | None = None username: str | None = None class GenerateTokenRequest(BaseModel): """Request model for token generation""" user_context: dict[str, Any] requested_scopes: list[str] = [] expires_in_hours: int = DEFAULT_TOKEN_LIFETIME_HOURS description: str | None = None class GenerateTokenResponse(BaseModel): """Response model for token generation""" access_token: str refresh_token: str | None = None token_type: str = "Bearer" # nosec B105 - OAuth2 standard token type per RFC 6750 expires_in: int refresh_expires_in: int | None = None scope: str issued_at: int description: str | None = None class SimplifiedCognitoValidator: """ Simplified Cognito token validator that doesn't rely on environment variables """ def __init__(self, region: str = "us-east-1"): """ Initialize with minimal configuration Args: region: Default AWS region """ self.default_region = region self._cognito_clients = {} # Cache boto3 clients by region self._jwks_cache = {} # Cache JWKS by user pool def _get_cognito_client(self, region: str): """Get or create boto3 cognito client for region""" if region not in self._cognito_clients: self._cognito_clients[region] = boto3.client("cognito-idp", region_name=region) return self._cognito_clients[region] def _get_jwks(self, user_pool_id: str, region: str) -> dict: """ Get JSON Web Key Set (JWKS) from Cognito with caching """ cache_key = f"{region}:{user_pool_id}" if cache_key not in self._jwks_cache: try: issuer = f"https://cognito-idp.{region}.amazonaws.com/{user_pool_id}" jwks_url = f"{issuer}/.well-known/jwks.json" response = requests.get(jwks_url, timeout=10) response.raise_for_status() jwks = response.json() self._jwks_cache[cache_key] = jwks logger.debug( f"Retrieved JWKS for {cache_key} with {len(jwks.get('keys', []))} keys" ) except Exception as e: logger.error(f"Failed to retrieve JWKS from {jwks_url}: {e}") raise ValueError(f"Cannot retrieve JWKS: {e}") return self._jwks_cache[cache_key] def validate_jwt_token( self, access_token: str, user_pool_id: str, client_id: str, region: str = None ) -> dict: """ Validate JWT access token Args: access_token: The bearer token to validate user_pool_id: Cognito User Pool ID client_id: Expected client ID region: AWS region (uses default if not provided) Returns: Dict containing token claims if valid Raises: ValueError: If token is invalid """ if not region: region = self.default_region try: # Decode header to get key ID unverified_header = jwt.get_unverified_header(access_token) kid = unverified_header.get("kid") if not kid: raise ValueError("Token missing 'kid' in header") # Get JWKS and find matching key jwks = self._get_jwks(user_pool_id, region) signing_key = None for key in jwks.get("keys", []): if key.get("kid") == kid: # Handle different versions of PyJWT try: # For newer versions of PyJWT from jwt.algorithms import RSAAlgorithm signing_key = RSAAlgorithm.from_jwk(key) except (ImportError, AttributeError): try: # For older versions of PyJWT from jwt.algorithms import get_default_algorithms algorithms = get_default_algorithms() signing_key = algorithms["RS256"].from_jwk(key) except (ImportError, AttributeError): # For PyJWT 2.0.0+ signing_key = PyJWK.from_jwk(json.dumps(key)).key break if not signing_key: raise ValueError(f"No matching key found for kid: {kid}") # Set up issuer for validation issuer = f"https://cognito-idp.{region}.amazonaws.com/{user_pool_id}" # Validate and decode token claims = jwt.decode( access_token, signing_key, algorithms=["RS256"], issuer=issuer, options={ "verify_aud": False, # M2M tokens might not have audience "verify_exp": True, # Always check expiration "verify_iat": True, # Check issued at time }, ) # Additional validations token_use = claims.get("token_use") if token_use not in ["access", "id"]: # Allow both access and id tokens raise ValueError(f"Invalid token_use: {token_use}") # For M2M tokens, check client_id token_client_id = claims.get("client_id") if token_client_id and token_client_id != client_id: logger.warning("Token issued for different client than expected") # Don't fail immediately - could be user token with different structure logger.info("Successfully validated JWT token for client/user") return claims except jwt.ExpiredSignatureError: error_msg = "Token has expired" logger.warning(error_msg) raise ValueError(error_msg) except jwt.InvalidTokenError as e: error_msg = f"Invalid token: {e}" logger.warning(error_msg) raise ValueError(error_msg) except Exception as e: error_msg = f"JWT validation error: {e}" logger.error(error_msg) raise ValueError(f"Token validation failed: {e}") def validate_with_boto3(self, access_token: str, region: str = None) -> dict: """ Validate token using boto3 GetUser API (works for user tokens) Args: access_token: The bearer token to validate region: AWS region Returns: Dict containing user information if valid Raises: ValueError: If token is invalid """ if not region: region = self.default_region try: cognito_client = self._get_cognito_client(region) response = cognito_client.get_user(AccessToken=access_token) # Extract user attributes user_attributes = {} for attr in response.get("UserAttributes", []): user_attributes[attr["Name"]] = attr["Value"] result = { "username": response.get("Username"), "user_attributes": user_attributes, "user_status": response.get("UserStatus"), "token_use": "access", # boto3 method implies access token "auth_method": "boto3", } logger.info( f"Successfully validated token via boto3 for user {hash_username(result['username'])}" ) return result except ClientError as e: error_code = e.response["Error"]["Code"] error_message = e.response["Error"]["Message"] if error_code == "NotAuthorizedException": error_msg = "Invalid or expired access token" logger.warning(f"Cognito error {error_code}: {error_message}") raise ValueError(error_msg) elif error_code == "UserNotFoundException": error_msg = "User not found" logger.warning(f"Cognito error {error_code}: {error_message}") raise ValueError(error_msg) else: logger.error(f"Cognito error {error_code}: {error_message}") raise ValueError(f"Token validation failed: {error_message}") except Exception as e: logger.error(f"Boto3 validation error: {e}") raise ValueError(f"Token validation failed: {e}") def validate_self_signed_token(self, access_token: str) -> dict: """ Validate self-signed JWT token generated by this auth server. Args: access_token: The JWT token to validate Returns: Dict containing validation results Raises: ValueError: If token is invalid """ try: # Decode and validate JWT using shared SECRET_KEY claims = jwt.decode( access_token, SECRET_KEY, algorithms=["HS256"], issuer=JWT_ISSUER, audience=JWT_AUDIENCE, options={ "verify_exp": True, "verify_iat": True, "verify_iss": True, "verify_aud": True, }, leeway=30, # 30 second leeway for clock skew ) # Validate token_use token_use = claims.get("token_use") if token_use != "access": # nosec B105 - OAuth2 token type validation per RFC 6749, not a password raise ValueError(f"Invalid token_use: {token_use}") # Extract scopes from space-separated string scope_string = claims.get("scope", "") scopes = scope_string.split() if scope_string else [] # Extract groups from claims (for OAuth user tokens) groups = claims.get("groups", []) if isinstance(groups, str): groups = [groups] logger.info( f"Successfully validated self-signed token for user: {claims.get('sub')}, " f"groups: {groups}" ) return { "valid": True, "method": "self_signed", "data": claims, "client_id": claims.get("client_id", "user-generated"), "username": claims.get("sub", ""), "expires_at": claims.get("exp"), "scopes": scopes, "groups": groups, "token_type": "user_generated", } except jwt.ExpiredSignatureError: error_msg = "Self-signed token has expired" logger.warning(error_msg) raise ValueError(error_msg) except jwt.InvalidTokenError as e: error_msg = f"Invalid self-signed token: {e}" logger.warning(error_msg) raise ValueError(error_msg) except Exception as e: error_msg = f"Self-signed token validation error: {e}" logger.error(error_msg) raise ValueError(f"Self-signed token validation failed: {e}") def validate_token( self, access_token: str, user_pool_id: str, client_id: str, region: str = None ) -> dict: """ Comprehensive token validation with fallback methods. Now supports both Cognito tokens and self-signed tokens. Args: access_token: The bearer token to validate user_pool_id: Cognito User Pool ID client_id: Expected client ID region: AWS region Returns: Dict containing validation results and token information """ if not region: region = self.default_region # First try self-signed token validation (faster) try: # Quick check if it might be our token by attempting to decode without verification unverified_claims = jwt.decode(access_token, options={"verify_signature": False}) if unverified_claims.get("iss") == JWT_ISSUER: logger.debug("Token appears to be self-signed, validating...") return self.validate_self_signed_token(access_token) except Exception as e: # Not our token or malformed, continue to Cognito validation logger.debug(f"Token is not self-signed or malformed, falling back to Cognito: {e}") # Try JWT validation with Cognito try: jwt_claims = self.validate_jwt_token(access_token, user_pool_id, client_id, region) # Extract scopes and other info scopes = [] if "scope" in jwt_claims: scopes = jwt_claims["scope"].split() if jwt_claims["scope"] else [] return { "valid": True, "method": "jwt", "data": jwt_claims, "client_id": jwt_claims.get("client_id") or "", "username": jwt_claims.get("cognito:username") or jwt_claims.get("username") or "", "expires_at": jwt_claims.get("exp"), "scopes": scopes, "groups": jwt_claims.get("cognito:groups", []), } except ValueError as jwt_error: logger.debug(f"JWT validation failed: {jwt_error}, trying boto3") # Try boto3 validation as fallback try: boto3_data = self.validate_with_boto3(access_token, region) return { "valid": True, "method": "boto3", "data": boto3_data, "client_id": "", # boto3 method doesn't provide client_id "username": boto3_data.get("username") or "", "user_attributes": boto3_data.get("user_attributes", {}), "scopes": [], # boto3 method doesn't provide scopes "groups": [], } except ValueError as boto3_error: logger.debug(f"Boto3 validation failed: {boto3_error}") raise ValueError( f"All validation methods failed. JWT: {jwt_error}, Boto3: {boto3_error}" ) # Create global validator instance validator = SimplifiedCognitoValidator() def _is_registry_api_request( original_url: str, ) -> bool: """Check if the request is for the Registry API (vs MCP Gateway). Registry API requests include: - /api/* - Core registry operations - /v0.1/* - Anthropic registry API and A2A agent API Args: original_url: The X-Original-URL header value from nginx. Returns: True if this is a registry API request, False if MCP gateway request. """ if not original_url: return False parsed = urlparse(original_url) path = parsed.path for pattern in REGISTRY_API_PATTERNS: if path.startswith(pattern): return True return False def _check_registry_static_token( bearer_token: str, ) -> dict | None: """Return the identity payload if the bearer matches a configured static key, else None. Each pair-wise comparison uses hmac.compare_digest so individual comparisons are constant-time. We iterate all configured entries without early return as belt-and-braces so total comparison time is independent of which entry (if any) matched. With small N this matters less than the per-comparison guarantee, but costs almost nothing. For the legacy REGISTRY_API_TOKEN entry (map key "legacy"), the returned username and client_id are overridden to "network-user" / "network-trusted" to preserve back-compat with pre-#779 audit log consumers. See issue #779. """ bearer_bytes = bearer_token.encode("utf-8") matched_entry: dict | None = None matched_name: str | None = None for name, entry in _STATIC_TOKEN_MAP.items(): if hmac.compare_digest(bearer_bytes, entry["key_bytes"]): if matched_entry is None: matched_entry = entry matched_name = name if matched_entry is None: return None username = matched_entry.get("username_override", matched_name) client_id = matched_entry.get("client_id_override", matched_name) return { "username": username, "client_id": client_id, "groups": list(matched_entry["groups"]), "scopes": list(matched_entry["scopes"]), } def _is_federation_api_request( original_url: str, ) -> bool: """Check if the request is for federation or peer management APIs. Args: original_url: The X-Original-URL header value from nginx. Returns: True if this is a federation/peer API request. """ if not original_url: return False parsed = urlparse(original_url) path = parsed.path for pattern in FEDERATION_API_PATTERNS: if path.startswith(pattern): return True return False @app.get("/health") async def health_check(): """Health check endpoint""" return {"status": "healthy", "service": "simplified-auth-server"} @app.get("/validate") async def validate_request(request: Request): """ Validate a request by extracting configuration from headers and validating the bearer token. Expected headers: - Authorization: Bearer - X-User-Pool-Id: - X-Client-Id: - X-Region: (optional, defaults to us-east-1) - X-Original-URL: (optional, for scope validation) Returns: HTTP 200 with user info headers if valid, HTTP 401/403 if invalid Raises: HTTPException: If the token is missing, invalid, or configuration is incomplete """ # Capture start time for MCP audit logging import uuid start_time = time.perf_counter() request_id = request.headers.get("X-Request-ID", str(uuid.uuid4())) mcp_session_id = request.headers.get("Mcp-Session-Id") try: # Extract headers # Check for X-Authorization first (custom header used by this gateway) # Only if X-Authorization is not present, check standard Authorization header authorization = request.headers.get("X-Authorization") if not authorization: authorization = request.headers.get("Authorization") cookie_header = request.headers.get("Cookie", "") user_pool_id = request.headers.get("X-User-Pool-Id") client_id = request.headers.get("X-Client-Id") region = request.headers.get("X-Region", "us-east-1") original_url = request.headers.get("X-Original-URL") body = request.headers.get("X-Body") # Extract server_name and endpoint from original_url early for logging server_name_from_url = None endpoint_from_url = None if original_url: try: parsed_url = urlparse(original_url) path = parsed_url.path.strip("/") # Strip the registry's root path prefix so server_name extraction # works correctly when the registry is hosted on a sub-path (e.g. /registry) registry_prefix = REGISTRY_ROOT_PATH.strip("/") if registry_prefix and path.startswith(registry_prefix): path = path[len(registry_prefix) :].lstrip("/") path_parts = path.split("/") if path else [] # MCP endpoints that should be treated as endpoints, not server names mcp_endpoints = {"mcp", "sse", "messages"} # For peer/federated registries, path is: peer-name/server-name/endpoint # For local servers, path is: server-name/endpoint # We need to capture the full server path, excluding the MCP endpoint if len(path_parts) >= 2 and path_parts[-1] in mcp_endpoints: # Last part is MCP endpoint, everything before is server path server_name_from_url = "/".join(path_parts[:-1]) endpoint_from_url = path_parts[-1] elif len(path_parts) >= 1: # No recognized MCP endpoint at end - use entire path as server name # This handles MCP server URLs like /peer-registry-lob-1/cloudflare-docs # BUT exclude /api/ paths - those are Registry API requests, not MCP servers if path_parts[0] != "api": server_name_from_url = "/".join(path_parts) endpoint_from_url = None logger.info( f"Extracted server_name '{server_name_from_url}' and endpoint '{endpoint_from_url}' from original_url: {original_url}" ) except Exception as e: logger.warning( f"Failed to extract server_name from original_url {original_url}: {e}" ) # Read request body request_payload = None try: if body: payload_text = body # .decode('utf-8') logger.info( f"Raw Request Payload ({len(payload_text)} chars): {payload_text[:1000]}..." ) request_payload = json.loads(payload_text) logger.info(f"JSON RPC Request Payload: {json.dumps(request_payload, indent=2)}") else: logger.info("No request body provided, skipping payload parsing") except UnicodeDecodeError as e: logger.warning(f"Could not decode body as UTF-8: {e}") except json.JSONDecodeError as e: logger.warning(f"Could not parse JSON RPC payload: {e}") except Exception as e: logger.error(f"Error reading request payload: {type(e).__name__}: {e}") # Log request for debugging with anonymized IP client_ip = get_client_ip(request) logger.info(f"Validation request from {anonymize_ip(client_ip)}") logger.info(f"Request Method: {request.method}") # Log masked HTTP headers for GDPR/SOX compliance all_headers = dict(request.headers) masked_headers = mask_headers(all_headers) logger.debug(f"HTTP Headers (masked): {json.dumps(masked_headers, indent=2)}") # Log specific headers for debugging with masked sensitive data logger.info( f"Key Headers: Authorization={bool(authorization)}, Cookie={bool(cookie_header)}, " f"User-Pool-Id={mask_sensitive_id(user_pool_id) if user_pool_id else 'None'}, " f"Client-Id={mask_sensitive_id(client_id) if client_id else 'None'}, " f"Region={region}, Original-URL={original_url}" ) logger.info(f"Server Name from URL: {server_name_from_url}") # Only activate static token auth when there is no session cookie # (UI uses cookies, CLI uses Bearer) has_session_cookie = cookie_header and "mcp_gateway_session=" in cookie_header # Federation static token auth: scoped access to federation/peer endpoints only # Check this BEFORE the full admin static token if ( FEDERATION_STATIC_TOKEN_AUTH_ENABLED and _is_federation_api_request(original_url) and not has_session_cookie ): if not authorization: logger.warning( "Federation static token: Authorization header missing. " "Hint: Use 'Authorization: Bearer '." ) return JSONResponse( content={"detail": "Authorization header required"}, status_code=401, headers={"WWW-Authenticate": "Bearer", "Connection": "close"}, ) if not authorization.startswith("Bearer "): logger.warning( "Federation static token: Authorization header must use Bearer scheme" ) return JSONResponse( content={"detail": "Authorization header must use Bearer scheme"}, status_code=401, headers={"WWW-Authenticate": "Bearer", "Connection": "close"}, ) bearer_token = authorization[len("Bearer ") :].strip() # Check federation token first, then fall through to admin token check if hmac.compare_digest(bearer_token, FEDERATION_STATIC_TOKEN): logger.info(f"Federation static token: Authenticated for {original_url}") federation_scopes = [ "federation/read", "federation/peers", ] response_data = { "valid": True, "username": "federation-peer", "client_id": "federation-static", "scopes": federation_scopes, "method": "federation-static", "groups": [], "server_name": None, "tool_name": None, } response = JSONResponse(content=response_data, status_code=200) response.headers["X-User"] = "federation-peer" response.headers["X-Username"] = "federation-peer" response.headers["X-Client-Id"] = "federation-static" response.headers["X-Scopes"] = " ".join(federation_scopes) response.headers["X-Auth-Method"] = "federation-static" response.headers["X-Server-Name"] = "" response.headers["X-Tool-Name"] = "" return response # If federation token didn't match, DON'T return 403 here. # Fall through to the admin static token check below (if enabled). # If admin token also doesn't match, that block will return 403. # If admin token is NOT enabled, fall through to JWT validation. # Static token auth: accept REGISTRY_API_TOKEN as an ADDITIONAL accepted # credential on Registry API paths. A missing or mismatched bearer falls # through to JWT/session validation so Okta tokens and UI-issued self- # signed JWTs remain accepted. See issue #871. # # Extension point for #779 (multi-key static tokens) is the helper # _check_registry_static_token; the control flow here does not change. if ( REGISTRY_STATIC_TOKEN_AUTH_ENABLED and _is_registry_api_request(original_url) and not has_session_cookie ): if authorization and authorization.startswith("Bearer "): bearer_token = authorization[len("Bearer ") :].strip() identity = _check_registry_static_token(bearer_token) if identity is not None: logger.info( "Network-trusted mode: key='%s' for %s", identity["username"], original_url, ) response_data = { "valid": True, "username": identity["username"], "client_id": identity["client_id"], "scopes": identity["scopes"], "method": "network-trusted", "groups": identity["groups"], "server_name": None, "tool_name": None, } response = JSONResponse(content=response_data, status_code=200) response.headers["X-User"] = identity["username"] response.headers["X-Username"] = identity["username"] response.headers["X-Client-Id"] = identity["client_id"] response.headers["X-Scopes"] = " ".join(identity["scopes"]) response.headers["X-Auth-Method"] = "network-trusted" response.headers["X-Server-Name"] = "" response.headers["X-Tool-Name"] = "" return response # Bearer present but does not match any static token. Fall # through to JWT validation below (Okta RS256 / self-signed # HS256). Intentionally does NOT log any portion of the bearer. logger.debug("Static token mismatch; falling through to JWT validation") else: # No Authorization header or non-Bearer scheme. Fall through to # session/JWT validation, which returns 401 if nothing matches. logger.debug( "Registry API request without Bearer credential; " "falling through to session/JWT validation" ) # Initialize validation result validation_result = None # FIRST: Check for session cookie if present if "mcp_gateway_session=" in cookie_header: logger.info("Session cookie detected, attempting session validation") # Extract cookie value cookie_value = None for cookie in cookie_header.split(";"): if cookie.strip().startswith("mcp_gateway_session="): cookie_value = cookie.strip().split("=", 1)[1] break if cookie_value: try: validation_result = await validate_session_cookie(cookie_value) # Log validation result without exposing username or tokens safe_result = _mask_sensitive_dict(validation_result) safe_result["username"] = hash_username(validation_result.get("username", "")) logger.info(f"Session cookie validation result: {safe_result}") logger.info( f"Session cookie validation successful for user: {hash_username(validation_result['username'])}" ) except ValueError as e: logger.warning(f"Session cookie validation failed: {e}") # Fall through to JWT validation # SECOND: If no valid session cookie, check for JWT token if not validation_result: # Validate required headers for JWT if not authorization or not authorization.startswith("Bearer "): logger.warning( "Missing or invalid Authorization header and no valid session cookie" ) raise HTTPException( status_code=401, detail="Missing or invalid Authorization header. Expected: Bearer or valid session cookie", headers={"WWW-Authenticate": "Bearer", "Connection": "close"}, ) # Extract token access_token = authorization.split(" ")[1] # Get authentication provider based on AUTH_PROVIDER environment variable try: auth_provider = get_auth_provider() logger.info(f"Using authentication provider: {auth_provider.__class__.__name__}") # Provider-specific validation if hasattr(auth_provider, "validate_token"): # For Keycloak, no additional headers needed validation_result = auth_provider.validate_token(access_token) logger.info( f"Token validation successful using {auth_provider.__class__.__name__}" ) else: # Fallback to old validation for compatibility if not user_pool_id: logger.warning("Missing X-User-Pool-Id header for Cognito validation") raise HTTPException( status_code=400, detail="Missing X-User-Pool-Id header", headers={"Connection": "close"}, ) if not client_id: logger.warning("Missing X-Client-Id header for Cognito validation") raise HTTPException( status_code=400, detail="Missing X-Client-Id header", headers={"Connection": "close"}, ) # Use old validator for backward compatibility validation_result = validator.validate_token( access_token=access_token, user_pool_id=user_pool_id, client_id=client_id, region=region, ) except Exception as e: logger.error(f"Authentication provider error: {e}") raise HTTPException( status_code=500, detail="Authentication provider configuration error", headers={"Connection": "close"}, ) logger.info(f"Token validation successful using method: {validation_result['method']}") # Enrich groups from MongoDB if empty (for M2M clients) try: from mongodb_groups_enrichment import ( enrich_groups_from_mongodb, should_enrich_groups, ) client_id = validation_result.get("client_id") current_groups = validation_result.get("groups", []) should_enrich = should_enrich_groups(validation_result) logger.info( f"Enrichment check: client_id={client_id}, " f"groups={current_groups}, should_enrich={should_enrich}" ) if should_enrich: enriched_groups = await enrich_groups_from_mongodb(client_id, current_groups) if enriched_groups != current_groups: validation_result["groups"] = enriched_groups logger.info( f"Groups enriched from MongoDB for client {client_id}: {enriched_groups}" ) except Exception as e: logger.warning(f"Failed to enrich groups from MongoDB: {e}") # Don't fail validation if enrichment fails # Parse server and tool information from original URL if available server_name = server_name_from_url # Use the server_name we extracted earlier tool_name = None if original_url and request_payload: # We already extracted server_name above, now just get tool_name from URL parsing _, tool_name = parse_server_and_tool_from_url(original_url) logger.debug(f"Parsed from original URL: server='{server_name}', tool='{tool_name}'") # Try to extract tool name from request payload if not found in URL if server_name and not tool_name and request_payload: try: # Look for tool name in JSON-RPC 2.0 format and other MCP patterns if isinstance(request_payload, dict): # JSON-RPC 2.0 format: method field contains the tool name tool_name = request_payload.get("method") # If not found in method, check other common patterns if not tool_name: tool_name = request_payload.get("tool") or request_payload.get("name") # Check for nested tool reference in params if not tool_name and "params" in request_payload: params = request_payload["params"] if isinstance(params, dict): tool_name = ( params.get("name") or params.get("tool") or params.get("method") ) logger.info(f"Extracted tool name from JSON-RPC payload: '{tool_name}'") else: logger.warning(f"Payload is not a dictionary: {type(request_payload)}") except Exception as e: logger.error(f"Error processing request payload for tool extraction: {e}") # Validate scope-based access if we have server/tool information # For providers that use groups (Keycloak, Entra ID, Cognito, Okta, Auth0), map groups to scopes user_groups = validation_result.get("groups", []) auth_method = validation_result.get("method", "") if user_groups and auth_method in ["keycloak", "entra", "cognito", "okta", "auth0"]: # Map IdP groups to scopes using the group mappings (query DocumentDB) user_scopes = await map_groups_to_scopes(user_groups) logger.info(f"Mapped {auth_method} groups {user_groups} to scopes: {user_scopes}") else: user_scopes = validation_result.get("scopes", []) if server_name: # For ANY server access, enforce scope validation (fail closed principle) # This includes MCP initialization methods that may not have a specific tool # Determine the method to validate: # 1. If we have a tool_name from JSON-RPC payload, use that # 2. If we have an endpoint from the REST API URL, use that # 3. Otherwise default to "initialize" method = ( tool_name if tool_name else (endpoint_from_url if endpoint_from_url else "initialize") ) logger.info( f"Method determined for validation: '{method}' (tool_name={tool_name}, endpoint_from_url={endpoint_from_url})" ) actual_tool_name = None # For tools/call, extract the actual tool name from params if method == "tools/call" and isinstance(request_payload, dict): params = request_payload.get("params", {}) if isinstance(params, dict): actual_tool_name = params.get("name") logger.info(f"Extracted actual tool name for tools/call: '{actual_tool_name}'") # Check if user has any scopes - if not, deny access (fail closed) if not user_scopes: logger.warning( f"Access denied for user {hash_username(validation_result.get('username', ''))} to {server_name}.{method} (tool: {actual_tool_name}) - no scopes configured" ) raise HTTPException( status_code=403, detail=f"Access denied to {server_name}.{method} - user has no scopes configured", headers={"Connection": "close"}, ) if not await validate_server_tool_access( server_name, method, actual_tool_name, user_scopes ): logger.warning( f"Access denied for user {hash_username(validation_result.get('username', ''))} to {server_name}.{method} (tool: {actual_tool_name})" ) raise HTTPException( status_code=403, detail=f"Access denied to {server_name}.{method}", headers={"Connection": "close"}, ) logger.info( f"Scope validation passed for {server_name}.{method} (tool: {actual_tool_name})" ) else: logger.debug("No server information available, skipping scope validation") # Prepare JSON response data response_data = { "valid": True, "username": validation_result.get("username") or "", "client_id": validation_result.get("client_id") or "", "scopes": user_scopes, "method": validation_result.get("method") or "", "groups": validation_result.get("groups", []), "server_name": server_name, "tool_name": tool_name, } logger.info( f"Full validation result: {json.dumps(_mask_sensitive_dict(validation_result), indent=2)}" ) logger.info(f"Response data being sent: {json.dumps(response_data, indent=2)}") # Log MCP server access event if this is an MCP request (has server_name) if server_name: duration_ms = (time.perf_counter() - start_time) * 1000 mcp_logger = get_mcp_logger() if mcp_logger: try: # Build identity from validation result identity = Identity( username=validation_result.get("username") or "anonymous", auth_method=validation_result.get("method") or "unknown", provider=validation_result.get("provider"), groups=validation_result.get("groups", []), scopes=user_scopes, is_admin=validation_result.get("is_admin", False), credential_type="bearer_token" if authorization else "session_cookie", ) # Build MCP server info mcp_server = MCPServer( name=server_name, path=f"/{server_name}" if server_name else "/", proxy_target=original_url or "", ) # Log the MCP access event await mcp_logger.log_mcp_access( request_id=request_id, identity=identity, mcp_server=mcp_server, request_body=body.encode("utf-8") if body else b"", response_status="success", duration_ms=duration_ms, mcp_session_id=mcp_session_id, transport="streamable-http", # Default, could be extracted from request client_ip=get_client_ip(request), forwarded_for=request.headers.get("X-Forwarded-For"), user_agent=request.headers.get("User-Agent"), ) logger.debug(f"MCP access logged for {server_name}") except Exception as e: # Don't fail the request if logging fails logger.warning(f"Failed to log MCP access event: {e}") # Create JSON response with headers that nginx can use response = JSONResponse(content=response_data, status_code=200) # Set headers for nginx auth_request_set directives response.headers["X-User"] = validation_result.get("username") or "" response.headers["X-Username"] = validation_result.get("username") or "" response.headers["X-Client-Id"] = validation_result.get("client_id") or "" response.headers["X-Scopes"] = " ".join(user_scopes) response.headers["X-Auth-Method"] = validation_result.get("method") or "" response.headers["X-Server-Name"] = server_name or "" response.headers["X-Tool-Name"] = tool_name or "" response.headers["X-Groups"] = " ".join(validation_result.get("groups", [])) return response except ValueError as e: logger.warning(f"Token validation failed: {e}") # Log failed MCP access attempt if server_name_from_url: duration_ms = (time.perf_counter() - start_time) * 1000 mcp_logger = get_mcp_logger() if mcp_logger: try: identity = Identity( username="anonymous", auth_method="unknown", credential_type="none", ) mcp_server = MCPServer( name=server_name_from_url, path=f"/{server_name_from_url}", proxy_target=original_url or "", ) await mcp_logger.log_mcp_access( request_id=request_id, identity=identity, mcp_server=mcp_server, request_body=body.encode("utf-8") if body else b"", response_status="error", duration_ms=duration_ms, mcp_session_id=mcp_session_id, error_code=401, error_message=str(e), client_ip=get_client_ip(request), forwarded_for=request.headers.get("X-Forwarded-For"), user_agent=request.headers.get("User-Agent"), ) except Exception as log_err: logger.warning(f"Failed to log MCP access error: {log_err}") raise HTTPException( status_code=401, detail=str(e), headers={"WWW-Authenticate": "Bearer", "Connection": "close"}, ) except HTTPException as e: # Re-raise client error HTTPExceptions (4xx) as-is if 400 <= e.status_code < 500: raise # For non-client HTTPExceptions, convert to 500 logger.error(f"HTTP error during validation: {e}") raise HTTPException( status_code=500, detail="Internal validation error", headers={"Connection": "close"}, ) except Exception as e: logger.exception("Unexpected error during validation") raise HTTPException( status_code=500, detail="Internal validation error", headers={"Connection": "close"}, ) finally: pass @app.get("/config") async def get_auth_config(): """Return the authentication configuration info""" try: auth_provider = get_auth_provider() provider_info = auth_provider.get_provider_info() if provider_info.get("provider_type") == "keycloak": return { "auth_type": "keycloak", "description": "Keycloak JWT token validation", "required_headers": ["Authorization: Bearer "], "optional_headers": [], "provider_info": provider_info, } else: return { "auth_type": "cognito", "description": "Header-based Cognito token validation", "required_headers": [ "Authorization: Bearer ", "X-User-Pool-Id: ", "X-Client-Id: ", ], "optional_headers": ["X-Region: (default: us-east-1)"], "provider_info": provider_info, } except Exception as e: logger.exception("Error getting auth config") return { "auth_type": "unknown", "description": "Error getting provider config", "error": "Internal server error", } @app.post("/admin/federation-token") async def manage_federation_token(request: Request): """Revoke or rotate federation static token at runtime. Requires the admin static token (REGISTRY_API_TOKEN) for authentication. """ global FEDERATION_STATIC_TOKEN, FEDERATION_STATIC_TOKEN_AUTH_ENABLED # Authenticate with admin token authorization = request.headers.get("Authorization", "") if not authorization.startswith("Bearer "): return JSONResponse( content={"detail": "Bearer token required"}, status_code=401, ) bearer_token = authorization[len("Bearer ") :].strip() if not REGISTRY_API_TOKEN or not hmac.compare_digest(bearer_token, REGISTRY_API_TOKEN): return JSONResponse( content={"detail": "Admin token required"}, status_code=403, ) body = await request.json() new_token = body.get("new_token") # Validate minimum token length if a new token is provided if new_token and len(new_token) < MIN_FEDERATION_TOKEN_LENGTH: return JSONResponse( content={ "detail": ( f"Token must be at least {MIN_FEDERATION_TOKEN_LENGTH} characters. " 'Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))"' ) }, status_code=400, ) if new_token: FEDERATION_STATIC_TOKEN = new_token FEDERATION_STATIC_TOKEN_AUTH_ENABLED = True logger.info("Federation static token rotated via admin API") return { "action": "rotated", "message": ( "Federation static token rotated. " "WARNING: This is an in-memory change only. Update FEDERATION_STATIC_TOKEN " "in your .env file or container environment for persistence across restarts." ), } else: FEDERATION_STATIC_TOKEN = "" # nosec B105 - Intentional token revocation, clearing the variable FEDERATION_STATIC_TOKEN_AUTH_ENABLED = False logger.info("Federation static token revoked via admin API") return { "action": "revoked", "message": ( "Federation static token revoked. Federation endpoints now require OAuth2 JWT. " "WARNING: This is an in-memory change only. Update your .env file or container " "environment to set FEDERATION_STATIC_TOKEN_AUTH_ENABLED=false for persistence " "across restarts." ), } @app.post("/internal/tokens", response_model=GenerateTokenResponse) async def generate_user_token(request: GenerateTokenRequest): """ Generate or refresh a JWT token for a user. This endpoint supports two modes: 1. If user has stored OAuth tokens (from login), refresh them if needed and return 2. Otherwise, fall back to generating M2M token using client credentials This is an internal API endpoint meant to be called only by the registry service. The generated token will have the same or fewer privileges than the user currently has. Args: request: Token generation request containing user context and requested scopes Returns: JWT token with expiration info (either refreshed user token or M2M token) Raises: HTTPException: If request is invalid or user doesn't have required permissions """ try: # Extract user context user_context = request.user_context username = user_context.get("username") user_scopes = user_context.get("scopes", []) if not username: raise HTTPException( status_code=400, detail="Username is required in user context", headers={"Connection": "close"}, ) # Check rate limiting if not check_rate_limit(username): raise HTTPException( status_code=429, detail=f"Rate limit exceeded. Maximum {MAX_TOKENS_PER_USER_PER_HOUR} tokens per hour.", headers={"Connection": "close"}, ) # Use user's current scopes if no specific scopes requested requested_scopes = request.requested_scopes if request.requested_scopes else user_scopes # Validate that requested scopes are subset of user's current scopes if not validate_scope_subset(user_scopes, requested_scopes): invalid_scopes = set(requested_scopes) - set(user_scopes) raise HTTPException( status_code=403, detail=f"Requested scopes exceed user permissions. Invalid scopes: {list(invalid_scopes)}", headers={"Connection": "close"}, ) # Check if user has stored OAuth tokens from their login session provider = user_context.get("provider") auth_method = user_context.get("auth_method") user_groups = user_context.get("groups", []) user_email = user_context.get("email", "") logger.info( f"Token request for user '{hash_username(username)}': " f"auth_method={auth_method}, provider={provider}, " f"groups={user_groups}, scopes={requested_scopes}" ) # For OAuth and network-trusted users, generate a self-signed JWT with their identity and groups # This token is issued by our auth server and can be verified using SECRET_KEY if auth_method in ("oauth2", "network-trusted"): logger.info( f"Generating self-signed JWT for {auth_method} user '{hash_username(username)}' " f"with groups: {user_groups}" ) current_time = int(time.time()) expires_in = DEFAULT_TOKEN_LIFETIME_HOURS * 3600 # 8 hours default # Build JWT claims jwt_claims = { "iss": JWT_ISSUER, "aud": JWT_AUDIENCE, "sub": username, "preferred_username": username, "email": user_email, "groups": user_groups, "scope": " ".join(requested_scopes) if requested_scopes else "", "token_use": "access", "auth_method": auth_method, "provider": provider, "iat": current_time, "exp": current_time + expires_in, "description": request.description, } # Sign the JWT with our SECRET_KEY access_token = jwt.encode(jwt_claims, SECRET_KEY, algorithm="HS256") logger.info( f"Generated self-signed JWT for user '{hash_username(username)}', " f"expires in {expires_in} seconds" ) return GenerateTokenResponse( access_token=access_token, refresh_token=None, expires_in=expires_in, refresh_expires_in=0, scope=" ".join(requested_scopes) if requested_scopes else "openid profile email", issued_at=current_time, description=request.description, ) # Fall back to M2M token using client credentials flow try: auth_provider = get_auth_provider() provider_info = auth_provider.get_provider_info() provider_type = provider_info.get("provider_type", "unknown") logger.info( f"Generating M2M token for user '{hash_username(username)}' using {provider_type}" ) if provider_type == "keycloak": # Request token from Keycloak using M2M client credentials token_data = auth_provider.get_m2m_token(scope="openid email profile") elif provider_type == "entra": # Request token from Entra ID using client credentials token_data = auth_provider.get_m2m_token() else: raise HTTPException( status_code=500, detail=f"Token generation not supported for provider: {provider_type}", headers={"Connection": "close"}, ) access_token = token_data.get("access_token") refresh_token_value = token_data.get("refresh_token") expires_in = token_data.get("expires_in", 300) refresh_expires_in = token_data.get("refresh_expires_in", 0) scope = token_data.get("scope", "openid email profile") if not access_token: raise ValueError(f"No access token returned from {provider_type}") current_time = int(time.time()) logger.info( f"Generated {provider_type} M2M token for user '{hash_username(username)}' " f"with scopes: {requested_scopes}, expires in {expires_in} seconds" ) return GenerateTokenResponse( access_token=access_token, refresh_token=refresh_token_value, expires_in=expires_in, refresh_expires_in=refresh_expires_in, scope=scope, issued_at=current_time, description=request.description, ) except ValueError as e: logger.error(f"Token generation failed: {e}") raise HTTPException( status_code=500, detail=f"Failed to generate token: {e}", headers={"Connection": "close"}, ) except HTTPException: raise except Exception as e: logger.error(f"Unexpected error generating token: {e}") raise HTTPException( status_code=500, detail="Internal error generating token", headers={"Connection": "close"}, ) @app.post("/internal/reload-scopes") async def reload_scopes(request: Request, authorization: str | None = Header(None)): """ Reload the scopes configuration. Accepts internal service authentication via self-signed JWT (Bearer token) signed with the shared SECRET_KEY. """ if not authorization: logger.warning("No Authorization header found for reload-scopes request") raise HTTPException( status_code=401, detail="Authentication required", headers={"WWW-Authenticate": "Bearer"}, ) caller_identity = "unknown" if authorization.startswith("Bearer "): # Validate self-signed JWT using shared SECRET_KEY token = authorization.split(" ", 1)[1] try: claims = jwt.decode( token, SECRET_KEY, algorithms=["HS256"], issuer=JWT_ISSUER, audience=JWT_AUDIENCE, options={ "verify_exp": True, "verify_iat": True, "verify_iss": True, "verify_aud": True, }, leeway=30, ) token_use = claims.get("token_use") if token_use != "access": # nosec B105 - OAuth2 token type validation per RFC 6749, not a password raise ValueError(f"Invalid token_use: {token_use}") caller_identity = claims.get("sub", "service") logger.info(f"Reload-scopes authorized via JWT for: {caller_identity}") except jwt.ExpiredSignatureError: logger.warning("Expired JWT token for reload-scopes request") raise HTTPException(status_code=401, detail="Token has expired") except (jwt.InvalidTokenError, ValueError) as e: logger.warning(f"JWT validation failed for reload-scopes: {e}") raise HTTPException(status_code=401, detail="Invalid token") else: raise HTTPException(status_code=401, detail="Unsupported authentication scheme") # Reload the scopes configuration global SCOPES_CONFIG try: SCOPES_CONFIG = await reload_scopes_config() logger.info(f"Successfully reloaded scopes configuration by '{caller_identity}'") # Rebuild static token map so per-key scopes pick up any # group-to-scope mapping changes that triggered this reload. await _build_static_token_map() return JSONResponse( status_code=200, content={ "message": "Scopes configuration reloaded successfully", "timestamp": datetime.utcnow().isoformat(), "group_mappings_count": len(SCOPES_CONFIG.get("group_mappings", {})), }, ) except Exception as e: logger.error(f"Failed to reload scopes configuration: {e}") raise HTTPException(status_code=500, detail="Failed to reload scopes configuration") def parse_arguments(): """Parse command line arguments.""" parser = argparse.ArgumentParser(description="Simplified Auth Server") parser.add_argument( "--host", type=str, default=os.getenv("AUTH_SERVER_HOST", "127.0.0.1"), # nosec B104 help="Host for the server to listen on (default: 127.0.0.1, override with AUTH_SERVER_HOST env var)", ) parser.add_argument( "--port", type=int, default=8888, help="Port for the server to listen on (default: 8888)", ) parser.add_argument( "--region", type=str, default="us-east-1", help="Default AWS region (default: us-east-1)", ) return parser.parse_args() def main(): """Run the server""" args = parse_arguments() # Update global validator with default region global validator validator = SimplifiedCognitoValidator(region=args.region) logger.info(f"Starting simplified auth server on {args.host}:{args.port}") logger.info(f"Default region: {args.region}") uvicorn.run(app, host=args.host, port=args.port, proxy_headers=True, forwarded_allow_ips="*") if __name__ == "__main__": main() # Load OAuth2 providers configuration def load_oauth2_config(): """Load the OAuth2 providers configuration from oauth2_providers.yml""" try: oauth2_file = Path(__file__).parent / "oauth2_providers.yml" with open(oauth2_file) as f: config = yaml.safe_load(f) # Substitute environment variables in configuration processed_config = substitute_env_vars(config) return processed_config except Exception as e: logger.error(f"Failed to load OAuth2 configuration: {e}") return {"providers": {}, "session": {}, "registry": {}} def auto_derive_cognito_domain(user_pool_id: str) -> str: """ Auto-derive Cognito domain from User Pool ID. Example: us-east-1_KmP5A3La3 → us-east-1kmp5a3la3 """ if not user_pool_id: return "" # Remove underscore and convert to lowercase domain = user_pool_id.replace("_", "").lower() logger.info(f"Auto-derived Cognito domain '{domain}' from user pool ID '{user_pool_id}'") return domain def substitute_env_vars(config): """Recursively substitute environment variables in configuration""" if isinstance(config, dict): return {k: substitute_env_vars(v) for k, v in config.items()} elif isinstance(config, list): return [substitute_env_vars(item) for item in config] elif isinstance(config, str) and "${" in config: try: # Handle special case for auto-derived Cognito domain if "COGNITO_DOMAIN:-auto" in config: # Check if COGNITO_DOMAIN is set, if not auto-derive from user pool ID cognito_domain = os.environ.get("COGNITO_DOMAIN") if not cognito_domain: user_pool_id = os.environ.get("COGNITO_USER_POOL_ID", "") cognito_domain = auto_derive_cognito_domain(user_pool_id) # Replace the template with the derived domain config = config.replace("${COGNITO_DOMAIN:-auto}", cognito_domain) template = Template(config) result = template.substitute(os.environ) # Convert string booleans to actual booleans if result.lower() == "true": return True elif result.lower() == "false": return False return result except KeyError as e: logger.warning(f"Environment variable not found for template {config}: {e}") return config else: return config # Global OAuth2 configuration OAUTH2_CONFIG = load_oauth2_config() # Initialize SECRET_KEY and signer for session management SECRET_KEY = os.environ.get("SECRET_KEY") if not SECRET_KEY: # Generate a secure random key (32 bytes = 256 bits of entropy) SECRET_KEY = secrets.token_hex(32) logger.warning( "No SECRET_KEY environment variable found. Using a randomly generated key. " "While this is more secure than a hardcoded default, it will change on restart. " "Set a permanent SECRET_KEY environment variable for production." ) signer = URLSafeTimedSerializer(SECRET_KEY) # Initialize MCP audit logger for logging MCP server access events # This logs all MCP requests that pass through the auth validation _mcp_audit_logger = None _mcp_logger = None _mcp_audit_repository = None def get_mcp_logger() -> MCPLogger | None: """Get or initialize the MCP logger instance.""" global _mcp_audit_logger, _mcp_logger, _mcp_audit_repository if _mcp_logger is None: try: # Check if MCP audit logging is enabled via settings if settings.audit_log_enabled: # Initialize MongoDB repository if MongoDB is enabled audit_repository = None mongodb_enabled = getattr(settings, "audit_log_mongodb_enabled", False) if mongodb_enabled: try: from registry.repositories.audit_repository import DocumentDBAuditRepository _mcp_audit_repository = DocumentDBAuditRepository() audit_repository = _mcp_audit_repository logger.info("MCP audit MongoDB repository initialized") except Exception as e: logger.warning(f"Failed to initialize MCP audit MongoDB repository: {e}") mongodb_enabled = False _mcp_audit_logger = AuditLogger( log_dir=settings.audit_log_dir, rotation_hours=settings.audit_log_rotation_hours, rotation_max_mb=settings.audit_log_rotation_max_mb, local_retention_hours=settings.audit_log_local_retention_hours, stream_name="mcp-server-access", mongodb_enabled=mongodb_enabled, audit_repository=audit_repository, ) _mcp_logger = MCPLogger(_mcp_audit_logger) logger.info( f"MCP audit logger initialized successfully (MongoDB: {mongodb_enabled})" ) else: logger.info("MCP audit logging is disabled") except Exception as e: logger.warning(f"Failed to initialize MCP audit logger: {e}") _mcp_logger = None return _mcp_logger def get_enabled_providers(): """Get list of enabled OAuth2 providers, filtered by AUTH_PROVIDER env var if set""" enabled = [] # Check if AUTH_PROVIDER env var is set to filter to only one provider auth_provider_env = os.getenv("AUTH_PROVIDER") # First, collect all enabled providers from YAML yaml_enabled_providers = [] for provider_name, config in OAUTH2_CONFIG.get("providers", {}).items(): if config.get("enabled", False): yaml_enabled_providers.append(provider_name) if auth_provider_env: logger.info( f"AUTH_PROVIDER is set to '{auth_provider_env}', filtering providers accordingly" ) # Check if the specified provider exists in the config if auth_provider_env not in OAUTH2_CONFIG.get("providers", {}): logger.error( f"AUTH_PROVIDER '{auth_provider_env}' not found in oauth2_providers.yml configuration" ) return [] # Check if the specified provider is enabled in YAML provider_config = OAUTH2_CONFIG["providers"][auth_provider_env] if not provider_config.get("enabled", False): logger.warning( f"AUTH_PROVIDER '{auth_provider_env}' is set but this provider is disabled in oauth2_providers.yml" ) logger.warning( f"To fix this, either set AUTH_PROVIDER to one of the enabled providers: {yaml_enabled_providers} or enable '{auth_provider_env}' in oauth2_providers.yml" ) return [] # Warn about providers being filtered out filtered_providers = [p for p in yaml_enabled_providers if p != auth_provider_env] if filtered_providers: logger.warning( f"AUTH_PROVIDER override: Filtering out enabled providers {filtered_providers} - only showing '{auth_provider_env}'" ) logger.warning( "To show all enabled providers, remove the AUTH_PROVIDER environment variable" ) else: logger.info("AUTH_PROVIDER not set, returning all enabled providers from config") for provider_name, config in OAUTH2_CONFIG.get("providers", {}).items(): if config.get("enabled", False): # If AUTH_PROVIDER is set, only include that specific provider if auth_provider_env and provider_name != auth_provider_env: logger.debug(f"Skipping provider '{provider_name}' due to AUTH_PROVIDER filter") continue enabled.append( { "name": provider_name, "display_name": config.get("display_name", provider_name.title()), } ) logger.debug(f"Enabled provider: {provider_name}") logger.info(f"Returning {len(enabled)} enabled providers: {[p['name'] for p in enabled]}") return enabled @app.get("/oauth2/providers") async def get_oauth2_providers(): """Get list of enabled OAuth2 providers for the login page""" try: # Debug: log environment variable for troubleshooting auth_provider_env = os.getenv("AUTH_PROVIDER") logger.info(f"Debug: AUTH_PROVIDER environment variable = '{auth_provider_env}'") providers = get_enabled_providers() return {"providers": providers} except Exception as e: logger.exception("Error getting OAuth2 providers") return {"providers": [], "error": "Internal server error"} @app.get("/oauth2/login/{provider}") async def oauth2_login(provider: str, request: Request, redirect_uri: str = None): """Initiate OAuth2 login flow""" try: if provider not in OAUTH2_CONFIG.get("providers", {}): raise HTTPException(status_code=404, detail=f"Provider {provider} not found") provider_config = OAUTH2_CONFIG["providers"][provider] if not provider_config.get("enabled", False): raise HTTPException(status_code=400, detail=f"Provider {provider} is disabled") # Generate state parameter for security state = secrets.token_urlsafe(32) # Determine the OAuth2 callback URI based on the request origin # This is critical for dual-mode (CloudFront + custom domain) deployments # The callback_uri MUST match exactly between authorization and token exchange auth_server_external_url = os.environ.get("AUTH_SERVER_EXTERNAL_URL", "").rstrip("/") if auth_server_external_url: auth_server_url = f"{auth_server_external_url}{ROOT_PATH}" scheme = "https" if auth_server_external_url.startswith("https") else "http" logger.info(f"OAuth2 login - using AUTH_SERVER_EXTERNAL_URL: {auth_server_url}") else: host = request.headers.get("host", "localhost:8888") cloudfront_proto = request.headers.get("x-cloudfront-forwarded-proto", "").lower() forwarded_proto = request.headers.get("x-forwarded-proto", "").lower() scheme = ( "https" if cloudfront_proto == "https" or forwarded_proto == "https" or request.url.scheme == "https" else "http" ) logger.info( f"OAuth2 login - host: {host}, x-cloudfront-forwarded-proto: {cloudfront_proto}, x-forwarded-proto: {forwarded_proto}, scheme: {scheme}" ) if "localhost" in host and ":" not in host: auth_server_url = f"{scheme}://localhost:8888{ROOT_PATH}" else: auth_server_url = f"{scheme}://{host}{ROOT_PATH}" callback_uri = f"{auth_server_url}/oauth2/callback/{provider}" logger.info(f"OAuth2 callback URI (from request host): {callback_uri}") # Store state, redirect URI, and callback_uri in session for callback validation # The callback_uri is stored so token exchange uses the exact same URI session_data = { "state": state, "provider": provider, "redirect_uri": redirect_uri or OAUTH2_CONFIG.get("registry", {}).get("success_redirect", "/"), "callback_uri": callback_uri, # Store for token exchange } # Create temporary session for OAuth2 flow temp_session = signer.dumps(session_data) auth_params = { "client_id": provider_config["client_id"], "response_type": provider_config["response_type"], "scope": " ".join(provider_config["scopes"]), "state": state, "redirect_uri": callback_uri, } auth_url = f"{provider_config['auth_url']}?{urllib.parse.urlencode(auth_params)}" # Validate the OAuth provider auth URL has a safe scheme before redirecting parsed_auth_url = urlparse(auth_url) if parsed_auth_url.scheme not in ("http", "https"): logger.error( f"Unsafe OAuth2 auth URL scheme '{parsed_auth_url.scheme}' for provider {provider}" ) raise HTTPException( status_code=400, detail="Invalid OAuth2 provider configuration", ) # Create response with temporary session cookie response = RedirectResponse(url=auth_url, status_code=302) cookie_secure = scheme == "https" response.set_cookie( key="oauth2_temp_session", value=temp_session, max_age=600, # 10 minutes for OAuth2 flow httponly=True, secure=cookie_secure, samesite="lax", ) logger.info(f"Initiated OAuth2 login for provider {provider}") return response except HTTPException: raise except Exception as e: logger.error(f"Error initiating OAuth2 login for {provider}: {e}") error_url = OAUTH2_CONFIG.get("registry", {}).get("error_redirect", "/login") if not _is_safe_redirect_url(error_url): error_url = "/login" return RedirectResponse(url=f"{error_url}?error=oauth2_init_failed", status_code=302) @app.get("/oauth2/callback/{provider}") async def oauth2_callback( provider: str, request: Request, code: str = None, state: str = None, error: str = None, oauth2_temp_session: str = Cookie(None), ): """Handle OAuth2 callback and create user session""" try: if error: logger.warning(f"OAuth2 error from {provider}: {error}") error_url = OAUTH2_CONFIG.get("registry", {}).get("error_redirect", "/login") # Validate error_url is a safe redirect target and URL-encode user-supplied error details if not _is_safe_redirect_url(error_url): error_url = "/login" safe_details = urllib.parse.quote(str(error), safe="") return RedirectResponse( url=f"{error_url}?error=oauth2_error&details={safe_details}", status_code=302 ) if not code or not state or not oauth2_temp_session: raise HTTPException(status_code=400, detail="Missing required OAuth2 parameters") # Validate temporary session try: temp_session_data = signer.loads(oauth2_temp_session, max_age=600) except (SignatureExpired, BadSignature): raise HTTPException(status_code=400, detail="Invalid or expired OAuth2 session") # Validate state parameter if state != temp_session_data.get("state"): raise HTTPException(status_code=400, detail="Invalid state parameter") # Validate provider if provider != temp_session_data.get("provider"): raise HTTPException(status_code=400, detail="Provider mismatch") provider_config = OAUTH2_CONFIG["providers"][provider] # Exchange authorization code for access token # Use the callback_uri stored in the session (must match what was used in authorization) callback_uri = temp_session_data.get("callback_uri") if callback_uri: # Extract auth_server_url from the stored callback_uri # callback_uri format: {auth_server_url}/oauth2/callback/{provider} auth_server_url = callback_uri.rsplit(f"/oauth2/callback/{provider}", 1)[0] logger.info(f"Using stored callback_uri for token exchange: {callback_uri}") else: # Fallback for sessions created before this fix auth_server_external_url = os.environ.get("AUTH_SERVER_EXTERNAL_URL") if auth_server_external_url: auth_server_url = auth_server_external_url.rstrip("/") logger.info( f"Fallback: Using AUTH_SERVER_EXTERNAL_URL for token exchange: {auth_server_url}" ) else: host = request.headers.get("host", "localhost:8888") scheme = ( "https" if request.headers.get("x-forwarded-proto") == "https" or request.url.scheme == "https" else "http" ) if "localhost" in host and ":" not in host: auth_server_url = f"{scheme}://localhost:8888{ROOT_PATH}" else: auth_server_url = f"{scheme}://{host}{ROOT_PATH}" logger.warning(f"Fallback: Using dynamic URL for token exchange: {auth_server_url}") token_data = await exchange_code_for_token(provider, code, provider_config, auth_server_url) logger.info(f"Token data keys: {list(token_data.keys())}") # For Cognito and Keycloak, try to extract user info from JWT tokens if provider in ["cognito", "keycloak"]: try: if provider == "cognito": # Extract Cognito configuration from environment user_pool_id = os.environ.get("COGNITO_USER_POOL_ID") client_id = provider_config["client_id"] region = os.environ.get("AWS_REGION", "us-east-1") if user_pool_id and client_id: # Use our existing token validation to get groups from JWT validator = SimplifiedCognitoValidator(region) token_validation = validator.validate_token( token_data["access_token"], user_pool_id, client_id, region ) logger.info(f"Token validation result: {token_validation}") # Extract user info from token validation mapped_user = { "username": token_validation.get("username"), "email": token_validation.get( "username" ), # Cognito username is usually email "name": token_validation.get("username"), "groups": token_validation.get("groups", []), } logger.info(f"User extracted from JWT token: {mapped_user}") else: logger.warning( "Missing Cognito configuration for JWT validation, falling back to userInfo" ) raise ValueError("Missing Cognito config") elif provider == "keycloak": # For Keycloak, decode the ID token to get user information if "id_token" in token_data: import jwt # Decode without verification for now (we trust the token since we just got it) id_token_claims = jwt.decode( token_data["id_token"], options={"verify_signature": False} ) logger.info(f"ID token claims: {id_token_claims}") # Extract user info from ID token claims mapped_user = { "username": id_token_claims.get("preferred_username") or id_token_claims.get("sub"), "email": id_token_claims.get("email"), "name": id_token_claims.get("name") or id_token_claims.get("given_name"), "groups": id_token_claims.get("groups", []), } logger.info(f"User extracted from Keycloak ID token: {mapped_user}") else: logger.warning( "No ID token found in Keycloak response, falling back to userInfo" ) raise ValueError("Missing ID token") except Exception as e: logger.warning( f"JWT token validation failed: {e}, falling back to userInfo endpoint" ) # Fallback to userInfo endpoint user_info = await get_user_info(token_data["access_token"], provider_config) logger.info(f"Raw user info from {provider}: {user_info}") mapped_user = map_user_info(user_info, provider_config) logger.info(f"Mapped user info from userInfo: {mapped_user}") elif provider == "entra": # For Entra ID, prioritize ID token claims over userinfo endpoint try: if "id_token" in token_data: import jwt # Decode without verification (we trust the token since we just got it from Microsoft) id_token_claims = jwt.decode( token_data["id_token"], options={"verify_signature": False} ) logger.info(f"Entra ID token claims: {id_token_claims}") # Extract user info from ID token claims # Entra ID can return groups as either 'groups' or 'roles' depending on configuration groups = id_token_claims.get("groups", []) if not groups: groups = id_token_claims.get("roles", []) mapped_user = { "username": id_token_claims.get("preferred_username") or id_token_claims.get("email") or id_token_claims.get("upn") or id_token_claims.get("sub"), "email": id_token_claims.get("email") or id_token_claims.get("preferred_username"), "name": id_token_claims.get("name") or id_token_claims.get("given_name"), "groups": groups, } logger.info(f"User extracted from Entra ID token: {mapped_user}") else: logger.warning("No ID token found in Entra response, falling back to userInfo") raise ValueError("Missing ID token") except Exception as e: logger.warning( f"Entra ID token parsing failed: {e}, falling back to userInfo endpoint" ) # Fallback to userInfo endpoint user_info = await get_user_info(token_data["access_token"], provider_config) logger.info(f"Raw user info from {provider}: {user_info}") mapped_user = map_user_info(user_info, provider_config) logger.info(f"Mapped user info from userInfo: {mapped_user}") elif provider == "okta": # For Okta, decode the ID token to get groups (userinfo doesn't include groups) try: if "id_token" in token_data: import jwt id_token_claims = jwt.decode( token_data["id_token"], options={"verify_signature": False} ) logger.info(f"Okta ID token claims: {id_token_claims}") mapped_user = { "username": id_token_claims.get("preferred_username") or id_token_claims.get("email") or id_token_claims.get("sub"), "email": id_token_claims.get("email"), "name": id_token_claims.get("name") or id_token_claims.get("given_name"), "groups": id_token_claims.get("groups", []), } logger.info(f"User extracted from Okta ID token: {mapped_user}") else: logger.warning("No ID token found in Okta response, falling back to userInfo") raise ValueError("Missing ID token") except Exception as e: logger.warning( f"Okta ID token parsing failed: {e}, falling back to userInfo endpoint" ) user_info = await get_user_info(token_data["access_token"], provider_config) logger.info(f"Raw user info from {provider}: {user_info}") mapped_user = map_user_info(user_info, provider_config) logger.info(f"Mapped user info from userInfo: {mapped_user}") elif provider == "auth0": # For Auth0, delegate ID token parsing to the Auth0Provider # which validates issuer/audience claims and extracts groups # from a custom namespaced claim configured via Auth0 Actions/Rules try: auth0_provider = get_auth_provider("auth0") mapped_user = auth0_provider.extract_user_from_tokens(token_data) logger.info(f"User extracted from Auth0 ID token: {mapped_user}") except Exception as e: logger.warning( f"Auth0 ID token parsing failed: {e}, falling back to userInfo endpoint" ) # Fallback to userInfo endpoint user_info = await get_user_info(token_data["access_token"], provider_config) logger.info(f"Raw user info from {provider}: {user_info}") mapped_user = map_user_info(user_info, provider_config) logger.info(f"Mapped user info from userInfo: {mapped_user}") else: # For other providers, use userInfo endpoint user_info = await get_user_info(token_data["access_token"], provider_config) logger.info(f"Raw user info from {provider}: {user_info}") mapped_user = map_user_info(user_info, provider_config) logger.info(f"Mapped user info: {mapped_user}") # Create session cookie compatible with registry session_data = { "username": mapped_user["username"], "email": mapped_user.get("email"), "name": mapped_user.get("name"), "groups": mapped_user.get("groups", []), "provider": provider, "auth_method": "oauth2", # Always store id_token for OIDC logout (not a credential, just identity info) # Required for proper SSO logout with id_token_hint parameter "id_token": token_data.get("id_token"), } # Optionally store token metadata (legacy flag, not needed for security) # Note: access_token and refresh_token are never stored (removed in issue #490) if OAUTH_STORE_TOKENS_IN_SESSION: session_data.update( { "token_expires_in": token_data.get("expires_in"), "token_obtained_at": int(time.time()), } ) registry_session = signer.dumps(session_data) # Redirect to registry with session cookie redirect_url = temp_session_data.get( "redirect_uri", OAUTH2_CONFIG.get("registry", {}).get("success_redirect", "/") ) # Validate redirect_url to prevent open redirect attacks. # Allow relative URLs and absolute URLs within the deployment's cookie domain. # SESSION_COOKIE_DOMAIN (e.g., ".example.com") defines the trust boundary — # any service sharing the session cookie is a safe redirect target. cookie_domain = os.environ.get("SESSION_COOKIE_DOMAIN", "").strip() redirect_parsed = urlparse(redirect_url) redirect_is_safe = False if not redirect_parsed.scheme and not redirect_parsed.netloc: # Relative URL — always safe redirect_is_safe = True elif redirect_parsed.scheme in ("http", "https"): redirect_hostname = redirect_parsed.hostname or "" if cookie_domain and redirect_hostname.endswith(cookie_domain): # Redirect is within the deployment's cookie domain redirect_is_safe = True if not redirect_is_safe: logger.warning(f"Blocked unsafe redirect URL: {redirect_url}, falling back to /") redirect_url = "/" response = RedirectResponse(url=redirect_url, status_code=302) # Set registry-compatible session cookie # Check if HTTPS is terminated at load balancer/CloudFront is_https = is_request_https(request) # Only set secure=True if the original request was HTTPS cookie_secure_config = OAUTH2_CONFIG.get("session", {}).get("secure", False) cookie_secure = cookie_secure_config and is_https cookie_samesite = OAUTH2_CONFIG.get("session", {}).get("samesite", "lax") cookie_domain = OAUTH2_CONFIG.get("session", {}).get("domain", "") # Handle domain configuration - only use explicitly configured values # Empty string or placeholder means no domain attribute (exact host only) if not cookie_domain or cookie_domain == "${SESSION_COOKIE_DOMAIN}": cookie_domain = None logger.info("No cookie domain configured - cookie will be set for exact host only") else: logger.info("Using explicitly configured cookie domain") logger.info( f"Auth server setting session cookie: is_https={is_https}, domain={'configured' if cookie_domain else 'not set'}, x-forwarded-proto={request.headers.get('x-forwarded-proto', 'not set')}, request_scheme={request.url.scheme}" ) cookie_params = { "key": "mcp_gateway_session", # Same as registry SESSION_COOKIE_NAME "value": registry_session, "max_age": OAUTH2_CONFIG.get("session", {}).get("max_age_seconds", 28800), "httponly": OAUTH2_CONFIG.get("session", {}).get("httponly", True), "samesite": cookie_samesite, "secure": cookie_secure, "path": "/", # Ensure cookie is sent for all paths } # Only set domain if configured or inferred (for cross-subdomain cookies) if cookie_domain: cookie_params["domain"] = cookie_domain response.set_cookie(**cookie_params) # Clear temporary OAuth2 session response.delete_cookie("oauth2_temp_session") logger.info( f"Successfully authenticated user {hash_username(mapped_user['username'])} via {provider}" ) return response except HTTPException: raise except Exception as e: logger.error(f"Error in OAuth2 callback for {provider}: {e}") error_url = OAUTH2_CONFIG.get("registry", {}).get("error_redirect", "/login") if not _is_safe_redirect_url(error_url): error_url = "/login" return RedirectResponse(url=f"{error_url}?error=oauth2_callback_failed", status_code=302) async def exchange_code_for_token( provider: str, code: str, provider_config: dict, auth_server_url: str = None ) -> dict: """Exchange authorization code for access token""" if auth_server_url is None: auth_server_url = ( os.environ.get("AUTH_SERVER_URL", "http://localhost:8888").rstrip("/") + ROOT_PATH ) async with httpx.AsyncClient() as client: token_data = { "grant_type": provider_config["grant_type"], "client_id": provider_config["client_id"], "client_secret": provider_config["client_secret"], "code": code, "redirect_uri": f"{auth_server_url}/oauth2/callback/{provider}", } headers = {"Accept": "application/json"} if provider == "github": headers["Accept"] = "application/json" response = await client.post(provider_config["token_url"], data=token_data, headers=headers) response.raise_for_status() return response.json() async def get_user_info(access_token: str, provider_config: dict) -> dict: """Get user information from OAuth2 provider""" async with httpx.AsyncClient() as client: headers = {"Authorization": f"Bearer {access_token}"} response = await client.get(provider_config["user_info_url"], headers=headers) response.raise_for_status() return response.json() def map_user_info(user_info: dict, provider_config: dict) -> dict: """Map provider-specific user info to our standard format""" mapped = { "username": user_info.get(provider_config["username_claim"]), "email": user_info.get(provider_config["email_claim"]), "name": user_info.get(provider_config["name_claim"]), "groups": [], } # Handle groups if provider supports them groups_claim = provider_config.get("groups_claim") logger.info(f"Looking for groups claim (configured={'yes' if groups_claim else 'no'})") logger.info(f"Available claims in user_info: {list(user_info.keys())}") if groups_claim and groups_claim in user_info: groups = user_info[groups_claim] if isinstance(groups, list): mapped["groups"] = groups elif isinstance(groups, str): mapped["groups"] = [groups] logger.info(f"Found groups via {groups_claim}: {mapped['groups']}") else: # Try alternative group claims for Cognito for possible_group_claim in ["cognito:groups", "groups", "custom:groups"]: if possible_group_claim in user_info: groups = user_info[possible_group_claim] if isinstance(groups, list): mapped["groups"] = groups elif isinstance(groups, str): mapped["groups"] = [groups] logger.info( f"Found groups via alternative claim {possible_group_claim}: {mapped['groups']}" ) break if not mapped["groups"]: logger.warning( f"No groups found in user_info. Available fields: {list(user_info.keys())}" ) return mapped @app.get("/oauth2/logout/{provider}") async def oauth2_logout( provider: str, request: Request, redirect_uri: str = None, id_token_hint: str | None = None, ): """Initiate OAuth2 logout flow to clear provider session""" try: if provider not in OAUTH2_CONFIG.get("providers", {}): raise HTTPException(status_code=404, detail=f"Provider {provider} not found") provider_config = OAUTH2_CONFIG["providers"][provider] logout_url = provider_config.get("logout_url") if not logout_url: # If provider doesn't support logout URL, just redirect redirect_url = redirect_uri or OAUTH2_CONFIG.get("registry", {}).get( "success_redirect", "/login" ) return RedirectResponse(url=redirect_url, status_code=302) # Build full redirect URI full_redirect_uri = redirect_uri or "/login" if not full_redirect_uri.startswith("http"): # Make it a full URL - extract registry URL from request's referer or use environment registry_base = os.environ.get("REGISTRY_URL") if not registry_base: # Try to derive from the request referer = request.headers.get("referer", "") if referer: from urllib.parse import urlparse parsed = urlparse(referer) registry_base = f"{parsed.scheme}://{parsed.netloc}" else: registry_base = "http://localhost" full_redirect_uri = f"{registry_base.rstrip('/')}{full_redirect_uri}" # Detect provider type and build appropriate logout URL # Keycloak uses post_logout_redirect_uri, Cognito uses logout_uri parsed_logout_url = urlparse(logout_url) logout_hostname = parsed_logout_url.hostname or "" logout_path = parsed_logout_url.path or "" if "keycloak" in provider.lower() or "/realms/" in logout_path: # Keycloak logout parameters logout_params = { "client_id": provider_config["client_id"], "post_logout_redirect_uri": full_redirect_uri, } if id_token_hint: logout_params["id_token_hint"] = id_token_hint logger.debug(f"Keycloak logout params built: has_id_token_hint={bool(id_token_hint)}") elif logout_hostname == "login.microsoftonline.com" or "entra" in provider.lower(): # Entra ID logout parameters logout_params = { "post_logout_redirect_uri": full_redirect_uri, } if id_token_hint: logout_params["id_token_hint"] = id_token_hint logger.debug(f"Entra ID logout params built: has_id_token_hint={bool(id_token_hint)}") elif "okta" in provider.lower() or ( logout_hostname and logout_hostname.endswith(".okta.com") ): # Okta logout parameters logout_params = { "post_logout_redirect_uri": full_redirect_uri, } if id_token_hint: logout_params["id_token_hint"] = id_token_hint logger.debug(f"Okta logout params built: has_id_token_hint={bool(id_token_hint)}") else: # Cognito logout parameters (no id_token_hint support) logout_params = { "client_id": provider_config["client_id"], "logout_uri": full_redirect_uri, } logger.debug("Cognito logout params built (no id_token_hint)") logout_redirect_url = f"{logout_url}?{urllib.parse.urlencode(logout_params)}" logger.info(f"Redirecting to {provider} logout") return RedirectResponse(url=logout_redirect_url, status_code=302) except HTTPException: raise except Exception as e: logger.error(f"Error initiating logout for {provider}: {e}") # Fallback to local redirect redirect_url = redirect_uri or OAUTH2_CONFIG.get("registry", {}).get( "success_redirect", "/login" ) return RedirectResponse(url=redirect_url, status_code=302) ================================================ FILE: build-config.yaml ================================================ # Unified Container Build Configuration # Central definition of all Docker images to build and push to ECR # This is the SINGLE SOURCE OF TRUTH for all container builds # # NOTE: The build scripts now dynamically construct the ECR registry URL # based on the AWS_REGION environment variable and current AWS credentials. # The values below are DEFAULTS and will be overridden if AWS_REGION is set. # # To deploy to a different region: # export AWS_REGION=us-east-1 # make build-push # # The build scripts will automatically use the correct ECR registry for your region. aws: account_id: "123456789012" # Placeholder - overridden by AWS credentials at runtime region: "us-west-2" # Default region - override with AWS_REGION env var ecr_registry: "123456789012.dkr.ecr.us-west-2.amazonaws.com" # Placeholder - constructed dynamically at runtime images: # ======================================== # ECS-Deployed Core Services (5) # ======================================== # Main MCP Gateway Registry with nginx reverse proxy, FAISS, models registry: repo_name: "mcp-gateway-registry" dockerfile: "docker/Dockerfile.registry" context: "." description: "MCP Gateway Registry with nginx, FAISS, models" tags: - latest build_args: {} # OAuth2/OIDC Authentication Server auth_server: repo_name: "mcp-gateway-auth-server" dockerfile: "docker/Dockerfile.auth" context: "." description: "OAuth2/OIDC authentication server" tags: - latest build_args: {} # Keycloak Identity Provider keycloak: repo_name: "keycloak" dockerfile: "docker/keycloak/Dockerfile" context: "docker/keycloak" description: "Keycloak identity provider" tags: - latest build_args: BASE_IMAGE: "quay.io/keycloak/keycloak:latest" # Keycloak Scopes and Roles Initialization scopes_init: repo_name: "mcp-gateway-scopes-init" dockerfile: "docker/Dockerfile.scopes-init" context: "." description: "Initialize Keycloak scopes and roles" tags: - latest build_args: {} # Metrics Collection and Monitoring Service metrics_service: repo_name: "mcp-gateway-metrics-service" dockerfile: "metrics-service/Dockerfile" context: "metrics-service" description: "Metrics collection and monitoring service" tags: - latest build_args: {} # Grafana OSS with pre-provisioned AMP datasource and dashboards grafana: repo_name: "mcp-gateway-grafana" dockerfile: "terraform/aws-ecs/grafana/Dockerfile" context: "terraform/aws-ecs/grafana" description: "Grafana OSS with baked-in AMP datasource and MCP dashboards" tags: - latest build_args: {} # ======================================== # Infrastructure Services # ======================================== # MongoDB Community Edition 8.2 mongodb: repo_name: "mongodb" external_image: "mongo:8.2" description: "MongoDB Community Edition 8.2 with replica set support for local development (mirrored from Docker Hub)" tags: - "8.2" - latest build_args: {} # ======================================== # MCP Servers (5) # ======================================== # Generic MCP Server Template mcp_server: repo_name: "mcp-gateway-mcp-server" dockerfile: "docker/Dockerfile.mcp-server" context: "." description: "Generic MCP server template" tags: - latest build_args: {} # CurrentTime MCP Server currenttime: repo_name: "mcp-gateway-currenttime" dockerfile: "docker/Dockerfile.mcp-server" context: "servers/currenttime" description: "CurrentTime MCP server providing current time functionality" tags: - latest build_args: {} # MCPGW MCP Server mcpgw: repo_name: "mcp-gateway-mcpgw" dockerfile: "docker/Dockerfile.mcp-server" context: "." description: "MCPGW MCP server with embeddings support" tags: - latest build_args: SERVER_DIR: "servers/mcpgw" # Real Server Fake Tools MCP Server realserverfaketools: repo_name: "mcp-gateway-realserverfaketools" dockerfile: "docker/Dockerfile.mcp-server" context: "servers/realserverfaketools" description: "Real Server Fake Tools MCP server" tags: - latest build_args: {} # Financial Info MCP Server fininfo: repo_name: "mcp-gateway-fininfo" dockerfile: "docker/Dockerfile.mcp-server" context: "servers/fininfo" description: "Financial Info MCP server" tags: - latest build_args: {} # ======================================== # Agent Services (A2A) (2) # ======================================== # Flight Booking Agent flight_booking_agent: repo_name: "mcp-gateway-flight-booking-agent" dockerfile: "agents/a2a/src/flight-booking-agent/Dockerfile" context: "agents/a2a/src/flight-booking-agent" description: "Flight booking A2A agent" tags: - latest build_args: {} # Travel Assistant Agent travel_assistant_agent: repo_name: "mcp-gateway-travel-assistant-agent" dockerfile: "agents/a2a/src/travel-assistant-agent/Dockerfile" context: "agents/a2a/src/travel-assistant-agent" description: "Travel assistant A2A agent" tags: - latest build_args: {} ================================================ FILE: build_and_run.sh ================================================ #!/bin/bash # Enable error handling set -e # Function for logging with timestamp log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" } # Function for error handling handle_error() { log "ERROR: $1" exit 1 } # Parse command line arguments USE_PREBUILT=false USE_PODMAN=false USE_DHI=false DOCKER_COMPOSE_FILE="docker-compose.yml" DHI_COMPOSE_FILE="docker-compose.dhi.yml" PODMAN_COMPOSE_FILE="docker-compose.podman.yml" while [[ $# -gt 0 ]]; do case $1 in --prebuilt) USE_PREBUILT=true DOCKER_COMPOSE_FILE="docker-compose.prebuilt.yml" shift ;; --podman) USE_PODMAN=true shift ;; --dhi) USE_DHI=true shift ;; --help) echo "Usage: $0 [--prebuilt] [--podman] [--dhi] [--help]" echo "" echo "Options:" echo " --prebuilt Use pre-built container images (faster startup)" echo " --podman Use Podman instead of Docker (rootless-friendly)" echo " --dhi Use Docker Hardened Images (DHI) from dhi.io" echo " --help Show this help message" echo "" echo "Examples:" echo " $0 # Build containers locally with Docker (default)" echo " $0 --prebuilt # Use pre-built images from registry with Docker" echo " $0 --podman # Build containers locally with Podman" echo " $0 --prebuilt --podman # Use pre-built images with Podman" echo " $0 --dhi # Use Docker Hardened Images for infra containers" echo "" echo "Benefits of --prebuilt:" echo " - Instant deployment (no build time)" echo " - Reduced friction (eliminate build environment issues)" echo " - Consistent experience (all users get the same tested images)" echo " - Bandwidth efficient (pull optimized, compressed images)" echo "" echo "Benefits of --podman:" echo " - Rootless container execution (no privileged ports)" echo " - Compatible with macOS Podman Desktop" echo " - Uses non-privileged ports (8080 for HTTP, 8443 for HTTPS)" echo " - No Docker daemon required" echo "" echo "Benefits of --dhi:" echo " - Security-hardened container images from dhi.io" echo " - Reduced attack surface for infrastructure containers" echo " - Non-root execution enforced (MongoDB, Prometheus, Grafana, PostgreSQL)" echo " - Requires: docker login dhi.io (before first use)" exit 0 ;; *) echo "Unknown option $1" echo "Use --help for usage information" exit 1 ;; esac done echo "MCP Gateway Registry Deployment" echo "===============================" # Detect and configure container engine COMPOSE_CMD="" COMPOSE_FILES="" if [ "$USE_PODMAN" = true ]; then # User explicitly requested Podman if command -v podman &> /dev/null; then COMPOSE_CMD="podman compose" # Use standalone Podman compose file to avoid port merge issues COMPOSE_FILES="-f $PODMAN_COMPOSE_FILE" log "Using Podman (rootless mode)" log "Services will be available at:" log " - HTTP: http://localhost:8080" log " - HTTPS: https://localhost:8443" else log "ERROR: --podman flag specified but podman command not found" log "Please install Podman: https://podman.io/getting-started/installation" exit 1 fi else # Auto-detect: prefer Docker, fallback to Podman if command -v docker &> /dev/null && docker compose version &> /dev/null; then COMPOSE_CMD="docker compose" COMPOSE_FILES="-f $DOCKER_COMPOSE_FILE" log "Using Docker" log "Services will be available at:" log " - HTTP: http://localhost" log " - HTTPS: https://localhost" elif command -v podman &> /dev/null; then log "WARNING: Docker not found, automatically using Podman (rootless mode)" log "To suppress this message, use --podman flag explicitly" COMPOSE_CMD="podman compose" # Use standalone Podman compose file to avoid port merge issues COMPOSE_FILES="-f $PODMAN_COMPOSE_FILE" log "Services will be available at:" log " - HTTP: http://localhost:8080" log " - HTTPS: https://localhost:8443" else log "ERROR: Neither 'docker compose' nor 'podman compose' is available" log "Please install one of:" log " - Docker: https://docs.docker.com/compose/install/" log " - Podman: https://podman.io/getting-started/installation" exit 1 fi fi # Append DHI override file if --dhi flag is set if [ "$USE_DHI" = true ]; then if [ ! -f "$DHI_COMPOSE_FILE" ]; then log "ERROR: DHI compose file not found: $DHI_COMPOSE_FILE" exit 1 fi COMPOSE_FILES="$COMPOSE_FILES -f $DHI_COMPOSE_FILE" log "Using Docker Hardened Images (DHI) from dhi.io" log "Ensure you have authenticated: docker login dhi.io" fi OVERRIDE_FILE="docker-compose.override.yml" if [ -f "$OVERRIDE_FILE" ]; then COMPOSE_FILES="$COMPOSE_FILES -f $OVERRIDE_FILE" log "Applying local overrides from $OVERRIDE_FILE" fi if [ "$USE_PREBUILT" = true ]; then log "Using pre-built container images for fast deployment" log "Will pull latest images from container registry during startup..." # Warn about ARM64 compatibility with Podman if [[ "$COMPOSE_CMD" == "podman compose" ]] && [[ $(uname -m) == "arm64" ]]; then log "WARNING: Pre-built images are amd64. On Apple Silicon, consider:" log " - Building locally: ./build_and_run.sh --podman" log " - Or using Docker Desktop: ./build_and_run.sh --prebuilt" log " Continuing in 5 seconds... (Ctrl+C to cancel)" sleep 5 fi else log "Building containers locally (this may take several minutes)" fi log "Using compose files: $COMPOSE_FILES" log "Starting MCP Gateway deployment script" # Only check Node.js and build frontend when building locally if [ "$USE_PREBUILT" = false ]; then # Check if Node.js and npm are installed if ! command -v node &> /dev/null; then log "ERROR: Node.js is not installed" log "Please install Node.js (version 16 or higher): https://nodejs.org/" exit 1 fi if ! command -v npm &> /dev/null; then log "ERROR: npm is not installed" log "Please install npm (usually comes with Node.js): https://nodejs.org/" exit 1 fi # Check Node.js version NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) if [ "$NODE_VERSION" -lt 16 ]; then log "ERROR: Node.js version $NODE_VERSION is too old. Please install Node.js 16 or higher." exit 1 fi log "Node.js $(node -v) and npm $(npm -v) are available" # Build the React frontend log "Building React frontend..." if [ ! -d "frontend" ]; then handle_error "Frontend directory not found" fi cd frontend # Install frontend dependencies log "Installing frontend dependencies..." npm install || handle_error "Failed to install frontend dependencies" # Build the React application log "Building React application for production..." npm run build || handle_error "Failed to build React application" log "Frontend build completed successfully" cd .. else log "Skipping frontend build (using pre-built images)" fi # Check if .env file exists if [ ! -f .env ]; then log "ERROR: .env file not found" log "Please create a .env file with your configuration values:" log "Example .env file:" log "SECRET_KEY=your_secret_key_here" log "# SECRET_KEY is auto-generated if not set. It is used to sign JWT session tokens." log "# For Financial Info server API keys, see servers/fininfo/README_SECRETS.md" exit 1 fi log "Found .env file" # Load environment variables from .env file early so we can check STORAGE_BACKEND source .env # Check if docker compose is installed if ! docker compose version &> /dev/null; then log "ERROR: docker compose is not available" log "Please install Docker Compose v2: https://docs.docker.com/compose/install/" exit 1 fi # Stop and remove existing services if they exist log "Stopping existing services (if any)..." $COMPOSE_CMD $COMPOSE_FILES down --remove-orphans || log "No existing services to stop" log "Existing services stopped" # Clean up FAISS index files to force registry to recreate them log "Checking FAISS index files..." MCPGATEWAY_SERVERS_DIR="${HOME}/mcp-gateway/servers" FAISS_FILES=("service_index.faiss" "service_index_metadata.json") # Check if FAISS index files exist FAISS_EXISTS=false for file in "${FAISS_FILES[@]}"; do file_path="$MCPGATEWAY_SERVERS_DIR/$file" if [ -f "$file_path" ]; then FAISS_EXISTS=true break fi done if [ "$FAISS_EXISTS" = true ]; then echo "" echo "╔════════════════════════════════════════════════════════════════════════════╗" echo "║ FAISS INDEX FILES EXIST ║" echo "╠════════════════════════════════════════════════════════════════════════════╣" echo "║ ║" echo "║ Existing FAISS index files were found in: ║" echo "║ $MCPGATEWAY_SERVERS_DIR/" echo "║ ║" echo "║ These files contain your server registry and search index. ║" echo "║ To preserve your registered servers, these files will NOT be deleted. ║" echo "║ ║" echo "║ If you need to regenerate the FAISS index (e.g., after corruption): ║" echo "║ 1. Delete the existing files: ║" echo "║ rm $MCPGATEWAY_SERVERS_DIR/service_index*" echo "║ 2. The registry will automatically rebuild the index on startup ║" echo "║ ║" echo "╚════════════════════════════════════════════════════════════════════════════╝" echo "" log "Keeping existing FAISS index files - NOT deleting" else log "No existing FAISS index files found - will be created on first startup" fi # Clean up any root-owned directories from previous Docker runs log "Checking for root-owned directories from previous Docker runs..." # Check and remove root-owned directories for dir in "$MCPGATEWAY_SERVERS_DIR" "${HOME}/mcp-gateway/agents" "${HOME}/mcp-gateway/auth_server" "${HOME}/mcp-gateway/security_scans" "${HOME}/mcp-gateway/federation.json"; do if [ -e "$dir" ] && [ "$(stat -c '%U' "$dir" 2>/dev/null)" = "root" ]; then log "Removing root-owned: $dir" sudo rm -rf "$dir" fi done # Copy JSON files from registry/servers to ${HOME}/mcp-gateway/servers with environment variable substitution log "Copying JSON files from registry/servers to $MCPGATEWAY_SERVERS_DIR..." if [ -d "registry/servers" ]; then # Create the target directory if it doesn't exist mkdir -p "$MCPGATEWAY_SERVERS_DIR" # Copy all JSON files with environment variable substitution if ls registry/servers/*.json 1> /dev/null 2>&1; then # Export all environment variables from .env file for envsubst set -a # Automatically export all variables source .env set +a # Turn off automatic export for json_file in registry/servers/*.json; do filename=$(basename "$json_file") log "Processing $filename with environment variable substitution..." # Use envsubst to substitute environment variables, then copy to target envsubst < "$json_file" > "$MCPGATEWAY_SERVERS_DIR/$filename" done log "JSON files copied successfully with environment variable substitution" # Verify atlassian.json was copied if [ -f "$MCPGATEWAY_SERVERS_DIR/atlassian.json" ]; then log "atlassian.json copied successfully" else log "WARNING: atlassian.json not found in copied files" fi else log "No JSON files found in registry/servers" fi else log "WARNING: registry/servers directory not found" fi # Copy seed agent JSON files from cli/examples to ${HOME}/mcp-gateway/agents AGENTS_DIR="${HOME}/mcp-gateway/agents" log "Copying seed agent files from cli/examples to $AGENTS_DIR..." if [ -d "cli/examples" ]; then # Create the target directory if it doesn't exist mkdir -p "$AGENTS_DIR" # Copy all agent JSON files from cli/examples if ls cli/examples/*agent*.json 1> /dev/null 2>&1; then for json_file in cli/examples/*agent*.json; do filename=$(basename "$json_file") log "Copying seed agent $filename..." # Copy agent file to target directory cp "$json_file" "$AGENTS_DIR/$filename" done log "Seed agent files copied successfully" else log "No seed agent files found in cli/examples" fi else log "WARNING: cli/examples directory not found - seed agents will not be copied" fi # Copy scopes.yml to ${HOME}/mcp-gateway/auth_server AUTH_SERVER_DIR="${HOME}/mcp-gateway/auth_server" TARGET_SCOPES_FILE="$AUTH_SERVER_DIR/scopes.yml" log "Checking scopes.yml configuration..." if [ -f "auth_server/scopes.yml" ]; then # Create the target directory if it doesn't exist mkdir -p "$AUTH_SERVER_DIR" # Check if scopes.yml already exists in the target directory if [ -f "$TARGET_SCOPES_FILE" ]; then echo "" echo "╔════════════════════════════════════════════════════════════════════════════╗" echo "║ SCOPES.YML EXISTS ║" echo "╠════════════════════════════════════════════════════════════════════════════╣" echo "║ ║" echo "║ An existing scopes.yml file was found at: ║" echo "║ $TARGET_SCOPES_FILE" echo "║ ║" echo "║ This file contains your custom groups and server configurations. ║" echo "║ To preserve your settings, this file will NOT be overwritten. ║" echo "║ ║" echo "║ If you need to restore the default scopes.yml from the codebase: ║" echo "║ 1. Delete the existing file: ║" echo "║ rm $TARGET_SCOPES_FILE" echo "║ 2. Re-run this script ║" echo "║ ║" echo "╚════════════════════════════════════════════════════════════════════════════╝" echo "" log "Keeping existing scopes.yml - NOT overwriting" else # Copy scopes.yml for first-time setup cp auth_server/scopes.yml "$AUTH_SERVER_DIR/" log "scopes.yml copied successfully to $AUTH_SERVER_DIR (initial setup)" fi else log "WARNING: auth_server/scopes.yml not found in codebase" fi # Create empty security_scans directory for Docker mount SECURITY_SCANS_DIR="${HOME}/mcp-gateway/security_scans" log "Creating empty security_scans directory for Docker mount" mkdir -p "$SECURITY_SCANS_DIR" # Create empty federation.json file for Docker mount FEDERATION_JSON_FILE="${HOME}/mcp-gateway/federation.json" log "Creating empty federation.json for Docker mount" touch "$FEDERATION_JSON_FILE" # Setup SSL certificate directory structure SSL_DIR="${HOME}/mcp-gateway/ssl" log "Setting up SSL certificate directory structure..." mkdir -p "$SSL_DIR/certs" mkdir -p "$SSL_DIR/private" # Check if SSL certificates exist and are properly located if [ -f "$SSL_DIR/certs/fullchain.pem" ] && [ -f "$SSL_DIR/private/privkey.pem" ]; then log "SSL certificates found - HTTPS will be enabled" chmod 644 "$SSL_DIR/certs/fullchain.pem" chmod 600 "$SSL_DIR/private/privkey.pem" else log "No SSL certificates found - HTTP-only mode will be used" log "To enable HTTPS, place certificates at:" log " - $SSL_DIR/certs/fullchain.pem" log " - $SSL_DIR/private/privkey.pem" fi # Generate a random SECRET_KEY if not already in .env if ! grep -q "SECRET_KEY=" .env || grep -q "SECRET_KEY=$" .env || grep -q "SECRET_KEY=\"\"" .env; then log "Generating SECRET_KEY..." SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_hex(32))') || handle_error "Failed to generate SECRET_KEY" # Remove any existing empty SECRET_KEY line sed -i '/^SECRET_KEY=$/d' .env 2>/dev/null || true sed -i '/^SECRET_KEY=""$/d' .env 2>/dev/null || true # Add new SECRET_KEY echo "SECRET_KEY=$SECRET_KEY" >> .env log "SECRET_KEY added to .env" else log "SECRET_KEY already exists in .env" fi # Validate required environment variables log "Validating required environment variables..." source .env # Determine BUILD_VERSION from git log "Determining version from git..." if command -v git &> /dev/null && [ -d .git ]; then # Get the current git tag GIT_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "") if [ -n "$GIT_TAG" ]; then # We're on a tagged commit - use just the tag (remove 'v' prefix) export BUILD_VERSION="${GIT_TAG#v}" log "Building release version: $BUILD_VERSION" else # Not on a tag - include branch name and commit info GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") GIT_DESCRIBE=$(git describe --tags --always 2>/dev/null || echo "dev") # Format: version-branch or describe-branch if [[ "$GIT_DESCRIBE" =~ ^[0-9] ]]; then # Starts with version number from describe export BUILD_VERSION="${GIT_DESCRIBE#v}-${GIT_BRANCH}" else # No version tags found, use commit hash export BUILD_VERSION="${GIT_DESCRIBE}-${GIT_BRANCH}" fi log "Building development version: $BUILD_VERSION" fi else export BUILD_VERSION="1.0.0-dev" log "Git not available, using default version: $BUILD_VERSION" fi # Build or pull container images if [ "$USE_PREBUILT" = true ]; then log "Pulling pre-built container images..." $COMPOSE_CMD $COMPOSE_FILES pull || handle_error "Compose pull failed" log "Pre-built container images pulled successfully" else log "Building container images with optimization..." # Enable BuildKit for better caching and parallel builds (Docker only) if [[ "$COMPOSE_CMD" == "docker compose" ]]; then export DOCKER_BUILDKIT=1 export COMPOSE_DOCKER_CLI_BUILD=1 fi # Build with parallel jobs and build cache $COMPOSE_CMD $COMPOSE_FILES build --parallel --progress=auto || handle_error "Compose build failed" log "Container images built successfully with optimization" fi # Start metrics service first to generate API keys log "Starting metrics service first..." $COMPOSE_CMD $COMPOSE_FILES up -d metrics-service || handle_error "Failed to start metrics service" # Wait for metrics service to be ready log "Waiting for metrics service to be ready..." max_retries=30 retry_count=0 while [ $retry_count -lt $max_retries ]; do if curl -f http://localhost:8890/health &>/dev/null; then log "Metrics service is ready" break fi sleep 2 retry_count=$((retry_count + 1)) log "Waiting for metrics service... ($retry_count/$max_retries)" done if [ $retry_count -eq $max_retries ]; then handle_error "Metrics service did not become ready within expected time" fi # Generate dynamic pre-shared tokens for metrics authentication log "Setting up dynamic pre-shared tokens for services..." # Get all services from compose file that might need metrics (exclude monitoring services) METRICS_SERVICES=$($COMPOSE_CMD $COMPOSE_FILES config --services 2>/dev/null | grep -v -E "(prometheus|grafana|metrics-db)" | sort | uniq) if [ -z "$METRICS_SERVICES" ]; then log "WARNING: No services found for metrics configuration" else log "Found services for metrics: $(echo $METRICS_SERVICES | tr '\n' ' ')" fi # Check if tokens already exist in .env source .env 2>/dev/null || true # Generate tokens for each service dynamically for service in $METRICS_SERVICES; do # Convert service name to environment variable format # auth-server -> METRICS_API_KEY_AUTH_SERVER # metrics-service -> METRICS_API_KEY_METRICS_SERVICE (will be skipped as it's the metrics service itself) ENV_VAR_NAME="METRICS_API_KEY_$(echo "$service" | tr '[:lower:]-' '[:upper:]_')" # Skip the metrics service itself and non-metrics services if [ "$service" = "metrics-service" ] || [ "$service" = "prometheus" ] || [ "$service" = "grafana" ]; then continue fi # Get current value CURRENT_VALUE=$(eval echo "\$$ENV_VAR_NAME") # Generate token only if it doesn't exist or is empty if [ -z "$CURRENT_VALUE" ] || [ "$CURRENT_VALUE" = "" ]; then NEW_TOKEN="mcp_metrics_$(openssl rand -hex 16)" # Remove any existing line for this variable sed -i "/^$ENV_VAR_NAME=/d" .env 2>/dev/null || true # Add new token echo "$ENV_VAR_NAME=$NEW_TOKEN" >> .env log "Generated new $service token: ${NEW_TOKEN:0:20}..." else log "Using existing $service token: ${CURRENT_VALUE:0:20}..." fi done log "Dynamic metrics API tokens configured successfully" # Now start all other services with the API keys in environment log "Starting remaining services..." $COMPOSE_CMD $COMPOSE_FILES up -d || handle_error "Failed to start remaining services" # Wait a moment for services to initialize log "Waiting for services to initialize..." sleep 10 # Check service status log "Checking service status..." $COMPOSE_CMD $COMPOSE_FILES ps # Verify key services are running log "Verifying services are healthy..." # Check registry service if curl -f http://localhost:7860/health &>/dev/null; then log "Registry service is healthy" else log "WARNING: Registry service may still be starting up..." fi # Check auth service if curl -f http://localhost:18888/health &>/dev/null; then log "Auth service is healthy" else log "WARNING: Auth service may still be starting up..." fi # Check nginx is responding if curl -f http://localhost:80 &>/dev/null || curl -k -f https://localhost:443 &>/dev/null; then log "Nginx is responding" else log "WARNING: Nginx may still be starting up..." fi # Verify FAISS index creation log "Verifying FAISS index creation..." sleep 5 # Give registry service time to create the index if [ -f "$MCPGATEWAY_SERVERS_DIR/service_index.faiss" ]; then log "FAISS index created successfully at $MCPGATEWAY_SERVERS_DIR/service_index.faiss" # Check if metadata file also exists if [ -f "$MCPGATEWAY_SERVERS_DIR/service_index_metadata.json" ]; then log "FAISS index metadata created successfully" else log "WARNING: FAISS index metadata file not found" fi else log "WARNING: FAISS index not yet created. The registry service will create it on first access." fi # Verify server list includes Atlassian log "Verifying server list..." if [ -f "$MCPGATEWAY_SERVERS_DIR/atlassian.json" ]; then log "Atlassian server configuration present" fi # List all available server JSON files log "Available server configurations in $MCPGATEWAY_SERVERS_DIR:" if ls "$MCPGATEWAY_SERVERS_DIR"/*.json 2>/dev/null | head -n 10; then TOTAL_SERVERS=$(ls "$MCPGATEWAY_SERVERS_DIR"/*.json 2>/dev/null | wc -l) log "Total server configurations: $TOTAL_SERVERS" else log "WARNING: No server configurations found in $MCPGATEWAY_SERVERS_DIR" fi log "Deployment completed successfully" log "" # Display correct URLs based on container engine if [[ "$COMPOSE_CMD" == "podman compose" ]]; then log "Services are available at:" log " - Main interface: http://localhost:8080 or https://localhost:8443" log " - Registry API: http://localhost:7860" log " - Auth service: http://localhost:8888" log " - Current Time MCP: http://localhost:8000" log " - Financial Info MCP: http://localhost:8001" log " - Real Server Fake Tools MCP: http://localhost:8002" log " - MCP Gateway MCP: http://localhost:8003" else log "Services are available at:" log " - Main interface: http://localhost or https://localhost" log " - Registry API: http://localhost:7860" log " - Auth service: http://localhost:8888" log " - Current Time MCP: http://localhost:8000" log " - Financial Info MCP: http://localhost:8001" log " - Real Server Fake Tools MCP: http://localhost:8002" log " - MCP Gateway MCP: http://localhost:8003" fi log "" log "To view logs for all services: $COMPOSE_CMD $COMPOSE_FILES logs -f" log "To view logs for a specific service: $COMPOSE_CMD $COMPOSE_FILES logs -f " log "To stop services: $COMPOSE_CMD $COMPOSE_FILES down" log "" # Ask if user wants to follow logs read -p "Do you want to follow the logs? (y/n): " -n 1 -r echo if [[ $REPLY =~ ^[Yy]$ ]]; then log "Following container logs (press Ctrl+C to stop following logs without stopping the services):" echo "---------- CONTAINER LOGS ----------" $COMPOSE_CMD $COMPOSE_FILES logs -f else log "Services are running in the background. Use '$COMPOSE_CMD $COMPOSE_FILES logs -f' to view logs." fi ================================================ FILE: charts/README.md ================================================ # MCP Gateway Registry Helm Charts This directory contains Helm charts for deploying the MCP Gateway Registry stack on Kubernetes. ## Prerequisites ### EKS Cluster Setup For deploying on Amazon EKS, we recommend using the [AWS AI/ML on Amazon EKS](https://github.com/awslabs/ai-on-eks) blueprints to provision an EKS cluster with GPU support, autoscaling, and AI/ML optimizations. **Quick Start with AI on EKS:** ```bash # Clone the AI on EKS repository git clone https://github.com/awslabs/ai-on-eks.git cd ai-on-eks # Until https://github.com/awslabs/ai-on-eks/pull/232 is merged, the custom stack can be used cd infra/custom ./install.sh ``` Once your EKS cluster is provisioned, return to this directory to deploy the MCP Gateway Registry using the Helm charts. ### Required Components - Kubernetes cluster (EKS, GKE, AKS, or self-managed) - `helm` CLI installed (v3.0+) - `kubectl` configured to access your cluster - Ingress controller (ALB, NGINX, or Traefik) - DNS configuration for your domain - SSL/TLS certificates (optional but recommended) ## Charts Overview ### Individual Charts - **auth-server**: Authentication service for the MCP Gateway (supports Keycloak and Entra ID) - **registry**: MCP server registry service - **keycloak-configure**: Job to configure Keycloak realms and clients - **mongodb-configure**: Job to configure MongoDB and scopes ### Stack Chart - **mcp-gateway-registry-stack**: Complete stack deployment including identity provider, auth-server, registry, and configuration ## Authentication Providers The charts support two authentication providers: - **Keycloak** (default): Open-source identity and access management - **Microsoft Entra ID**: Azure Active Directory / Microsoft Entra ID ### Selecting a Provider Set the authentication provider in your values file: ```yaml global: authProvider: type: keycloak # or "entra" # For Keycloak in stack keycloak: create: true # For external Keycloak keycloak: create: false externalUrl: https://your-keycloak.com # For Entra ID keycloak: create: false ``` ## Improved Values Structure The values files have been standardized with the following structure: ### Global Configuration ```yaml global: image: repository: mcpgateway/service-name tag: v1.0.7 pullPolicy: IfNotPresent ``` ### Application Configuration ```yaml app: name: service-name replicas: 1 externalUrl: http://localhost:8080 secretKey: your-secret-key ``` ### Service Configuration ```yaml service: type: ClusterIP port: 8080 annotations: { } ``` ### Resources ```yaml resources: requests: cpu: 1 memory: 1Gi limits: cpu: 2 memory: 2Gi ``` ### Ingress ```yaml ingress: enabled: false className: alb hostname: "" annotations: { } tls: false ``` ## Key Improvements 1. **Consistent Structure**: All charts now follow the same values organization 2. **Standardized Naming**: Unified naming conventions across all charts 3. **Reduced Duplication**: Eliminated redundant resource definitions 4. **Better Defaults**: Sensible default values for development and production 5. **Clean Templates**: Updated all templates to use the new values structure 6. **Clear Documentation**: Inline comments explaining configuration options ## Usage ### Deploy Individual Services ```bash helm install auth-server ./charts/auth-server helm install registry ./charts/registry ``` ### Deploy Complete Stack ```bash # Option 1: Update values.yaml file directly # Edit charts/mcp-gateway-registry-stack/values.yaml and change global.domain # Option 2: Override via command line helm install mcp-stack ./charts/mcp-gateway-registry-stack \ --set global.domain=yourdomain.com \ --set global.secretKey=your-production-secret ``` ## Configuration Notes - **Domain**: The stack chart uses the domain from `global.domain` and applies it to all subcharts - **Secret Keys**: Change default secret keys in production - they should match across all services - **Resources**: Adjust CPU/memory based on your requirements - **Ingress**: Configure ingress settings for your environment - **Existing Secrets**: All charts support referencing pre-existing Kubernetes secrets instead of having Helm manage them. See the [stack chart README](mcp-gateway-registry-stack/README.md#using-existing-secrets) for details. ### Domain Configuration The stack chart uses `global.domain` to automatically configure all service endpoints. You can choose between two routing modes: ``` ┌─────────────────────────────────────────────────────────────┐ │ ROUTING MODES │ ├─────────────────────────────────────────────────────────────┤ │ │ │ SUBDOMAIN MODE (Default) PATH MODE │ │ ───────────────────── ───────── │ │ │ │ ✓ keycloak.domain.com ✓ domain.com/keycloak │ │ ✓ auth-server.domain.com ✓ domain.com/auth-server│ │ ✓ mcpregistry.domain.com ✓ domain.com/registry │ │ ✓ domain.com/ │ │ │ │ DNS: Multiple records DNS: Single record │ │ Cert: Wildcard or multiple Cert: Single │ │ │ └─────────────────────────────────────────────────────────────┘ ``` #### Subdomain-Based Routing (Default) Services are accessed via subdomains: - `keycloak.{domain}` - Keycloak authentication server - `auth-server.{domain}` - MCP Gateway auth server - `mcpregistry.{domain}` - MCP server registry **Configuration:** ```yaml global: domain: "yourdomain.com" ingress: routingMode: subdomain ``` #### Path-Based Routing Services are accessed via paths on a single domain: - `{domain}/keycloak` - Keycloak authentication server - `{domain}/auth-server` - MCP Gateway auth server - `{domain}/registry` - MCP server registry **Note:** All paths are configurable. You can customize them to match your URL structure (e.g., `/api/auth`, `/api/registry`). **Configuration:** ```yaml global: domain: "yourdomain.com" ingress: routingMode: path paths: authServer: /auth-server # Customize as needed registry: /registry # Customize as needed keycloak: /keycloak # Customize as needed ``` **Important:** If you change the Keycloak path, you must also update the `keycloak.httpRelativePath` environment variable: ```yaml keycloak: httpRelativePath: /keycloak/ ``` **How it works:** 1. Set `global.domain` in the stack values file 2. Choose `routingMode: subdomain` or `routingMode: path` 3. All subchart templates reference these values to build URLs and hostnames 4. Change the domain or routing mode once and all services update automatically **To change the domain or routing mode:** ```bash # Edit the values file vim charts/mcp-gateway-registry-stack/values.yaml # Change: global.domain: "your-new-domain.com" # Change: global.ingress.routingMode: "path" # Or override via command line helm upgrade mcp-stack ./charts/mcp-gateway-registry-stack \ --set global.domain=your-new-domain.com \ --set global.ingress.routingMode=path ``` **DNS Configuration:** - **Subdomain mode:** Configure DNS A/CNAME records for each subdomain pointing to your ingress - **Path mode:** Configure a single DNS A/CNAME record for your domain pointing to your ingress ## Deployment Options: Kubernetes vs AWS ECS This project supports two deployment methods: ### 1. Kubernetes Deployment (This Directory) Deploy the MCP Gateway Registry on any Kubernetes cluster using Helm charts. Ideal for: - Multi-cloud deployments (AWS EKS, Google GKE, Azure AKS) - On-premises Kubernetes clusters - Organizations with existing Kubernetes infrastructure - Scenarios requiring portability and vendor neutrality **Location:** `/charts` directory (this location) **Tools:** Helm charts, Kubernetes manifests ### 2. AWS ECS Deployment (Terraform) Deploy the MCP Gateway Registry on AWS ECS using Terraform for infrastructure-as-code. Ideal for: - AWS-native deployments with full AWS integration - Organizations using AWS Fargate for serverless containers - Teams preferring Terraform for infrastructure management - Deployments requiring tight AWS service integration (ALB, ECR, EFS, Secrets Manager) **Location:** `/terraform/aws-ecs` directory **Tools:** Terraform modules, AWS ECS task definitions, AWS Fargate ### Choosing Between Kubernetes and ECS | Feature | Kubernetes (Helm) | AWS ECS (Terraform) | |---------|------------------|---------------------| | **Portability** | High - works on any K8s cluster | AWS-specific | | **Multi-cloud** | Yes | No (AWS only) | | **Complexity** | Moderate - requires K8s knowledge | Lower - managed by AWS | | **Customization** | High - full K8s ecosystem | Moderate - AWS services | | **Auto-scaling** | K8s HPA, Cluster Autoscaler | ECS Service Auto Scaling | | **Cost** | Depends on cluster costs | Pay-per-task (Fargate) | | **Tools** | kubectl, helm | AWS CLI, terraform | **Note:** The Helm charts and Terraform configurations are separate deployment methods. Choose the one that best fits your infrastructure and team expertise. ================================================ FILE: charts/auth-server/Chart.yaml ================================================ apiVersion: v2 name: auth-server description: A Helm chart for auth-server for MCP Gateway Registry type: application version: 0.1.0 appVersion: "1.0.0" ================================================ FILE: charts/auth-server/templates/configmap-app-log.yaml ================================================ apiVersion: v1 kind: ConfigMap metadata: name: auth-server-app-log-config namespace: {{ .Release.Namespace | quote }} labels: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} data: APP_LOG_MAX_BYTES: {{ .Values.app.appLogMaxBytes | default "52428800" | quote }} APP_LOG_BACKUP_COUNT: {{ .Values.app.appLogBackupCount | default "5" | quote }} APP_LOG_CENTRALIZED_ENABLED: {{ .Values.app.appLogCentralizedEnabled | default "true" | quote }} APP_LOG_CENTRALIZED_TTL_DAYS: {{ .Values.app.appLogCentralizedTtlDays | default "1" | quote }} APP_LOG_MONGODB_BUFFER_SIZE: {{ .Values.app.appLogMongodbBufferSize | default "50" | quote }} APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS: {{ .Values.app.appLogMongodbFlushIntervalSeconds | default "5.0" | quote }} APP_LOG_LEVEL: {{ .Values.app.appLogLevel | default "INFO" | quote }} APP_LOG_EXCLUDED_LOGGERS: {{ .Values.app.appLogExcludedLoggers | default "uvicorn.access,httpx,pymongo,motor" | quote }} ================================================ FILE: charts/auth-server/templates/deployment.yaml ================================================ {{- /* Determine auth provider type - prefer global, fallback to local, default to keycloak */ -}} {{- $authProviderType := .Values.authProvider.type | default "keycloak" }} {{- if .Values.global.authProvider }} {{- $authProviderType = .Values.global.authProvider.type | default $authProviderType }} {{- end }} apiVersion: apps/v1 kind: Deployment metadata: name: {{ .Values.app.name }} namespace: {{ .Release.Namespace | quote }} labels: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} spec: replicas: {{ .Values.app.replicas }} selector: matchLabels: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} template: metadata: labels: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} spec: {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 containers: - name: {{ .Values.app.name }} image: "{{ .Values.global.image.repository }}:{{ .Values.global.image.tag }}" imagePullPolicy: {{ .Values.global.image.pullPolicy }} securityContext: allowPrivilegeEscalation: false capabilities: drop: - ALL ports: - containerPort: {{ .Values.service.port }} name: http resources: {{- toYaml .Values.resources | nindent 12 }} envFrom: - configMapRef: name: auth-server-app-log-config - secretRef: name: {{ .Values.app.existingSecret | default .Values.app.envSecretName }} {{- if eq $authProviderType "keycloak" }} - secretRef: name: keycloak-client-secret {{- end }} - secretRef: name: {{ .Values.global.existingMongoCredentialsSecret | default "mongo-credentials" }} {{- if .Values.global.sharedSecretName }} - secretRef: name: {{ .Values.global.existingSharedSecret | default .Values.global.sharedSecretName }} {{- end }} {{- if .Values.global.oauthProviderSecretName }} - secretRef: name: {{ .Values.global.existingOauthProviderSecret | default .Values.global.oauthProviderSecretName }} {{- end }} env: {{- if .Values.entra.clientSecretExistingSecret }} - name: ENTRA_CLIENT_SECRET valueFrom: secretKeyRef: name: {{ .Values.entra.clientSecretExistingSecret }} key: {{ .Values.entra.clientSecretExistingSecretKey }} {{- end }} {{- if .Values.okta.clientSecretExistingSecret }} - name: OKTA_CLIENT_SECRET valueFrom: secretKeyRef: name: {{ .Values.okta.clientSecretExistingSecret }} key: {{ .Values.okta.clientSecretExistingSecretKey }} {{- end }} {{- if .Values.okta.m2mClientSecretExistingSecret }} - name: OKTA_M2M_CLIENT_SECRET valueFrom: secretKeyRef: name: {{ .Values.okta.m2mClientSecretExistingSecret }} key: {{ .Values.okta.m2mClientSecretExistingSecretKey }} {{- end }} {{- if .Values.okta.apiTokenExistingSecret }} - name: OKTA_API_TOKEN valueFrom: secretKeyRef: name: {{ .Values.okta.apiTokenExistingSecret }} key: {{ .Values.okta.apiTokenExistingSecretKey }} {{- end }} {{- if .Values.auth0.clientSecretExistingSecret }} - name: AUTH0_CLIENT_SECRET valueFrom: secretKeyRef: name: {{ .Values.auth0.clientSecretExistingSecret }} key: {{ .Values.auth0.clientSecretExistingSecretKey }} {{- end }} {{- if .Values.auth0.m2mClientSecretExistingSecret }} - name: AUTH0_M2M_CLIENT_SECRET valueFrom: secretKeyRef: name: {{ .Values.auth0.m2mClientSecretExistingSecret }} key: {{ .Values.auth0.m2mClientSecretExistingSecretKey }} {{- end }} {{- if .Values.auth0.managementApiTokenExistingSecret }} - name: AUTH0_MANAGEMENT_API_TOKEN valueFrom: secretKeyRef: name: {{ .Values.auth0.managementApiTokenExistingSecret }} key: {{ .Values.auth0.managementApiTokenExistingSecretKey }} {{- end }} livenessProbe: httpGet: path: /health port: 8888 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /health port: 8888 initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 ================================================ FILE: charts/auth-server/templates/ingress.yaml ================================================ {{- if .Values.ingress.enabled }} {{- $routingMode := .Values.global.ingress.routingMode | default "subdomain" }} {{- $domain := .Values.global.domain | default .Values.ingress.hostname }} {{- $pathPrefix := .Values.global.ingress.paths.authServer | default .Values.ingress.path | default "/auth-server" }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ .Values.app.name }} namespace: {{ .Release.Namespace | quote }} annotations: {{- if eq $routingMode "path" }} alb.ingress.kubernetes.io/group.name: mcp-gateway-stack alb.ingress.kubernetes.io/group.order: '10' {{- end }} alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS": 443}]' alb.ingress.kubernetes.io/scheme: internet-facing alb.ingress.kubernetes.io/ssl-redirect: '443' alb.ingress.kubernetes.io/target-type: ip alb.ingress.kubernetes.io/success-codes: 200,302 alb.ingress.kubernetes.io/healthcheck-path: /health {{- if .Values.global.ingress.inboundCidrs }} alb.ingress.kubernetes.io/inbound-cidrs: {{ .Values.global.ingress.inboundCidrs }} {{- end }} spec: ingressClassName: {{ .Values.ingress.className }} rules: {{- if eq $routingMode "path" }} - host: {{ $domain | quote }} http: paths: - path: {{ $pathPrefix }} pathType: Prefix backend: service: name: {{ .Values.app.name }} port: name: http {{- else }} - host: {{ printf "auth-server.%s" $domain | quote }} http: paths: - path: / pathType: Prefix backend: service: name: {{ .Values.app.name }} port: name: http {{- end }} {{- end }} ================================================ FILE: charts/auth-server/templates/secret.yaml ================================================ {{- if and .Values.entra.clientSecret .Values.entra.clientSecretExistingSecret }} {{- fail "Cannot set both entra.clientSecret and entra.clientSecretExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.okta.clientSecret .Values.okta.clientSecretExistingSecret }} {{- fail "Cannot set both okta.clientSecret and okta.clientSecretExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.okta.m2mClientSecret .Values.okta.m2mClientSecretExistingSecret }} {{- fail "Cannot set both okta.m2mClientSecret and okta.m2mClientSecretExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.okta.apiToken .Values.okta.apiTokenExistingSecret }} {{- fail "Cannot set both okta.apiToken and okta.apiTokenExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.auth0.clientSecret .Values.auth0.clientSecretExistingSecret }} {{- fail "Cannot set both auth0.clientSecret and auth0.clientSecretExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.auth0.m2mClientSecret .Values.auth0.m2mClientSecretExistingSecret }} {{- fail "Cannot set both auth0.m2mClientSecret and auth0.m2mClientSecretExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.auth0.managementApiToken .Values.auth0.managementApiTokenExistingSecret }} {{- fail "Cannot set both auth0.managementApiToken and auth0.managementApiTokenExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if not .Values.app.existingSecret }} {{- $routingMode := .Values.global.ingress.routingMode | default "subdomain" }} {{- $domain := .Values.global.domain | default "localhost" }} {{- $protocol := ternary "https" "http" .Values.global.ingress.tls }} {{- $authServerPath := .Values.global.ingress.paths.authServer | default "/auth-server" }} {{- $registryPath := .Values.global.ingress.paths.registry | default "/registry" }} {{- $keycloakPath := .Values.global.ingress.paths.keycloak | default "/keycloak" }} {{- $authServerExternalUrl := "" }} {{- $keycloakExternalUrl := "" }} {{- $keycloakUrl := printf "http://%s-keycloak-headless.%s.svc.cluster.local:8080" .Release.Name .Release.Namespace}} {{- $rootPath := "" }} {{- $registryRootPath := "" }} {{- /* Determine auth provider type - prefer global, fallback to local, default to keycloak */ -}} {{- $authProviderType := .Values.authProvider.type | default "keycloak" }} {{- if .Values.global.authProvider }} {{- $authProviderType = .Values.global.authProvider.type | default $authProviderType }} {{- end }} {{- if eq $routingMode "path" }} {{- $authServerExternalUrl = printf "%s://%s%s" $protocol $domain $authServerPath }} {{- $keycloakExternalUrl = printf "%s://%s%s" $protocol $domain $keycloakPath }} {{- $keycloakUrl = printf "%s%s" $keycloakUrl $keycloakPath }} {{- $rootPath = $authServerPath }} {{- $registryRootPath = $registryPath }} {{- else }} {{- $authServerExternalUrl = printf "%s://auth-server.%s" $protocol $domain }} {{- $keycloakExternalUrl = printf "%s://keycloak.%s" $protocol $domain }} {{- end }} {{- /* Auto-generate federation tokens if not provided */ -}} {{- /* Resolve federation values - prefer global, fallback to local app values */ -}} {{- $federationEnabled := .Values.app.federationStaticTokenAuthEnabled | default false }} {{- if .Values.global.federation }} {{- $federationEnabled = .Values.global.federation.staticTokenAuthEnabled | default $federationEnabled }} {{- end }} {{- $federationStaticTokenRaw := .Values.app.federationStaticToken }} {{- $federationEncryptionKeyRaw := .Values.app.federationEncryptionKey }} {{- $registryId := .Values.app.registryId }} {{- if .Values.global.federation }} {{- $federationStaticTokenRaw = .Values.global.federation.staticToken | default $federationStaticTokenRaw }} {{- $federationEncryptionKeyRaw = .Values.global.federation.encryptionKey | default $federationEncryptionKeyRaw }} {{- $registryId = .Values.global.federation.registryId | default $registryId }} {{- end }} {{- /* Generate URL-safe token (equivalent to secrets.token_urlsafe(32)) */ -}} {{- $federationStaticToken := $federationStaticTokenRaw | default (randBytes 32 | replace "+" "-" | replace "/" "_" | trimSuffix "=") }} {{- /* Generate Fernet-compatible key (32 random bytes, base64-encoded) */ -}} {{- $federationEncryptionKey := $federationEncryptionKeyRaw | default (randBytes 32) }} apiVersion: v1 kind: Secret metadata: name: {{ .Values.app.envSecretName }} namespace: {{ .Release.Namespace | quote }} data: AUTH_SERVER_EXTERNAL_URL: {{ $authServerExternalUrl | b64enc | quote }} REGISTRY_URL: {{ printf "http://registry.%s.svc.cluster.local:8000" .Release.Namespace | b64enc | quote }} {{- if not .Values.global.oauthProviderSecretName }} {{/* OAuth provider vars managed per-chart in standalone deployment */}} AUTH_PROVIDER: {{ $authProviderType | b64enc | quote }} {{- if eq $authProviderType "keycloak" }} KEYCLOAK_ENABLED: {{ .Values.keycloak.enabled | toString | b64enc | quote }} KEYCLOAK_EXTERNAL_URL: {{ $keycloakExternalUrl | b64enc | quote }} KEYCLOAK_REALM: {{ .Values.keycloak.realm | b64enc | quote }} KEYCLOAK_URL: {{ $keycloakUrl | b64enc | quote }} {{- if .Values.keycloak.m2mClientId }} KEYCLOAK_M2M_CLIENT_ID: {{ .Values.keycloak.m2mClientId | b64enc | quote }} {{- end }} {{- if .Values.keycloak.m2mClientSecret }} KEYCLOAK_M2M_CLIENT_SECRET: {{ .Values.keycloak.m2mClientSecret | b64enc | quote }} {{- end }} {{- else if eq $authProviderType "entra" }} ENTRA_ENABLED: {{ "true" | b64enc | quote }} ENTRA_CLIENT_ID: {{ .Values.entra.clientId | b64enc | quote }} {{- if not .Values.entra.clientSecretExistingSecret }} ENTRA_CLIENT_SECRET: {{ .Values.entra.clientSecret | b64enc | quote }} {{- end }} ENTRA_TENANT_ID: {{ .Values.entra.tenantId | b64enc | quote }} {{- if .Values.entra.loginBaseUrl }} ENTRA_LOGIN_BASE_URL: {{ .Values.entra.loginBaseUrl | b64enc | quote }} {{- end }} {{- else if eq $authProviderType "okta" }} OKTA_ENABLED: {{ "true" | b64enc | quote }} OKTA_DOMAIN: {{ .Values.okta.domain | b64enc | quote }} OKTA_CLIENT_ID: {{ .Values.okta.clientId | b64enc | quote }} {{- if not .Values.okta.clientSecretExistingSecret }} OKTA_CLIENT_SECRET: {{ .Values.okta.clientSecret | b64enc | quote }} {{- end }} {{- if .Values.okta.m2mClientId }} OKTA_M2M_CLIENT_ID: {{ .Values.okta.m2mClientId | b64enc | quote }} {{- end }} {{- if and .Values.okta.m2mClientSecret (not .Values.okta.m2mClientSecretExistingSecret) }} OKTA_M2M_CLIENT_SECRET: {{ .Values.okta.m2mClientSecret | b64enc | quote }} {{- end }} {{- if and .Values.okta.apiToken (not .Values.okta.apiTokenExistingSecret) }} OKTA_API_TOKEN: {{ .Values.okta.apiToken | b64enc | quote }} {{- end }} {{- if .Values.okta.authServerId }} OKTA_AUTH_SERVER_ID: {{ .Values.okta.authServerId | b64enc | quote }} {{- end }} {{- else if eq $authProviderType "auth0" }} AUTH0_ENABLED: {{ "true" | b64enc | quote }} AUTH0_DOMAIN: {{ .Values.auth0.domain | b64enc | quote }} AUTH0_CLIENT_ID: {{ .Values.auth0.clientId | b64enc | quote }} {{- if not .Values.auth0.clientSecretExistingSecret }} AUTH0_CLIENT_SECRET: {{ .Values.auth0.clientSecret | b64enc | quote }} {{- end }} {{- if .Values.auth0.audience }} AUTH0_AUDIENCE: {{ .Values.auth0.audience | b64enc | quote }} {{- end }} AUTH0_GROUPS_CLAIM: {{ .Values.auth0.groupsClaim | b64enc | quote }} {{- if .Values.auth0.m2mClientId }} AUTH0_M2M_CLIENT_ID: {{ .Values.auth0.m2mClientId | b64enc | quote }} {{- end }} {{- if and .Values.auth0.m2mClientSecret (not .Values.auth0.m2mClientSecretExistingSecret) }} AUTH0_M2M_CLIENT_SECRET: {{ .Values.auth0.m2mClientSecret | b64enc | quote }} {{- end }} {{- if and .Values.auth0.managementApiToken (not .Values.auth0.managementApiTokenExistingSecret) }} AUTH0_MANAGEMENT_API_TOKEN: {{ .Values.auth0.managementApiToken | b64enc | quote }} {{- end }} {{- else if eq $authProviderType "cognito" }} COGNITO_ENABLED: {{ "true" | b64enc | quote }} COGNITO_USER_POOL_ID: {{ required "cognito.userPoolId is required when authProvider.type is cognito" .Values.cognito.userPoolId | b64enc | quote }} COGNITO_CLIENT_ID: {{ required "cognito.clientId is required when authProvider.type is cognito" .Values.cognito.clientId | b64enc | quote }} COGNITO_CLIENT_SECRET: {{ required "cognito.clientSecret is required when authProvider.type is cognito" .Values.cognito.clientSecret | b64enc | quote }} {{- if .Values.cognito.domain }} COGNITO_DOMAIN: {{ .Values.cognito.domain | b64enc | quote }} {{- end }} {{- if .Values.cognito.region }} AWS_REGION: {{ .Values.cognito.region | b64enc | quote }} {{- end }} {{- end }} {{- end }} ROOT_PATH: {{ $rootPath | b64enc | quote }} REGISTRY_ROOT_PATH: {{ $registryRootPath | b64enc | quote }} JWT_ISSUER: {{ (.Values.app.jwtIssuer | default "mcp-auth-server") | b64enc | quote }} JWT_AUDIENCE: {{ (.Values.app.jwtAudience | default "mcp-registry") | b64enc | quote }} {{- if .Values.app.registryStaticTokenAuthEnabled }} REGISTRY_STATIC_TOKEN_AUTH_ENABLED: {{ "true" | b64enc | quote }} REGISTRY_API_TOKEN: {{ required "app.registryApiToken is required when registryStaticTokenAuthEnabled is true" .Values.app.registryApiToken | b64enc | quote }} {{- end }} MAX_TOKENS_PER_USER_PER_HOUR: {{ (.Values.app.maxTokensPerUserPerHour | default "100") | toString | b64enc | quote }} OAUTH_STORE_TOKENS_IN_SESSION: {{ .Values.app.oauthStoreTokensInSession | toString | b64enc | quote }} SESSION_COOKIE_SECURE: {{ .Values.app.sessionCookieSecure | toString | b64enc | quote }} SESSION_COOKIE_DOMAIN: {{ printf ".%s" $domain | b64enc | quote }} {{- if not .Values.global.sharedSecretName }} {{/* Federation and SECRET_KEY managed per-chart in standalone deployment */}} {{- if $federationEnabled }} FEDERATION_STATIC_TOKEN_AUTH_ENABLED: {{ $federationEnabled | toString | b64enc | quote }} FEDERATION_STATIC_TOKEN: {{ $federationStaticToken | b64enc | quote }} FEDERATION_ENCRYPTION_KEY: {{ $federationEncryptionKey | b64enc | quote }} {{- end }} {{- if $registryId }} REGISTRY_ID: {{ $registryId | b64enc | quote }} {{- end }} {{/* SECRET_KEY required for standalone deployment - must match registry's key */}} SECRET_KEY: {{ required "app.secretKey or global.secretKey is required for standalone deployment" (.Values.global.secretKey | default .Values.app.secretKey) | b64enc | quote }} {{- end }} {{- end }} ================================================ FILE: charts/auth-server/templates/service.yaml ================================================ apiVersion: v1 kind: Service metadata: name: {{ .Values.app.name }} namespace: {{ .Release.Namespace | quote }} {{- with .Values.service.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: type: {{ .Values.service.type }} ports: - port: {{ .Values.service.port }} targetPort: http protocol: TCP name: http selector: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} ================================================ FILE: charts/auth-server/values.yaml ================================================ # Global configuration global: image: repository: public.ecr.aws/p3v1o3c6/auth-server tag: 1.0.21 pullPolicy: IfNotPresent # Application configuration app: name: auth-server replicas: 1 envSecretName: auth-server-secret existingSecret: "" # If set, use this existing secret instead of creating one # External URLs externalUrl: http://localhost:8888 # Security settings # secretKey: If not provided, a random 64-character key is auto-generated. # When deployed via mcp-gateway-registry-stack, the key is shared with registry. # Uncomment to use a specific key: # secretKey: "your-secure-key-here" sessionCookieDomain: "" # Auto-inferred from Host header if empty sessionCookieSecure: true oauthStoreTokensInSession: false # Store OAuth tokens in session (default: false) # Internal JWT configuration for service-to-service tokens jwtIssuer: "mcp-auth-server" # Issuer claim for internal JWT tokens jwtAudience: "mcp-registry" # Audience claim for internal JWT tokens # Static token authentication (alternative to IdP JWT for Registry API) registryStaticTokenAuthEnabled: false # Enable static API key auth for Registry API registryApiToken: "" # Static API key value (required when registryStaticTokenAuthEnabled is true) # Rate limiting maxTokensPerUserPerHour: "100" # Max token generations per user per hour # Federation configuration federationStaticTokenAuthEnabled: false #If not provided, defaults to false federationStaticToken: # If not provided, a random token is auto-generated federationEncryptionKey: # If not provided, a Fernet key is auto-generated registryId: # Unique identifier for this registry instance (optional) # Application Log Configuration (centralized log rotation and retrieval) appLogMaxBytes: "52428800" # Max size per log file before rotation (default 50 MB) appLogBackupCount: "5" # Number of rotated backup log files to keep appLogCentralizedEnabled: "true" # Write application logs to centralized store (requires MongoDB backend) appLogCentralizedTtlDays: "1" # Days to retain log entries in centralized store (TTL index) appLogMongodbBufferSize: "50" # Records to buffer before flushing to MongoDB appLogMongodbFlushIntervalSeconds: "5.0" # Seconds between periodic flushes appLogLevel: "INFO" # Application log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) appLogExcludedLoggers: "uvicorn.access,httpx,pymongo,motor" # Comma-separated logger names to exclude from MongoDB # Authentication Provider Configuration # Choose ONE provider: keycloak, entra, okta, auth0, or cognito authProvider: # Provider type: "keycloak", "entra", "okta", "auth0", or "cognito" type: keycloak # Keycloak integration (used when authProvider.type = "keycloak") keycloak: enabled: true externalUrl: http://localhost:8080 realm: mcp-gateway m2mClientId: "" # Optional: M2M client ID for machine-to-machine authentication m2mClientSecret: "" # Optional: M2M client secret # Entra ID integration (used when authProvider.type = "entra") entra: clientId: "" clientSecret: "" clientSecretExistingSecret: "" # If set, read ENTRA_CLIENT_SECRET from this K8s secret instead of clientSecret clientSecretExistingSecretKey: "ENTRA_CLIENT_SECRET" # Key within the existing secret tenantId: "" loginBaseUrl: "" # Custom Entra login base URL for sovereign/national clouds (default: https://login.microsoftonline.com) # Okta integration (used when authProvider.type = "okta") okta: domain: "" # e.g., dev-123456.okta.com clientId: "" clientSecret: "" clientSecretExistingSecret: "" # If set, read OKTA_CLIENT_SECRET from this K8s secret clientSecretExistingSecretKey: "OKTA_CLIENT_SECRET" m2mClientId: "" # Optional: defaults to clientId m2mClientSecret: "" # Optional: defaults to clientSecret m2mClientSecretExistingSecret: "" # If set, read OKTA_M2M_CLIENT_SECRET from this K8s secret m2mClientSecretExistingSecretKey: "OKTA_M2M_CLIENT_SECRET" apiToken: "" # Optional: required for IAM operations apiTokenExistingSecret: "" # If set, read OKTA_API_TOKEN from this K8s secret apiTokenExistingSecretKey: "OKTA_API_TOKEN" authServerId: "" # Optional: uses default Org Authorization Server if not set # Auth0 integration (used when authProvider.type = "auth0") auth0: domain: "" # e.g., your-tenant.us.auth0.com clientId: "" clientSecret: "" clientSecretExistingSecret: "" # If set, read AUTH0_CLIENT_SECRET from this K8s secret clientSecretExistingSecretKey: "AUTH0_CLIENT_SECRET" audience: "" # Optional: API audience for M2M tokens groupsClaim: "https://mcp-gateway/groups" # Custom namespaced claim for groups m2mClientId: "" # Required for IAM Management (user/role administration) m2mClientSecret: "" # Required for IAM Management m2mClientSecretExistingSecret: "" # If set, read AUTH0_M2M_CLIENT_SECRET from this K8s secret m2mClientSecretExistingSecretKey: "AUTH0_M2M_CLIENT_SECRET" managementApiToken: "" # Optional: alternative to M2M credentials (expires after 24h) managementApiTokenExistingSecret: "" # If set, read AUTH0_MANAGEMENT_API_TOKEN from this K8s secret managementApiTokenExistingSecretKey: "AUTH0_MANAGEMENT_API_TOKEN" # Cognito integration (used when authProvider.type = "cognito") cognito: userPoolId: "" # Cognito User Pool ID clientId: "" clientSecret: "" domain: "" # Optional: custom Cognito domain region: "us-east-1" # AWS region for the User Pool # Service configuration service: type: ClusterIP port: 8888 annotations: { } # Resource limits and requests resources: requests: cpu: 1 memory: 1Gi limits: cpu: 2 memory: 2Gi # Ingress configuration ingress: enabled: false className: alb hostname: "" annotations: { } tls: false # Routing mode: "subdomain" or "path" # - subdomain: auth-server.domain.com # - path: domain.com/auth-server (configurable via path setting) routingMode: subdomain # Path prefix when using path-based routing (default: /auth-server) path: /auth-server nodeSelector: {} ================================================ FILE: charts/keycloak-configure/Chart.yaml ================================================ apiVersion: v2 name: keycloak-configure description: A Helm chart for configuring Keycloak type: application version: 0.1.0 appVersion: "1.0.0" ================================================ FILE: charts/keycloak-configure/templates/configmap.yaml ================================================ apiVersion: v1 kind: ConfigMap metadata: name: setup-keycloak namespace: {{ .Release.Namespace | quote }} data: script.sh: | #!/bin/bash apt update apt install -y curl jq kubectl # Initialize Keycloak with MCP Gateway configuration # This script sets up the initial realm, clients, groups, and users set -e # These will be set properly after loading .env in main() KEYCLOAK_URL="" # Will be overridden with KEYCLOAK_ADMIN_URL after .env is loaded REALM="mcp-gateway" KEYCLOAK_ADMIN=$KEYCLOAK_ADMIN KEYCLOAK_ADMIN_PASSWORD=$KEYCLOAK_ADMIN_PASSWORD # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color echo -e "${YELLOW}Keycloak initialization script for MCP Gateway Registry${NC}" echo "==============================================" # Function to wait for Keycloak to be ready wait_for_keycloak() { echo -n "Waiting for Keycloak to be ready..." local max_attempts=60 local attempt=0 while [ $attempt -lt $max_attempts ]; do # Try to access the admin console which indicates Keycloak is ready if curl -f -s "${KEYCLOAK_URL}/admin/" > /dev/null 2>&1; then echo -e " ${GREEN}Ready!${NC}" return 0 fi echo -n "." sleep 5 attempt=$((attempt + 1)) done echo -e " ${RED}Timeout!${NC}" echo "Keycloak did not become ready within 5 minutes" exit 1 } # Function to get admin token get_admin_token() { local response=$(curl -s -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=${KEYCLOAK_ADMIN}" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli") echo "$response" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4 } # Function to check if realm exists realm_exists() { local token=$1 local response=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}") [ "$response" = "200" ] } # Function to create realm step by step create_realm() { local token=$1 echo "Creating MCP Gateway realm..." # Check if realm already exists if realm_exists "$token"; then echo -e "${YELLOW}Realm already exists. Skipping creation...${NC}" return 0 fi # Create basic realm local realm_json='{ "realm": "mcp-gateway", "enabled": true, "registrationAllowed": false, "loginWithEmailAllowed": true, "duplicateEmailsAllowed": false, "resetPasswordAllowed": true, "editUsernameAllowed": false, "sslRequired": "none" }' local response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$realm_json") if [ "$response" = "201" ]; then echo -e "${GREEN}Realm created successfully!${NC}" return 0 elif [ "$response" = "409" ]; then echo -e "${YELLOW}Realm already exists. Continuing...${NC}" return 0 else echo -e "${RED}Failed to create realm. HTTP status: ${response}${NC}" echo "Response body:" curl -s -X POST "${KEYCLOAK_URL}/admin/realms" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$realm_json" echo "" return 1 fi } # Function to create clients create_clients() { local token=$1 echo "Creating OAuth2 clients..." # Create web client local web_client_json='{ "clientId": "mcp-gateway-web", "name": "MCP Gateway Web Client", "enabled": true, "clientAuthenticatorType": "client-secret", "redirectUris": [ "'${AUTH_SERVER_EXTERNAL_URL:-http://localhost:8888}'/oauth2/callback/keycloak", "'${REGISTRY_URL:-http://localhost:7860}'/*", "http://localhost:7860/*", "http://localhost:8888/*" ], "webOrigins": [ "'${REGISTRY_URL:-http://localhost:7860}'", "http://localhost:7860", "+" ], "protocol": "openid-connect", "standardFlowEnabled": true, "implicitFlowEnabled": false, "directAccessGrantsEnabled": true, "serviceAccountsEnabled": false, "publicClient": false }' curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$web_client_json" > /dev/null # Create M2M client local m2m_client_json='{ "clientId": "mcp-gateway-m2m", "name": "MCP Gateway M2M Client", "enabled": true, "clientAuthenticatorType": "client-secret", "protocol": "openid-connect", "standardFlowEnabled": false, "implicitFlowEnabled": false, "directAccessGrantsEnabled": false, "serviceAccountsEnabled": true, "publicClient": false }' curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$m2m_client_json" > /dev/null echo -e "${GREEN}Clients created successfully!${NC}" } # Function to create groups create_groups() { local token=$1 echo "Creating user groups..." local groups=("mcp-registry-admin" "mcp-registry-user" "mcp-registry-developer" "mcp-registry-operator" "mcp-servers-unrestricted" "mcp-servers-restricted" "a2a-agent-admin" "a2a-agent-publisher" "a2a-agent-user") for group in "${groups[@]}"; do local group_json='{ "name": "'$group'", "attributes": { "description": ["'$group' group for MCP Gateway access"] } }' curl -s -X POST "${KEYCLOAK_URL}/admin/realms/mcp-gateway/groups" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$group_json" > /dev/null done echo -e "${GREEN}Groups created successfully!${NC}" } # Function to create custom scopes create_scopes() { local token=$1 echo "Creating custom MCP scopes..." local scopes=("mcp-servers-unrestricted/read" "mcp-servers-unrestricted/execute" "mcp-servers-restricted/read" "mcp-servers-restricted/execute") for scope in "${scopes[@]}"; do local scope_json='{ "name": "'$scope'", "description": "MCP Gateway scope for '$scope' access", "protocol": "openid-connect" }' local response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/client-scopes" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$scope_json") if [ "$response" = "201" ]; then echo " - Created scope: $scope" elif [ "$response" = "409" ]; then echo " - Scope already exists: $scope" else echo -e "${RED} - Failed to create scope: $scope (HTTP $response)${NC}" fi done echo -e "${GREEN}Custom scopes created successfully!${NC}" } # Function to assign scopes to M2M client setup_m2m_scopes() { local token=$1 echo "Setting up M2M client scopes..." # Get M2M client ID local m2m_client_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=mcp-gateway-m2m" | \ jq -r '.[0].id') if [ -z "$m2m_client_id" ] || [ "$m2m_client_id" = "null" ]; then echo -e "${RED}Error: Could not find mcp-gateway-m2m client${NC}" return 1 fi # Get all available client scopes local scopes=("mcp-servers-unrestricted/read" "mcp-servers-unrestricted/execute" "mcp-servers-restricted/read" "mcp-servers-restricted/execute") for scope in "${scopes[@]}"; do # Get scope ID local scope_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/client-scopes" | \ jq -r '.[] | select(.name=="'$scope'") | .id') if [ ! -z "$scope_id" ] && [ "$scope_id" != "null" ]; then # Add scope as default client scope local response=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${m2m_client_id}/default-client-scopes/${scope_id}" \ -H "Authorization: Bearer ${token}") if [ "$response" = "204" ]; then echo " - Assigned scope: $scope" else echo -e "${YELLOW} - Warning: Could not assign scope $scope (HTTP $response)${NC}" fi else echo -e "${RED} - Error: Could not find scope: $scope${NC}" fi done echo -e "${GREEN}M2M client scopes configured successfully!${NC}" } # Function to create service account user for M2M client create_service_account_user() { local token=$1 local service_account_username="service-account-mcp-gateway-m2m" echo "Creating service account user: $service_account_username" # Check if user already exists local existing_user=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/users?username=$service_account_username" | \ jq -r '.[0].id // empty') if [ ! -z "$existing_user" ]; then echo -e "${YELLOW}Service account user already exists with ID: $existing_user${NC}" return 0 fi # Create service account user local user_json='{ "username": "'$service_account_username'", "enabled": true, "emailVerified": true, "serviceAccountClientId": "mcp-gateway-m2m" }' local response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/users" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$user_json") if [ "$response" = "201" ]; then echo -e "${GREEN}Service account user created successfully!${NC}" # Get the newly created user ID local user_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/users?username=$service_account_username" | \ jq -r '.[0].id') echo "Created service account user with ID: $user_id" # Assign user to mcp-servers-unrestricted group local group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" | \ jq -r '.[] | select(.name=="mcp-servers-unrestricted") | .id') if [ ! -z "$group_id" ] && [ "$group_id" != "null" ]; then local group_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$user_id/groups/$group_id" \ -H "Authorization: Bearer ${token}") if [ "$group_response" = "204" ]; then echo -e "${GREEN}Service account assigned to mcp-servers-unrestricted group!${NC}" else echo -e "${YELLOW}Warning: Could not assign service account to mcp-servers-unrestricted group (HTTP $group_response)${NC}" fi else echo -e "${RED}Error: Could not find mcp-servers-unrestricted group${NC}" fi # Assign user to a2a-agent-admin group for A2A agent access local a2a_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" | \ jq -r '.[] | select(.name=="a2a-agent-admin") | .id') if [ ! -z "$a2a_group_id" ] && [ "$a2a_group_id" != "null" ]; then local a2a_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$user_id/groups/$a2a_group_id" \ -H "Authorization: Bearer ${token}") if [ "$a2a_response" = "204" ]; then echo -e "${GREEN}Service account assigned to a2a-agent-admin group!${NC}" else echo -e "${YELLOW}Warning: Could not assign service account to a2a-agent-admin group (HTTP $a2a_response)${NC}" fi else echo -e "${YELLOW}Warning: a2a-agent-admin group not found. Create it manually if A2A agent support is needed.${NC}" fi return 0 elif [ "$response" = "409" ]; then echo -e "${YELLOW}Service account user already exists. Continuing...${NC}" return 0 else echo -e "${RED}Failed to create service account user. HTTP status: ${response}${NC}" return 1 fi } # Function to create test users create_users() { local token=$1 echo "Creating test users..." # Define usernames for consistency local admin_username="admin" local test_username="testuser" # Create admin user local admin_user_json='{ "username": "'$admin_username'", "email": "'$admin_username'@example.com", "enabled": true, "emailVerified": true, "firstName": "Admin", "lastName": "User", "credentials": [ { "type": "password", "value": "'${INITIAL_ADMIN_PASSWORD}'", "temporary": false } ] }' curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/users" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$admin_user_json" > /dev/null # Create test user local test_user_json='{ "username": "'$test_username'", "email": "'$test_username'@example.com", "enabled": true, "emailVerified": true, "firstName": "Test", "lastName": "User", "credentials": [ { "type": "password", "value": "'${INITIAL_USER_PASSWORD}'", "temporary": false } ] }' curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/users" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$test_user_json" > /dev/null echo "Assigning users to groups..." # Get user IDs local admin_user_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/users?username=$admin_username" | \ jq -r '.[0].id') local test_user_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/users?username=$test_username" | \ jq -r '.[0].id') # Get all group IDs local admin_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" | \ jq -r '.[] | select(.name=="mcp-registry-admin") | .id') local user_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" | \ jq -r '.[] | select(.name=="mcp-registry-user") | .id') local developer_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" | \ jq -r '.[] | select(.name=="mcp-registry-developer") | .id') local operator_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" | \ jq -r '.[] | select(.name=="mcp-registry-operator") | .id') local unrestricted_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" | \ jq -r '.[] | select(.name=="mcp-servers-unrestricted") | .id') local restricted_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" | \ jq -r '.[] | select(.name=="mcp-servers-restricted") | .id') # Define usernames for consistent logging local admin_username="admin" local test_username="testuser" # Assign admin user to admin group and unrestricted servers group if [ ! -z "$admin_user_id" ] && [ ! -z "$admin_group_id" ]; then curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$admin_user_id/groups/$admin_group_id" \ -H "Authorization: Bearer ${token}" > /dev/null echo " - $admin_username assigned to mcp-registry-admin group" fi # Also assign admin to unrestricted servers group for full access if [ ! -z "$admin_user_id" ] && [ ! -z "$unrestricted_group_id" ]; then curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$admin_user_id/groups/$unrestricted_group_id" \ -H "Authorization: Bearer ${token}" > /dev/null echo " - $admin_username assigned to mcp-servers-unrestricted group" fi # Assign test user to all groups except admin if [ ! -z "$test_user_id" ]; then # Arrays of group IDs and names for loop processing local group_ids=("$user_group_id" "$developer_group_id" "$operator_group_id" "$unrestricted_group_id" "$restricted_group_id") local group_names=("mcp-registry-user" "mcp-registry-developer" "mcp-registry-operator" "mcp-servers-unrestricted" "mcp-servers-restricted") # Loop through groups and assign test user to each for i in "${!group_ids[@]}"; do local group_id="${group_ids[$i]}" local group_name="${group_names[$i]}" if [ ! -z "$group_id" ]; then curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$test_user_id/groups/$group_id" \ -H "Authorization: Bearer ${token}" > /dev/null echo " - $test_username assigned to $group_name group" fi done fi echo -e "${GREEN}Users created and assigned to groups successfully!${NC}" } # Function to create client secrets setup_client_secrets() { local token=$1 echo "Setting up client secrets..." # Get web client ID local web_client_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=mcp-gateway-web" | \ jq -r '.[0].id') # Generate secret for web client curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${web_client_id}/client-secret" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" > /dev/null local web_secret_response=$(curl -s "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${web_client_id}/client-secret" \ -H "Authorization: Bearer ${token}") web_secret=$(echo "$web_secret_response" | jq -r '.value // empty') # Get M2M client ID local m2m_client_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=mcp-gateway-m2m" | \ jq -r '.[0].id') # Generate secret for M2M client curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${m2m_client_id}/client-secret" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" > /dev/null local m2m_secret_response=$(curl -s "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${m2m_client_id}/client-secret" \ -H "Authorization: Bearer ${token}") m2m_secret=$(echo "$m2m_secret_response" | jq -r '.value // empty') echo -e "${GREEN}Client secrets generated!${NC}" echo "" echo "==============================================" echo -e "${YELLOW}Client credentials have been created.${NC}" echo "==============================================" echo "" echo -e "${GREEN}To retrieve all client credentials, run:${NC}" echo " ./keycloak/setup/get-all-client-credentials.sh" echo "" echo "This will save all credentials to .oauth-tokens/" echo "==============================================" kubectl create secret generic keycloak-client-secret --from-literal=KEYCLOAK_CLIENT_ID=mcp-gateway-web --from-literal=KEYCLOAK_CLIENT_SECRET=$web_secret --from-literal=KEYCLOAK_M2M_CLIENT_ID=mcp-gateway-m2m --from-literal=KEYCLOAK_M2M_CLIENT_SECRET=$m2m_secret } # Function to setup groups mapper for OAuth2 clients setup_groups_mapper() { local token=$1 echo "Setting up groups mapper for OAuth2 clients..." # Create groups mapper JSON local groups_mapper_json='{ "name": "groups", "protocol": "openid-connect", "protocolMapper": "oidc-group-membership-mapper", "consentRequired": false, "config": { "full.path": "false", "id.token.claim": "true", "access.token.claim": "true", "claim.name": "groups", "userinfo.token.claim": "true" } }' # Setup groups mapper for mcp-gateway-web client echo "Setting up groups mapper for mcp-gateway-web client..." local web_client_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=mcp-gateway-web" | \ jq -r '.[0].id') if [ -z "$web_client_id" ] || [ "$web_client_id" = "null" ]; then echo -e "${RED}Error: Could not find mcp-gateway-web client${NC}" return 1 fi local response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${web_client_id}/protocol-mappers/models" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$groups_mapper_json") if [ "$response" = "201" ]; then echo -e "${GREEN}Groups mapper created for mcp-gateway-web!${NC}" elif [ "$response" = "409" ]; then echo -e "${YELLOW}Groups mapper already exists for mcp-gateway-web. Continuing...${NC}" else echo -e "${RED}Failed to create groups mapper for mcp-gateway-web. HTTP status: ${response}${NC}" return 1 fi # Setup groups mapper for mcp-gateway-m2m client echo "Setting up groups mapper for mcp-gateway-m2m client..." local m2m_client_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=mcp-gateway-m2m" | \ jq -r '.[0].id') if [ -z "$m2m_client_id" ] || [ "$m2m_client_id" = "null" ]; then echo -e "${RED}Error: Could not find mcp-gateway-m2m client${NC}" return 1 fi local m2m_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${m2m_client_id}/protocol-mappers/models" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$groups_mapper_json") if [ "$m2m_response" = "201" ]; then echo -e "${GREEN}Groups mapper created for mcp-gateway-m2m!${NC}" elif [ "$m2m_response" = "409" ]; then echo -e "${YELLOW}Groups mapper already exists for mcp-gateway-m2m. Continuing...${NC}" else echo -e "${RED}Failed to create groups mapper for mcp-gateway-m2m. HTTP status: ${m2m_response}${NC}" return 1 fi } # Function to generate random password generate_password() { # Generate a 16-character random password with alphanumeric characters openssl rand -base64 12 | tr -d "=+/" | cut -c1-16 } # Main execution main() { # Get script directory and find .env file SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PROJECT_ROOT="$( cd "$SCRIPT_DIR/../.." && pwd )" ENV_FILE="$PROJECT_ROOT/.env" # Load environment variables from .env file if it exists if [ -f "$ENV_FILE" ]; then echo "Loading environment variables from $ENV_FILE..." set -a # Automatically export all variables source "$ENV_FILE" set +a # Turn off automatic export echo "Environment variables loaded successfully" else echo "No .env file found at $ENV_FILE" echo "Current directory: $(pwd)" echo "Script directory: $SCRIPT_DIR" echo "Project root: $PROJECT_ROOT" fi # Generate random passwords if not provided if [ -z "$INITIAL_ADMIN_PASSWORD" ]; then INITIAL_ADMIN_PASSWORD=$(generate_password) echo -e "${YELLOW}Generated random admin password${NC}" fi if [ -z "$INITIAL_USER_PASSWORD" ]; then INITIAL_USER_PASSWORD=$(generate_password) echo -e "${YELLOW}Generated random user password${NC}" fi # Store passwords in variables for later use export INITIAL_ADMIN_PASSWORD export INITIAL_USER_PASSWORD # Override KEYCLOAK_URL with KEYCLOAK_ADMIN_URL for API calls KEYCLOAK_URL="${KEYCLOAK_ADMIN_URL:-http://localhost:8080}" KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}" echo "Using Keycloak API URL: $KEYCLOAK_URL" # Check if admin password is set if [ -z "$KEYCLOAK_ADMIN_PASSWORD" ]; then echo -e "${RED}Error: KEYCLOAK_ADMIN_PASSWORD environment variable is not set${NC}" echo "Please set it in .env file or export it before running this script" exit 1 fi # Wait for Keycloak to be ready wait_for_keycloak # Get admin token echo "Authenticating with Keycloak..." TOKEN=$(get_admin_token) if [ -z "$TOKEN" ]; then echo -e "${RED}Error: Failed to authenticate with Keycloak${NC}" echo "Please check your admin credentials" exit 1 fi echo -e "${GREEN}Authentication successful!${NC}" # Create realm and configure it step by step if create_realm "$TOKEN"; then create_clients "$TOKEN" create_scopes "$TOKEN" create_groups "$TOKEN" create_users "$TOKEN" create_service_account_user "$TOKEN" setup_client_secrets "$TOKEN" setup_groups_mapper "$TOKEN" setup_m2m_scopes "$TOKEN" else exit 1 fi # Save generated passwords to a Kubernetes secret kubectl create secret generic registry-login-credentials --from-literal=REGISTRY_ADMIN_NAME=admin --from-literal=REGISTRY_ADMIN_PASSWORD=$INITIAL_ADMIN_PASSWORD --from-literal=REGISTRY_USER_NAME=testuser --from-literal=REGISTRY_USER_PASSWORD=$INITIAL_USER_PASSWORD echo "" echo -e "${GREEN}Keycloak initialization complete!${NC}" echo "" echo "You can now access Keycloak at: ${KEYCLOAK_URL}" echo "Admin console: ${KEYCLOAK_URL}/admin" echo "Realm: ${REALM}" echo "" echo "Default users created:" echo " - admin/${INITIAL_ADMIN_PASSWORD} (admin access)" echo " - testuser/${INITIAL_USER_PASSWORD} (user access)" echo "" echo -e "${YELLOW}IMPORTANT: Save these passwords! They are randomly generated.${NC}" echo -e "${YELLOW}Passwords have been saved to the Kubernetes secret: registry-login-credentials${NC}" echo -e "${YELLOW}Consider changing them after first login for security.${NC}" } # Run main function main ================================================ FILE: charts/keycloak-configure/templates/job.yaml ================================================ apiVersion: batch/v1 kind: Job metadata: name: setup-keycloak namespace: {{ .Release.Namespace | quote }} spec: template: spec: containers: - name: job image: public.ecr.aws/docker/library/python:3.13-slim command: ["/bin/bash", "/app/script.sh"] envFrom: - secretRef: name: {{ .Values.keycloak.existingSecret | default "keycloak-configure-secret" }} env: - valueFrom: secretKeyRef: key: admin-password name: {{ .Release.Name}}-keycloak name: KEYCLOAK_ADMIN_PASSWORD volumeMounts: - mountPath: /app/script.sh name: script subPath: script.sh restartPolicy: Never volumes: - name: script configMap: name: setup-keycloak serviceAccountName: keycloak-configure-sa backoffLimit: 4 ================================================ FILE: charts/keycloak-configure/templates/role.yaml ================================================ apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: secret-read-write namespace: {{ .Release.Namespace | quote }} rules: - apiGroups: [""] # "" indicates the core API group resources: ["secrets"] verbs: ["get", "watch", "list", "create"] ================================================ FILE: charts/keycloak-configure/templates/rolebinding.yaml ================================================ apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: secret-read-write namespace: {{ .Release.Namespace | quote }} subjects: - kind: ServiceAccount name: keycloak-configure-sa # "name" is case sensitive apiGroup: "" roleRef: # "roleRef" specifies the binding to a Role / ClusterRole kind: Role #this must be Role or ClusterRole name: secret-read-write # this must match the name of the Role or ClusterRole you wish to bind to apiGroup: rbac.authorization.k8s.io ================================================ FILE: charts/keycloak-configure/templates/sa.yaml ================================================ apiVersion: v1 kind: ServiceAccount metadata: name: keycloak-configure-sa namespace: {{ .Release.Namespace | quote }} automountServiceAccountToken: true ================================================ FILE: charts/keycloak-configure/templates/secret.yaml ================================================ {{- if not .Values.keycloak.existingSecret }} {{- $routingMode := .Values.global.ingress.routingMode | default "subdomain" }} {{- $domain := .Values.global.domain | default "localhost" }} {{- $protocol := ternary "https" "http" .Values.global.ingress.tls }} {{- $authServerPath := .Values.global.ingress.paths.authServer | default "/auth-server" }} {{- $keycloakPath := .Values.global.ingress.paths.keycloak | default "/keycloak" }} {{- $keycloakBaseUrl := printf "http://%s-keycloak-headless.%s.svc.cluster.local:8080" .Release.Name .Release.Namespace }} {{- $keycloakUrl := $keycloakBaseUrl }} {{- $authServerExternalUrl := "" }} {{- if eq $routingMode "path" }} {{- $keycloakUrl = printf "%s%s" $keycloakBaseUrl $keycloakPath }} {{- $authServerExternalUrl = printf "%s://%s%s" $protocol $domain $authServerPath }} {{- else }} {{- $authServerExternalUrl = printf "%s://auth-server.%s" $protocol $domain }} {{- end }} apiVersion: v1 kind: Secret metadata: name: keycloak-configure-secret namespace: {{ .Release.Namespace | quote }} data: KEYCLOAK_ADMIN: {{ .Values.keycloak.adminUser | b64enc | quote }} KEYCLOAK_ADMIN_URL: {{ $keycloakUrl | b64enc | quote }} KEYCLOAK_URL: {{ $keycloakUrl | b64enc | quote }} REALM: {{ .Values.keycloak.realm | b64enc | quote }} AUTH_SERVER_EXTERNAL_URL: {{ $authServerExternalUrl | b64enc | quote }} {{- end }} ================================================ FILE: charts/keycloak-configure/values.yaml ================================================ # Keycloak configuration keycloak: adminUser: user realm: mcp-gateway existingSecret: "" # If set, use this existing secret instead of creating one # Auth server configuration authServer: externalUrl: http://localhost:8888 ================================================ FILE: charts/mcp-gateway-registry-stack/Chart.yaml ================================================ apiVersion: v2 name: mcp-gateway-registry-stack description: A Helm chart for deploying the MCP Gateway Registry Stack type: application version: 0.1.0 appVersion: "1.0.0" dependencies: - name: keycloak version: 25.2.0 repository: oci://registry-1.docker.io/bitnamicharts condition: keycloak.create - name: mongodb-kubernetes version: 1.6.1 repository: https://mongodb.github.io/helm-charts condition: mongodb.enabled - name: auth-server version: 0.1.0 repository: "file://../auth-server" - name: registry version: 0.1.0 repository: "file://../registry" - name: mcpgw version: 0.1.0 repository: "file://../mcpgw" condition: mcpgw.enabled - name: keycloak-configure version: 0.1.0 repository: "file://../keycloak-configure" condition: keycloak-configure.enabled - name: mongodb-configure version: 0.1.0 repository: "file://../mongodb-configure" condition: mongodb-configure.enabled ================================================ FILE: charts/mcp-gateway-registry-stack/README.md ================================================ # MCP Gateway Registry Stack Charts This collection of charts deploys everything needed to install the MCP Gateway Registry using Helm or ArgoCD. ## Prerequisites ### Amazon EKS Cluster For production deployments, we recommend using the [AWS AI/ML on Amazon EKS](https://github.com/awslabs/ai-on-eks) blueprints to provision an EKS cluster: ```bash # Clone AI on EKS repository git clone https://github.com/awslabs/ai-on-eks.git cd ai-on-eks cd infra/solutions/agents-on-eks # Edit the terraform/blueprint.tfvars to set your domain ./install.sh ``` The ai-on-eks blueprints provide: - GPU support for AI/ML workloads - Karpenter for efficient auto-scaling - EKS-optimized configurations - Security best practices - Observability with Prometheus/Grafana - Well-documented infrastructure patterns ### Additional Requirements - `helm` CLI installed (v3.0+) - `kubectl` configured to access your EKS cluster - AWS Load Balancer Controller for EKS - ExternalDNS (optional, for automatic DNS management) - Domain name with DNS access - TLS certificates (AWS Certificate Manager or Let's Encrypt) ## Setup ``` git clone https://github.com/agentic-community/mcp-gateway-registry cd mcp-gateway-registry/charts/mcp-gateway-registry-stack ``` ## Values file The `values.yaml` file needs to be updated for your setup, specifically: - `DOMAIN`: there are placeholders for `DOMAIN` that should be updated with your full domain. For example, if you intend to use `example.com`, replace `DOMAIN` with `example.com`. If you intend to use a subdomain like `subdomain.example.com`, `DOMAIN` should be replaced with `subdomain.example.com` - `secretKey`: the registry and auth-server both have a placeholder for `secretKey`, this should be updated to the same random, secure key that is used in both locations - `routingMode`: choose between `subdomain` (default) or `path` based routing (see Routing Modes section below) ### Authentication Provider Selection This chart supports five authentication providers: Keycloak (default), Microsoft Entra ID, Okta, Auth0, and AWS Cognito. When using any provider other than Keycloak, disable the Keycloak components: ```yaml keycloak: create: false keycloak-configure: enabled: false ``` #### Option 1: Keycloak (Default) **Deploy Keycloak in the stack:** ```yaml global: authProvider: type: keycloak keycloak: create: true # Deploy Keycloak as part of this stack keycloak-configure: enabled: true # Run Keycloak configuration job ``` **Use an external Keycloak instance:** ```yaml global: authProvider: type: keycloak keycloak: create: false # Don't deploy Keycloak externalUrl: https://your-keycloak.example.com realm: mcp-gateway keycloak-configure: enabled: true # Still configure the external Keycloak ``` **Optional: Keycloak M2M authentication:** ```yaml auth-server: keycloak: m2mClientId: "mcp-gateway-m2m" m2mClientSecret: "your-m2m-client-secret" ``` #### Option 2: Microsoft Entra ID ```yaml global: authProvider: type: entra entra: adminGroupId: "your-admin-group-uuid" # Optional: maps Entra group to admin role auth-server: entra: clientId: "your-entra-client-id" clientSecret: "your-entra-client-secret" tenantId: "your-entra-tenant-id" loginBaseUrl: "" # Optional: override for sovereign clouds (e.g., https://login.microsoftonline.us) ``` See the [Entra ID documentation](../../docs/entra.md) for details on setting up your Entra ID app registration. #### Option 3: Okta ```yaml global: authProvider: type: okta auth-server: okta: domain: "dev-123456.okta.com" clientId: "your-client-id" clientSecret: "your-client-secret" m2mClientId: "" # Optional: for machine-to-machine auth m2mClientSecret: "" # Optional: for machine-to-machine auth apiToken: "" # Optional: for IAM operations authServerId: "" # Optional: custom authorization server ``` #### Option 4: Auth0 ```yaml global: authProvider: type: auth0 auth-server: auth0: domain: "your-tenant.us.auth0.com" clientId: "your-client-id" clientSecret: "your-client-secret" audience: "" # Optional: API audience for M2M tokens groupsClaim: "https://mcp-gateway/groups" # Custom claim for group memberships m2mClientId: "" # Required for IAM management m2mClientSecret: "" # Required for IAM management managementApiToken: "" # Optional: alternative to M2M credentials (expires 24h) ``` #### Option 5: AWS Cognito ```yaml global: authProvider: type: cognito auth-server: cognito: userPoolId: "us-east-1_xxxxxxxxx" clientId: "your-client-id" clientSecret: "your-client-secret" domain: "" # Optional: custom Cognito domain region: "us-east-1" # AWS region for the User Pool ``` ### Routing Modes The stack supports two routing modes for accessing services: #### Subdomain-Based Routing (Default) Services are accessed via subdomains: - `keycloak.{domain}` - Keycloak authentication server - `auth-server.{domain}` - MCP Gateway auth server - `mcpregistry.{domain}` - MCP server registry **Configuration:** ```yaml global: domain: "yourdomain.com" ingress: routingMode: subdomain ``` **DNS Requirements:** Configure A/CNAME records for each subdomain pointing to your ingress load balancer. #### Path-Based Routing Services are accessed via paths on a single domain: - `{domain}/keycloak` - Keycloak authentication server (default, configurable) - `{domain}/auth-server` - MCP Gateway auth server (default, configurable) - `{domain}/registry` - MCP server registry (default, configurable) - `{domain}/` - MCP server registry (root path) **Configuration:** ```yaml global: domain: "yourdomain.com" ingress: routingMode: path paths: authServer: /auth-server # Customize as needed (e.g., /api/auth) registry: /registry # Customize as needed (e.g., /api) keycloak: /keycloak # Customize as needed (e.g., /auth/keycloak) ``` **Important:** If you customize the Keycloak path, update the helm variable: ```yaml keycloak: httpRelativePath: /keycloak/ ``` **DNS Requirements:** Configure a single A/CNAME record for your domain pointing to your ingress load balancer. ## Install Once the `values.yaml` file is updated and saved, run (substitute MYNAMESPACE for the namespace in which this should be installed): ```bash helm dependency build && helm dependency update helm install mcp-gateway-registry -n MYNAMESPACE --create-namespace . ``` This will deploy the necessary resources for a Kubernetes deployment of the MCP Gateway Registry **Note:** You can add `--set global.chartVersion=$(git rev-parse HEAD)` to your helm install command, which will create a configmap that has the version of the repository as the value. This can aid in debugging by making it much faster to identify which version was used to deploy the charts. ## Deploy Process ### With Keycloak: - postgres, keycloak, registry, and auth-server will be deployed as the core components - A `keycloak-configure` job will also be created - Postgres will need to be running first before Keycloak will run - Keycloak needs to be available before the `keycloak-configure` job will run - auth-server will not start until the `keycloak-configure` job has succeeded and generated a secret that is needed for the auth-server. - The registry will start as soon as the image is pulled ### With Entra ID, Okta, Auth0, or Cognito: - MongoDB, registry, and auth-server will be deployed as the core components - Keycloak and keycloak-configure are skipped - auth-server will use the configured IdP credentials from your values file - The registry will start as soon as the image is pulled ## Deployment Examples (all run from charts/mcp-gateway-registry-stack) ### Subdomain with Keycloak Creates a self-contained deployment. This is the simplest deployment. ```bash helm install mcp-gateway-registry -n mcp-gateway-registry --create-namespace . \ --set global.domain=agents.domain.example ``` ### Subdomain with Entra and Inbound IP Allowlisting Creates a deployment using Entra. Please follow the [instructions](../../docs/entra-id-setup.md) to set up Entra. ```bash helm install mcp-gateway-registry -n mcp-gateway-registry --create-namespace . \ --set global.domain=agents.domain.example \ --set global.ingress.routingMode=subdomain \ --set global.authProvider.type=entra \ --set auth-server.entra.clientId=ENTRA_CLIENT_UUID \ --set auth-server.entra.clientSecret=ENTRA_CLIENT_SECRET \ --set auth-server.entra.tenantId=ENTRA_TENANT_ID \ --set global.authProvider.entra.adminGroupId=ENTRA_ADMIN_GROUP_UUID \ --set keycloak-configure.enabled=false \ --set keycloak.create=false \ --set global.ingress.inboundCidrs='my.public.ip.address/32' ``` ### Subdomain with Okta and Inbound IP Allowlisting Creates a deployment using Okta. ```bash helm install mcp-gateway-registry -n mcp-gateway-registry --create-namespace . \ --set global.domain=agents.domain.example \ --set global.ingress.routingMode=subdomain \ --set global.authProvider.type=okta \ --set auth-server.okta.domain=OKTA_DOMAIN \ --set auth-server.okta.clientId=OKTA_CLIENT_ID \ --set auth-server.okta.clientSecret=OKTA_CLIENT_SECRET \ --set keycloak-configure.enabled=false \ --set keycloak.create=false \ --set global.ingress.inboundCidrs='my.public.ip.address/32' ``` ### Subdomain with Auth0 Creates a deployment using Auth0. ```bash helm install mcp-gateway-registry -n mcp-gateway-registry --create-namespace . \ --set global.domain=agents.domain.example \ --set global.authProvider.type=auth0 \ --set auth-server.auth0.domain=YOUR_TENANT.us.auth0.com \ --set auth-server.auth0.clientId=AUTH0_CLIENT_ID \ --set auth-server.auth0.clientSecret=AUTH0_CLIENT_SECRET \ --set keycloak-configure.enabled=false \ --set keycloak.create=false ``` ### Subdomain with AWS Cognito Creates a deployment using AWS Cognito. ```bash helm install mcp-gateway-registry -n mcp-gateway-registry --create-namespace . \ --set global.domain=agents.domain.example \ --set global.authProvider.type=cognito \ --set auth-server.cognito.userPoolId=us-east-1_XXXXXXXXX \ --set auth-server.cognito.clientId=COGNITO_CLIENT_ID \ --set auth-server.cognito.clientSecret=COGNITO_CLIENT_SECRET \ --set keycloak-configure.enabled=false \ --set keycloak.create=false ``` ### Path with Keycloak and git hash retention for debugging Will create a configmap in the `mcp-gateway-registry` namespace called `chart-version` with the git hash of the current repo (if cloned) to aid in debugging. ```bash helm install mcp-gateway-registry -n mcp-gateway-registry --create-namespace . \ --set global.domain=agents.domain.example \ --set global.ingress.routingMode=path \ --set keycloak.httpRelativePath=/keycloak/ \ --set global.chartVersion=$(git rev-parse --short HEAD) ``` ### Federation with Keycloak on path Will enable registry federation for this deployment. Creates a static token in the `shared-secret` in the `mcp-gateway-registry` namespace that needs to be shared with the connecting registry. If used with `inboundCidr` allow listing, the connecting registry IP needs to be part of the allowed CIDR range. ```bash helm install mcp-gateway-registry -n mcp-gateway-registry --create-namespace . \ --set global.domain=agents.domain.example \ --set global.ingress.routingMode=path \ --set keycloak.httpRelativePath=/keycloak/ \ --set global.federation.staticTokenAuthEnabled=true ``` **Federation with OAuth2 for outbound peer connections:** ```bash helm install mcp-gateway-registry -n mcp-gateway-registry --create-namespace . \ --set global.domain=agents.domain.example \ --set global.federation.staticTokenAuthEnabled=true \ --set registry.app.federationTokenEndpoint=https://idp.example.com/oauth2/token \ --set registry.app.federationClientId=federation-client \ --set registry.app.federationClientSecret=federation-secret ``` ### ASOR (Workday) Integration ASOR integration is independent of peer federation and can be enabled alongside any auth provider: ```bash helm install mcp-gateway-registry -n mcp-gateway-registry --create-namespace . \ --set global.domain=agents.domain.example \ --set registry.app.asorAccessToken=your-asor-access-token \ --set registry.app.workdayTokenUrl=https://services.wd101.myworkday.com/ccx/oauth2/instance/token ``` ### Auth Server Advanced Configuration **Static token authentication** (use a static API key instead of IdP JWT for Registry API): ```bash helm install mcp-gateway-registry -n mcp-gateway-registry --create-namespace . \ --set global.domain=agents.domain.example \ --set auth-server.app.registryStaticTokenAuthEnabled=true \ --set auth-server.app.registryApiToken=your-secure-api-token ``` **Custom JWT configuration** (override internal service-to-service token claims): ```yaml auth-server: app: jwtIssuer: "custom-issuer" # Default: mcp-auth-server jwtAudience: "custom-audience" # Default: mcp-registry maxTokensPerUserPerHour: "50" # Default: 100 ``` ## Use Navigate to the registry based on your routing mode: **Subdomain mode:** https://mcpregistry.DOMAIN **Path mode:** https://DOMAIN/registry or https://DOMAIN/ ### With Keycloak The username/password are displayed in the output of the `keycloak-configure job` ```bash kubectl get pods -l job-name=setup-keycloak -n MYNAMESPACE ``` The output will look similar to: ``` NAME READY STATUS RESTARTS AGE setup-keycloak-d6g2r 0/1 Completed 0 29m setup-keycloak-nnqgj 0/1 Error 0 31m ``` Use the pod name that completed successfully: ``` kubectl logs -n MYNAMESPACE setup-keycloak-d6g2r --tail 20 ``` You will see the credentials in the output ### With Entra ID: Navigate to https://mcpregistry.DOMAIN to log in. Users will authenticate using their Microsoft Entra ID credentials. Ensure that: 1. Your Entra ID app registration has the correct redirect URIs configured 2. Users are assigned to the appropriate Entra ID groups 3. Group mappings are configured in your scopes.yml or MongoDB See the [Entra ID documentation](../../docs/entra.md) for complete setup instructions. ### With Okta, Auth0, or Cognito: Navigate to https://mcpregistry.DOMAIN to log in. Users will authenticate through your configured identity provider. Ensure that your IdP application has the correct redirect URIs configured: - Callback URL: `https://auth-server.DOMAIN/callback` (subdomain) or `https://DOMAIN/auth-server/callback` (path) - Logout URL: `https://mcpregistry.DOMAIN` (subdomain) or `https://DOMAIN/registry` (path) ## Scaling and Redundancy ### Replica Configuration Both the auth-server and registry deployments support configuring the number of replicas via `values.yaml`: ```yaml auth-server: replicaCount: 2 registry: replicaCount: 2 ``` For production environments, we recommend running at least 2 replicas of each service for redundancy. ### Topology Spread Constraints By default, neither the auth-server nor registry deployments include `topologySpreadConstraints`. This is intentional for several reasons: 1. **Routing Complexity**: Routing is complex and handled differently between deployments 2. **Development Flexibility**: Single-node or small clusters (common in dev/test) would fail to schedule pods with strict spread constraints 3. **Custom Requirements**: Organizations often have specific topology requirements that vary by environment For production deployments on multi-AZ clusters, we recommend adding topology spread constraints to both deployments to distribute pods across availability zones and nodes. This improves fault tolerance and ensures service availability during zone or node failures. #### Adding Topology Spread Constraints To add topology spread constraints, patch the deployments after installation: ```bash # Patch auth-server deployment kubectl patch deployment auth-server -n MYNAMESPACE --type='json' -p='[ { "op": "add", "path": "/spec/template/spec/topologySpreadConstraints", "value": [ { "maxSkew": 1, "topologyKey": "topology.kubernetes.io/zone", "whenUnsatisfiable": "ScheduleAnyway", "labelSelector": { "matchLabels": { "app.kubernetes.io/name": "auth-server", "app.kubernetes.io/component": "auth-server" } } }, { "maxSkew": 1, "topologyKey": "kubernetes.io/hostname", "whenUnsatisfiable": "ScheduleAnyway", "labelSelector": { "matchLabels": { "app.kubernetes.io/name": "auth-server", "app.kubernetes.io/component": "auth-server" } } } ] } ]' # Patch registry deployment kubectl patch deployment registry -n MYNAMESPACE --type='json' -p='[ { "op": "add", "path": "/spec/template/spec/topologySpreadConstraints", "value": [ { "maxSkew": 1, "topologyKey": "topology.kubernetes.io/zone", "whenUnsatisfiable": "ScheduleAnyway", "labelSelector": { "matchLabels": { "app.kubernetes.io/name": "registry", "app.kubernetes.io/component": "registry" } } }, { "maxSkew": 1, "topologyKey": "kubernetes.io/hostname", "whenUnsatisfiable": "ScheduleAnyway", "labelSelector": { "matchLabels": { "app.kubernetes.io/name": "registry", "app.kubernetes.io/component": "registry" } } } ] } ]' ``` #### Constraint Explanation - **`topology.kubernetes.io/zone`**: Spreads pods across availability zones for zone-level fault tolerance - **`kubernetes.io/hostname`**: Spreads pods across different nodes within each zone for node-level fault tolerance - **`maxSkew: 1`**: Ensures pods are distributed as evenly as possible (difference between zones/nodes is at most 1) - **`whenUnsatisfiable: ScheduleAnyway`**: Uses soft constraints that prefer even distribution but won't block scheduling if perfect distribution isn't possible. Change to `DoNotSchedule` for strict enforcement ## Using Existing Secrets By default, the stack chart creates and manages Kubernetes Secrets for all components. For production environments using external secret management (e.g., AWS Secrets Manager with External Secrets Operator, HashiCorp Vault), you can reference pre-existing secrets instead. ### Stack-Level Existing Secrets | Value | Default Secret Name | Description | |-------|---------------------|-------------| | `global.existingSharedSecret` | `shared-secret` | SECRET_KEY and federation tokens shared by auth-server and registry | | `global.existingOauthProviderSecret` | `oauth-provider-secret` | Auth provider credentials (Keycloak/Entra/Okta/Auth0/Cognito) | | `global.existingMongoCredentialsSecret` | `mongo-credentials` | MongoDB connection credentials used by auth-server and registry | | `mongodb.existingPasswordSecret` | `my-user-password` | MongoDB operator user password | ### Per-Service Existing Secrets When deploying individual charts (not the stack), each chart supports its own existing secret: | Chart | Value | Default Secret Name | |-------|-------|---------------------| | auth-server | `app.existingSecret` | `auth-server-secret` | | registry | `app.existingSecret` | `registry-secret` | | mcpgw | `app.existingSecret` | `mcpgw-secret` | | keycloak-configure | `keycloak.existingSecret` | `keycloak-configure-secret` | | mongodb-configure | `mongodb.existingSecret` | `mongo-credentials` | ### Per-Key Existing Secrets For finer-grained control, individual sensitive values can be sourced from separate existing secrets. Each sensitive field supports two companion values: `{field}ExistingSecret` (secret name) and `{field}ExistingSecretKey` (key within that secret, defaults to the env var name). **auth-server and registry:** | Field | ExistingSecret value | ExistingSecretKey default | |-------|---------------------|--------------------------| | `entra.clientSecret` | `entra.clientSecretExistingSecret` | `ENTRA_CLIENT_SECRET` | | `okta.clientSecret` | `okta.clientSecretExistingSecret` | `OKTA_CLIENT_SECRET` | | `okta.m2mClientSecret` | `okta.m2mClientSecretExistingSecret` | `OKTA_M2M_CLIENT_SECRET` | | `okta.apiToken` | `okta.apiTokenExistingSecret` | `OKTA_API_TOKEN` | | `auth0.clientSecret` | `auth0.clientSecretExistingSecret` | `AUTH0_CLIENT_SECRET` | | `auth0.m2mClientSecret` | `auth0.m2mClientSecretExistingSecret` | `AUTH0_M2M_CLIENT_SECRET` | | `auth0.managementApiToken` | `auth0.managementApiTokenExistingSecret` | `AUTH0_MANAGEMENT_API_TOKEN` | **registry only:** | Field | ExistingSecret value | ExistingSecretKey default | |-------|---------------------|--------------------------| | `ans.apiKey` | `ans.apiKeyExistingSecret` | `ANS_API_KEY` | | `ans.apiSecret` | `ans.apiSecretExistingSecret` | `ANS_API_SECRET` | **mcpgw only:** | Field | ExistingSecret value | ExistingSecretKey default | |-------|---------------------|--------------------------| | `app.embeddingsApiKey` | `app.embeddingsApiKeyExistingSecret` | `EMBEDDINGS_API_KEY` | When a per-key existing secret is set, the chart skips writing that key into its managed secret and instead injects the value via `env.valueFrom.secretKeyRef`. The key name within the existing secret can be customized using the corresponding `ExistingSecretKey` value. ### Example: Using External Secrets ```bash # Deploy stack using pre-existing secrets helm install mcp-gateway-registry -n mcp-gateway-registry --create-namespace . \ --set global.domain=agents.domain.example \ --set global.existingSharedSecret=my-shared-secret \ --set global.existingOauthProviderSecret=my-oauth-secret \ --set global.existingMongoCredentialsSecret=my-mongo-creds \ --set mongodb.existingPasswordSecret=my-mongo-password ``` ```bash # Deploy auth-server with Okta client secret from a separate existing secret helm install mcp-gateway-registry -n mcp-gateway-registry --create-namespace . \ --set global.domain=agents.domain.example \ --set global.authProvider.type=okta \ --set auth-server.okta.domain=dev-123456.okta.com \ --set auth-server.okta.clientId=MY_CLIENT_ID \ --set auth-server.okta.clientSecretExistingSecret=my-okta-secret \ --set auth-server.okta.clientSecretExistingSecretKey=clientSecret ``` When an existing secret is specified: 1. The chart skips creating the corresponding managed Secret resource (or skips that key for per-key references) 2. Deployments and jobs reference the specified secret name instead 3. The existing secret must contain the expected key (defaulting to the env var name) ================================================ FILE: charts/mcp-gateway-registry-stack/templates/_helpers.tpl ================================================ {{/* Expand the name of the chart. */}} {{- define "mcp-gateway-registry-stack.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Create a default fully qualified app name. */}} {{- define "mcp-gateway-registry-stack.fullname" -}} {{- if .Values.fullnameOverride }} {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} {{- else }} {{- $name := default .Chart.Name .Values.nameOverride }} {{- if contains $name .Release.Name }} {{- .Release.Name | trunc 63 | trimSuffix "-" }} {{- else }} {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} {{- end }} {{- end }} {{- end }} {{/* Create chart name and version as used by the chart label. */}} {{- define "mcp-gateway-registry-stack.chart" -}} {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} {{- end }} {{/* Common labels */}} {{- define "mcp-gateway-registry-stack.labels" -}} helm.sh/chart: {{ include "mcp-gateway-registry-stack.chart" . }} {{ include "mcp-gateway-registry-stack.selectorLabels" . }} {{- if .Chart.AppVersion }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} {{- end }} app.kubernetes.io/managed-by: {{ .Release.Service }} {{- end }} {{/* Selector labels */}} {{- define "mcp-gateway-registry-stack.selectorLabels" -}} app.kubernetes.io/name: {{ include "mcp-gateway-registry-stack.name" . }} app.kubernetes.io/instance: {{ .Release.Name }} {{- end }} ================================================ FILE: charts/mcp-gateway-registry-stack/templates/keycloak-admin-secret.yaml ================================================ {{/* Keycloak admin password secret. Generates a random password on first install and preserves it across helm upgrades. This prevents the admin password from being regenerated by the Bitnami chart on each helm upgrade, which can cause authentication issues. */}} {{- if .Values.keycloak.create }} {{- $secretName := printf "%s-keycloak" .Release.Name }} {{- $existingSecret := lookup "v1" "Secret" .Release.Namespace $secretName }} {{- $adminPassword := "" }} {{- if $existingSecret }} {{- $adminPassword = index $existingSecret.data "admin-password" | b64dec }} {{- else }} {{- $adminPassword = randAlphaNum 32 }} {{- end }} apiVersion: v1 kind: Secret metadata: name: {{ $secretName }} namespace: {{ .Release.Namespace | quote }} labels: {{- include "mcp-gateway-registry-stack.labels" . | nindent 4 }} type: Opaque data: admin-password: {{ $adminPassword | b64enc | quote }} {{- end }} ================================================ FILE: charts/mcp-gateway-registry-stack/templates/keycloak-ingress-patch.yaml ================================================ # This template patches the Keycloak ingress hostname to use the global domain # Only deployed when the auth provider is keycloak (not entra) {{- if eq .Values.global.authProvider.type "keycloak" }} {{- if .Values.keycloakIngress.enabled }} {{- $routingMode := .Values.global.ingress.routingMode | default "subdomain" }} {{- $pathPrefix := .Values.global.ingress.paths.keycloak | default "/keycloak" }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ .Release.Name }}-keycloak namespace: {{ .Release.Namespace | quote }} annotations: {{- if eq $routingMode "path" }} alb.ingress.kubernetes.io/group.name: mcp-gateway-stack alb.ingress.kubernetes.io/group.order: '30' {{- end }} alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS": 443}]' alb.ingress.kubernetes.io/scheme: internet-facing alb.ingress.kubernetes.io/ssl-redirect: '443' alb.ingress.kubernetes.io/target-type: ip alb.ingress.kubernetes.io/success-codes: 200,302 {{- if .Values.global.ingress.inboundCidrs }} alb.ingress.kubernetes.io/inbound-cidrs: {{ .Values.global.ingress.inboundCidrs }} {{- end }} spec: ingressClassName: {{ .Values.global.ingress.className }} rules: {{- if eq $routingMode "path" }} - host: {{ .Values.global.domain }} http: paths: - path: {{ $pathPrefix }} pathType: Prefix backend: service: name: {{ .Release.Name }}-keycloak-headless port: number: 8080 {{- else }} - host: keycloak.{{ .Values.global.domain }} http: paths: - path: / pathType: Prefix backend: service: name: {{ .Release.Name }}-keycloak-headless port: number: 8080 {{- end }} {{- end }} {{- end }} ================================================ FILE: charts/mcp-gateway-registry-stack/templates/keycloak-pg-secret.yaml ================================================ {{/* Keycloak PostgreSQL secret. Generates random passwords on first install and preserves them across helm upgrades. This prevents the bn_keycloak authentication failure that occurs when Bitnami's PostgreSQL subchart regenerates passwords but the PVC retains the original ones. */}} {{- if .Values.keycloak.create }} {{- $secretName := printf "%s-keycloak-postgresql" .Release.Name }} {{- $existingSecret := lookup "v1" "Secret" .Release.Namespace $secretName }} {{- $password := "" }} {{- $postgresPassword := "" }} {{- if $existingSecret }} {{- $password = index $existingSecret.data "password" | b64dec }} {{- $postgresPassword = index $existingSecret.data "postgres-password" | b64dec }} {{- else }} {{- $password = randAlphaNum 32 }} {{- $postgresPassword = randAlphaNum 32 }} {{- end }} apiVersion: v1 kind: Secret metadata: name: {{ $secretName }} namespace: {{ .Release.Namespace | quote }} labels: {{- include "mcp-gateway-registry-stack.labels" . | nindent 4 }} type: Opaque data: password: {{ $password | b64enc | quote }} postgres-password: {{ $postgresPassword | b64enc | quote }} {{- end }} ================================================ FILE: charts/mcp-gateway-registry-stack/templates/mongodb-cluster.yaml ================================================ {{ if .Values.mongodb.enabled }} apiVersion: mongodbcommunity.mongodb.com/v1 kind: MongoDBCommunity metadata: name: {{ default .Values.mongodb.host "mcp-registry-mongodb" }} namespace: {{ .Release.Namespace | quote }} spec: members: {{ default .Values.mongodb.replicas "3"}} type: ReplicaSet version: {{ default .Values.mongodb.version "8.0.16" | quote }} security: authentication: modes: ["SCRAM"] users: - name: {{ .Values.mongodb.user }} db: admin passwordSecretRef: name: {{ .Values.mongodb.existingPasswordSecret | default "my-user-password" }} roles: - name: clusterAdmin db: admin - name: root db: admin - name: userAdminAnyDatabase db: admin scramCredentialsSecretName: my-scram additionalMongodConfig: storage.wiredTiger.engineConfig.journalCompressor: zlib {{- with .Values.global.nodeSelector }} statefulSet: spec: template: spec: nodeSelector: {{- toYaml . | nindent 12 }} {{- end }} {{ end }} ================================================ FILE: charts/mcp-gateway-registry-stack/templates/mongodb-secret.yaml ================================================ {{ if .Values.mongodb.enabled }} {{- if not .Values.mongodb.existingPasswordSecret }} apiVersion: v1 kind: Secret metadata: name: my-user-password namespace: {{ .Release.Namespace | quote }} type: Opaque data: password: {{ .Values.mongodb.password | b64enc | quote}} {{- end }} {{ end }} ================================================ FILE: charts/mcp-gateway-registry-stack/templates/oauth-provider-secret.yaml ================================================ {{/* Shared OAuth provider secret for auth-server and registry. Contains the auth provider type and identity provider credentials (Keycloak, Entra, or Okta) that both services need. */}} {{- if not .Values.global.existingOauthProviderSecret }} {{- $secretName := .Values.global.oauthProviderSecretName | default "oauth-provider-secret" }} {{- $existingSecret := lookup "v1" "Secret" .Release.Namespace $secretName }} {{- $authProviderType := .Values.global.authProvider.type | default "keycloak" }} {{- /* Resolve Keycloak URLs and realm */ -}} {{- $routingMode := .Values.global.ingress.routingMode | default "subdomain" }} {{- $domain := .Values.global.domain | default "localhost" }} {{- $protocol := ternary "https" "http" .Values.global.ingress.tls }} {{- $keycloakPath := .Values.global.ingress.paths.keycloak | default "/keycloak" }} {{- $keycloakUrl := printf "http://%s-keycloak-headless.%s.svc.cluster.local:8080" .Release.Name .Release.Namespace }} {{- $keycloakExternalUrl := "" }} {{- if eq $routingMode "path" }} {{- $keycloakUrl = printf "%s%s" $keycloakUrl $keycloakPath }} {{- $keycloakExternalUrl = printf "%s://%s%s" $protocol $domain $keycloakPath }} {{- else }} {{- $keycloakExternalUrl = printf "%s://keycloak.%s" $protocol $domain }} {{- end }} {{- $keycloakRealm := .Values.global.authProvider.keycloak.realm | default "mcp-gateway" }} {{- /* Resolve Keycloak M2M credentials */ -}} {{- $keycloakM2mClientId := "" }} {{- $keycloakM2mClientSecret := "" }} {{- if (index .Values "auth-server") }} {{- if (index .Values "auth-server" "keycloak") }} {{- $keycloakM2mClientId = (index .Values "auth-server" "keycloak" "m2mClientId") | default "" }} {{- $keycloakM2mClientSecret = (index .Values "auth-server" "keycloak" "m2mClientSecret") | default "" }} {{- end }} {{- end }} {{- /* Resolve Entra credentials - prefer values, fallback to existing secret */ -}} {{- $entraClientId := "" }} {{- $entraClientSecret := "" }} {{- $entraTenantId := "" }} {{- if (index .Values "auth-server") }} {{- if (index .Values "auth-server" "entra") }} {{- $entraClientId = (index .Values "auth-server" "entra" "clientId") | default "" }} {{- $entraClientSecret = (index .Values "auth-server" "entra" "clientSecret") | default "" }} {{- $entraTenantId = (index .Values "auth-server" "entra" "tenantId") | default "" }} {{- end }} {{- end }} {{- /* Resolve Entra login base URL */ -}} {{- $entraLoginBaseUrl := "" }} {{- if (index .Values "auth-server") }} {{- if (index .Values "auth-server" "entra") }} {{- $entraLoginBaseUrl = (index .Values "auth-server" "entra" "loginBaseUrl") | default "" }} {{- end }} {{- end }} {{- /* Persist Entra credentials across upgrades */ -}} {{- if and (not $entraClientId) $existingSecret }} {{- if index $existingSecret.data "ENTRA_CLIENT_ID" }} {{- $entraClientId = index $existingSecret.data "ENTRA_CLIENT_ID" | b64dec }} {{- end }} {{- end }} {{- if and (not $entraClientSecret) $existingSecret }} {{- if index $existingSecret.data "ENTRA_CLIENT_SECRET" }} {{- $entraClientSecret = index $existingSecret.data "ENTRA_CLIENT_SECRET" | b64dec }} {{- end }} {{- end }} {{- if and (not $entraTenantId) $existingSecret }} {{- if index $existingSecret.data "ENTRA_TENANT_ID" }} {{- $entraTenantId = index $existingSecret.data "ENTRA_TENANT_ID" | b64dec }} {{- end }} {{- end }} {{- /* Resolve Okta credentials - prefer values, fallback to existing secret */ -}} {{- $oktaDomain := "" }} {{- $oktaClientId := "" }} {{- $oktaClientSecret := "" }} {{- $oktaM2mClientId := "" }} {{- $oktaM2mClientSecret := "" }} {{- $oktaApiToken := "" }} {{- $oktaAuthServerId := "" }} {{- if (index .Values "auth-server") }} {{- if (index .Values "auth-server" "okta") }} {{- $oktaDomain = (index .Values "auth-server" "okta" "domain") | default "" }} {{- $oktaClientId = (index .Values "auth-server" "okta" "clientId") | default "" }} {{- $oktaClientSecret = (index .Values "auth-server" "okta" "clientSecret") | default "" }} {{- $oktaM2mClientId = (index .Values "auth-server" "okta" "m2mClientId") | default "" }} {{- $oktaM2mClientSecret = (index .Values "auth-server" "okta" "m2mClientSecret") | default "" }} {{- $oktaApiToken = (index .Values "auth-server" "okta" "apiToken") | default "" }} {{- $oktaAuthServerId = (index .Values "auth-server" "okta" "authServerId") | default "" }} {{- end }} {{- end }} {{- /* Persist Okta credentials across upgrades */ -}} {{- if and (not $oktaDomain) $existingSecret }} {{- if index $existingSecret.data "OKTA_DOMAIN" }} {{- $oktaDomain = index $existingSecret.data "OKTA_DOMAIN" | b64dec }} {{- end }} {{- end }} {{- if and (not $oktaClientId) $existingSecret }} {{- if index $existingSecret.data "OKTA_CLIENT_ID" }} {{- $oktaClientId = index $existingSecret.data "OKTA_CLIENT_ID" | b64dec }} {{- end }} {{- end }} {{- if and (not $oktaClientSecret) $existingSecret }} {{- if index $existingSecret.data "OKTA_CLIENT_SECRET" }} {{- $oktaClientSecret = index $existingSecret.data "OKTA_CLIENT_SECRET" | b64dec }} {{- end }} {{- end }} {{- if and (not $oktaM2mClientId) $existingSecret }} {{- if index $existingSecret.data "OKTA_M2M_CLIENT_ID" }} {{- $oktaM2mClientId = index $existingSecret.data "OKTA_M2M_CLIENT_ID" | b64dec }} {{- end }} {{- end }} {{- if and (not $oktaM2mClientSecret) $existingSecret }} {{- if index $existingSecret.data "OKTA_M2M_CLIENT_SECRET" }} {{- $oktaM2mClientSecret = index $existingSecret.data "OKTA_M2M_CLIENT_SECRET" | b64dec }} {{- end }} {{- end }} {{- if and (not $oktaApiToken) $existingSecret }} {{- if index $existingSecret.data "OKTA_API_TOKEN" }} {{- $oktaApiToken = index $existingSecret.data "OKTA_API_TOKEN" | b64dec }} {{- end }} {{- end }} {{- if and (not $oktaAuthServerId) $existingSecret }} {{- if index $existingSecret.data "OKTA_AUTH_SERVER_ID" }} {{- $oktaAuthServerId = index $existingSecret.data "OKTA_AUTH_SERVER_ID" | b64dec }} {{- end }} {{- end }} {{- /* Resolve Auth0 credentials - prefer values, fallback to existing secret */ -}} {{- $auth0Domain := "" }} {{- $auth0ClientId := "" }} {{- $auth0ClientSecret := "" }} {{- $auth0Audience := "" }} {{- $auth0GroupsClaim := "https://mcp-gateway/groups" }} {{- $auth0M2mClientId := "" }} {{- $auth0M2mClientSecret := "" }} {{- $auth0ManagementApiToken := "" }} {{- if (index .Values "auth-server") }} {{- if (index .Values "auth-server" "auth0") }} {{- $auth0Domain = (index .Values "auth-server" "auth0" "domain") | default "" }} {{- $auth0ClientId = (index .Values "auth-server" "auth0" "clientId") | default "" }} {{- $auth0ClientSecret = (index .Values "auth-server" "auth0" "clientSecret") | default "" }} {{- $auth0Audience = (index .Values "auth-server" "auth0" "audience") | default "" }} {{- $auth0GroupsClaim = (index .Values "auth-server" "auth0" "groupsClaim") | default "https://mcp-gateway/groups" }} {{- $auth0M2mClientId = (index .Values "auth-server" "auth0" "m2mClientId") | default "" }} {{- $auth0M2mClientSecret = (index .Values "auth-server" "auth0" "m2mClientSecret") | default "" }} {{- $auth0ManagementApiToken = (index .Values "auth-server" "auth0" "managementApiToken") | default "" }} {{- end }} {{- end }} {{- /* Persist Auth0 credentials across upgrades */ -}} {{- if and (not $auth0Domain) $existingSecret }} {{- if index $existingSecret.data "AUTH0_DOMAIN" }} {{- $auth0Domain = index $existingSecret.data "AUTH0_DOMAIN" | b64dec }} {{- end }} {{- end }} {{- if and (not $auth0ClientId) $existingSecret }} {{- if index $existingSecret.data "AUTH0_CLIENT_ID" }} {{- $auth0ClientId = index $existingSecret.data "AUTH0_CLIENT_ID" | b64dec }} {{- end }} {{- end }} {{- if and (not $auth0ClientSecret) $existingSecret }} {{- if index $existingSecret.data "AUTH0_CLIENT_SECRET" }} {{- $auth0ClientSecret = index $existingSecret.data "AUTH0_CLIENT_SECRET" | b64dec }} {{- end }} {{- end }} {{- if and (not $auth0Audience) $existingSecret }} {{- if index $existingSecret.data "AUTH0_AUDIENCE" }} {{- $auth0Audience = index $existingSecret.data "AUTH0_AUDIENCE" | b64dec }} {{- end }} {{- end }} {{- if and (not $auth0M2mClientId) $existingSecret }} {{- if index $existingSecret.data "AUTH0_M2M_CLIENT_ID" }} {{- $auth0M2mClientId = index $existingSecret.data "AUTH0_M2M_CLIENT_ID" | b64dec }} {{- end }} {{- end }} {{- if and (not $auth0M2mClientSecret) $existingSecret }} {{- if index $existingSecret.data "AUTH0_M2M_CLIENT_SECRET" }} {{- $auth0M2mClientSecret = index $existingSecret.data "AUTH0_M2M_CLIENT_SECRET" | b64dec }} {{- end }} {{- end }} {{- if and (not $auth0ManagementApiToken) $existingSecret }} {{- if index $existingSecret.data "AUTH0_MANAGEMENT_API_TOKEN" }} {{- $auth0ManagementApiToken = index $existingSecret.data "AUTH0_MANAGEMENT_API_TOKEN" | b64dec }} {{- end }} {{- end }} {{- /* Resolve Cognito credentials - prefer values, fallback to existing secret */ -}} {{- $cognitoUserPoolId := "" }} {{- $cognitoClientId := "" }} {{- $cognitoClientSecret := "" }} {{- $cognitoDomain := "" }} {{- $cognitoRegion := "us-east-1" }} {{- if (index .Values "auth-server") }} {{- if (index .Values "auth-server" "cognito") }} {{- $cognitoUserPoolId = (index .Values "auth-server" "cognito" "userPoolId") | default "" }} {{- $cognitoClientId = (index .Values "auth-server" "cognito" "clientId") | default "" }} {{- $cognitoClientSecret = (index .Values "auth-server" "cognito" "clientSecret") | default "" }} {{- $cognitoDomain = (index .Values "auth-server" "cognito" "domain") | default "" }} {{- $cognitoRegion = (index .Values "auth-server" "cognito" "region") | default "us-east-1" }} {{- end }} {{- end }} {{- /* Persist Cognito credentials across upgrades */ -}} {{- if and (not $cognitoUserPoolId) $existingSecret }} {{- if index $existingSecret.data "COGNITO_USER_POOL_ID" }} {{- $cognitoUserPoolId = index $existingSecret.data "COGNITO_USER_POOL_ID" | b64dec }} {{- end }} {{- end }} {{- if and (not $cognitoClientId) $existingSecret }} {{- if index $existingSecret.data "COGNITO_CLIENT_ID" }} {{- $cognitoClientId = index $existingSecret.data "COGNITO_CLIENT_ID" | b64dec }} {{- end }} {{- end }} {{- if and (not $cognitoClientSecret) $existingSecret }} {{- if index $existingSecret.data "COGNITO_CLIENT_SECRET" }} {{- $cognitoClientSecret = index $existingSecret.data "COGNITO_CLIENT_SECRET" | b64dec }} {{- end }} {{- end }} apiVersion: v1 kind: Secret metadata: name: {{ $secretName }} namespace: {{ .Release.Namespace | quote }} labels: {{- include "mcp-gateway-registry-stack.labels" . | nindent 4 }} type: Opaque data: AUTH_PROVIDER: {{ $authProviderType | b64enc | quote }} {{- if eq $authProviderType "keycloak" }} KEYCLOAK_ENABLED: {{ "true" | b64enc | quote }} KEYCLOAK_URL: {{ $keycloakUrl | b64enc | quote }} KEYCLOAK_EXTERNAL_URL: {{ $keycloakExternalUrl | b64enc | quote }} KEYCLOAK_REALM: {{ $keycloakRealm | b64enc | quote }} {{- if $keycloakM2mClientId }} KEYCLOAK_M2M_CLIENT_ID: {{ $keycloakM2mClientId | b64enc | quote }} {{- end }} {{- if $keycloakM2mClientSecret }} KEYCLOAK_M2M_CLIENT_SECRET: {{ $keycloakM2mClientSecret | b64enc | quote }} {{- end }} {{- else if eq $authProviderType "entra" }} ENTRA_ENABLED: {{ "true" | b64enc | quote }} ENTRA_CLIENT_ID: {{ $entraClientId | b64enc | quote }} ENTRA_CLIENT_SECRET: {{ $entraClientSecret | b64enc | quote }} ENTRA_TENANT_ID: {{ $entraTenantId | b64enc | quote }} {{- if $entraLoginBaseUrl }} ENTRA_LOGIN_BASE_URL: {{ $entraLoginBaseUrl | b64enc | quote }} {{- end }} {{- else if eq $authProviderType "okta" }} OKTA_ENABLED: {{ "true" | b64enc | quote }} OKTA_DOMAIN: {{ $oktaDomain | b64enc | quote }} OKTA_CLIENT_ID: {{ $oktaClientId | b64enc | quote }} OKTA_CLIENT_SECRET: {{ $oktaClientSecret | b64enc | quote }} {{- if $oktaM2mClientId }} OKTA_M2M_CLIENT_ID: {{ $oktaM2mClientId | b64enc | quote }} {{- end }} {{- if $oktaM2mClientSecret }} OKTA_M2M_CLIENT_SECRET: {{ $oktaM2mClientSecret | b64enc | quote }} {{- end }} {{- if $oktaApiToken }} OKTA_API_TOKEN: {{ $oktaApiToken | b64enc | quote }} {{- end }} {{- if $oktaAuthServerId }} OKTA_AUTH_SERVER_ID: {{ $oktaAuthServerId | b64enc | quote }} {{- end }} {{- else if eq $authProviderType "auth0" }} AUTH0_ENABLED: {{ "true" | b64enc | quote }} AUTH0_DOMAIN: {{ $auth0Domain | b64enc | quote }} AUTH0_CLIENT_ID: {{ $auth0ClientId | b64enc | quote }} AUTH0_CLIENT_SECRET: {{ $auth0ClientSecret | b64enc | quote }} {{- if $auth0Audience }} AUTH0_AUDIENCE: {{ $auth0Audience | b64enc | quote }} {{- end }} AUTH0_GROUPS_CLAIM: {{ $auth0GroupsClaim | b64enc | quote }} {{- if $auth0M2mClientId }} AUTH0_M2M_CLIENT_ID: {{ $auth0M2mClientId | b64enc | quote }} {{- end }} {{- if $auth0M2mClientSecret }} AUTH0_M2M_CLIENT_SECRET: {{ $auth0M2mClientSecret | b64enc | quote }} {{- end }} {{- if $auth0ManagementApiToken }} AUTH0_MANAGEMENT_API_TOKEN: {{ $auth0ManagementApiToken | b64enc | quote }} {{- end }} {{- else if eq $authProviderType "cognito" }} COGNITO_ENABLED: {{ "true" | b64enc | quote }} COGNITO_USER_POOL_ID: {{ $cognitoUserPoolId | b64enc | quote }} COGNITO_CLIENT_ID: {{ $cognitoClientId | b64enc | quote }} COGNITO_CLIENT_SECRET: {{ $cognitoClientSecret | b64enc | quote }} {{- if $cognitoDomain }} COGNITO_DOMAIN: {{ $cognitoDomain | b64enc | quote }} {{- end }} {{- if $cognitoRegion }} AWS_REGION: {{ $cognitoRegion | b64enc | quote }} {{- end }} {{- end }} {{- end }} ================================================ FILE: charts/mcp-gateway-registry-stack/templates/shared-secret.yaml ================================================ {{/* Shared secret for auth-server and registry. Generates random values if not provided via global values. Both services reference this single secret for SECRET_KEY and federation tokens. */}} {{- if not .Values.global.existingSharedSecret }} {{- $secretName := .Values.global.sharedSecretName | default "shared-secret" }} {{- $existingSecret := lookup "v1" "Secret" .Release.Namespace $secretName }} {{- $secretKey := "" }} {{- if .Values.global.secretKey }} {{- $secretKey = .Values.global.secretKey }} {{- else if $existingSecret }} {{- $secretKey = index $existingSecret.data "SECRET_KEY" | b64dec }} {{- else }} {{- $secretKey = randAlphaNum 64 }} {{- end }} {{- /* Resolve federation values */ -}} {{- $federationEnabled := false }} {{- $federationStaticTokenRaw := "" }} {{- $federationEncryptionKeyRaw := "" }} {{- $registryId := "" }} {{- if .Values.global.federation }} {{- $federationEnabled = .Values.global.federation.staticTokenAuthEnabled | default false }} {{- $federationStaticTokenRaw = .Values.global.federation.staticToken | default "" }} {{- $federationEncryptionKeyRaw = .Values.global.federation.encryptionKey | default "" }} {{- $registryId = .Values.global.federation.registryId | default "" }} {{- end }} {{- /* Reuse existing values from secret on upgrade, or generate new ones */ -}} {{- $federationStaticToken := "" }} {{- $federationEncryptionKey := "" }} {{- if $federationStaticTokenRaw }} {{- $federationStaticToken = $federationStaticTokenRaw }} {{- else if and $existingSecret (index $existingSecret.data "FEDERATION_STATIC_TOKEN") }} {{- $federationStaticToken = index $existingSecret.data "FEDERATION_STATIC_TOKEN" | b64dec }} {{- else }} {{- $federationStaticToken = randBytes 32 | replace "+" "-" | replace "/" "_" | trimSuffix "=" }} {{- end }} {{- if $federationEncryptionKeyRaw }} {{- $federationEncryptionKey = $federationEncryptionKeyRaw }} {{- else if and $existingSecret (index $existingSecret.data "FEDERATION_ENCRYPTION_KEY") }} {{- $federationEncryptionKey = index $existingSecret.data "FEDERATION_ENCRYPTION_KEY" | b64dec }} {{- else }} {{- $federationEncryptionKey = randBytes 32 }} {{- end }} apiVersion: v1 kind: Secret metadata: name: {{ $secretName }} namespace: {{ .Release.Namespace | quote }} labels: {{- include "mcp-gateway-registry-stack.labels" . | nindent 4 }} type: Opaque data: SECRET_KEY: {{ $secretKey | b64enc | quote }} {{- /* Resolve registry app values for federation and ASOR */ -}} {{- $fedTokenEndpoint := "" }} {{- $fedClientId := "" }} {{- $fedClientSecret := "" }} {{- $asorAccessToken := "" }} {{- $workdayTokenUrl := "" }} {{- if .Values.registry }} {{- if .Values.registry.app }} {{- $fedTokenEndpoint = .Values.registry.app.federationTokenEndpoint | default "" }} {{- $fedClientId = .Values.registry.app.federationClientId | default "" }} {{- $fedClientSecret = .Values.registry.app.federationClientSecret | default "" }} {{- $asorAccessToken = .Values.registry.app.asorAccessToken | default "" }} {{- $workdayTokenUrl = .Values.registry.app.workdayTokenUrl | default "" }} {{- end }} {{- end }} {{- if $federationEnabled }} FEDERATION_STATIC_TOKEN_AUTH_ENABLED: {{ $federationEnabled | toString | b64enc | quote }} FEDERATION_STATIC_TOKEN: {{ $federationStaticToken | b64enc | quote }} FEDERATION_ENCRYPTION_KEY: {{ $federationEncryptionKey | b64enc | quote }} {{- if $fedTokenEndpoint }} FEDERATION_TOKEN_ENDPOINT: {{ $fedTokenEndpoint | b64enc | quote }} {{- end }} {{- if $fedClientId }} FEDERATION_CLIENT_ID: {{ $fedClientId | b64enc | quote }} {{- end }} {{- if $fedClientSecret }} FEDERATION_CLIENT_SECRET: {{ $fedClientSecret | b64enc | quote }} {{- end }} {{- end }} {{- if $registryId }} REGISTRY_ID: {{ $registryId | b64enc | quote }} {{- end }} {{- /* ASOR/Workday integration (independent of federation) */ -}} {{- if $asorAccessToken }} ASOR_ACCESS_TOKEN: {{ $asorAccessToken | b64enc | quote }} {{- end }} {{- if $workdayTokenUrl }} WORKDAY_TOKEN_URL: {{ $workdayTokenUrl | b64enc | quote }} {{- end }} {{- end }} ================================================ FILE: charts/mcp-gateway-registry-stack/templates/version-configmap.yaml ================================================ {{ if .Values.global.chartVersion }} apiVersion: v1 kind: ConfigMap metadata: name: chart-version namespace: {{ include "common.names.namespace" . | quote }} data: version: {{ .Values.global.chartVersion | quote }} {{ end }} ================================================ FILE: charts/mcp-gateway-registry-stack/values.yaml ================================================ # Global configuration - these values are passed to all subcharts global: # Image tag for all services (auth-server, registry, mcpgw) # This value is passed down to all subcharts via Helm's global scope image: tag: 1.0.21 # When installing chart from repository, add --set global.chartVersion=$(git rev-parse HEAD) to add the git hash to a configmap for debugging chartVersion: # Domain configuration - update this to your actual domain domain: "DOMAIN" # Security settings # secretKey: If not provided, a random 64-character key is auto-generated # and shared between auth-server and registry via a shared secret. # The generated key persists across helm upgrades. # Uncomment and set to use a specific key: # secretKey: "your-secure-key-here" # Shared secret name - automatically set for stack deployment # Both auth-server and registry will use this secret for SECRET_KEY and federation (if enabled) sharedSecretName: "shared-secret" # OAuth provider shared secret - contains auth provider type and IdP credentials # Both auth-server and registry reference this secret for Keycloak/Entra configuration oauthProviderSecretName: "oauth-provider-secret" # Existing secret references - set these to use pre-existing secrets instead of chart-managed ones. # When set, the chart skips creating the corresponding managed Secret resource. # The existing secret must contain the same keys the chart would have created. existingSharedSecret: "" # If set, skip creating shared-secret and use this name instead existingOauthProviderSecret: "" # If set, skip creating oauth-provider-secret and use this name instead existingMongoCredentialsSecret: "" # If set, use this name instead of mongo-credentials in deployments # Federation configuration - shared between registry and auth-server federation: staticTokenAuthEnabled: false # If not provided, defaults to false staticToken: # If not provided, a random token is auto-generated encryptionKey: # If not provided, a Fernet key is auto-generated registryId: # Unique identifier for this registry instance (optional) # Authentication Provider Configuration # Choose ONE provider: keycloak, entra, okta, auth0, or cognito authProvider: # Provider type: "keycloak", "entra", "okta", "auth0", or "cognito" type: keycloak keycloak: adminUsername: &keycloakAdmin "user" realm: &keycloakRealm "mcp-gateway" # Don't set password here - let Keycloak chart generate it entra: adminGroupId: # UUID of Entra admin group # Common ingress settings ingress: inboundCidrs: # optional comma separated list of allowed inbound CIDR ranges className: alb tls: true # Routing mode: "subdomain" or "path" # - subdomain: auth-server.domain.com, mcpregistry.domain.com, keycloak.domain.com # - path: domain.com/auth-server, domain.com/registry, domain.com/keycloak routingMode: subdomain # Path configuration (only used when routingMode: path) paths: authServer: /auth-server registry: /registry mcpgw: /mcpgw keycloak: /keycloak # make sure to update keycloak.httpRelativePath (/keycloak/) mongodb-kubernetes: operator: enableClusterMongoDBRoles: false telemetry: installClusterRole: false nodeSelector: {} # MongoDB configuration mongodb: enabled: true user: &mongoUser my-user # username for MongoDB password: &mongoPassword CHANGEME # Set the password for the MongoDB user database: &mongoDatabase mcp_registry existingPasswordSecret: "" # If set, skip creating my-user-password secret and use this name instead # Keycloak configuration # Set create: true to deploy Keycloak as part of this stack # Set create: false to use an external Keycloak instance # NOTE: When using Entra (global.authProvider.type: entra), set create: false keycloak: create: true # Deploy Keycloak in this stack (set to false for external Keycloak or Entra) image: registry: public.ecr.aws global: security: allowInsecureImages: true auth: adminUser: *keycloakAdmin existingSecret: '{{ .Release.Name }}-keycloak' postgresql: auth: existingSecret: '{{ .Release.Name }}-keycloak-postgresql' image: registry: public.ecr.aws # HTTP relative path for path-based routing # IMPORTANT: This must match global.ingress.paths.keycloak when routingMode is "path" # For subdomain routing, set to "/" # IMPORTANT: This must have a trailing "/" httpRelativePath: / extraEnvVars: - name: KC_PROXY value: edge - name: KC_PROXY_HEADERS value: xforwarded ingress: enabled: false # We use a custom ingress template that supports global.domain nodeSelector: {} # Lifecycle hook to configure realm SSL settings lifecycleHooks: postStart: exec: command: - "/bin/bash" - "-c" - | ( echo "PostStart: Waiting for Keycloak to be ready..." # Determine the base path - check if KC_HTTP_RELATIVE_PATH is set BASE_PATH="${KC_HTTP_RELATIVE_PATH:-}" BASE_URL="http://localhost:8080${BASE_PATH}" echo "Using base URL: $BASE_URL" for i in {1..120}; do if curl -sf ${BASE_URL}/realms/$KC_SPI_ADMIN_REALM > /dev/null 2>&1; then echo "Keycloak ready after $i attempts" break fi sleep 5 done sleep 10 echo "Configuring $KC_SPI_ADMIN_REALM realm..." /opt/bitnami/keycloak/bin/kcadm.sh config credentials \ --config /tmp/kcadm.config \ --server ${BASE_URL} \ --realm $KC_SPI_ADMIN_REALM \ --user $KC_BOOTSTRAP_ADMIN_USERNAME \ --password $(cat $KC_BOOTSTRAP_ADMIN_PASSWORD_FILE) /opt/bitnami/keycloak/bin/kcadm.sh update \ --config /tmp/kcadm.config \ realms/$KC_SPI_ADMIN_REALM \ -s sslRequired=NONE echo "✓ $KC_SPI_ADMIN_REALM realm configured!" ) > /tmp/poststart-config.log 2>&1 & # Keycloak configuration job # Automatically enabled when global.authProvider.type = "keycloak" # Set to false to skip configuration (e.g., when using pre-configured Keycloak) # NOTE: When using Entra (global.authProvider.type: entra), set enabled: false keycloak-configure: enabled: true # Set to false to skip Keycloak configuration or when using Entra keycloak: realm: *keycloakRealm adminUser: *keycloakAdmin # authServer.externalUrl will be templated in the subchart using global.domain # Keycloak ingress for mcp-gateway-registry # Whether to create an ingress for Keycloak with this Chart (only applicable when keycloak.create: true) keycloakIngress: enabled: true # Mongodb configuration job mongodb-configure: enabled: true # Whether to run the MongoDB configuration job mongodb: username: *mongoUser password: *mongoPassword database: *mongoDatabase # Registry service configuration registry: app: replicas: 2 # set to > 1 replica for high availability # Deployment mode: with-gateway (nginx integration) or registry-only (catalog only) deploymentMode: with-gateway # Registry mode: full, skills-only, mcp-servers-only, agents-only registryMode: full # Tab visibility overrides (AND-ed with registryMode) showServersTab: true showVirtualServersTab: true showSkillsTab: true showAgentsTab: true # Federation OAuth2 authentication (alternative to static token) federationTokenEndpoint: "" federationClientId: "" federationClientSecret: "" # ASOR (Workday) federation integration asorAccessToken: "" workdayTokenUrl: "" # Skill security scanning configuration skillSecurityScanEnabled: true # Enable/disable skill security scanning skillSecurityAnalyzers: "static" # Comma-separated: static, behavioral, llm, meta, virustotal, ai-defense # Static API keys for registry path authentication (JSON string) registryApiKeys: "" registryApiKeysExistingSecret: "" # If set, read REGISTRY_API_KEYS from this K8s secret instead registryApiKeysExistingSecretKey: "REGISTRY_API_KEYS" # Key within the existing secret # Registration webhook (issue #742) registrationWebhookUrl: "" registrationWebhookAuthHeader: "Authorization" registrationWebhookAuthToken: "" registrationWebhookTimeoutSeconds: "10" # Registration gate / admission control (issue #809) registrationGateEnabled: false registrationGateUrl: "" registrationGateAuthType: "none" registrationGateAuthCredential: "" registrationGateAuthHeaderName: "X-Api-Key" registrationGateTimeoutSeconds: "5" registrationGateMaxRetries: "2" # M2M direct client registration (issue #851) # Exposes /api/iam/m2m-clients admin API for registering M2M client_ids and # their group mappings directly, without requiring an IdP Admin API token. m2mDirectRegistrationEnabled: true # OpenTelemetry direct OTLP push export configuration otelOtlpEndpoint: "" # OTLP endpoint URL (e.g., https://otlp.datadoghq.com) otelExporterOtlpHeaders: "" # OTLP headers (e.g., dd-api-key=YOUR_KEY) otelOtlpExportIntervalMs: "30000" # Export interval in milliseconds otelExporterOtlpMetricsTemporalityPreference: "cumulative" # cumulative or delta # Telemetry configuration # Anonymous usage telemetry (startup ping + daily heartbeat, both on by default) mcpTelemetryDisabled: false # Set to true to disable all telemetry mcpTelemetryOptOut: false # Set to true to disable daily heartbeat only (startup ping still sent) telemetryHeartbeatIntervalMinutes: "1440" # Heartbeat interval in minutes (default: 1440 = 24 hours) telemetryDebug: false # Set to true to log payloads instead of sending # Application Log Configuration (centralized log rotation and retrieval) # NOTE: Centralized logging is ON by default (appLogCentralizedEnabled: "true"). # Set to "false" to disable writing application logs to centralized store for the /admin/logs API. # Anchor &appLogConfig is reused by auth-server below to keep values in sync. <<: &appLogConfig appLogMaxBytes: "52428800" # Max size per log file before rotation (default 50 MB) appLogBackupCount: "5" # Number of rotated backup log files to keep appLogCentralizedEnabled: "true" # Write application logs to centralized store (requires MongoDB backend) appLogCentralizedTtlDays: "1" # Days to retain log entries in centralized store (TTL index) appLogMongodbBufferSize: "50" # Records to buffer before flushing to MongoDB appLogMongodbFlushIntervalSeconds: "5.0" # Seconds between periodic flushes appLogLevel: "INFO" # Application log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) appLogExcludedLoggers: "uvicorn.access,httpx,pymongo,motor" # Comma-separated logger names to exclude from MongoDB # Demo server configuration disableAiRegistryToolsServer: false # Set to true to disable auto-registration of the built-in airegistry-tools server # AWS Agent Registry Federation awsRegistry: federationEnabled: false # Enable AWS Agent Registry federation (overrides MongoDB config on startup) # ANS (Agent Name Service) Integration ans: enabled: false # Enable ANS integration for trust verification apiEndpoint: "https://api.godaddy.com" # ANS API base URL apiKey: "" # GoDaddy API key (required when enabled) apiSecret: "" # GoDaddy API secret (required when enabled) apiTimeoutSeconds: "30" # HTTP request timeout for ANS API calls syncIntervalHours: "6" # Background re-verification interval verificationCacheTtlSeconds: "3600" # Cache TTL for verification results # Registry Card Configuration (for federation and discovery) registryCard: url: "" # External URL of the registry (defaults to computed ingress URL) name: "AI Registry" # Human-readable name organizationName: "" # Organization operating the registry description: "" # Optional description contactEmail: "" # Optional contact email contactUrl: "" # Optional contact URL/website # IdP group filtering (applies to all identity providers) # When set, only groups whose name starts with any of these prefixes are shown in IAM > Groups # Example: "mcp-,registry-,ai-" idpGroupFilterPrefix: "" ingress: enabled: true ingressClassName: alb nodeSelector: {} # MCPGW MCP server configuration mcpgw: enabled: true # Set to true to deploy the MCPGW MCP server app: replicas: 1 # Embeddings configuration embeddingsProvider: sentence-transformers embeddingsModelName: all-MiniLM-L6-v2 embeddingsModelDimensions: "384" ingress: enabled: true ingressClassName: alb nodeSelector: {} # Auth server configuration auth-server: app: replicas: 2 # set to > 1 replica for high availability oauthStoreTokensInSession: false # Store OAuth tokens in session (default: false) # Internal JWT configuration jwtIssuer: "mcp-auth-server" # Issuer claim for internal JWT tokens jwtAudience: "mcp-registry" # Audience claim for internal JWT tokens # Static token authentication (alternative to IdP JWT for Registry API) registryStaticTokenAuthEnabled: false registryApiToken: "" # Rate limiting maxTokensPerUserPerHour: "100" # Application Log Configuration - reuses anchor from registry.app above <<: *appLogConfig keycloak: enabled: true realm: *keycloakRealm m2mClientId: "" # Optional: M2M client ID m2mClientSecret: "" # Optional: M2M client secret # externalUrl will be templated in the subchart using global.domain # Entra ID settings (used when global.authProvider.type = "entra") entra: clientId: "" clientSecret: "" tenantId: "" loginBaseUrl: "" # Custom Entra login base URL for sovereign clouds # Okta settings (used when global.authProvider.type = "okta") okta: domain: "" # e.g., dev-123456.okta.com clientId: "" clientSecret: "" m2mClientId: "" # Optional: defaults to clientId m2mClientSecret: "" # Optional: defaults to clientSecret apiToken: "" # Optional: required for IAM operations authServerId: "" # Optional: uses default Org Authorization Server if not set # Auth0 settings (used when global.authProvider.type = "auth0") auth0: domain: "" # e.g., your-tenant.us.auth0.com clientId: "" clientSecret: "" audience: "" # Optional: API audience for M2M tokens groupsClaim: "https://mcp-gateway/groups" m2mClientId: "" m2mClientSecret: "" managementApiToken: "" # Optional: alternative to M2M credentials (expires after 24h) # Cognito settings (used when global.authProvider.type = "cognito") cognito: userPoolId: "" clientId: "" clientSecret: "" domain: "" # Optional: custom Cognito domain region: "us-east-1" ingress: enabled: true ingressClassName: alb nodeSelector: {} ================================================ FILE: charts/mcpgw/Chart.yaml ================================================ apiVersion: v2 name: mcpgw description: A Helm chart for the MCPGW MCP server with embeddings support type: application version: 0.1.0 appVersion: "1.0.0" ================================================ FILE: charts/mcpgw/templates/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: {{ .Values.app.name }} namespace: {{ .Release.Namespace | quote }} labels: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} spec: replicas: {{ .Values.app.replicas }} selector: matchLabels: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} template: metadata: labels: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} spec: {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 containers: - name: {{ .Values.app.name }} image: "{{ .Values.global.image.repository }}:{{ .Values.global.image.tag }}" imagePullPolicy: {{ .Values.global.image.pullPolicy }} securityContext: allowPrivilegeEscalation: false capabilities: drop: - ALL ports: - containerPort: {{ .Values.app.port }} name: http resources: {{- toYaml .Values.resources | nindent 12 }} envFrom: - secretRef: name: {{ .Values.app.existingSecret | default .Values.app.envSecretName }} {{- if .Values.global.sharedSecretName }} - secretRef: name: {{ .Values.global.existingSharedSecret | default .Values.global.sharedSecretName }} {{- end }} env: - name: HOST value: 0.0.0.0 {{- if .Values.app.embeddingsApiKeyExistingSecret }} - name: EMBEDDINGS_API_KEY valueFrom: secretKeyRef: name: {{ .Values.app.embeddingsApiKeyExistingSecret }} key: {{ .Values.app.embeddingsApiKeyExistingSecretKey }} {{- end }} {{- /* GitHub private repo auth (SKILL.md fetching) */}} {{- if .Values.app.githubAppId }} - name: GITHUB_APP_ID value: {{ .Values.app.githubAppId | quote }} {{- end }} {{- if .Values.app.githubAppInstallationId }} - name: GITHUB_APP_INSTALLATION_ID value: {{ .Values.app.githubAppInstallationId | quote }} {{- end }} {{- if .Values.app.githubExtraHosts }} - name: GITHUB_EXTRA_HOSTS value: {{ .Values.app.githubExtraHosts | quote }} {{- end }} {{- if ne .Values.app.githubApiBaseUrl "https://api.github.com" }} - name: GITHUB_API_BASE_URL value: {{ .Values.app.githubApiBaseUrl | quote }} {{- end }} {{- if .Values.app.githubPatExistingSecret }} - name: GITHUB_PAT valueFrom: secretKeyRef: name: {{ .Values.app.githubPatExistingSecret }} key: {{ .Values.app.githubPatExistingSecretKey }} {{- else if .Values.app.githubPat }} - name: GITHUB_PAT value: {{ .Values.app.githubPat | quote }} {{- end }} {{- if .Values.app.githubAppPrivateKeyExistingSecret }} - name: GITHUB_APP_PRIVATE_KEY valueFrom: secretKeyRef: name: {{ .Values.app.githubAppPrivateKeyExistingSecret }} key: {{ .Values.app.githubAppPrivateKeyExistingSecretKey }} {{- else if .Values.app.githubAppPrivateKey }} - name: GITHUB_APP_PRIVATE_KEY value: {{ .Values.app.githubAppPrivateKey | quote }} {{- end }} livenessProbe: tcpSocket: port: 8000 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: tcpSocket: port: 8000 initialDelaySeconds: 10 periodSeconds: 5 ================================================ FILE: charts/mcpgw/templates/ingress.yaml ================================================ {{- if .Values.ingress.enabled }} {{- $routingMode := .Values.global.ingress.routingMode | default "subdomain" }} {{- $domain := .Values.global.domain | default .Values.ingress.hostname }} {{- $pathPrefix := .Values.global.ingress.paths.mcpgw | default .Values.ingress.path | default "/mcpgw" }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ .Values.app.name }} namespace: {{ .Release.Namespace | quote }} annotations: {{- if eq $routingMode "path" }} alb.ingress.kubernetes.io/group.name: mcp-gateway-stack alb.ingress.kubernetes.io/group.order: '40' {{- end }} alb.ingress.kubernetes.io/healthcheck-path: /mcp/ alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS": 443}]' alb.ingress.kubernetes.io/scheme: internet-facing alb.ingress.kubernetes.io/ssl-redirect: '443' alb.ingress.kubernetes.io/target-type: ip alb.ingress.kubernetes.io/success-codes: 200,302,307 {{- if .Values.global.ingress.inboundCidrs }} alb.ingress.kubernetes.io/inbound-cidrs: {{ .Values.global.ingress.inboundCidrs }} {{- end }} spec: ingressClassName: {{ .Values.ingress.className }} rules: {{- if eq $routingMode "path" }} - host: {{ $domain | quote }} http: paths: - path: {{ $pathPrefix }} pathType: Prefix backend: service: name: {{ .Values.app.name }} port: name: http {{- else }} - host: {{ printf "mcpgw.%s" $domain | quote }} http: paths: - path: / pathType: Prefix backend: service: name: {{ .Values.app.name }} port: name: http {{- end }} {{- end }} ================================================ FILE: charts/mcpgw/templates/secret.yaml ================================================ {{- if and .Values.app.embeddingsApiKey .Values.app.embeddingsApiKeyExistingSecret }} {{- fail "Cannot set both app.embeddingsApiKey and app.embeddingsApiKeyExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if not .Values.app.existingSecret }} {{- $registryUrl := .Values.app.registryUrl }} {{- if .Values.global.domain }} {{- $registryUrl = printf "http://registry.%s.svc.cluster.local:8000" .Release.Namespace }} {{- end }} apiVersion: v1 kind: Secret metadata: name: {{ .Values.app.envSecretName }} namespace: {{ .Release.Namespace | quote }} data: PORT: {{ .Values.app.port | toString | b64enc | quote }} REGISTRY_BASE_URL: {{ $registryUrl | b64enc | quote }} EMBEDDINGS_PROVIDER: {{ .Values.app.embeddingsProvider | b64enc | quote }} EMBEDDINGS_MODEL_NAME: {{ .Values.app.embeddingsModelName | b64enc | quote }} EMBEDDINGS_MODEL_DIMENSIONS: {{ .Values.app.embeddingsModelDimensions | b64enc | quote }} {{- if and .Values.app.embeddingsApiKey (not .Values.app.embeddingsApiKeyExistingSecret) }} EMBEDDINGS_API_KEY: {{ .Values.app.embeddingsApiKey | b64enc | quote }} {{- end }} {{- if .Values.app.embeddingsApiBase }} EMBEDDINGS_API_BASE: {{ .Values.app.embeddingsApiBase | b64enc | quote }} {{- end }} EMBEDDINGS_AWS_REGION: {{ .Values.app.embeddingsAwsRegion | b64enc | quote }} {{- if not .Values.global.sharedSecretName }} {{/* SECRET_KEY managed per-chart in standalone deployment */}} SECRET_KEY: {{ required "app.secretKey or global.secretKey is required for standalone deployment" (.Values.global.secretKey | default .Values.app.secretKey) | b64enc | quote }} {{- end }} {{- end }} ================================================ FILE: charts/mcpgw/templates/service.yaml ================================================ apiVersion: v1 kind: Service metadata: name: {{ .Values.app.name }} namespace: {{ .Release.Namespace | quote }} {{- with .Values.service.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: type: {{ .Values.service.type }} ports: - port: {{ .Values.service.port }} targetPort: http protocol: TCP name: http selector: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} ================================================ FILE: charts/mcpgw/values.yaml ================================================ # Global configuration global: image: repository: public.ecr.aws/p3v1o3c6/mcpgw tag: 1.0.21 pullPolicy: IfNotPresent # Application configuration app: name: mcpgw-server replicas: 1 envSecretName: mcpgw-secret existingSecret: "" # If set, use this existing secret instead of creating one port: 8000 # Registry connection registryUrl: http://registry:8080 # Security settings # secretKey: Required for standalone deployment (not needed when deployed via stack). # When deployed via mcp-gateway-registry-stack, SECRET_KEY comes from the shared secret. # Uncomment to use a specific key: # secretKey: "your-secure-key-here" # Embeddings configuration embeddingsProvider: sentence-transformers embeddingsModelName: all-MiniLM-L6-v2 embeddingsModelDimensions: "384" embeddingsApiKey: "" embeddingsApiKeyExistingSecret: "" # If set, read EMBEDDINGS_API_KEY from this K8s secret instead of embeddingsApiKey embeddingsApiKeyExistingSecretKey: "EMBEDDINGS_API_KEY" # Key within the existing secret embeddingsApiBase: "" embeddingsAwsRegion: us-east-1 # GitHub private repo auth (SKILL.md fetching) githubPat: "" githubPatExistingSecret: "" # If set, read GITHUB_PAT from this K8s secret githubPatExistingSecretKey: "GITHUB_PAT" githubAppId: "" githubAppInstallationId: "" githubAppPrivateKey: "" githubAppPrivateKeyExistingSecret: "" # If set, read GITHUB_APP_PRIVATE_KEY from this K8s secret githubAppPrivateKeyExistingSecretKey: "GITHUB_APP_PRIVATE_KEY" githubExtraHosts: "" githubApiBaseUrl: "https://api.github.com" # Service configuration service: type: ClusterIP port: 8003 annotations: {} # Resource limits and requests resources: requests: cpu: 500m memory: 1Gi limits: cpu: 1 memory: 2Gi # Ingress configuration ingress: enabled: false className: alb hostname: "" annotations: {} tls: false # Routing mode: "subdomain" or "path" # - subdomain: mcpgw.domain.com # - path: domain.com/mcpgw routingMode: subdomain # Path prefix when using path-based routing (default: /mcpgw) path: /mcpgw nodeSelector: {} ================================================ FILE: charts/mongodb-configure/Chart.yaml ================================================ apiVersion: v2 name: mongodb-configure description: A Helm chart for configuring MongoDB type: application version: 0.1.0 appVersion: "1.0.0" ================================================ FILE: charts/mongodb-configure/templates/configmap.yaml ================================================ apiVersion: v1 kind: ConfigMap metadata: name: setup-mongodb namespace: {{ .Release.Namespace | quote }} data: registry-admins.json: | { "_id": "registry-admins", "group_mappings": [ "registry-admins"{{ if eq .Values.global.authProvider.type "entra"}}{{ printf ", %s" (.Values.global.authProvider.entra.adminGroupId | quote) }}{{ end }} ], "server_access": [ { "server": "*", "methods": ["all"], "tools": ["all"] }, { "agents": { "actions": [ {"action": "list_agents", "resources": ["all"]}, {"action": "get_agent", "resources": ["all"]}, {"action": "publish_agent", "resources": ["all"]}, {"action": "modify_agent", "resources": ["all"]}, {"action": "delete_agent", "resources": ["all"]} ] } } ], "ui_permissions": { "list_agents": ["all"], "get_agent": ["all"], "publish_agent": ["all"], "modify_agent": ["all"], "delete_agent": ["all"], "list_service": ["all"], "register_service": ["all"], "health_check_service": ["all"], "toggle_service": ["all"], "modify_service": ["all"], "delete_service": ["all"] } } wait.py: | import pymongo import os import time import sys MONGO_HOST = os.getenv("DOCUMENTDB_HOST", "mongodb") MONGO_PORT = os.getenv("DOCUMENTDB_PORT", "27017") REPLICA_SET = os.getenv("DOCUMENTDB_REPLICA_SET", "rs0") USERNAME = os.getenv("DOCUMENTDB_USERNAME", "") PASSWORD = os.getenv("DOCUMENTDB_PASSWORD", "") def wait_for_mongodb(): while True: try: # First check basic connectivity client = pymongo.MongoClient(f"mongodb://{USERNAME}:{PASSWORD}@{MONGO_HOST}:{MONGO_PORT}/?authMechanism=SCRAM-SHA-256&authSource=admin", serverSelectionTimeoutMS=5000, connectTimeoutMS=5000) client.admin.command('ping') print("MongoDB is accepting connections. Checking replica set status...") # Check replica set status status = client.admin.command('replSetGetStatus') if status['ok'] != 1: print("Replica set not initialized yet") time.sleep(10) continue ready_members = [m for m in status['members'] if m['state'] in [1, 2]] # PRIMARY or SECONDARY total_members = len(status['members']) if len(ready_members) == total_members: print(f"All replica set members are ready ({len(ready_members)}/{total_members})") break else: print(f"Waiting for replica set members: {len(ready_members)}/{total_members} ready") time.sleep(10) except Exception as e: print(f"MongoDB not ready yet: {e}") time.sleep(5) finally: try: client.close() except: pass wait_for_mongodb() print("MongoDB replica set is fully ready!") mcp-registry-admin.json: | { "_id": "mcp-registry-admin", "group_mappings": ["mcp-registry-admin", "mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute"], "server_access": [ { "server": "*", "methods": ["all"], "tools": ["all"] }, { "server": "api", "methods": ["tokens", "GET", "POST"] } ], "ui_permissions": { "list_agents": ["all"], "get_agent": ["all"], "publish_agent": ["all"], "modify_agent": ["all"], "delete_agent": ["all"], "list_service": ["all"], "register_service": ["all"], "health_check_service": ["all"], "toggle_service": ["all"], "modify_service": ["all"], "delete_service": ["all"] } } mcp-servers-unrestricted-execute.json: | { "_id": "mcp-servers-unrestricted/execute", "group_mappings": [], "server_access": [ { "server": "*", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list", "GET", "POST", "PUT", "DELETE"], "tools": "*" }, { "server": "api", "methods": ["tokens", "GET", "POST"] } ] } mcp-servers-unrestricted-read.json: | { "_id": "mcp-servers-unrestricted/read", "group_mappings": [], "server_access": [ { "server": "*", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list", "GET"], "tools": "*" }, { "server": "api", "methods": ["tokens", "GET"] } ] } script.py: | #!/usr/bin/env python3 """ Initialize MongoDB CE for local development. This script: 1. Initializes replica set (rs0) 2. Creates collections and indexes 3. Loads default admin scope from registry-admins.json Usage: python init-mongodb-ce.py """ import asyncio import json import logging import os import sys import time from pathlib import Path from typing import Optional from motor.motor_asyncio import AsyncIOMotorClient from pymongo import ASCENDING from pymongo.errors import ServerSelectionTimeoutError, OperationFailure # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Collection names COLLECTION_SERVERS = "mcp_servers" COLLECTION_AGENTS = "mcp_agents" COLLECTION_SCOPES = "mcp_scopes" COLLECTION_EMBEDDINGS = "mcp_embeddings_1536" COLLECTION_SECURITY_SCANS = "mcp_security_scans" COLLECTION_FEDERATION_CONFIG = "mcp_federation_config" COLLECTION_AUDIT_EVENTS = "audit_events" def _get_config_from_env() -> dict: """Get MongoDB CE configuration from environment variables.""" return { "host": os.getenv("DOCUMENTDB_HOST", "mongodb"), "port": int(os.getenv("DOCUMENTDB_PORT", "27017")), "database": os.getenv("DOCUMENTDB_DATABASE", "mcp_registry"), "namespace": os.getenv("DOCUMENTDB_NAMESPACE", "default"), "username": os.getenv("DOCUMENTDB_USERNAME", ""), "password": os.getenv("DOCUMENTDB_PASSWORD", ""), "replicaset": os.getenv("DOCUMENTDB_REPLICA_SET", "rs0"), } def _initialize_replica_set( host: str, port: int, username: str, password: str, ) -> None: """Initialize MongoDB replica set using pymongo (synchronous).""" from pymongo import MongoClient logger.info("Initializing MongoDB replica set...") try: # Connect without replica set for initialization client = MongoClient( f"mongodb://{username}:{password}@{host}:{port}/?authMechanism=SCRAM-SHA-256&authSource=admin", serverSelectionTimeoutMS=5000, directConnection=True, ) # Check if already initialized try: status = client.admin.command("replSetGetStatus") logger.info("Replica set already initialized") client.close() return except OperationFailure as e: if "no replset config has been received" in str(e).lower(): # Not initialized, proceed pass else: raise # Initialize replica set config = { "_id": "rs0", "members": [ {"_id": 0, "host": f"{host}:{port}"} ] } result = client.admin.command("replSetInitiate", config) logger.info(f"Replica set initialized: {result}") client.close() # Wait for replica set to elect primary logger.info("Waiting for replica set to elect primary...") time.sleep(10) except Exception as e: logger.error(f"Error initializing replica set: {e}") raise async def _create_standard_indexes( collection, collection_name: str, namespace: str, ) -> None: """Create standard indexes for collections.""" full_name = f"{collection_name}_{namespace}" if collection_name == COLLECTION_SERVERS: # Note: path is stored as _id, so no separate path index needed await collection.create_index([("enabled", ASCENDING)]) await collection.create_index([("tags", ASCENDING)]) await collection.create_index([("manifest.serverInfo.name", ASCENDING)]) logger.info(f"Created indexes for {full_name}") elif collection_name == COLLECTION_AGENTS: # Note: path is stored as _id, so no separate path index needed await collection.create_index([("enabled", ASCENDING)]) await collection.create_index([("tags", ASCENDING)]) await collection.create_index([("card.name", ASCENDING)]) logger.info(f"Created indexes for {full_name}") elif collection_name == COLLECTION_SCOPES: # No additional indexes needed - scopes use _id as primary key # group_mappings is an array, not indexed logger.info(f"Created indexes for {full_name}") elif collection_name == COLLECTION_EMBEDDINGS: # Note: path is stored as _id, so no separate path index needed await collection.create_index([("entity_type", ASCENDING)]) logger.info(f"Created indexes for {full_name} (vector search via app code)") elif collection_name == COLLECTION_SECURITY_SCANS: await collection.create_index([("server_path", ASCENDING)]) await collection.create_index([("scan_status", ASCENDING)]) await collection.create_index([("scanned_at", ASCENDING)]) logger.info(f"Created indexes for {full_name}") elif collection_name == COLLECTION_FEDERATION_CONFIG: await collection.create_index([("registry_name", ASCENDING)], unique=True) await collection.create_index([("enabled", ASCENDING)]) logger.info(f"Created indexes for {full_name}") elif collection_name == COLLECTION_AUDIT_EVENTS: # Indexes for audit event queries (Requirements 6.2) # Note: timestamp index is created as TTL index below, so we use compound indexes here await collection.create_index([("identity.username", ASCENDING), ("timestamp", ASCENDING)]) await collection.create_index([("action.operation", ASCENDING), ("timestamp", ASCENDING)]) await collection.create_index([("action.resource_type", ASCENDING), ("timestamp", ASCENDING)]) await collection.create_index([("request_id", ASCENDING)], unique=True) # TTL index for automatic expiration (Requirements 6.3) # This also serves as the timestamp index for sorting # Default 7 days (604800 seconds), configurable via AUDIT_LOG_MONGODB_TTL_DAYS ttl_days = int(os.getenv("AUDIT_LOG_MONGODB_TTL_DAYS", "7")) ttl_seconds = ttl_days * 24 * 60 * 60 await collection.create_index( [("timestamp", ASCENDING)], expireAfterSeconds=ttl_seconds, name="timestamp_ttl" ) logger.info(f"Created indexes for {full_name} (TTL: {ttl_days} days)") async def _load_default_scopes( db, namespace: str, ) -> None: """Load default scopes from JSON files into scopes collection. This loads all scope JSON files from the scripts directory: - registry-admins.json: Bootstrap admin scope with full permissions - mcp-registry-admin.json: MCP registry admin scope (Keycloak group) - mcp-servers-unrestricted-read.json: Read-only access to all servers - mcp-servers-unrestricted-execute.json: Full CRUD access to all servers """ collection_name = f"{COLLECTION_SCOPES}_{namespace}" collection = db[collection_name] # Find scope files in the same directory as this script script_dir = Path(__file__).parent # List of scope files to load (order matters - base scopes first) scope_files = [ "registry-admins.json", "mcp-registry-admin.json", "mcp-servers-unrestricted-read.json", "mcp-servers-unrestricted-execute.json", ] loaded_count = 0 for scope_filename in scope_files: scope_file = script_dir / scope_filename if not scope_file.exists(): logger.warning(f"Scope file not found: {scope_file}") continue try: with open(scope_file, "r") as f: scope_data = json.load(f) logger.info(f"Loading scope from {scope_filename}") # Upsert the scope document result = await collection.update_one( {"_id": scope_data["_id"]}, {"$set": scope_data}, upsert=True ) if result.upserted_id: logger.info(f"Inserted scope: {scope_data['_id']}") loaded_count += 1 elif result.modified_count > 0: logger.info(f"Updated scope: {scope_data['_id']}") loaded_count += 1 else: logger.info(f"Scope already up-to-date: {scope_data['_id']}") if "group_mappings" in scope_data: logger.info( f" group_mappings: {scope_data.get('group_mappings', [])}" ) except Exception as e: logger.error(f"Failed to load scope from {scope_filename}: {e}", exc_info=True) logger.info(f"Loaded {loaded_count} scopes into {collection_name}") async def _initialize_mongodb_ce() -> None: """Main initialization function.""" config = _get_config_from_env() logger.info("=" * 60) logger.info("MongoDB CE Initialization for MCP Gateway") logger.info("=" * 60) logger.info(f"Host: {config['host']}:{config['port']}") logger.info(f"Database: {config['database']}") logger.info(f"Namespace: {config['namespace']}") logger.info("") # Wait for MongoDB to be ready logger.info("Waiting for MongoDB to be ready...") time.sleep(10) # Initialize replica set (synchronous) _initialize_replica_set(config["host"], config["port"], config["username"], config["password"]) # Connect with motor for async operations connection_string = f"mongodb://{config['username']}:{config['password']}@{config['host']}:{config['port']}/{config['database']}?replicaSet={config['replicaset']}&authMechanism=SCRAM-SHA-256&authSource=admin" try: client = AsyncIOMotorClient( connection_string, serverSelectionTimeoutMS=10000, ) # Verify connection await client.admin.command("ping") logger.info("Connected to MongoDB successfully") db = client[config["database"]] namespace = config["namespace"] # Create collections and indexes logger.info("Creating collections and indexes...") collections = [ COLLECTION_SERVERS, COLLECTION_AGENTS, COLLECTION_SCOPES, COLLECTION_EMBEDDINGS, COLLECTION_SECURITY_SCANS, COLLECTION_FEDERATION_CONFIG, COLLECTION_AUDIT_EVENTS, ] for coll_name in collections: full_name = f"{coll_name}_{namespace}" # Check if collection already exists existing_collections = await db.list_collection_names() if full_name in existing_collections: logger.info(f"Collection {full_name} already exists, skipping creation") else: logger.info(f"Creating collection: {full_name}") await db.create_collection(full_name) # Create indexes (idempotent - MongoDB handles duplicates) collection = db[full_name] await _create_standard_indexes(collection, coll_name, namespace) # Load default admin scope await _load_default_scopes(db, namespace) logger.info("") logger.info("=" * 60) logger.info("MongoDB CE Initialization Complete!") logger.info("=" * 60) logger.info("Collections created:") for coll_name in collections: if coll_name == COLLECTION_EMBEDDINGS: logger.info(f" - {coll_name}_{namespace} (with vector search)") elif coll_name == COLLECTION_AUDIT_EVENTS: ttl_days = int(os.getenv("AUDIT_LOG_MONGODB_TTL_DAYS", "7")) logger.info(f" - {coll_name}_{namespace} (TTL: {ttl_days} days)") else: logger.info(f" - {coll_name}_{namespace}") logger.info("") logger.info("To use MongoDB CE:") logger.info(" export STORAGE_BACKEND=mongodb-ce") logger.info(" docker-compose up registry") logger.info("") logger.info("Or for AWS DocumentDB:") logger.info(" export STORAGE_BACKEND=documentdb") logger.info(" docker-compose up registry") logger.info("=" * 60) client.close() except ServerSelectionTimeoutError as e: logger.error(f"Failed to connect to MongoDB: {e}") logger.error("Make sure MongoDB is running and accessible") sys.exit(1) except Exception as e: logger.error(f"Error during initialization: {e}") raise def main() -> None: """Entry point.""" asyncio.run(_initialize_mongodb_ce()) if __name__ == "__main__": main() ================================================ FILE: charts/mongodb-configure/templates/job.yaml ================================================ {{- $existingSecret := .Values.mongodb.existingSecret | default .Values.global.existingMongoCredentialsSecret }} apiVersion: batch/v1 kind: Job metadata: name: setup-mongodb namespace: {{ .Release.Namespace | quote }} spec: template: spec: initContainers: - name: wait-for-mongodb image: public.ecr.aws/docker/library/python:3.13-slim command: ['sh', '-c'] envFrom: - secretRef: name: {{ $existingSecret | default "mongo-credentials" }} args: - | echo "Installing pymongo..." pip install --no-cache-dir pymongo echo "Waiting for MongoDB replica set to be ready..." python3 /app/wait.py volumeMounts: - mountPath: /app/wait.py name: script subPath: wait.py containers: - name: job image: public.ecr.aws/docker/library/python:3.13-slim command: ["/bin/sh", "-c"] args: [ "pip install --no-cache-dir pyyaml motor && python /app/script.py" ] envFrom: - secretRef: name: {{ $existingSecret | default "mongo-credentials" }} volumeMounts: - mountPath: /app/script.py name: script subPath: script.py - mountPath: /app/registry-admins.json name: script subPath: registry-admins.json - mountPath: /app/mcp-registry-admin.json name: script subPath: mcp-registry-admin.json - mountPath: /app/mcp-servers-unrestricted-execute.json name: script subPath: mcp-servers-unrestricted-execute.json - mountPath: /app/mcp-servers-unrestricted-read.json name: script subPath: mcp-servers-unrestricted-read.json restartPolicy: Never volumes: - name: script configMap: name: setup-mongodb backoffLimit: 4 ================================================ FILE: charts/mongodb-configure/templates/secret.yaml ================================================ {{- $existingSecret := .Values.mongodb.existingSecret | default .Values.global.existingMongoCredentialsSecret }} {{- if not $existingSecret }} apiVersion: v1 kind: Secret metadata: name: mongo-credentials namespace: {{ .Release.Namespace | quote }} data: DOCUMENTDB_DATABASE: {{.Values.mongodb.database | b64enc | quote}} DOCUMENTDB_HOST: {{ if contains "." .Values.mongodb.host }}{{ .Values.mongodb.host | b64enc | quote }}{{ else }}{{ printf "%s.%s.svc.cluster.local" .Values.mongodb.host .Release.Namespace | b64enc | quote }}{{ end }} DOCUMENTDB_NAMESPACE: {{.Values.mongodb.namespace | b64enc | quote}} DOCUMENTDB_PASSWORD: {{.Values.mongodb.password | b64enc | quote}} DOCUMENTDB_PORT: {{.Values.mongodb.port | toString | b64enc | quote}} DOCUMENTDB_REPLICA_SET: {{.Values.mongodb.replica_set | b64enc | quote}} DOCUMENTDB_USERNAME: {{.Values.mongodb.username | b64enc | quote}} DOCUMENTDB_USE_TLS: {{.Values.mongodb.use_tls | toString | b64enc | quote}} STORAGE_BACKEND: {{.Values.mongodb.storage_backend | b64enc | quote}} type: Opaque {{- end }} ================================================ FILE: charts/mongodb-configure/values.yaml ================================================ global: existingMongoCredentialsSecret: "" # If set, use this existing secret instead of creating one authProvider: type: keycloak entra: adminGroupId: # MongoDB configuration mongodb: database: mcp_registry # host: Can be either: # - A Kubernetes service name (e.g., "mcp-registry-mongodb-svc") - will be templated to include namespace # - A full hostname/FQDN (e.g., "mongodb.example.com" or "10.0.1.100") - will be used as-is host: mcp-registry-mongodb-svc namespace: default password: CHANGEME port: 27017 replica_set: mcp-registry-mongodb username: my-user use_tls: false storage_backend: mongodb-ce ================================================ FILE: charts/registry/Chart.yaml ================================================ apiVersion: v2 name: registry description: A Helm chart for registry for MCP Gateway Registry type: application version: 0.1.0 appVersion: "1.0.0" ================================================ FILE: charts/registry/templates/configmap-app-log.yaml ================================================ apiVersion: v1 kind: ConfigMap metadata: name: registry-app-log-config namespace: {{ .Release.Namespace | quote }} labels: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} data: APP_LOG_MAX_BYTES: {{ .Values.app.appLogMaxBytes | default "52428800" | quote }} APP_LOG_BACKUP_COUNT: {{ .Values.app.appLogBackupCount | default "5" | quote }} APP_LOG_CENTRALIZED_ENABLED: {{ .Values.app.appLogCentralizedEnabled | default "true" | quote }} APP_LOG_CENTRALIZED_TTL_DAYS: {{ .Values.app.appLogCentralizedTtlDays | default "1" | quote }} APP_LOG_MONGODB_BUFFER_SIZE: {{ .Values.app.appLogMongodbBufferSize | default "50" | quote }} APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS: {{ .Values.app.appLogMongodbFlushIntervalSeconds | default "5.0" | quote }} APP_LOG_LEVEL: {{ .Values.app.appLogLevel | default "INFO" | quote }} APP_LOG_EXCLUDED_LOGGERS: {{ .Values.app.appLogExcludedLoggers | default "uvicorn.access,httpx,pymongo,motor" | quote }} ================================================ FILE: charts/registry/templates/configmap-otel.yaml ================================================ apiVersion: v1 kind: ConfigMap metadata: name: registry-otel-config namespace: {{ .Release.Namespace | quote }} labels: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} data: {{- if .Values.app.otelOtlpEndpoint }} OTEL_OTLP_ENDPOINT: {{ .Values.app.otelOtlpEndpoint | quote }} {{- end }} {{- if .Values.app.otelExporterOtlpHeaders }} OTEL_EXPORTER_OTLP_HEADERS: {{ .Values.app.otelExporterOtlpHeaders | quote }} {{- end }} {{- if .Values.app.otelOtlpExportIntervalMs }} OTEL_OTLP_EXPORT_INTERVAL_MS: {{ .Values.app.otelOtlpExportIntervalMs | quote }} {{- end }} {{- if .Values.app.otelExporterOtlpMetricsTemporalityPreference }} OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: {{ .Values.app.otelExporterOtlpMetricsTemporalityPreference | quote }} {{- end }} {{- if .Values.app.mcpTelemetryDisabled }} MCP_TELEMETRY_DISABLED: {{ .Values.app.mcpTelemetryDisabled | quote }} {{- end }} {{- if .Values.app.mcpTelemetryOptOut }} MCP_TELEMETRY_OPT_OUT: {{ .Values.app.mcpTelemetryOptOut | quote }} {{- end }} {{- if .Values.app.telemetryHeartbeatIntervalMinutes }} MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES: {{ .Values.app.telemetryHeartbeatIntervalMinutes | quote }} {{- end }} {{- if .Values.app.telemetryDebug }} MCP_TELEMETRY_DEBUG: {{ .Values.app.telemetryDebug | quote }} {{- end }} {{- if .Values.app.disableAiRegistryToolsServer }} DISABLE_AI_REGISTRY_TOOLS_SERVER: {{ .Values.app.disableAiRegistryToolsServer | quote }} {{- end }} ================================================ FILE: charts/registry/templates/deployment.yaml ================================================ apiVersion: apps/v1 kind: Deployment metadata: name: {{ .Values.app.name }} namespace: {{ .Release.Namespace | quote }} labels: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} spec: replicas: {{ .Values.app.replicas }} selector: matchLabels: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} template: metadata: labels: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} spec: {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} {{- end }} securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 containers: - name: {{ .Values.app.name }} image: "{{ .Values.global.image.repository }}:{{ .Values.global.image.tag }}" imagePullPolicy: {{ .Values.global.image.pullPolicy }} securityContext: allowPrivilegeEscalation: false capabilities: drop: - ALL ports: - containerPort: 8080 name: http - containerPort: 8443 name: https - containerPort: 7860 name: registry resources: {{- toYaml .Values.resources | nindent 12 }} envFrom: - configMapRef: name: registry-otel-config - configMapRef: name: registry-app-log-config - secretRef: name: {{ .Values.app.existingSecret | default .Values.app.envSecretName }} {{- if eq (.Values.global.authProvider.type | default "keycloak") "keycloak" }} - secretRef: name: keycloak-client-secret {{- end }} - secretRef: name: {{ .Values.global.existingMongoCredentialsSecret | default "mongo-credentials" }} {{- if .Values.global.sharedSecretName }} - secretRef: name: {{ .Values.global.existingSharedSecret | default .Values.global.sharedSecretName }} {{- end }} {{- if .Values.global.oauthProviderSecretName }} - secretRef: name: {{ .Values.global.existingOauthProviderSecret | default .Values.global.oauthProviderSecretName }} {{- end }} env: - name: DEPLOYMENT_MODE value: {{ .Values.app.deploymentMode | default "with-gateway" | quote }} - name: REGISTRY_MODE value: {{ .Values.app.registryMode | default "full" | quote }} - name: SHOW_SERVERS_TAB value: {{ .Values.app.showServersTab | default true | quote }} - name: SHOW_VIRTUAL_SERVERS_TAB value: {{ .Values.app.showVirtualServersTab | default true | quote }} - name: SHOW_SKILLS_TAB value: {{ .Values.app.showSkillsTab | default true | quote }} - name: SHOW_AGENTS_TAB value: {{ .Values.app.showAgentsTab | default true | quote }} {{- if .Values.awsRegistry.federationEnabled }} - name: AWS_REGISTRY_FEDERATION_ENABLED value: "true" {{- end }} {{- if eq (.Values.global.authProvider.type | default "keycloak") "keycloak" }} - name: KEYCLOAK_ADMIN_PASSWORD valueFrom: secretKeyRef: name: {{ .Release.Name }}-keycloak key: admin-password {{- end }} {{- if .Values.entra.clientSecretExistingSecret }} - name: ENTRA_CLIENT_SECRET valueFrom: secretKeyRef: name: {{ .Values.entra.clientSecretExistingSecret }} key: {{ .Values.entra.clientSecretExistingSecretKey }} {{- end }} {{- if .Values.okta.clientSecretExistingSecret }} - name: OKTA_CLIENT_SECRET valueFrom: secretKeyRef: name: {{ .Values.okta.clientSecretExistingSecret }} key: {{ .Values.okta.clientSecretExistingSecretKey }} {{- end }} {{- if .Values.okta.m2mClientSecretExistingSecret }} - name: OKTA_M2M_CLIENT_SECRET valueFrom: secretKeyRef: name: {{ .Values.okta.m2mClientSecretExistingSecret }} key: {{ .Values.okta.m2mClientSecretExistingSecretKey }} {{- end }} {{- if .Values.okta.apiTokenExistingSecret }} - name: OKTA_API_TOKEN valueFrom: secretKeyRef: name: {{ .Values.okta.apiTokenExistingSecret }} key: {{ .Values.okta.apiTokenExistingSecretKey }} {{- end }} {{- if .Values.auth0.clientSecretExistingSecret }} - name: AUTH0_CLIENT_SECRET valueFrom: secretKeyRef: name: {{ .Values.auth0.clientSecretExistingSecret }} key: {{ .Values.auth0.clientSecretExistingSecretKey }} {{- end }} {{- if .Values.auth0.m2mClientSecretExistingSecret }} - name: AUTH0_M2M_CLIENT_SECRET valueFrom: secretKeyRef: name: {{ .Values.auth0.m2mClientSecretExistingSecret }} key: {{ .Values.auth0.m2mClientSecretExistingSecretKey }} {{- end }} {{- if .Values.auth0.managementApiTokenExistingSecret }} - name: AUTH0_MANAGEMENT_API_TOKEN valueFrom: secretKeyRef: name: {{ .Values.auth0.managementApiTokenExistingSecret }} key: {{ .Values.auth0.managementApiTokenExistingSecretKey }} {{- end }} {{- if .Values.app.registryApiKeysExistingSecret }} - name: REGISTRY_API_KEYS valueFrom: secretKeyRef: name: {{ .Values.app.registryApiKeysExistingSecret }} key: {{ .Values.app.registryApiKeysExistingSecretKey }} {{- end }} {{- if .Values.ans.apiKeyExistingSecret }} - name: ANS_API_KEY valueFrom: secretKeyRef: name: {{ .Values.ans.apiKeyExistingSecret }} key: {{ .Values.ans.apiKeyExistingSecretKey }} {{- end }} {{- if .Values.ans.apiSecretExistingSecret }} - name: ANS_API_SECRET valueFrom: secretKeyRef: name: {{ .Values.ans.apiSecretExistingSecret }} key: {{ .Values.ans.apiSecretExistingSecretKey }} {{- end }} livenessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 3 ================================================ FILE: charts/registry/templates/ingress.yaml ================================================ {{- if .Values.ingress.enabled }} {{- $routingMode := .Values.global.ingress.routingMode | default "subdomain" }} {{- $domain := .Values.global.domain | default .Values.ingress.hostname }} {{- $pathPrefix := .Values.global.ingress.paths.registry | default .Values.ingress.path | default "/registry" }} apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: {{ .Values.app.name }} namespace: {{ .Release.Namespace | quote }} annotations: {{- if eq $routingMode "path" }} alb.ingress.kubernetes.io/group.name: mcp-gateway-stack alb.ingress.kubernetes.io/group.order: '20' {{- end }} alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS": 443}]' alb.ingress.kubernetes.io/scheme: internet-facing alb.ingress.kubernetes.io/ssl-redirect: '443' alb.ingress.kubernetes.io/target-type: ip alb.ingress.kubernetes.io/success-codes: 200,302 {{- if .Values.global.ingress.inboundCidrs }} alb.ingress.kubernetes.io/inbound-cidrs: {{ .Values.global.ingress.inboundCidrs }} {{- end }} spec: ingressClassName: {{ .Values.ingress.className }} rules: {{- if eq $routingMode "path" }} - host: {{ $domain | quote }} http: paths: - path: {{ $pathPrefix }} pathType: Prefix backend: service: name: {{ .Values.app.name }} port: name: http {{- else }} - host: {{ printf "mcpregistry.%s" $domain | quote }} http: paths: - path: / pathType: Prefix backend: service: name: {{ .Values.app.name }} port: name: http {{- end }} {{- end }} ================================================ FILE: charts/registry/templates/secret.yaml ================================================ {{- if and .Values.entra.clientSecret .Values.entra.clientSecretExistingSecret }} {{- fail "Cannot set both entra.clientSecret and entra.clientSecretExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.okta.clientSecret .Values.okta.clientSecretExistingSecret }} {{- fail "Cannot set both okta.clientSecret and okta.clientSecretExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.okta.m2mClientSecret .Values.okta.m2mClientSecretExistingSecret }} {{- fail "Cannot set both okta.m2mClientSecret and okta.m2mClientSecretExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.okta.apiToken .Values.okta.apiTokenExistingSecret }} {{- fail "Cannot set both okta.apiToken and okta.apiTokenExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.auth0.clientSecret .Values.auth0.clientSecretExistingSecret }} {{- fail "Cannot set both auth0.clientSecret and auth0.clientSecretExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.auth0.m2mClientSecret .Values.auth0.m2mClientSecretExistingSecret }} {{- fail "Cannot set both auth0.m2mClientSecret and auth0.m2mClientSecretExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.auth0.managementApiToken .Values.auth0.managementApiTokenExistingSecret }} {{- fail "Cannot set both auth0.managementApiToken and auth0.managementApiTokenExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.ans.apiKey .Values.ans.apiKeyExistingSecret }} {{- fail "Cannot set both ans.apiKey and ans.apiKeyExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.ans.apiSecret .Values.ans.apiSecretExistingSecret }} {{- fail "Cannot set both ans.apiSecret and ans.apiSecretExistingSecret — env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if and .Values.app.registryApiKeys .Values.app.registryApiKeysExistingSecret }} {{- fail "Cannot set both app.registryApiKeys and app.registryApiKeysExistingSecret, env overrides envFrom and can cause confusing behavior" }} {{- end }} {{- if not .Values.app.existingSecret }} {{- $routingMode := .Values.global.ingress.routingMode | default "subdomain" }} {{- $domain := .Values.global.domain | default "localhost" }} {{- $protocol := ternary "https" "http" .Values.global.ingress.tls }} {{- $authServerPath := .Values.global.ingress.paths.authServer | default "/auth-server" }} {{- $registryPath := .Values.global.ingress.paths.registry | default "/registry" }} {{- $keycloakPath := .Values.global.ingress.paths.keycloak | default "/keycloak" }} {{- $authServerExternalUrl := "" }} {{- $registryExternalUrl := "" }} {{- $keycloakUrl := printf "http://%s-keycloak-headless.%s.svc.cluster.local:8080" .Release.Name .Release.Namespace}} {{- $rootPath := "" }} {{- $gatewayAdditionalServerNames := $domain}} {{- if eq $routingMode "path" }} {{- $authServerExternalUrl = printf "%s://%s%s" $protocol $domain $authServerPath }} {{- $registryExternalUrl = printf "%s://%s%s" $protocol $domain $registryPath }} {{- $keycloakUrl = printf "%s%s" $keycloakUrl $keycloakPath }} {{- $rootPath = $registryPath }} {{- else }} {{- $authServerExternalUrl = printf "%s://auth-server.%s" $protocol $domain }} {{- $registryExternalUrl = printf "%s://mcpregistry.%s" $protocol $domain }} {{- $gatewayAdditionalServerNames = printf "mcpregistry.%s" $domain }} {{- end }} {{- /* Auto-generate federation tokens if not provided */ -}} {{- /* Resolve federation values - prefer global, fallback to local app values */ -}} {{- $federationEnabled := .Values.app.federationStaticTokenAuthEnabled | default false }} {{- if .Values.global.federation }} {{- $federationEnabled = .Values.global.federation.staticTokenAuthEnabled | default $federationEnabled }} {{- end }} {{- $federationStaticTokenRaw := .Values.app.federationStaticToken }} {{- $federationEncryptionKeyRaw := .Values.app.federationEncryptionKey }} {{- $registryId := .Values.app.registryId }} {{- if .Values.global.federation }} {{- $federationStaticTokenRaw = .Values.global.federation.staticToken | default $federationStaticTokenRaw }} {{- $federationEncryptionKeyRaw = .Values.global.federation.encryptionKey | default $federationEncryptionKeyRaw }} {{- $registryId = .Values.global.federation.registryId | default $registryId }} {{- end }} {{- /* Generate URL-safe token (equivalent to secrets.token_urlsafe(32)) */ -}} {{- $federationStaticToken := $federationStaticTokenRaw | default (randBytes 32 | replace "+" "-" | replace "/" "_" | trimSuffix "=") }} {{- /* Generate Fernet-compatible key (32 random bytes, base64-encoded) */ -}} {{- $federationEncryptionKey := $federationEncryptionKeyRaw | default (randBytes 32) }} apiVersion: v1 kind: Secret metadata: name: {{ .Values.app.envSecretName }} namespace: {{ .Release.Namespace | quote }} data: AUTH_SERVER_EXTERNAL_URL: {{ $authServerExternalUrl | b64enc | quote }} AUTH_SERVER_URL: {{ printf "http://auth-server.%s.svc.cluster.local:8888" .Release.Namespace | b64enc | quote }} {{- if eq (.Values.global.authProvider.type | default "keycloak") "keycloak" }} KEYCLOAK_ADMIN: {{ (.Values.global.authProvider.keycloak.adminUsername | default "user") | b64enc | quote }} {{- end }} GATEWAY_ADDITIONAL_SERVER_NAMES: {{ $gatewayAdditionalServerNames| b64enc | quote }} {{- if not .Values.global.oauthProviderSecretName }} {{/* OAuth provider vars managed per-chart in standalone deployment */}} AUTH_PROVIDER: {{ (.Values.global.authProvider.type | default "keycloak") | b64enc | quote }} {{- if eq (.Values.global.authProvider.type | default "keycloak") "keycloak" }} KEYCLOAK_ENABLED: {{ "true" | b64enc | quote }} KEYCLOAK_URL: {{ $keycloakUrl | b64enc | quote }} KEYCLOAK_REALM: {{ (.Values.global.authProvider.keycloak.realm | default "mcp-gateway") | b64enc | quote }} {{- else if eq (.Values.global.authProvider.type | default "keycloak") "entra" }} ENTRA_ENABLED: {{ "true" | b64enc | quote }} ENTRA_CLIENT_ID: {{ .Values.entra.clientId | b64enc | quote }} {{- if not .Values.entra.clientSecretExistingSecret }} ENTRA_CLIENT_SECRET: {{ .Values.entra.clientSecret | b64enc | quote }} {{- end }} ENTRA_TENANT_ID: {{ .Values.entra.tenantId | b64enc | quote }} {{- else if eq (.Values.global.authProvider.type | default "keycloak") "okta" }} OKTA_ENABLED: {{ "true" | b64enc | quote }} OKTA_DOMAIN: {{ .Values.okta.domain | b64enc | quote }} OKTA_CLIENT_ID: {{ .Values.okta.clientId | b64enc | quote }} {{- if not .Values.okta.clientSecretExistingSecret }} OKTA_CLIENT_SECRET: {{ .Values.okta.clientSecret | b64enc | quote }} {{- end }} {{- if .Values.okta.m2mClientId }} OKTA_M2M_CLIENT_ID: {{ .Values.okta.m2mClientId | b64enc | quote }} {{- end }} {{- if and .Values.okta.m2mClientSecret (not .Values.okta.m2mClientSecretExistingSecret) }} OKTA_M2M_CLIENT_SECRET: {{ .Values.okta.m2mClientSecret | b64enc | quote }} {{- end }} {{- if and .Values.okta.apiToken (not .Values.okta.apiTokenExistingSecret) }} OKTA_API_TOKEN: {{ .Values.okta.apiToken | b64enc | quote }} {{- end }} {{- if .Values.okta.authServerId }} OKTA_AUTH_SERVER_ID: {{ .Values.okta.authServerId | b64enc | quote }} {{- end }} {{- else if eq (.Values.global.authProvider.type | default "keycloak") "auth0" }} AUTH0_ENABLED: {{ "true" | b64enc | quote }} AUTH0_DOMAIN: {{ .Values.auth0.domain | b64enc | quote }} AUTH0_CLIENT_ID: {{ .Values.auth0.clientId | b64enc | quote }} {{- if not .Values.auth0.clientSecretExistingSecret }} AUTH0_CLIENT_SECRET: {{ .Values.auth0.clientSecret | b64enc | quote }} {{- end }} {{- if .Values.auth0.audience }} AUTH0_AUDIENCE: {{ .Values.auth0.audience | b64enc | quote }} {{- end }} AUTH0_GROUPS_CLAIM: {{ .Values.auth0.groupsClaim | b64enc | quote }} {{- if .Values.auth0.m2mClientId }} AUTH0_M2M_CLIENT_ID: {{ .Values.auth0.m2mClientId | b64enc | quote }} {{- end }} {{- if and .Values.auth0.m2mClientSecret (not .Values.auth0.m2mClientSecretExistingSecret) }} AUTH0_M2M_CLIENT_SECRET: {{ .Values.auth0.m2mClientSecret | b64enc | quote }} {{- end }} {{- if and .Values.auth0.managementApiToken (not .Values.auth0.managementApiTokenExistingSecret) }} AUTH0_MANAGEMENT_API_TOKEN: {{ .Values.auth0.managementApiToken | b64enc | quote }} {{- end }} {{- else if eq (.Values.global.authProvider.type | default "keycloak") "cognito" }} COGNITO_ENABLED: {{ "true" | b64enc | quote }} COGNITO_USER_POOL_ID: {{ required "cognito.userPoolId is required" .Values.cognito.userPoolId | b64enc | quote }} COGNITO_CLIENT_ID: {{ required "cognito.clientId is required" .Values.cognito.clientId | b64enc | quote }} COGNITO_CLIENT_SECRET: {{ required "cognito.clientSecret is required" .Values.cognito.clientSecret | b64enc | quote }} {{- if .Values.cognito.domain }} COGNITO_DOMAIN: {{ .Values.cognito.domain | b64enc | quote }} {{- end }} {{- if .Values.cognito.region }} AWS_REGION: {{ .Values.cognito.region | b64enc | quote }} {{- end }} {{- end }} {{- end }} ROOT_PATH: {{ $rootPath | b64enc | quote }} {{- if .Values.idpGroupFilterPrefix }} IDP_GROUP_FILTER_PREFIX: {{ .Values.idpGroupFilterPrefix | b64enc | quote }} {{- end }} SKILL_SECURITY_SCAN_ENABLED: {{ .Values.app.skillSecurityScanEnabled | toString | b64enc | quote }} SKILL_SECURITY_ANALYZERS: {{ .Values.app.skillSecurityAnalyzers | b64enc | quote }} # Static API keys for registry path authentication (JSON string) {{- if and .Values.app.registryApiKeys (not .Values.app.registryApiKeysExistingSecret) }} REGISTRY_API_KEYS: {{ .Values.app.registryApiKeys | b64enc | quote }} {{- end }} # Registration webhook (issue #742) {{- if .Values.app.registrationWebhookUrl }} REGISTRATION_WEBHOOK_URL: {{ .Values.app.registrationWebhookUrl | b64enc | quote }} {{- end }} REGISTRATION_WEBHOOK_AUTH_HEADER: {{ .Values.app.registrationWebhookAuthHeader | b64enc | quote }} {{- if .Values.app.registrationWebhookAuthToken }} REGISTRATION_WEBHOOK_AUTH_TOKEN: {{ .Values.app.registrationWebhookAuthToken | b64enc | quote }} {{- end }} REGISTRATION_WEBHOOK_TIMEOUT_SECONDS: {{ .Values.app.registrationWebhookTimeoutSeconds | toString | b64enc | quote }} # Registration gate / admission control (issue #809) REGISTRATION_GATE_ENABLED: {{ .Values.app.registrationGateEnabled | toString | b64enc | quote }} {{- if .Values.app.registrationGateUrl }} REGISTRATION_GATE_URL: {{ .Values.app.registrationGateUrl | b64enc | quote }} {{- end }} REGISTRATION_GATE_AUTH_TYPE: {{ .Values.app.registrationGateAuthType | b64enc | quote }} {{- if .Values.app.registrationGateAuthCredential }} REGISTRATION_GATE_AUTH_CREDENTIAL: {{ .Values.app.registrationGateAuthCredential | b64enc | quote }} {{- end }} REGISTRATION_GATE_AUTH_HEADER_NAME: {{ .Values.app.registrationGateAuthHeaderName | b64enc | quote }} REGISTRATION_GATE_TIMEOUT_SECONDS: {{ .Values.app.registrationGateTimeoutSeconds | toString | b64enc | quote }} REGISTRATION_GATE_MAX_RETRIES: {{ .Values.app.registrationGateMaxRetries | toString | b64enc | quote }} # M2M direct client registration (issue #851) M2M_DIRECT_REGISTRATION_ENABLED: {{ .Values.app.m2mDirectRegistrationEnabled | toString | b64enc | quote }} # ANS (Agent Name Service) Integration {{- if .Values.ans.enabled }} ANS_INTEGRATION_ENABLED: {{ "true" | b64enc | quote }} ANS_API_ENDPOINT: {{ .Values.ans.apiEndpoint | b64enc | quote }} {{- if and .Values.ans.apiKey (not .Values.ans.apiKeyExistingSecret) }} ANS_API_KEY: {{ .Values.ans.apiKey | b64enc | quote }} {{- end }} {{- if and .Values.ans.apiSecret (not .Values.ans.apiSecretExistingSecret) }} ANS_API_SECRET: {{ .Values.ans.apiSecret | b64enc | quote }} {{- end }} {{- if .Values.ans.apiTimeoutSeconds }} ANS_API_TIMEOUT_SECONDS: {{ .Values.ans.apiTimeoutSeconds | toString | b64enc | quote }} {{- end }} {{- if .Values.ans.syncIntervalHours }} ANS_SYNC_INTERVAL_HOURS: {{ .Values.ans.syncIntervalHours | toString | b64enc | quote }} {{- end }} {{- if .Values.ans.verificationCacheTtlSeconds }} ANS_VERIFICATION_CACHE_TTL_SECONDS: {{ .Values.ans.verificationCacheTtlSeconds | toString | b64enc | quote }} {{- end }} {{- end }} # Registry Card Configuration REGISTRY_URL: {{ (.Values.registryCard.url | default $registryExternalUrl) | b64enc | quote }} REGISTRY_NAME: {{ (.Values.registryCard.name | default "AI Registry") | b64enc | quote }} REGISTRY_ORGANIZATION_NAME: {{ (.Values.registryCard.organizationName | default "ACME Inc.") | b64enc | quote }} REGISTRY_DESCRIPTION: {{ (.Values.registryCard.description | default "") | b64enc | quote }} REGISTRY_CONTACT_EMAIL: {{ (.Values.registryCard.contactEmail | default "") | b64enc | quote }} REGISTRY_CONTACT_URL: {{ (.Values.registryCard.contactUrl | default "") | b64enc | quote }} {{- if not .Values.global.sharedSecretName }} {{/* Federation and SECRET_KEY managed per-chart in standalone deployment */}} {{- if $federationEnabled }} FEDERATION_STATIC_TOKEN_AUTH_ENABLED: {{ $federationEnabled | toString | b64enc | quote }} FEDERATION_STATIC_TOKEN: {{ $federationStaticToken | b64enc | quote }} FEDERATION_ENCRYPTION_KEY: {{ $federationEncryptionKey | b64enc | quote }} {{- if .Values.app.federationTokenEndpoint }} FEDERATION_TOKEN_ENDPOINT: {{ .Values.app.federationTokenEndpoint | b64enc | quote }} {{- end }} {{- if .Values.app.federationClientId }} FEDERATION_CLIENT_ID: {{ .Values.app.federationClientId | b64enc | quote }} {{- end }} {{- if .Values.app.federationClientSecret }} FEDERATION_CLIENT_SECRET: {{ .Values.app.federationClientSecret | b64enc | quote }} {{- end }} {{- end }} {{- if $registryId }} REGISTRY_ID: {{ $registryId | b64enc | quote }} {{- end }} {{- if .Values.app.asorAccessToken }} ASOR_ACCESS_TOKEN: {{ .Values.app.asorAccessToken | b64enc | quote }} {{- end }} {{- if .Values.app.workdayTokenUrl }} WORKDAY_TOKEN_URL: {{ .Values.app.workdayTokenUrl | b64enc | quote }} {{- end }} {{/* SECRET_KEY required for standalone deployment - must match auth-server's key */}} SECRET_KEY: {{ required "app.secretKey or global.secretKey is required for standalone deployment" (.Values.global.secretKey | default .Values.app.secretKey) | b64enc | quote }} {{- end }} {{- end }} ================================================ FILE: charts/registry/templates/service.yaml ================================================ apiVersion: v1 kind: Service metadata: name: {{ .Values.app.name }} namespace: {{ .Release.Namespace | quote }} {{- with .Values.service.annotations }} annotations: {{- toYaml . | nindent 4 }} {{- end }} spec: type: {{ .Values.service.type }} ports: - port: {{ .Values.service.port }} targetPort: http protocol: TCP name: http - port: 443 targetPort: https protocol: TCP name: https - port: 7860 targetPort: registry protocol: TCP name: registry selector: app.kubernetes.io/name: {{ .Values.app.name }} app.kubernetes.io/component: {{ .Values.app.name }} ================================================ FILE: charts/registry/values.yaml ================================================ # Global configuration global: image: repository: public.ecr.aws/p3v1o3c6/registry tag: 1.0.21 pullPolicy: IfNotPresent # Application configuration app: name: registry replicas: 1 envSecretName: registry-secret existingSecret: "" # If set, use this existing secret instead of creating one # External URLs authServerUrl: http://localhost:8888 # Security settings # secretKey: If not provided, a random 64-character key is auto-generated. # When deployed via mcp-gateway-registry-stack, the key is shared with auth-server. # Uncomment to use a specific key: # secretKey: "your-secure-key-here" # Federation configuration federationStaticTokenAuthEnabled: false #If not provided, defaults to false federationStaticToken: # If not provided, a random token is auto-generated federationEncryptionKey: # If not provided, a Fernet key is auto-generated registryId: # Unique identifier for this registry instance (optional) # Federation OAuth2 authentication (alternative to static token for outbound peer connections) federationTokenEndpoint: "" # OAuth2 token endpoint for federation authentication federationClientId: "" # OAuth2 client ID for federation federationClientSecret: "" # OAuth2 client secret for federation # ASOR (Workday) integration (independent of peer federation) asorAccessToken: "" # Pre-obtained access token for ASOR federation workdayTokenUrl: "" # Workday OAuth2 token endpoint URL # Skill security scanning configuration skillSecurityScanEnabled: true # Enable/disable skill security scanning skillSecurityAnalyzers: "static" # Comma-separated: static, behavioral, llm, meta, virustotal, ai-defense # Static API keys for registry path authentication (JSON string) # Configures multiple static API keys that fall through to JWT validation when unmatched registryApiKeys: "" registryApiKeysExistingSecret: "" # If set, read REGISTRY_API_KEYS from this K8s secret instead registryApiKeysExistingSecretKey: "REGISTRY_API_KEYS" # Key within the existing secret # Registration webhook (issue #742) # Fire an async POST when a server, agent, or skill is registered or deleted. registrationWebhookUrl: "" # Webhook URL. Disabled when empty. registrationWebhookAuthHeader: "Authorization" # If "Authorization", Bearer is auto-prepended. registrationWebhookAuthToken: "" # Auth token. Leave empty for unauthenticated webhooks. registrationWebhookTimeoutSeconds: "10" # Registration gate / admission control (issue #809) # Calls an external endpoint to approve or deny registrations and updates # BEFORE they are persisted. Fail-closed when gate is unreachable. registrationGateEnabled: false registrationGateUrl: "" # Gate URL. Must be set when enabled. registrationGateAuthType: "none" # none, api_key, or bearer registrationGateAuthCredential: "" # Credential for api_key or bearer registrationGateAuthHeaderName: "X-Api-Key" # Header for api_key auth registrationGateTimeoutSeconds: "5" registrationGateMaxRetries: "2" # M2M direct client registration (issue #851) # Exposes /api/iam/m2m-clients admin API for registering M2M client_ids and # their group mappings directly, without requiring an IdP Admin API token. m2mDirectRegistrationEnabled: true # OpenTelemetry direct OTLP push export configuration otelOtlpEndpoint: "" # OTLP endpoint URL (e.g., https://otlp.datadoghq.com) otelExporterOtlpHeaders: "" # OTLP headers (e.g., dd-api-key=YOUR_KEY) otelOtlpExportIntervalMs: "30000" # Export interval in milliseconds otelExporterOtlpMetricsTemporalityPreference: "cumulative" # cumulative or delta # Telemetry configuration # Anonymous usage telemetry (startup ping + daily heartbeat, both on by default) mcpTelemetryDisabled: false # Set to true to disable all telemetry mcpTelemetryOptOut: false # Set to true to disable daily heartbeat only (startup ping still sent) telemetryHeartbeatIntervalMinutes: "1440" # Heartbeat interval in minutes (default: 1440 = 24 hours) telemetryDebug: false # Set to true to log payloads instead of sending # Demo server configuration disableAiRegistryToolsServer: false # Set to true to disable auto-registration of the built-in airegistry-tools server # Deployment mode: with-gateway (nginx integration) or registry-only (catalog only) deploymentMode: with-gateway # Registry mode: full, skills-only, mcp-servers-only, agents-only registryMode: full # Tab visibility overrides (AND-ed with registryMode) showServersTab: true showVirtualServersTab: true showSkillsTab: true showAgentsTab: true # Application Log Configuration (centralized log rotation and retrieval) appLogMaxBytes: "52428800" # Max size per log file before rotation (default 50 MB) appLogBackupCount: "5" # Number of rotated backup log files to keep appLogCentralizedEnabled: "true" # Write application logs to centralized store (requires MongoDB backend) appLogCentralizedTtlDays: "1" # Days to retain log entries in centralized store (TTL index) appLogMongodbBufferSize: "50" # Records to buffer before flushing to MongoDB appLogMongodbFlushIntervalSeconds: "5.0" # Seconds between periodic flushes appLogLevel: "INFO" # Application log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) appLogExcludedLoggers: "uvicorn.access,httpx,pymongo,motor" # Comma-separated logger names to exclude from MongoDB # AWS Agent Registry Federation awsRegistry: federationEnabled: false # Enable AWS Agent Registry federation (overrides MongoDB config on startup) # ANS (Agent Name Service) Integration ans: enabled: false # Enable ANS integration for trust verification apiEndpoint: "https://api.godaddy.com" # ANS API base URL apiKey: "" # GoDaddy API key (required when enabled) apiKeyExistingSecret: "" # If set, read ANS_API_KEY from this K8s secret instead of apiKey apiKeyExistingSecretKey: "ANS_API_KEY" # Key within the existing secret apiSecret: "" # GoDaddy API secret (required when enabled) apiSecretExistingSecret: "" # If set, read ANS_API_SECRET from this K8s secret instead of apiSecret apiSecretExistingSecretKey: "ANS_API_SECRET" # Key within the existing secret apiTimeoutSeconds: "30" # HTTP request timeout for ANS API calls syncIntervalHours: "6" # Background re-verification interval verificationCacheTtlSeconds: "3600" # Cache TTL for verification results # Registry Card Configuration (for federation and discovery) registryCard: url: "" # External URL of the registry (e.g., https://registry.example.com). Defaults to computed ingress URL. name: "AI Registry" # Human-readable name organizationName: "ACME Inc." # Organization operating the registry description: "" # Optional description contactEmail: "" # Optional contact email contactUrl: "" # Optional contact URL/website # Entra ID integration (used when authProvider.type = "entra" in standalone deployment) entra: clientId: "" clientSecret: "" clientSecretExistingSecret: "" # If set, read ENTRA_CLIENT_SECRET from this K8s secret instead of clientSecret clientSecretExistingSecretKey: "ENTRA_CLIENT_SECRET" # Key within the existing secret tenantId: "" # IdP group filtering (applies to all identity providers) # When set, only groups matching any of these prefixes are shown in IAM > Groups # Example: "mcp-,registry-,ai-" idpGroupFilterPrefix: "" # Okta integration (used when authProvider.type = "okta" in standalone deployment) okta: domain: "" # e.g., dev-123456.okta.com clientId: "" clientSecret: "" clientSecretExistingSecret: "" # If set, read OKTA_CLIENT_SECRET from this K8s secret clientSecretExistingSecretKey: "OKTA_CLIENT_SECRET" m2mClientId: "" # Optional: defaults to clientId m2mClientSecret: "" # Optional: defaults to clientSecret m2mClientSecretExistingSecret: "" # If set, read OKTA_M2M_CLIENT_SECRET from this K8s secret m2mClientSecretExistingSecretKey: "OKTA_M2M_CLIENT_SECRET" apiToken: "" # Optional: required for IAM operations apiTokenExistingSecret: "" # If set, read OKTA_API_TOKEN from this K8s secret apiTokenExistingSecretKey: "OKTA_API_TOKEN" authServerId: "" # Optional: uses default Org Authorization Server if not set # Auth0 integration (used when authProvider.type = "auth0" in standalone deployment) auth0: domain: "" # e.g., your-tenant.us.auth0.com clientId: "" clientSecret: "" clientSecretExistingSecret: "" # If set, read AUTH0_CLIENT_SECRET from this K8s secret clientSecretExistingSecretKey: "AUTH0_CLIENT_SECRET" audience: "" # Optional: API audience for M2M tokens groupsClaim: "https://mcp-gateway/groups" # Custom namespaced claim for groups m2mClientId: "" # Required for IAM Management (user/role administration) m2mClientSecret: "" # Required for IAM Management m2mClientSecretExistingSecret: "" # If set, read AUTH0_M2M_CLIENT_SECRET from this K8s secret m2mClientSecretExistingSecretKey: "AUTH0_M2M_CLIENT_SECRET" managementApiToken: "" # Optional: alternative to M2M credentials (expires after 24h) managementApiTokenExistingSecret: "" # If set, read AUTH0_MANAGEMENT_API_TOKEN from this K8s secret managementApiTokenExistingSecretKey: "AUTH0_MANAGEMENT_API_TOKEN" # Cognito integration (used when authProvider.type = "cognito" in standalone deployment) cognito: userPoolId: "" # Cognito User Pool ID clientId: "" clientSecret: "" domain: "" # Optional: custom Cognito domain region: "us-east-1" # AWS region for the User Pool # Service configuration service: type: ClusterIP port: 8000 annotations: {} # Resource limits and requests resources: requests: cpu: 1 memory: 1Gi limits: cpu: 2 memory: 2Gi # Ingress configuration ingress: enabled: false className: alb hostname: "" annotations: {} tls: false # Routing mode: "subdomain" or "path" # - subdomain: mcpregistry.domain.com # - path: domain.com/registry (configurable via path setting) routingMode: subdomain # Path prefix when using path-based routing (default: /registry) path: /registry nodeSelector: {} ================================================ FILE: cli/agent_mgmt.py ================================================ #!/usr/bin/env python3 """ Agent Management Script for MCP Gateway Registry. This tool provides CLI commands for managing A2A agents via the A2A Agent Management API. It uses JWT Bearer tokens from Keycloak for authentication via the mcp-gateway-m2m service account. SERVICE ACCOUNT: mcp-gateway-m2m The mcp-gateway-m2m service account is a Keycloak M2M client that provides authentication for both MCP server management and A2A agent management operations. The JWT token from this account is automatically loaded from .oauth-tokens/ingress.json. PERMISSIONS: - Token scopes: mcp-servers-restricted/*, mcp-servers-unrestricted/*, a2a-agent-admin - Agent operations: register, modify, delete, list (full admin access) - Group assignment: mcp-servers-unrestricted, a2a-agent-admin API: /api/agents (A2A Agent Management API - dedicated endpoints for agent management) - List agents: GET /api/agents - Get agent: GET /api/agents/{path} - Register agent: POST /api/agents/register - Update agent: PUT /api/agents/{path} - Delete agent: DELETE /api/agents/{path} - Toggle agent: POST /api/agents/{path}/toggle - Search agents: POST /api/agents/discover/semantic HEALTH CHECKS: - The 'test' command performs two-level verification: 1. Registry Check: Verifies agent metadata in the registry (always performed) 2. Service Health: Fetches agent card from /.well-known/agent-card.json (if agent is enabled) - Results show: PASSED (agent responds), FAILED (agent unavailable), SKIPPED (disabled/no URL) SEMANTIC SEARCH: - The 'search' command performs natural language semantic search using FAISS vector index - Returns enabled agents matching the query with relevance scores Usage: # Automatically loads token from .oauth-tokens/ingress.json (generated by credentials-provider/generate_creds.sh) uv run python cli/agent_mgmt.py list uv run python cli/agent_mgmt.py get /code-reviewer uv run python cli/agent_mgmt.py test /code-reviewer # Test single agent with health check uv run python cli/agent_mgmt.py test-all # Test all agents with health checks uv run python cli/agent_mgmt.py search "code review agent" # Semantic search for agents For agent creation, registration, toggle, and delete operations, the mcp-gateway-m2m service account must be assigned to the a2a-agent-admin group in Keycloak. """ import argparse import base64 import json import logging import os import subprocess # nosec B404 import sys import time from typing import Any import requests # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) DEFAULT_BASE_URL: str = "http://localhost" # Goes through nginx (port 80), not direct :7860 DEFAULT_TOKEN_FILE: str = ".oauth-tokens/ingress.json" REQUEST_TIMEOUT: int = 10 API_BASE: str = "/api/agents" def _extract_username_from_jwt(token: str) -> str: """Extract username from JWT token payload.""" try: parts = token.split(".") if len(parts) != 3: return "unknown" payload = parts[1] padding = 4 - (len(payload) % 4) if padding != 4: payload += "=" * padding decoded = base64.urlsafe_b64decode(payload) claims = json.loads(decoded) username = claims.get("preferred_username") or claims.get("sub") or "unknown" return username except Exception: return "unknown" def _get_token_expiration(token: str) -> int | None: """Extract expiration timestamp from JWT token. Returns: Expiration timestamp (seconds since epoch) or None if unable to extract """ try: parts = token.split(".") if len(parts) != 3: return None payload = parts[1] padding = 4 - (len(payload) % 4) if padding != 4: payload += "=" * padding decoded = base64.urlsafe_b64decode(payload) claims = json.loads(decoded) return claims.get("exp") except Exception: return None def _is_token_expired(token: str, buffer_seconds: int = 30) -> bool: """Check if token is expired or about to expire. Args: token: JWT token string buffer_seconds: Seconds before actual expiration to consider token expired Returns: True if token is expired or expiring soon, False otherwise """ exp_timestamp = _get_token_expiration(token) if exp_timestamp is None: return False current_time = time.time() return current_time >= (exp_timestamp - buffer_seconds) def _regenerate_token(token_file: str) -> bool: """Regenerate token using generate_creds.sh script. Args: token_file: Path to the token file Returns: True if regeneration succeeded, False otherwise """ logger.info("Token expired, regenerating credentials...") # Extract the agent name from token file if it's a bot account # e.g., .oauth-tokens/bot-x-token.json -> bot-x token_filename = os.path.basename(token_file) if token_filename.endswith("-token.json"): agent_name = token_filename[:-11] # Remove "-token.json" else: # Fallback to running generate_creds.sh for main ingress token agent_name = None try: script_dir = os.path.dirname(os.path.abspath(__file__)) project_root = os.path.dirname(script_dir) if agent_name: # Use generate-agent-token.sh for specific agent # Call from keycloak/setup directory so relative paths work token_script = os.path.join(project_root, "keycloak/setup/generate-agent-token.sh") keycloak_setup_dir = os.path.join(project_root, "keycloak/setup") logger.info(f"Running: {token_script} {agent_name}") result = subprocess.run( # nosec B603 - hardcoded internal script path [token_script, agent_name], cwd=keycloak_setup_dir, # Run from keycloak/setup so ../../.oauth-tokens works capture_output=True, text=True, timeout=30, ) else: # Use generate_creds.sh for ingress token creds_script = os.path.join(project_root, "credentials-provider/generate_creds.sh") logger.info(f"Running: {creds_script} --ingress-only") result = subprocess.run( # nosec B603 - hardcoded internal script path and flags [creds_script, "--ingress-only"], cwd=project_root, capture_output=True, text=True, timeout=60, ) if result.returncode == 0: logger.info("✓ Token regenerated successfully") return True else: logger.error("✗ Token regeneration failed") logger.error(f" Error output: {result.stderr}") return False except FileNotFoundError as e: logger.error(f"✗ Token regeneration script not found: {e}") return False except subprocess.TimeoutExpired: logger.error("✗ Token regeneration script timed out") return False except Exception as e: logger.error(f"✗ Token regeneration failed: {e}") return False def _load_token(token_file: str) -> tuple[str, str]: """Load JWT token from file and extract username. If token is expired, automatically regenerate it. Returns: Tuple of (token, username) """ abs_path = os.path.abspath(token_file) try: with open(abs_path) as f: data = json.load(f) token = data.get("access_token") or data.get("token") if not token: raise ValueError("No access_token found in token file") # Check if token is expired if _is_token_expired(token): logger.warning("Token is expired or expiring soon, regenerating...") if _regenerate_token(token_file): # Reload token from file after regeneration with open(abs_path) as f2: data = json.load(f2) token = data.get("access_token") or data.get("token") if not token: raise ValueError("No access_token found after regeneration") else: raise RuntimeError("Failed to regenerate expired token") username = _extract_username_from_jwt(token) logger.info(f"✓ Token loaded from: {abs_path}") logger.info(f" User: {username}") logger.info(f" Token length: {len(token)} characters") return token, username except FileNotFoundError: logger.error(f"✗ Token file not found: {abs_path}") logger.error(f" Current directory: {os.getcwd()}") logger.error(f" Looking for: {abs_path}") raise FileNotFoundError(f"Token file not found: {abs_path}") except json.JSONDecodeError as e: logger.error(f"✗ Invalid JSON in token file: {abs_path}") logger.error(f" Error: {e}") raise ValueError(f"Invalid JSON in token file: {abs_path}") def _make_request( method: str, url: str, token: str, data: dict[str, Any] | None = None, params: dict[str, Any] | None = None, timeout: int = REQUEST_TIMEOUT, ) -> requests.Response: """Make HTTP request with Bearer token.""" headers = { "Authorization": f"Bearer {token[:50]}..." if token else "Bearer ", "Content-Type": "application/json", } logger.info(f"HTTP {method} Request:") logger.info(f" URL: {url}") logger.info(" Headers:") logger.info( f" Authorization: Bearer {token[:50]}..." if token else " Authorization: " ) logger.info(" Content-Type: application/json") if params: logger.info(f" Query Params: {json.dumps(params, indent=2)}") if data: logger.info(f" Request Body: {len(json.dumps(data))} bytes") logger.debug(f" Request Data: {json.dumps(data, indent=2)}") logger.info(f" Timeout: {timeout}s") headers["Authorization"] = f"Bearer {token}" try: logger.info(f"→ Sending {method} request to {url}...") response = requests.request( method=method, url=url, json=data, params=params, headers=headers, timeout=timeout, ) # Log response details logger.info(f"← Received HTTP {response.status_code}") logger.info(" Response Headers:") for header_name, header_value in response.headers.items(): # Hide sensitive headers if header_name.lower() in ["authorization", "x-scopes"]: logger.info( f" {header_name}: {header_value[:50]}..." if len(str(header_value)) > 50 else f" {header_name}: {header_value}" ) else: logger.info(f" {header_name}: {header_value}") response_size = len(response.content) if response.content else 0 logger.info(f" Response Body: {response_size} bytes") if response.status_code >= 400: logger.warning(f"✗ HTTP {response.status_code} Error") try: resp_json = response.json() logger.warning(f" Error Response: {json.dumps(resp_json, indent=2)}") except json.JSONDecodeError: logger.warning(f" Error Response (raw): {response.text[:200]}") else: logger.info(f"✓ HTTP {response.status_code} Success") return response except requests.exceptions.Timeout as e: logger.error(f"✗ Request timed out after {timeout}s: {url}") logger.error(f" Error: {e}") raise TimeoutError(f"Request timed out after {timeout} seconds: {url}") except requests.exceptions.ConnectionError as e: logger.error(f"✗ Failed to connect to {url}") logger.error(f" Error: {e}") logger.error(f" Check if service is running at {url}") raise ConnectionError(f"Failed to connect to {url}: {e}") except requests.exceptions.RequestException as e: logger.error(f"✗ Request failed: {e}") raise RuntimeError(f"Request failed: {e}") def _print_response(response: requests.Response) -> None: """Pretty print response.""" try: data = response.json() print(json.dumps(data, indent=2)) except json.JSONDecodeError: print(response.text) def list_agents( base_url: str, token: str, ) -> None: """List all agents via A2A Agent Management API.""" endpoint = f"{base_url}{API_BASE}" logger.info(f"Listing all agents from {endpoint}...") try: response = _make_request("GET", endpoint, token) if response.status_code == 200: data = response.json() agents = data.get("agents", []) total_count = data.get("total_count", 0) if agents: print(f"Found {total_count} agent(s):") print("-" * 120) print(f"{'Agent Name':<40} | {'Path':<25} | {'Status':<8}") print("-" * 120) for agent in agents: name = agent.get("name", "unknown") path = agent.get("path", "unknown") is_enabled = agent.get("is_enabled", False) status = "ENABLED" if is_enabled else "DISABLED" print(f"{name:<40} | {path:<25} | {status:<8}") print("-" * 120) else: print("No agents found") elif response.status_code == 401: print("Error: Authentication failed (401)") print("Make sure your JWT token is valid and not expired") else: print(f"Error: HTTP {response.status_code}") _print_response(response) except (TimeoutError, ConnectionError, RuntimeError) as e: print(f"Error: {e}") sys.exit(1) def get_agent( base_url: str, token: str, agent_path: str, ) -> None: """Get agent details via A2A Agent Management API.""" # Normalize path: /code-reviewer -> /code-reviewer if not agent_path.startswith("/"): agent_path = "/" + agent_path endpoint = f"{base_url}{API_BASE}{agent_path}" logger.info(f"Getting agent details for path '{agent_path}'...") try: response = _make_request("GET", endpoint, token) if response.status_code == 200: print(f"Agent details for: {agent_path}") _print_response(response) elif response.status_code == 404: print(f"Error: Agent at path '{agent_path}' not found") elif response.status_code == 401: print("Error: Authentication failed (401)") elif response.status_code == 403: print("Error: Access denied - you do not have permission to view this agent") else: print(f"Error: HTTP {response.status_code}") _print_response(response) except (TimeoutError, ConnectionError, RuntimeError) as e: print(f"Error: {e}") sys.exit(1) def _check_agent_health( agent_url: str, ) -> tuple[bool, str]: """ Check agent health by fetching agent card from /.well-known/agent-card.json. Args: agent_url: Base URL of the agent service Returns: Tuple of (success: bool, message: str) """ if not agent_url: return False, "Agent URL not provided" health_endpoint = f"{agent_url}/.well-known/agent-card.json" logger.info(f"Checking agent health at: {health_endpoint}") try: response = requests.get( health_endpoint, timeout=REQUEST_TIMEOUT, headers={"Content-Type": "application/json"}, ) if response.status_code == 200: try: card_data = response.json() agent_name = card_data.get("name", "unknown") return True, f"Agent card retrieved successfully from {agent_name}" except json.JSONDecodeError: return False, "Agent returned invalid JSON for agent card" elif response.status_code == 404: return False, "Agent card endpoint not found (/.well-known/agent-card.json)" elif response.status_code == 503: return False, "Agent service unavailable (503)" else: return False, f"Agent returned HTTP {response.status_code}" except requests.exceptions.Timeout: return False, f"Agent health check timed out ({REQUEST_TIMEOUT}s)" except requests.exceptions.ConnectionError: return False, "Cannot connect to agent service (connection refused)" except Exception as e: return False, f"Health check error: {str(e)}" def test_agent( base_url: str, token: str, agent_path: str, ) -> None: """Test agent accessibility and health via A2A Agent Management API.""" # Normalize path if not agent_path.startswith("/"): agent_path = "/" + agent_path endpoint = f"{base_url}{API_BASE}{agent_path}" logger.info(f"Testing agent at path '{agent_path}'...") try: response = _make_request("GET", endpoint, token) if response.status_code == 200: data = response.json() name = data.get("name", "unknown") description = data.get("description", "") is_enabled = data.get("is_enabled", False) agent_url = data.get("url", "") print(f"Agent: {name}") print(f"Path: {agent_path}") print(f"Status: {'ENABLED' if is_enabled else 'DISABLED'}") print(f"Description: {description}") print(f"Service URL: {agent_url}") # Perform health check if agent is enabled and has URL if is_enabled and agent_url: print("\nPerforming health check...") health_passed, health_message = _check_agent_health(agent_url) if health_passed: print(" Health Check: PASSED") print(f" Details: {health_message}") else: print(" Health Check: FAILED") print(f" Reason: {health_message}") elif not is_enabled: print("\nHealth Check: SKIPPED (agent is disabled)") elif not agent_url: print("\nHealth Check: SKIPPED (no service URL configured)") print("\nAgent Registry Details:") _print_response(response) elif response.status_code == 404: print(f"Error: Agent at path '{agent_path}' not found") elif response.status_code == 401: print("Error: Authentication failed (401)") elif response.status_code == 403: print("Error: Access denied - you do not have permission to view this agent") else: print(f"Error: HTTP {response.status_code}") _print_response(response) except (TimeoutError, ConnectionError, RuntimeError) as e: print(f"Error: {e}") sys.exit(1) def test_all_agents( base_url: str, token: str, ) -> None: """Test all agents accessibility and health.""" endpoint = f"{base_url}{API_BASE}" logger.info("Testing all agents...") try: response = _make_request("GET", endpoint, token) if response.status_code == 200: data = response.json() agents = data.get("agents", []) if not agents: print("No agents to test") return passed = 0 failed = 0 print("Testing agents:") print("-" * 100) print(f"{'Agent Name':<35} | {'Registry':<8} | {'Health Check':<20}") print("-" * 100) for agent in agents: name = agent.get("name", "unknown") is_enabled = agent.get("is_enabled", False) agent_url = agent.get("url", "") registry_status = "ENABLED" if is_enabled else "DISABLED" # Perform health check if agent is enabled if is_enabled and agent_url: health_passed, _ = _check_agent_health(agent_url) health_status = "PASSED" if health_passed else "FAILED" if health_passed: passed += 1 else: failed += 1 else: health_status = "SKIPPED" passed += 1 # Color-coded status if health_status == "PASSED": result_icon = "✓" elif health_status == "FAILED": result_icon = "✗" else: result_icon = "-" print(f"{name:<35} | {registry_status:<8} | {result_icon} {health_status:<17}") print("-" * 100) print(f"Summary: {passed} passed, {failed} failed") elif response.status_code == 401: print("Error: Authentication failed (401)") else: print(f"Error: HTTP {response.status_code}") _print_response(response) except (TimeoutError, ConnectionError, RuntimeError) as e: print(f"Error: {e}") sys.exit(1) def search_agents( base_url: str, token: str, query: str, max_results: int = 10, ) -> None: """Search agents using semantic search via natural language query.""" endpoint = f"{base_url}{API_BASE}/discover/semantic" logger.info(f"Searching agents with query: {query}") params = { "query": query, "max_results": max_results, } try: response = _make_request("POST", endpoint, token, params=params) if response.status_code == 200: data = response.json() results = data.get("agents", []) if results: print(f"Found {len(results)} agent(s) matching '{query}':") print("-" * 110) print(f"{'Agent Name':<40} | {'Path':<25} | {'Score':<8}") print("-" * 110) for result in results: name = result.get("name", "unknown") path = result.get("path", "unknown") score = result.get("score", 0.0) print(f"{name:<40} | {path:<25} | {score:>7.4f}") print("-" * 110) else: print(f"No agents found matching '{query}'") elif response.status_code == 400: print("Error: Invalid search query (empty or malformed)") elif response.status_code == 401: print("Error: Authentication failed (401)") else: print(f"Error: HTTP {response.status_code}") _print_response(response) except (TimeoutError, ConnectionError, RuntimeError) as e: print(f"Error: {e}") sys.exit(1) def register_agent( base_url: str, token: str, agent_file: str, ) -> None: """Register agent from JSON file via A2A Agent Management API.""" import os abs_agent_file = os.path.abspath(agent_file) logger.info(f"Loading agent file from: {abs_agent_file}") try: with open(abs_agent_file) as f: agent_data = json.load(f) logger.info("✓ Agent file loaded successfully") except FileNotFoundError: logger.error(f"✗ Agent file not found: {abs_agent_file}") print(f"Error: File not found: {abs_agent_file}") sys.exit(1) except json.JSONDecodeError as e: logger.error(f"✗ Invalid JSON in file: {abs_agent_file}") logger.error(f" Error: {e}") print(f"Error: Invalid JSON in file: {abs_agent_file}") sys.exit(1) # Use A2A Agent Management API endpoint for registration # Note: Goes through Nginx for JWT Bearer token validation via auth-server endpoint = f"{base_url}/api/agents/register" agent_name = agent_data.get("name", "Unknown") logger.info("=" * 80) logger.info("AGENT REGISTRATION REQUEST") logger.info("=" * 80) logger.info(f"Base URL: {base_url}") logger.info(f"Endpoint: {endpoint}") logger.info(f"Agent Name: {agent_name}") logger.info(f"Agent Path: {agent_data.get('path', 'N/A')}") logger.info(f"Agent File: {abs_agent_file}") logger.info("=" * 80) try: response = _make_request("POST", endpoint, token, agent_data) if response.status_code == 201: logger.info(f"✓ Agent '{agent_name}' registered successfully!") print(f"Agent '{agent_name}' registered successfully!") _print_response(response) elif response.status_code == 401: logger.error("✗ Authentication failed (HTTP 401)") logger.error(f" Token file location: {os.path.abspath('.oauth-tokens/ingress.json')}") logger.error(f" Token length: {len(token) if token else 0} characters") logger.error( f" Authorization header: Bearer {token[:50]}..." if token else " Authorization header: " ) print("Error: Authentication failed (HTTP 401)") print("\nDEBUG INFORMATION:") print(f" Token file: {os.path.abspath('.oauth-tokens/ingress.json')}") print(f" Token length: {len(token) if token else 0} characters") print("\nNOTE: Make sure you have a valid token in '.oauth-tokens/ingress.json'") print(" The token should contain 'a2a-agent-admin' in groups claim") print(" Regenerate with: ./credentials-provider/generate_creds.sh") print("\nRESPONSE:") _print_response(response) elif response.status_code == 409: path = agent_data.get("path", "unknown") print(f"Error: Agent with path '{path}' already exists") _print_response(response) elif response.status_code == 422: print("Error: Validation failed - check agent JSON format") _print_response(response) elif response.status_code == 403: print("Error: Permission denied. You do not have permission to register agents") print( "\nNote: Agent registration requires proper Keycloak authentication with 'register_service' permission." ) print("For testing/development, you may need to:") print(" 1. Configure a Keycloak user with appropriate permissions") print(" 2. Use the web UI dashboard to register agents") print(" 3. Contact your administrator to grant registration permissions") _print_response(response) else: print(f"Error: HTTP {response.status_code}") _print_response(response) except (TimeoutError, ConnectionError, RuntimeError) as e: print(f"Error: {e}") sys.exit(1) def update_agent( base_url: str, token: str, agent_path: str, agent_file: str, ) -> None: """Update agent via A2A Agent Management API.""" # Normalize path if not agent_path.startswith("/"): agent_path = "/" + agent_path abs_agent_file = os.path.abspath(agent_file) logger.info(f"Loading agent file from: {abs_agent_file}") try: with open(abs_agent_file) as f: agent_data = json.load(f) logger.info("✓ Agent file loaded successfully") except FileNotFoundError: logger.error(f"✗ Agent file not found: {abs_agent_file}") print(f"Error: File not found: {abs_agent_file}") sys.exit(1) except json.JSONDecodeError as e: logger.error(f"✗ Invalid JSON in file: {abs_agent_file}") logger.error(f" Error: {e}") print(f"Error: Invalid JSON in file: {abs_agent_file}") sys.exit(1) endpoint = f"{base_url}{API_BASE}{agent_path}" agent_name = agent_data.get("name", "Unknown") logger.info(f"Updating agent at path '{agent_path}'...") try: response = _make_request("PUT", endpoint, token, agent_data) if response.status_code == 200: logger.info(f"✓ Agent '{agent_name}' updated successfully!") print(f"Agent '{agent_name}' updated successfully!") _print_response(response) elif response.status_code == 404: print(f"Error: Agent at path '{agent_path}' not found") elif response.status_code == 401: print("Error: Authentication failed (401)") elif response.status_code == 403: print("Error: Access denied - you do not have permission to update this agent") else: print(f"Error: HTTP {response.status_code}") _print_response(response) except (TimeoutError, ConnectionError, RuntimeError) as e: print(f"Error: {e}") sys.exit(1) def delete_agent( base_url: str, token: str, agent_path: str, ) -> None: """Delete agent via A2A Agent Management API.""" # Normalize path if not agent_path.startswith("/"): agent_path = "/" + agent_path endpoint = f"{base_url}{API_BASE}{agent_path}" logger.info(f"Deleting agent at path '{agent_path}'...") try: response = _make_request("DELETE", endpoint, token) if response.status_code == 204: logger.info(f"✓ Agent at path '{agent_path}' deleted successfully!") print(f"Agent at path '{agent_path}' deleted successfully!") elif response.status_code == 404: print(f"Error: Agent at path '{agent_path}' not found") elif response.status_code == 401: print("Error: Authentication failed (401)") elif response.status_code == 403: print("Error: Access denied - you do not have permission to delete this agent") else: print(f"Error: HTTP {response.status_code}") _print_response(response) except (TimeoutError, ConnectionError, RuntimeError) as e: print(f"Error: {e}") sys.exit(1) def toggle_agent( base_url: str, token: str, agent_path: str, enabled: bool, ) -> None: """Toggle agent enabled/disabled status via A2A Agent Management API.""" # Normalize path if not agent_path.startswith("/"): agent_path = "/" + agent_path endpoint = f"{base_url}{API_BASE}{agent_path}/toggle" params = f"?enabled={str(enabled).lower()}" logger.info(f"Setting agent at path '{agent_path}' to {enabled}...") try: response = _make_request("POST", endpoint + params, token) if response.status_code == 200: data = response.json() is_enabled = data.get("is_enabled", False) status = "ENABLED" if is_enabled else "DISABLED" logger.info(f"✓ Agent at path '{agent_path}' toggled successfully!") print(f"Agent at path '{agent_path}' is now {status}") _print_response(response) elif response.status_code == 404: print(f"Error: Agent at path '{agent_path}' not found") elif response.status_code == 401: print("Error: Authentication failed (401)") elif response.status_code == 403: print("Error: Access denied - you do not have permission to toggle this agent") else: print(f"Error: HTTP {response.status_code}") _print_response(response) except (TimeoutError, ConnectionError, RuntimeError) as e: print(f"Error: {e}") sys.exit(1) def main() -> None: """Main entry point.""" parser = argparse.ArgumentParser( description="Agent Management Script for MCP Gateway Registry - A2A Agent Management API", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # List all agents uv run python cli/agent_mgmt.py list # Get agent details uv run python cli/agent_mgmt.py get /code-reviewer # Register an agent from JSON file uv run python cli/agent_mgmt.py register cli/examples/test_code_reviewer_agent.json # Update an agent with new JSON uv run python cli/agent_mgmt.py update /code-reviewer cli/examples/updated_agent.json # Enable an agent uv run python cli/agent_mgmt.py toggle /code-reviewer true # Disable an agent uv run python cli/agent_mgmt.py toggle /code-reviewer false # Delete an agent uv run python cli/agent_mgmt.py delete /code-reviewer # Test agent accessibility uv run python cli/agent_mgmt.py test /code-reviewer # Test all agents uv run python cli/agent_mgmt.py test-all # Search agents with semantic query uv run python cli/agent_mgmt.py search "code review tool" For more information on creating agent JSON files: cat cli/examples/README.md """, ) parser.add_argument( "--base-url", default=DEFAULT_BASE_URL, help=f"Base URL for API (default: {DEFAULT_BASE_URL})", ) parser.add_argument( "--token-file", default=DEFAULT_TOKEN_FILE, help=f"Path to token JSON file (default: {DEFAULT_TOKEN_FILE})", ) parser.add_argument( "--debug", action="store_true", help="Enable debug logging", ) subparsers = parser.add_subparsers(dest="command", help="Command to execute") # Register command register_parser = subparsers.add_parser("register", help="Register agent from JSON file") register_parser.add_argument("file", help="Path to agent JSON file") # List command subparsers.add_parser("list", help="List all agents") # Get command get_parser = subparsers.add_parser("get", help="Get agent details") get_parser.add_argument("path", help="Agent path (e.g., /code-reviewer)") # Test command test_parser = subparsers.add_parser("test", help="Test agent accessibility") test_parser.add_argument("path", help="Agent path (e.g., /code-reviewer)") # Test all command subparsers.add_parser("test-all", help="Test all agents") # Search command search_parser = subparsers.add_parser("search", help="Search agents using semantic query") search_parser.add_argument( "query", help="Natural language search query (e.g., 'code review agent')" ) search_parser.add_argument( "--max-results", type=int, default=10, help="Maximum number of results (default: 10)", ) # Update command update_parser = subparsers.add_parser("update", help="Update agent from JSON file") update_parser.add_argument("path", help="Agent path (e.g., /code-reviewer)") update_parser.add_argument("file", help="Path to updated agent JSON file") # Delete command delete_parser = subparsers.add_parser("delete", help="Delete agent") delete_parser.add_argument("path", help="Agent path (e.g., /code-reviewer)") # Toggle command toggle_parser = subparsers.add_parser("toggle", help="Toggle agent enabled/disabled status") toggle_parser.add_argument("path", help="Agent path (e.g., /code-reviewer)") toggle_parser.add_argument( "enabled", type=lambda x: x.lower() == "true", help="Enable (true) or disable (false) the agent", ) args = parser.parse_args() if args.debug: logger.setLevel(logging.DEBUG) if not args.command: parser.print_help() sys.exit(1) # Load token try: token, username = _load_token(args.token_file) except (FileNotFoundError, ValueError) as e: print(f"Error: {e}") sys.exit(1) # Execute command if args.command == "list": list_agents(args.base_url, token) elif args.command == "get": get_agent(args.base_url, token, args.path) elif args.command == "test": test_agent(args.base_url, token, args.path) elif args.command == "test-all": test_all_agents(args.base_url, token) elif args.command == "search": search_agents(args.base_url, token, args.query, args.max_results) elif args.command == "register": register_agent(args.base_url, token, args.file) elif args.command == "update": update_agent(args.base_url, token, args.path, args.file) elif args.command == "delete": delete_agent(args.base_url, token, args.path) elif args.command == "toggle": toggle_agent(args.base_url, token, args.path, args.enabled) if __name__ == "__main__": main() ================================================ FILE: cli/agent_mgmt.sh ================================================ #!/bin/bash # DEPRECATED: This script is deprecated in favor of the Registry Management API # Use: uv run python api/registry_management.py OR cli/registry_cli_wrapper.py # See: api/README.md for documentation # # Agent Management Script for MCP Gateway Registry # Usage: ./cli/agent_mgmt.sh {register|list|get|test|test-all} [args...] echo "WARNING: This script is DEPRECATED. Please use the Registry Management API instead:" echo " uv run python api/registry_management.py agent-register --help" echo " uv run python api/registry_management.py agent-list --help" echo "See api/README.md for full documentation." echo "" set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # Get script directory and project root SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" # Load environment variables from .env file if it exists if [ -f "$PROJECT_ROOT/.env" ]; then set -a source "$PROJECT_ROOT/.env" set +a fi # Default values BASE_URL="${BASE_URL:-http://localhost}" # Goes through nginx (port 80), not direct :7860 TOKEN_FILE="${TOKEN_FILE:-.oauth-tokens/ingress.json}" DEBUG="${DEBUG:-false}" print_success() { echo -e "${GREEN}✓ $1${NC}" } print_error() { echo -e "${RED}✗ $1${NC}" } print_info() { echo -e "${YELLOW}ℹ $1${NC}" } show_usage() { cat << EOF Agent Management Script for MCP Gateway Registry Usage: $0 {command} [options] Commands: register Register agent from JSON file list List all agents get Get agent details update Update agent from JSON file delete Delete agent toggle Enable/disable agent test Test agent accessibility test-all Test all agents search Search agents using semantic query Options: --base-url URL Base URL for API (default: $BASE_URL) --token-file FILE Path to token JSON file (default: $TOKEN_FILE) --debug Enable debug logging Examples: # Register an agent from JSON file $0 register cli/examples/test_code_reviewer_agent.json # List all agents $0 list # Get agent details $0 get /test-reviewer # Update an agent $0 update /test-reviewer cli/examples/updated_agent.json # Enable an agent $0 toggle /test-reviewer true # Disable an agent $0 toggle /test-reviewer false # Delete an agent $0 delete /test-reviewer # Test agent accessibility $0 test /test-reviewer # Test all agents $0 test-all # Search agents with semantic query $0 search "code review tool" Prerequisites: Ensure the registry and nginx services are running: 1. Registry service (port 7860) 2. Nginx reverse proxy (port 80) Docker setup: docker-compose up -d For more information, run: uv run python cli/agent_mgmt.py --help cat cli/examples/README.md EOF } # Check if no arguments provided if [ $# -eq 0 ]; then show_usage exit 1 fi # Parse command command="$1" shift # Check if help is requested if [ "$command" = "-h" ] || [ "$command" = "--help" ]; then show_usage exit 0 fi # Build Python command with arguments python_args=("--base-url" "$BASE_URL" "--token-file" "$TOKEN_FILE") if [ "$DEBUG" = "true" ]; then python_args+=("--debug") fi python_args+=("$command") # Add remaining arguments while [ $# -gt 0 ]; do python_args+=("$1") shift done print_info "Running: uv run python cli/agent_mgmt.py ${python_args[@]}" # Execute Python script cd "$PROJECT_ROOT" if uv run python cli/agent_mgmt.py "${python_args[@]}"; then exit 0 else print_error "Agent management command failed" exit 1 fi ================================================ FILE: cli/agentcore/__init__.py ================================================ """AgentCore Auto-Registration CLI package. Automates discovery and registration of AWS Bedrock AgentCore Gateways and Agent Runtimes with the MCP Gateway Registry. Usage: python -m cli.agentcore.sync [sync|list] [options] """ __version__ = "0.1.0" ================================================ FILE: cli/agentcore/__main__.py ================================================ """Allow ``python -m cli.agentcore`` invocation.""" import sys from .sync import main sys.exit(main()) ================================================ FILE: cli/agentcore/discovery.py ================================================ """AWS AgentCore resource discovery via boto3. Scans AgentCore Gateways and Agent Runtimes using the ``bedrock-agentcore-control`` boto3 client, filtering to READY resources and paginating through all pages. """ from __future__ import annotations import logging from typing import Any import boto3 from botocore.config import Config as BotoConfig from .models import DEFAULT_TIMEOUT, READY_STATUS logger = logging.getLogger(__name__) class AgentCoreScanner: """Scans AWS AgentCore resources using boto3. Configures the boto3 client with connect/read timeouts and standard retry mode (3 attempts). All list operations paginate via ``nextToken`` and only READY resources are returned. """ def __init__( self, region: str, timeout: int = DEFAULT_TIMEOUT, session: boto3.Session | None = None, ) -> None: """Initialize scanner with AWS region, timeout, and optional boto3 session. Args: region: AWS region to scan. timeout: AWS API call timeout in seconds. session: Optional boto3 session (e.g. from STS AssumeRole for cross-account scanning). Uses default credentials if None. """ self.region = region self.timeout = timeout boto_config = BotoConfig( connect_timeout=timeout, read_timeout=timeout, retries={"max_attempts": 3, "mode": "standard"}, ) if session: self.client = session.client( "bedrock-agentcore-control", region_name=region, config=boto_config, ) else: self.client = boto3.client( "bedrock-agentcore-control", region_name=region, config=boto_config, ) logger.info( f"Initialized AgentCore scanner for region: {region} " f"(timeout: {timeout}s, cross_account: {session is not None})" ) # ------------------------------------------------------------------ # Gateway scanning # ------------------------------------------------------------------ def scan_gateways(self) -> list[dict[str, Any]]: """Scan all AgentCore Gateways in the region. Paginates through ``list_gateways()``, filters to READY status, fetches full details via ``get_gateway()``, and collects targets. """ gateways: list[dict[str, Any]] = [] paginator_params: dict[str, Any] = {} while True: response = self.client.list_gateways(**paginator_params) for item in response.get("items", []): if item.get("status") == READY_STATUS: gateway = self.client.get_gateway(gatewayIdentifier=item["gatewayId"]) gateway["targets"] = self._get_gateway_targets(item["gatewayId"]) gateways.append(gateway) else: logger.debug( f"Skipping gateway {item['gatewayId']} with status {item['status']}" ) if "nextToken" in response: paginator_params["nextToken"] = response["nextToken"] else: break logger.info(f"Found {len(gateways)} READY gateways") return gateways def _get_gateway_targets( self, gateway_id: str, ) -> list[dict[str, Any]]: """Get all targets for a gateway. Paginates through ``list_gateway_targets()`` and fetches full details for READY targets. """ targets: list[dict[str, Any]] = [] paginator_params: dict[str, Any] = {"gatewayIdentifier": gateway_id} while True: response = self.client.list_gateway_targets(**paginator_params) for item in response.get("items", []): if item.get("status") == READY_STATUS: target = self.client.get_gateway_target( gatewayIdentifier=gateway_id, targetId=item["targetId"], ) targets.append(target) if "nextToken" in response: paginator_params["nextToken"] = response["nextToken"] else: break return targets # ------------------------------------------------------------------ # Runtime scanning # ------------------------------------------------------------------ def scan_runtimes(self) -> list[dict[str, Any]]: """Scan all AgentCore Runtimes in the region. Paginates through ``list_agent_runtimes()``, filters to READY status, fetches full details via ``get_agent_runtime()``, and collects endpoints. """ runtimes: list[dict[str, Any]] = [] paginator_params: dict[str, Any] = {} while True: response = self.client.list_agent_runtimes(**paginator_params) for item in response.get("agentRuntimes", []): if item.get("status") == READY_STATUS: runtime = self.client.get_agent_runtime(agentRuntimeId=item["agentRuntimeId"]) runtime["endpoints"] = self._get_runtime_endpoints(item["agentRuntimeId"]) runtimes.append(runtime) else: logger.debug( f"Skipping runtime {item['agentRuntimeId']} with status {item['status']}" ) if "nextToken" in response: paginator_params["nextToken"] = response["nextToken"] else: break logger.info(f"Found {len(runtimes)} READY runtimes") return runtimes def _get_runtime_endpoints( self, runtime_id: str, ) -> list[dict[str, Any]]: """Get all endpoints for a runtime. Paginates through ``list_agent_runtime_endpoints()`` and returns READY endpoints. """ endpoints: list[dict[str, Any]] = [] paginator_params: dict[str, Any] = {"agentRuntimeId": runtime_id} while True: response = self.client.list_agent_runtime_endpoints(**paginator_params) for item in response.get("runtimeEndpoints", []): if item.get("status") == READY_STATUS: endpoints.append(item) if "nextToken" in response: paginator_params["nextToken"] = response["nextToken"] else: break return endpoints ================================================ FILE: cli/agentcore/models.py ================================================ """Pydantic models and helper functions for AgentCore auto-registration. Contains data models for discovered resources and sync results, plus utility functions for URL construction, slugification, auth scheme mapping, and token loading. """ from __future__ import annotations import json import logging import os import re from typing import Any from urllib.parse import quote from pydantic import BaseModel, Field logger = logging.getLogger(__name__) # Constants DEFAULT_REGISTRY_URL = "http://localhost" DEFAULT_TOKEN_FILE = ".token" DEFAULT_REGION = "us-east-1" DEFAULT_TIMEOUT = 30 DEFAULT_MANIFEST_PATH = "token_refresh_manifest.json" READY_STATUS = "READY" # --------------------------------------------------------------------------- # Pydantic Models # --------------------------------------------------------------------------- class TargetInfo(BaseModel): """Discovered Gateway Target information.""" target_id: str = Field(..., description="Target ID") name: str = Field(..., description="Target name") description: str | None = Field(None, description="Target description") status: str = Field(..., description="Target status") target_type: str = Field(..., description="mcpServer, lambda, apiGateway, etc.") endpoint: str | None = Field(None, description="MCP server endpoint (for mcpServer type)") class GatewayInfo(BaseModel): """Discovered AgentCore Gateway information.""" gateway_id: str = Field(..., description="Gateway ID") gateway_arn: str = Field(..., description="Gateway ARN") gateway_url: str = Field(..., description="Gateway MCP endpoint URL") name: str = Field(..., description="Gateway name") description: str | None = Field(None, description="Gateway description") status: str = Field(..., description="Gateway status") authorizer_type: str = Field(..., description="CUSTOM_JWT, AWS_IAM, or NONE") authorizer_config: dict[str, Any] | None = Field(None, description="Authorizer configuration") targets: list[TargetInfo] = Field(default_factory=list, description="Gateway targets") class RuntimeInfo(BaseModel): """Discovered AgentCore Runtime information.""" runtime_id: str = Field(..., description="Runtime ID") runtime_arn: str = Field(..., description="Runtime ARN") runtime_name: str = Field(..., description="Runtime name") description: str | None = Field(None, description="Runtime description") status: str = Field(..., description="Runtime status") server_protocol: str = Field(..., description="MCP, HTTP, or A2A") authorizer_config: dict[str, Any] | None = Field(None, description="Authorizer configuration") invocation_url: str = Field(..., description="Constructed invocation URL") class SyncResult(BaseModel): """Result of a sync operation.""" resource_type: str = Field(..., description="gateway, runtime, or target") resource_name: str = Field(..., description="Resource name") resource_arn: str = Field(..., description="Resource ARN") registration_type: str = Field(..., description="mcp_server or agent") path: str = Field(..., description="Registry path") status: str = Field(..., description="registered, skipped, failed, dry_run") message: str | None = Field(None, description="Status message or error") class SyncSummary(BaseModel): """Summary of sync operation.""" total_gateways: int = Field(0, description="Total gateways found") total_runtimes: int = Field(0, description="Total runtimes found") total_targets: int = Field(0, description="Total mcpServer targets found") registered: int = Field(0, description="Successfully registered") skipped: int = Field(0, description="Skipped (already exists)") failed: int = Field(0, description="Failed to register") credentials_saved: int = Field(0, description="Credentials persisted to .env") tokens_generated: int = Field(0, description="Egress tokens generated") dry_run: bool = Field(False, description="Whether this was a dry run") results: list[SyncResult] = Field(default_factory=list, description="Individual results") # --------------------------------------------------------------------------- # Helper functions # --------------------------------------------------------------------------- def _slugify(name: str) -> str: """Convert name to URL-safe slug. Lowercase, replace spaces/underscores with hyphens, remove non-alphanumeric characters, collapse consecutive hyphens, strip leading/trailing hyphens. Idempotent. """ slug = name.lower().replace(" ", "-").replace("_", "-") slug = re.sub(r"[^a-z0-9-]", "", slug) slug = re.sub(r"-+", "-", slug) slug = slug.strip("-") return slug _UPPERCASE_WORDS: set[str] = { "mcp", "a2a", "sre", "api", "http", "https", "aws", "iam", "jwt", "oidc", "sso", "idp", "llm", "ai", "ml", } def _display_name(name: str) -> str: """Convert a slug or underscore-separated name to a human-readable title. Preserves common acronyms in uppercase (MCP, A2A, SRE, API, etc.). Examples: geo-mcp -> Geo MCP weather_time_observability_gateway -> Weather Time Observability Gateway my-custom-sre-agent -> My Custom SRE Agent """ words = name.replace("-", " ").replace("_", " ").split() result = [] for word in words: if word.lower() in _UPPERCASE_WORDS: result.append(word.upper()) else: result.append(word.capitalize()) return " ".join(result) def _validate_https_url(url: str, resource_name: str) -> bool: """Validate that URL uses HTTPS protocol. Args: url: URL to validate. resource_name: Name of resource for logging. Returns: True if valid HTTPS URL, False otherwise. """ if not url: logger.warning(f"Empty URL for resource: {resource_name}") return False if not url.startswith("https://"): logger.warning( f"Insecure URL for {resource_name}: {url} - Expected HTTPS, skipping registration" ) return False return True def _build_invocation_url(region: str, runtime_arn: str) -> str: """Build the invocation URL for an AgentCore Runtime. Format: https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{encoded-ARN}/invocations """ encoded_arn = quote(runtime_arn, safe="") return f"https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{encoded_arn}/invocations" def _get_auth_scheme(authorizer_type: str) -> str: """Map AgentCore authorizer type to registry auth scheme. CUSTOM_JWT -> bearer, AWS_IAM -> bearer, NONE -> none. Unknown types default to none. """ mapping = { "CUSTOM_JWT": "bearer", "AWS_IAM": "bearer", "NONE": "none", } return mapping.get(authorizer_type, "none") def _load_token(token_file: str) -> str: """Load JWT token from a JSON file. Supports two formats: - Flat: ``{"access_token": "..."}`` or ``{"token": "..."}`` - Nested: ``{"tokens": {"access_token": "..."}}`` Raises FileNotFoundError, ValueError on missing file, bad JSON, or missing token field. """ abs_path = os.path.abspath(token_file) try: with open(abs_path) as f: data = json.load(f) # Try top-level first, then nested under "tokens" token = data.get("access_token") or data.get("token") if not token: tokens_obj = data.get("tokens", {}) if isinstance(tokens_obj, dict): token = tokens_obj.get("access_token") or tokens_obj.get("token") if not token: raise ValueError(f"No access_token or token field in token file: {abs_path}") return token except FileNotFoundError: raise FileNotFoundError(f"Token file not found: {abs_path}") except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON in token file {abs_path}: {e}") ================================================ FILE: cli/agentcore/registration.py ================================================ """Registry integration -- build registrations and orchestrate sync. Contains ``RegistrationBuilder`` (maps discovered AWS resources to registry models) and ``SyncOrchestrator`` (coordinates scanning, registration, and manifest generation for token refresh). """ from __future__ import annotations import json import logging import os import sys from typing import Any import boto3 import requests from tenacity import ( retry, retry_if_exception, stop_after_attempt, wait_exponential, ) from .models import ( _build_invocation_url, _display_name, _get_auth_scheme, _slugify, _validate_https_url, ) # Add parent directory to path for api imports sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) from api.registry_client import ( AgentRegistration, InternalServiceRegistration, RegistryClient, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- IDP_PATTERNS: dict[str, str] = { "cognito-idp": "cognito", "auth0.com": "auth0", "okta.com": "okta", "microsoftonline.com": "entra", "/realms/": "keycloak", } # --------------------------------------------------------------------------- # Private helper functions # --------------------------------------------------------------------------- def _detect_idp_vendor(discovery_url: str) -> str: """Detect IdP vendor from OIDC discovery URL. Scans the URL for known identity-provider patterns and returns a short vendor label (e.g. "cognito", "okta"). Returns "unknown" when no pattern matches. """ for pattern, vendor in IDP_PATTERNS.items(): if pattern in discovery_url: return vendor return "unknown" def _retry_registry_call(func): """Decorator: retry on ``requests.exceptions.RequestException``. 3 attempts, exponential backoff 1-4 s. Does NOT retry on 409 Conflict (idempotency -- resource already exists). """ def _should_retry(exc: BaseException) -> bool: if isinstance(exc, requests.exceptions.HTTPError): # Don't retry 409 Conflict -- it means the resource already exists if exc.response is not None and exc.response.status_code == 409: return False return isinstance(exc, requests.exceptions.RequestException) return retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=4), retry=retry_if_exception(_should_retry), before_sleep=lambda retry_state: logger.warning( f"Registry call failed, retrying in {retry_state.next_action.sleep}s..." ), )(func) def _is_conflict_error(exc: Exception) -> bool: """Check if an exception indicates a 409 Conflict (resource already exists). Handles: - Direct HTTPError with 409 status code - Error message containing "already exists" or "already registered" - Tenacity RetryError wrapping any of the above """ # Check direct HTTPError response if hasattr(exc, "response") and getattr(exc.response, "status_code", None) == 409: return True # Check error message err_str = str(exc).lower() if "already exists" in err_str or "already registered" in err_str: return True # Unwrap tenacity RetryError if hasattr(exc, "last_attempt"): inner = exc.last_attempt.exception() if inner: if hasattr(inner, "response") and getattr(inner.response, "status_code", None) == 409: return True inner_str = str(inner).lower() if "already exists" in inner_str or "already registered" in inner_str: return True return False # --------------------------------------------------------------------------- # Registration Builder # --------------------------------------------------------------------------- class RegistrationBuilder: """Builds registration models from discovered AWS resources.""" def __init__( self, region: str, visibility: str = "internal", session: boto3.Session | None = None, ) -> None: self.region = region self.visibility = visibility self._session = session self.account_id = self._get_account_id() def _get_account_id(self) -> str: if self._session: sts = self._session.client("sts") else: sts = boto3.client("sts") return sts.get_caller_identity()["Account"] def build_gateway_registration( self, gateway: dict[str, Any], ) -> InternalServiceRegistration: """Build MCP Server registration from a gateway. Includes OIDC metadata (discovery_url, allowed_clients, idp_vendor) when the gateway uses CUSTOM_JWT authorization with a discovery URL. """ raw_name = gateway.get("name", gateway["gatewayId"]) path = f"/{_slugify(raw_name)}" display = _display_name(raw_name) gateway_url = gateway.get("gatewayUrl", "") authorizer_type = gateway.get("authorizerType", "NONE") metadata: dict[str, Any] = { "source": "agentcore-sync", "gateway_arn": gateway.get("gatewayArn"), "gateway_id": gateway.get("gatewayId"), "authorizer_type": authorizer_type, "region": self.region, "account_id": self.account_id, } # Enrich metadata with OIDC details for CUSTOM_JWT gateways if authorizer_type == "CUSTOM_JWT": authorizer_config = gateway.get("authorizerConfiguration", {}) jwt_config = authorizer_config.get("customJWTAuthorizer", {}) discovery_url = jwt_config.get("discoveryUrl", "") allowed_clients = jwt_config.get("allowedClients", []) if discovery_url: metadata["discovery_url"] = discovery_url metadata["allowed_clients"] = allowed_clients metadata["idp_vendor"] = _detect_idp_vendor(discovery_url) return InternalServiceRegistration( path=path, name=display, description=gateway.get("description", f"AgentCore Gateway: {display}"), proxy_pass_url=gateway_url, mcp_endpoint=gateway_url, auth_provider="bedrock-agentcore", auth_scheme=_get_auth_scheme(authorizer_type), supported_transports=["streamable-http"], tags=["agentcore", "gateway", "auto-registered"], overwrite=False, metadata=metadata, ) def build_target_registration( self, gateway: dict[str, Any], target: dict[str, Any], ) -> InternalServiceRegistration | None: """Build MCP Server registration from an mcpServer target. Returns ``None`` for non-mcpServer targets. """ target_config = target.get("targetConfiguration", {}) mcp_config = target_config.get("mcp", {}) if "mcpServer" not in mcp_config: return None mcp_server = mcp_config["mcpServer"] endpoint = mcp_server.get("endpoint") if not endpoint: return None target_name = target.get("name", target["targetId"]) gateway_name = gateway.get("name", gateway["gatewayId"]) path = f"/{_slugify(gateway_name)}-{_slugify(target_name)}" return InternalServiceRegistration( path=path, name=f"{_display_name(gateway_name)} - {_display_name(target_name)}", description=target.get( "description", f"MCP Server target: {_display_name(target_name)}" ), proxy_pass_url=endpoint, mcp_endpoint=endpoint, auth_provider="bedrock-agentcore", auth_scheme="bearer", supported_transports=["streamable-http"], tags=["agentcore", "gateway-target", "mcp-server", "auto-registered"], overwrite=False, metadata={ "source": "agentcore-sync", "gateway_arn": gateway.get("gatewayArn"), "target_id": target.get("targetId"), "region": self.region, "account_id": self.account_id, }, ) def build_runtime_mcp_registration( self, runtime: dict[str, Any], ) -> InternalServiceRegistration: """Build MCP Server registration from a runtime with MCP protocol.""" raw_name = runtime.get("agentRuntimeName", runtime["agentRuntimeId"]) path = f"/{_slugify(raw_name)}" display = _display_name(raw_name) invocation_url = _build_invocation_url(self.region, runtime.get("agentRuntimeArn", "")) return InternalServiceRegistration( path=path, name=display, description=runtime.get("description", f"AgentCore MCP Server: {display}"), proxy_pass_url=invocation_url, mcp_endpoint=invocation_url, auth_provider="bedrock-agentcore", auth_scheme="bearer", supported_transports=["streamable-http"], tags=["agentcore", "runtime", "mcp-server", "auto-registered"], overwrite=False, metadata={ "source": "agentcore-sync", "runtime_arn": runtime.get("agentRuntimeArn"), "runtime_id": runtime.get("agentRuntimeId"), "server_protocol": "MCP", "region": self.region, "account_id": self.account_id, }, ) def build_runtime_agent_registration( self, runtime: dict[str, Any], ) -> AgentRegistration: """Build A2A Agent registration from a runtime with HTTP/A2A protocol.""" raw_name = runtime.get("agentRuntimeName", runtime["agentRuntimeId"]) path = f"/{_slugify(raw_name)}" display = _display_name(raw_name) invocation_url = _build_invocation_url(self.region, runtime.get("agentRuntimeArn", "")) protocol = runtime.get("protocolConfiguration", {}).get("serverProtocol", "HTTP") tags = ["agentcore", "runtime", "agent", "auto-registered"] if protocol == "A2A": tags.append("a2a") return AgentRegistration( name=display, description=runtime.get("description", f"AgentCore Agent: {display}"), url=invocation_url, path=path, version="1.0.0", tags=tags, # Agent validator accepts: public, private, group-restricted (not "internal"). # MCP Servers use "internal" but A2A Agents use "public" as the default, # so we map "internal" -> "public" for Agent registrations. visibility="public" if self.visibility == "internal" else self.visibility, security_schemes={ "sigv4": { "type": "http", "scheme": "AWS4-HMAC-SHA256", "description": "AWS SigV4 request signing (IAM auth)", } }, security=[{"sigv4": []}], metadata={ "source": "agentcore-sync", "runtime_arn": runtime.get("agentRuntimeArn"), "runtime_id": runtime.get("agentRuntimeId"), "server_protocol": protocol, "region": self.region, "account_id": self.account_id, }, ) # --------------------------------------------------------------------------- # Sync Orchestrator # --------------------------------------------------------------------------- class SyncOrchestrator: """Orchestrates discovery, registration, and manifest generation. Coordinates the full sync lifecycle: 1. Scan gateways / runtimes via ``AgentCoreScanner`` 2. Build registrations via ``RegistrationBuilder`` 3. Register with the registry via ``RegistryClient`` 4. Collect manifest entries for CUSTOM_JWT gateways 5. Write a token-refresh manifest file for downstream tooling Supports dry-run, overwrite, scope filtering, and JSON output. """ def __init__( self, scanner: AgentCoreScanner, builder: RegistrationBuilder, registry_client: RegistryClient, dry_run: bool = False, overwrite: bool = False, include_mcp_targets: bool = False, output_format: str = "text", manifest_path: str = "token_refresh_manifest.json", ) -> None: self.scanner = scanner self.builder = builder self.registry = registry_client self.dry_run = dry_run self.overwrite = overwrite self.include_mcp_targets = include_mcp_targets self.output_format = output_format self.manifest_path = manifest_path self.results: list[dict[str, Any]] = [] self._manifest_entries: list[dict[str, Any]] = [] # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def sync_gateways(self) -> None: """Scan and register all gateways.""" logger.info("Scanning AgentCore Gateways...") gateways = self.scanner.scan_gateways() for gateway in gateways: self._register_gateway(gateway) if self.include_mcp_targets: for target in gateway.get("targets", []): self._register_target(gateway, target) def sync_runtimes(self) -> None: """Scan and register all runtimes.""" logger.info("Scanning AgentCore Runtimes...") runtimes = self.scanner.scan_runtimes() for runtime in runtimes: self._register_runtime(runtime) def write_manifest(self) -> None: """Write the token-refresh manifest for CUSTOM_JWT gateways. The manifest is consumed by downstream tooling (e.g. a token refresh cron) to obtain and rotate egress tokens. """ if self.dry_run: logger.info( f"[DRY-RUN] Would write manifest with {len(self._manifest_entries)} entries" ) return if not self._manifest_entries: logger.info("No CUSTOM_JWT gateways -- skipping manifest") return with open(self.manifest_path, "w") as f: json.dump(self._manifest_entries, f, indent=2) logger.info(f"Wrote {len(self._manifest_entries)} entries to {self.manifest_path}") def print_summary(self) -> None: """Print sync summary in text or JSON format.""" registered = sum(1 for r in self.results if r["status"] == "registered") skipped = sum(1 for r in self.results if r["status"] == "skipped") failed = sum(1 for r in self.results if r["status"] == "failed") dry_run_count = sum(1 for r in self.results if r["status"] == "dry_run") summary = { "dry_run": self.dry_run, "registered": registered, "skipped": skipped, "failed": failed, "manifest_entries": len(self._manifest_entries), "would_register": dry_run_count if self.dry_run else 0, "total": len(self.results), "results": self.results, } if self.output_format == "json": print(json.dumps(summary, indent=2, default=str)) return print("\n" + "=" * 80) print("AGENTCORE SYNC SUMMARY") print("=" * 80) if self.dry_run: print("MODE: DRY-RUN (no changes made)") print(f"Would register: {dry_run_count}") else: print(f"Registered: {registered}") print(f"Skipped: {skipped}") print(f"Failed: {failed}") print(f"Manifest entries: {len(self._manifest_entries)}") print("\nDETAILS:") print("-" * 80) print(f"{'Type':<10} {'Name':<30} {'Path':<25} {'Status':<10}") print("-" * 80) for r in self.results: print( f"{r['resource_type']:<10} " f"{r['resource_name'][:30]:<30} " f"{r['path'][:25]:<25} " f"{r['status']:<10}" ) print("=" * 80) # ------------------------------------------------------------------ # Internal -- manifest collection # ------------------------------------------------------------------ def _collect_manifest_entry( self, gateway: dict[str, Any], server_path: str, ) -> None: """Collect a manifest entry for a CUSTOM_JWT gateway. Only gateways with CUSTOM_JWT authorization and a valid discovery URL are included in the manifest. """ if gateway.get("authorizerType") != "CUSTOM_JWT": return jwt_config = gateway.get("authorizerConfiguration", {}).get("customJWTAuthorizer", {}) discovery_url = jwt_config.get("discoveryUrl", "") if not discovery_url: return self._manifest_entries.append( { "server_path": server_path, "gateway_arn": gateway.get("gatewayArn", ""), "discovery_url": discovery_url, "allowed_clients": jwt_config.get("allowedClients", []), "idp_vendor": _detect_idp_vendor(discovery_url), } ) # ------------------------------------------------------------------ # Internal -- gateway registration # ------------------------------------------------------------------ def _register_gateway(self, gateway: dict[str, Any]) -> None: """Register a single gateway as an MCP Server.""" gateway_name = gateway.get("name", gateway["gatewayId"]) gateway_url = gateway.get("gatewayUrl", "") gateway_arn = gateway.get("gatewayArn", "") if not _validate_https_url(gateway_url, gateway_name): self.results.append( { "resource_type": "gateway", "resource_name": gateway_name, "resource_arn": gateway_arn, "registration_type": "mcp_server", "path": f"/{_slugify(gateway_name)}", "status": "skipped", "message": "Invalid URL (must be HTTPS)", } ) return registration = self.builder.build_gateway_registration(gateway) registration.overwrite = self.overwrite result: dict[str, Any] = { "resource_type": "gateway", "resource_name": gateway_name, "resource_arn": gateway_arn, "registration_type": "mcp_server", "path": registration.service_path, } if self.dry_run: result["status"] = "dry_run" result["message"] = "Would register as MCP Server" logger.info(f"[DRY-RUN] Would register gateway: {gateway_name}") self.results.append(result) self._collect_manifest_entry(gateway, registration.service_path) return try: self._register_service_with_retry(registration) result["status"] = "registered" result["message"] = "Successfully registered" logger.info(f"Registered gateway: {gateway_name}") except Exception as e: if _is_conflict_error(e) and not self.overwrite: result["status"] = "skipped" result["message"] = "Already registered - skipping (use --overwrite)" logger.warning(f"Already registered - skipping: {gateway_name} (use --overwrite)") else: result["status"] = "failed" result["message"] = str(e) logger.error(f"Failed to register gateway: {e}") self.results.append(result) return self.results.append(result) self._collect_manifest_entry(gateway, registration.service_path) # ------------------------------------------------------------------ # Internal -- target registration # ------------------------------------------------------------------ def _register_target(self, gateway: dict[str, Any], target: dict[str, Any]) -> None: registration = self.builder.build_target_registration(gateway, target) if not registration: return registration.overwrite = self.overwrite target_name = target.get("name", target["targetId"]) result: dict[str, Any] = { "resource_type": "target", "resource_name": target_name, "resource_arn": (f"{gateway.get('gatewayArn', '')}:target:{target['targetId']}"), "registration_type": "mcp_server", "path": registration.service_path, } if self.dry_run: result["status"] = "dry_run" result["message"] = "Would register as MCP Server" logger.info(f"[DRY-RUN] Would register target: {target_name}") else: try: self._register_service_with_retry(registration) result["status"] = "registered" result["message"] = "Successfully registered" logger.info(f"Registered target: {target_name}") except Exception as e: if "already exists" in str(e).lower() and not self.overwrite: result["status"] = "skipped" result["message"] = "Already exists" else: result["status"] = "failed" result["message"] = str(e) logger.error(f"Failed to register target: {e}") self.results.append(result) # ------------------------------------------------------------------ # Internal -- runtime registration # ------------------------------------------------------------------ def _register_runtime(self, runtime: dict[str, Any]) -> None: protocol_config = runtime.get("protocolConfiguration", {}) server_protocol = protocol_config.get("serverProtocol", "HTTP") if server_protocol == "MCP": self._register_runtime_as_server(runtime) else: self._register_runtime_as_agent(runtime) def _register_runtime_as_server(self, runtime: dict[str, Any]) -> None: registration = self.builder.build_runtime_mcp_registration(runtime) registration.overwrite = self.overwrite runtime_name = runtime.get("agentRuntimeName", runtime["agentRuntimeId"]) result: dict[str, Any] = { "resource_type": "runtime", "resource_name": runtime_name, "resource_arn": runtime.get("agentRuntimeArn", ""), "registration_type": "mcp_server", "path": registration.service_path, } if self.dry_run: result["status"] = "dry_run" result["message"] = "Would register as MCP Server" logger.info(f"[DRY-RUN] Would register runtime as MCP Server: {runtime_name}") else: try: self._register_service_with_retry(registration) result["status"] = "registered" logger.info(f"Registered runtime as MCP Server: {runtime_name}") except Exception as e: if "already exists" in str(e).lower() and not self.overwrite: result["status"] = "skipped" result["message"] = "Already exists" else: result["status"] = "failed" result["message"] = str(e) logger.error(f"Failed to register runtime: {e}") self.results.append(result) def _register_runtime_as_agent(self, runtime: dict[str, Any]) -> None: registration = self.builder.build_runtime_agent_registration(runtime) runtime_name = runtime.get("agentRuntimeName", runtime["agentRuntimeId"]) result: dict[str, Any] = { "resource_type": "runtime", "resource_name": runtime_name, "resource_arn": runtime.get("agentRuntimeArn", ""), "registration_type": "agent", "path": registration.path, } if self.dry_run: result["status"] = "dry_run" result["message"] = "Would register as A2A Agent" logger.info(f"[DRY-RUN] Would register runtime as Agent: {runtime_name}") else: try: self._register_agent_with_retry(registration) result["status"] = "registered" logger.info(f"Registered runtime as Agent: {runtime_name}") except Exception as e: if _is_conflict_error(e) and self.overwrite: # AgentRegistration has no overwrite field, # so update via PUT when conflict + overwrite try: self._update_agent_with_retry(registration.path, registration) result["status"] = "registered" result["message"] = "Updated (overwrite)" logger.info(f"Updated existing agent: {runtime_name}") except Exception as update_err: result["status"] = "failed" result["message"] = str(update_err) logger.error(f"Failed to update agent {runtime_name}: {update_err}") elif _is_conflict_error(e): result["status"] = "skipped" result["message"] = "Already registered - use --overwrite to update" else: result["status"] = "failed" result["message"] = str(e) logger.error(f"Failed to register runtime as agent: {e}") self.results.append(result) # ------------------------------------------------------------------ # Retry-wrapped registry calls # ------------------------------------------------------------------ @_retry_registry_call def _register_service_with_retry(self, registration: InternalServiceRegistration) -> None: self.registry.register_service(registration) @_retry_registry_call def _register_agent_with_retry(self, registration: AgentRegistration) -> None: self.registry.register_agent(registration) @_retry_registry_call def _update_agent_with_retry( self, path: str, registration: AgentRegistration, ) -> None: self.registry.update_agent(path, registration) ================================================ FILE: cli/agentcore/sync.py ================================================ """CLI entry point for AgentCore auto-registration. Provides ``sync`` and ``list`` subcommands via argparse. Usage:: python -m cli.agentcore.sync sync [options] python -m cli.agentcore.sync list [options] """ from __future__ import annotations import argparse import json import logging import os import sys from .models import ( DEFAULT_MANIFEST_PATH, DEFAULT_REGION, DEFAULT_REGISTRY_URL, DEFAULT_TIMEOUT, DEFAULT_TOKEN_FILE, _load_token, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Argparse setup # --------------------------------------------------------------------------- def build_parser() -> argparse.ArgumentParser: """Build the CLI argument parser with sync and list subcommands.""" parser = argparse.ArgumentParser( prog="agentcore-sync", description=( "Discover and register AWS Bedrock AgentCore Gateways and " "Agent Runtimes with the MCP Gateway Registry." ), epilog=( "Environment variables:\n" " AWS_REGION AWS region (default: us-east-1)\n" " REGISTRY_URL Registry base URL\n" " REGISTRY_TOKEN_FILE Path to registry auth token file\n" " AGENTCORE_ACCOUNTS Comma-separated account IDs (cross-account)\n" " AGENTCORE_ASSUME_ROLE_NAME Role name to assume (default: AgentCoreSyncRole)\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) subparsers = parser.add_subparsers(dest="command") # -- shared arguments -------------------------------------------------- def add_common_args(sub: argparse.ArgumentParser) -> None: sub.add_argument( "--region", default=os.environ.get("AWS_REGION", DEFAULT_REGION), help="AWS region (default: AWS_REGION env or us-east-1)", ) sub.add_argument( "--registry-url", default=os.environ.get("REGISTRY_URL", DEFAULT_REGISTRY_URL), help="Registry base URL (default: REGISTRY_URL env or http://localhost)", ) sub.add_argument( "--token-file", default=os.environ.get("REGISTRY_TOKEN_FILE", DEFAULT_TOKEN_FILE), help="Path to registry auth token file", ) sub.add_argument( "--timeout", type=int, default=DEFAULT_TIMEOUT, help="AWS API call timeout in seconds (default: 30)", ) sub.add_argument( "--gateways-only", action="store_true", help="Only process gateways", ) sub.add_argument( "--runtimes-only", action="store_true", help="Only process runtimes", ) sub.add_argument( "--output", choices=["text", "json"], default="text", help="Output format (default: text)", ) sub.add_argument( "--debug", action="store_true", help="Enable DEBUG logging", ) # -- cross-account arguments (shared by sync and list) ---------------- def add_cross_account_args(sub: argparse.ArgumentParser) -> None: sub.add_argument( "--accounts", default=os.environ.get("AGENTCORE_ACCOUNTS", ""), help=( "Comma-separated AWS account IDs to scan (cross-account). " "Requires a role in each account that the caller can assume. " "(default: current account only)" ), ) sub.add_argument( "--assume-role-name", default=os.environ.get("AGENTCORE_ASSUME_ROLE_NAME", "AgentCoreSyncRole"), help=( "IAM role name to assume in each target account " "(default: AGENTCORE_ASSUME_ROLE_NAME env or AgentCoreSyncRole)" ), ) # -- sync subcommand --------------------------------------------------- sync_parser = subparsers.add_parser( "sync", help="Discover and register AgentCore resources", ) add_common_args(sync_parser) add_cross_account_args(sync_parser) sync_parser.add_argument( "--dry-run", action="store_true", help="Preview without registering or persisting credentials", ) sync_parser.add_argument( "--overwrite", action="store_true", help="Overwrite existing registrations", ) sync_parser.add_argument( "--visibility", choices=["public", "internal", "group-restricted"], default="internal", help="Registration visibility (default: internal)", ) sync_parser.add_argument( "--include-mcp-targets", action="store_true", help="Register mcpServer gateway targets as separate MCP Servers", ) sync_parser.add_argument( "--manifest", default=DEFAULT_MANIFEST_PATH, help="Output path for token refresh manifest (default: token_refresh_manifest.json)", ) # -- list subcommand --------------------------------------------------- list_parser = subparsers.add_parser( "list", help="Discover and display AgentCore resources without registering", ) add_common_args(list_parser) add_cross_account_args(list_parser) return parser # --------------------------------------------------------------------------- # Cross-account helpers # --------------------------------------------------------------------------- def _parse_account_ids(accounts_str: str) -> list[str]: """Parse comma-separated account IDs, stripping whitespace.""" if not accounts_str or not accounts_str.strip(): return [] return [a.strip() for a in accounts_str.split(",") if a.strip()] def _assume_role_session( account_id: str, role_name: str, region: str, ) -> boto3.Session: """Assume an IAM role in a target account and return a boto3 Session. Args: account_id: Target AWS account ID. role_name: IAM role name to assume in the target account. region: AWS region for the STS call. Returns: boto3.Session with temporary credentials from the assumed role. Raises: botocore.exceptions.ClientError: If AssumeRole fails. """ import boto3 role_arn = f"arn:aws:iam::{account_id}:role/{role_name}" logger.info(f"Assuming role {role_arn} for cross-account access...") sts = boto3.client("sts", region_name=region) response = sts.assume_role( RoleArn=role_arn, RoleSessionName=f"agentcore-sync-{account_id}", DurationSeconds=3600, ) creds = response["Credentials"] session = boto3.Session( aws_access_key_id=creds["AccessKeyId"], aws_secret_access_key=creds["SecretAccessKey"], aws_session_token=creds["SessionToken"], region_name=region, ) logger.info(f"Assumed role in account {account_id} successfully") return session # --------------------------------------------------------------------------- # cmd_sync # --------------------------------------------------------------------------- def cmd_sync(args: argparse.Namespace) -> int: """Execute the sync subcommand: discover, register, write manifest.""" # Load registry token try: token = _load_token(args.token_file) except (FileNotFoundError, ValueError) as e: logger.error(str(e)) return 1 # Late imports to keep argparse fast from .discovery import AgentCoreScanner from .registration import RegistrationBuilder, SyncOrchestrator # Add project root so api.registry_client is importable sys.path.insert( 0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), ) from api.registry_client import RegistryClient registry = RegistryClient(registry_url=args.registry_url, token=token) # Determine accounts to scan account_ids = _parse_account_ids(getattr(args, "accounts", "")) role_name = getattr(args, "assume_role_name", "AgentCoreSyncRole") # Build list of (label, session_or_none) pairs # Empty list = current account only (session=None) account_sessions: list[tuple[str, object]] = [] if account_ids: for acct in account_ids: try: session = _assume_role_session(acct, role_name, args.region) account_sessions.append((acct, session)) except Exception as e: logger.error(f"Failed to assume role in account {acct}: {e}") if args.output == "json": print(json.dumps({"error": f"AssumeRole failed for {acct}: {e}"})) return 1 else: account_sessions.append(("current", None)) # Run sync for each account for label, session in account_sessions: if len(account_sessions) > 1: logger.info(f"\n{'=' * 60}") logger.info(f"Syncing account: {label}") logger.info(f"{'=' * 60}") scanner = AgentCoreScanner(region=args.region, timeout=args.timeout, session=session) builder = RegistrationBuilder( region=args.region, visibility=args.visibility, session=session ) orchestrator = SyncOrchestrator( scanner=scanner, builder=builder, registry_client=registry, dry_run=args.dry_run, overwrite=args.overwrite, include_mcp_targets=args.include_mcp_targets, output_format=args.output, manifest_path=args.manifest, ) # Scope filtering if not args.runtimes_only: orchestrator.sync_gateways() if not args.gateways_only: orchestrator.sync_runtimes() # Write token refresh manifest orchestrator.write_manifest() # Summary orchestrator.print_summary() return 0 # --------------------------------------------------------------------------- # cmd_list # --------------------------------------------------------------------------- def cmd_list(args: argparse.Namespace) -> int: """Execute the list subcommand: discover and display resources.""" from .discovery import AgentCoreScanner # Determine accounts to scan account_ids = _parse_account_ids(getattr(args, "accounts", "")) role_name = getattr(args, "assume_role_name", "AgentCoreSyncRole") account_sessions: list[tuple[str, object]] = [] if account_ids: for acct in account_ids: try: session = _assume_role_session(acct, role_name, args.region) account_sessions.append((acct, session)) except Exception as e: logger.error(f"Failed to assume role in account {acct}: {e}") return 1 else: account_sessions.append(("current", None)) all_gateways: list = [] all_runtimes: list = [] all_errors: list[str] = [] for label, session in account_sessions: scanner = AgentCoreScanner(region=args.region, timeout=args.timeout, session=session) if not args.runtimes_only: try: gateways = scanner.scan_gateways() # Tag with account for multi-account output if len(account_sessions) > 1: for gw in gateways: gw["_account"] = label all_gateways.extend(gateways) except Exception as e: all_errors.append(f"[{label}] Gateway scan error: {e}") logger.error(f"Failed to scan gateways in {label}: {e}") if not args.gateways_only: try: runtimes = scanner.scan_runtimes() if len(account_sessions) > 1: for rt in runtimes: rt["_account"] = label all_runtimes.extend(runtimes) except Exception as e: all_errors.append(f"[{label}] Runtime scan error: {e}") logger.error(f"Failed to scan runtimes in {label}: {e}") if args.output == "json": print( json.dumps( { "region": args.region, "accounts": account_ids or ["current"], "gateways": all_gateways, "runtimes": all_runtimes, "errors": all_errors, }, indent=2, default=str, ) ) else: _print_list_text(all_gateways, all_runtimes, args.region, all_errors) return 0 def _print_list_text( gateways: list, runtimes: list, region: str, errors: list[str], ) -> None: """Print discovered resources in text format.""" print(f"\nAgentCore Resources in {region}") print("=" * 70) if gateways: print(f"\nGateways ({len(gateways)}):") print("-" * 70) for gw in gateways: name = gw.get("name", gw.get("gatewayId", "unknown")) auth = gw.get("authorizerType", "unknown") status = gw.get("status", "unknown") targets = len(gw.get("targets", [])) print(f" {name:<30} auth={auth:<12} targets={targets} [{status}]") else: print("\nNo gateways found.") if runtimes: print(f"\nRuntimes ({len(runtimes)}):") print("-" * 70) for rt in runtimes: name = rt.get("agentRuntimeName", rt.get("agentRuntimeId", "unknown")) protocol = rt.get("protocolConfiguration", {}).get("serverProtocol", "unknown") status = rt.get("status", "unknown") print(f" {name:<30} protocol={protocol:<8} [{status}]") else: print("\nNo runtimes found.") if errors: print(f"\nErrors ({len(errors)}):") for err in errors: print(f" - {err}") print("=" * 70) # --------------------------------------------------------------------------- # main # --------------------------------------------------------------------------- def main(argv: list[str] | None = None) -> int: """Entry point: parse args, configure logging, dispatch subcommand.""" # Load .env before anything reads os.environ try: from dotenv import load_dotenv load_dotenv() except ImportError: pass parser = build_parser() args = parser.parse_args(argv) if not args.command: parser.print_help() return 1 # Logging level = logging.DEBUG if args.debug else logging.INFO logging.basicConfig( level=level, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger.debug(f"CLI args: {args}") if args.command == "sync": return cmd_sync(args) elif args.command == "list": return cmd_list(args) parser.print_help() return 1 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: cli/agentcore/token_refresher.py ================================================ """Token refresher for AgentCore CUSTOM_JWT gateways. Reads token_refresh_manifest.json (produced by ``cli.agentcore sync``), resolves client secrets per IdP vendor, fetches OAuth2 access tokens via standard OIDC client_credentials grant, and PATCHes them into the MCP Gateway Registry. Usage:: # One-time refresh uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token # Continuous mode (sidecar) uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token \ --loop --interval 2700 """ from __future__ import annotations import argparse import json import logging import os import time from datetime import UTC, datetime from typing import Any import boto3 import requests # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Constants OIDC_DISCOVERY_TIMEOUT: int = 10 TOKEN_REQUEST_TIMEOUT: int = 15 REGISTRY_REQUEST_TIMEOUT: int = 15 SECURITY_SCAN_TIMEOUT: int = 120 IDP_PATTERNS: dict[str, str] = { "cognito-idp": "cognito", "auth0.com": "auth0", "okta.com": "okta", "microsoftonline.com": "entra", "/realms/": "keycloak", } IDP_SECRET_ENV_VARS: dict[str, str] = { "auth0": "AUTH0_CLIENT_SECRET", "okta": "OKTA_CLIENT_SECRET", "entra": "ENTRA_CLIENT_SECRET", "keycloak": "KEYCLOAK_CLIENT_SECRET", } ENV_VAR_PREFIX: str = "OAUTH_CLIENT_SECRET_" # --------------------------------------------------------------------------- # Private functions # --------------------------------------------------------------------------- def _read_manifest( manifest_path: str, ) -> list[dict[str, Any]]: """Read token refresh manifest from JSON file. Args: manifest_path: Path to the manifest JSON file. Returns: List of manifest entries. Raises: FileNotFoundError: If manifest file does not exist. ValueError: If manifest file contains invalid JSON. """ abs_path = os.path.abspath(manifest_path) try: with open(abs_path) as f: entries = json.load(f) except FileNotFoundError: raise FileNotFoundError(f"Manifest file not found: {abs_path}") except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON in manifest file {abs_path}: {e}") if not isinstance(entries, list): raise ValueError(f"Manifest must be a JSON array, got {type(entries).__name__}") logger.info(f"Read {len(entries)} entries from {manifest_path}") return entries def _detect_idp_vendor( discovery_url: str, ) -> str: """Detect IdP vendor from OIDC discovery URL. Matches known patterns in the URL string. Args: discovery_url: OIDC discovery URL. Returns: Vendor name (cognito, auth0, okta, entra, keycloak, or unknown). """ for pattern, vendor in IDP_PATTERNS.items(): if pattern in discovery_url: return vendor return "unknown" def _get_cognito_client_secret( discovery_url: str, client_id: str, ) -> str | None: """Auto-retrieve client secret from Cognito. Parses user_pool_id and region from the discoveryUrl, calls describe_user_pool_client() via boto3. Args: discovery_url: Cognito OIDC discovery URL containing pool_id and region. client_id: Cognito app client ID. Returns: Client secret string, or None if not available. """ try: # Parse: https://cognito-idp.{region}.amazonaws.com/{pool_id}/... region = discovery_url.split("cognito-idp.")[1].split(".amazonaws")[0] pool_id = discovery_url.split("amazonaws.com/")[1].split("/")[0] client = boto3.client("cognito-idp", region_name=region) response = client.describe_user_pool_client( UserPoolId=pool_id, ClientId=client_id, ) secret = response["UserPoolClient"].get("ClientSecret") if secret: logger.info(f"Auto-retrieved client secret from Cognito (pool: {pool_id})") else: logger.warning(f"Cognito app client {client_id} has no client secret") return secret except Exception as e: logger.error(f"Failed to retrieve Cognito client secret: {e}") return None def _get_client_secret( idp_vendor: str, discovery_url: str, client_id: str, ) -> str | None: """Resolve client secret using this priority order: 1. Per-client env var: OAUTH_CLIENT_SECRET_ 2. Cognito auto-retrieval via AWS API (cognito only) 3. Vendor env var: AUTH0_CLIENT_SECRET, OKTA_CLIENT_SECRET, etc. Args: idp_vendor: Detected IdP vendor name. discovery_url: OIDC discovery URL (used for Cognito parsing). client_id: OAuth2 client ID. Returns: Client secret string, or None if not available. """ # Priority 1: per-client env var (OAUTH_CLIENT_SECRET_) env_var_name = f"{ENV_VAR_PREFIX}{client_id}" secret = os.environ.get(env_var_name) if secret: logger.info(f"Using client secret from env var {env_var_name}") return secret # Priority 2: Cognito auto-retrieval via AWS API if idp_vendor == "cognito": return _get_cognito_client_secret(discovery_url, client_id) # Priority 3: vendor-specific env var vendor_env_var = IDP_SECRET_ENV_VARS.get(idp_vendor) if not vendor_env_var: logger.warning(f"No env var mapping for IdP vendor: {idp_vendor}") return None secret = os.environ.get(vendor_env_var) if not secret: logger.warning(f"Env var {vendor_env_var} not set for {idp_vendor}") else: logger.debug(f"Using client secret from vendor env var {vendor_env_var}") return secret def _get_token_endpoint( discovery_url: str, ) -> str | None: """Fetch token_endpoint from OIDC discovery document. GETs the discoveryUrl and extracts the token_endpoint field. Standard OIDC -- works for all providers. Args: discovery_url: OIDC discovery URL. Returns: Token endpoint URL, or None on failure. """ try: response = requests.get( discovery_url, timeout=OIDC_DISCOVERY_TIMEOUT, ) response.raise_for_status() token_endpoint = response.json().get("token_endpoint") if not token_endpoint: logger.error(f"No token_endpoint in OIDC discovery: {discovery_url}") return token_endpoint except Exception as e: logger.error(f"OIDC discovery failed for {discovery_url}: {e}") return None def _request_token( token_endpoint: str, client_id: str, client_secret: str, ) -> str | None: """Request access token via OAuth2 client_credentials grant. Args: token_endpoint: OAuth2 token endpoint URL. client_id: OAuth2 client ID. client_secret: OAuth2 client secret. Returns: Access token string, or None on failure. """ try: response = requests.post( token_endpoint, headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, }, timeout=TOKEN_REQUEST_TIMEOUT, ) response.raise_for_status() token = response.json().get("access_token") if not token: logger.error("Token response missing access_token field") return token except Exception as e: logger.error(f"Token request failed: {e}") return None def _update_registry_credential( registry_url: str, registry_token: str, server_path: str, auth_credential: str, ) -> bool: """PATCH auth_credential for a server in the registry. Uses the /api/servers{path}/auth-credential endpoint. Args: registry_url: Registry base URL. registry_token: Registry auth token (Bearer). server_path: Server path in the registry (e.g., /my-server). auth_credential: New auth credential (access token). Returns: True if update succeeded, False otherwise. """ url = f"{registry_url.rstrip('/')}/api/servers{server_path}/auth-credential" try: response = requests.patch( url, headers={ "Authorization": f"Bearer {registry_token}", "Content-Type": "application/json", }, json={ "auth_scheme": "bearer", "auth_credential": auth_credential, }, timeout=REGISTRY_REQUEST_TIMEOUT, ) response.raise_for_status() logger.info(f"Updated auth_credential for {server_path}") return True except requests.HTTPError as e: status = e.response.status_code if e.response is not None else "?" if status == 500 and "text/html" in ( e.response.headers.get("content-type", "") if e.response is not None else "" ): logger.error( f"Failed to update credential for {server_path}: " f"HTTP {status} from nginx -- registry token may be expired, " f"regenerate and retry" ) else: logger.error(f"Failed to update credential for {server_path}: {e}") return False except Exception as e: logger.error(f"Failed to update credential for {server_path}: {e}") return False def _trigger_security_scan( registry_url: str, registry_token: str, server_path: str, ) -> bool: """Trigger a security rescan for a server after credential update. POSTs to /api/servers/{path}/rescan. Requires admin privileges on the registry token. Args: registry_url: Registry base URL. registry_token: Registry auth token (Bearer). server_path: Server path in the registry (e.g., /my-server). Returns: True if scan was triggered successfully, False otherwise. """ url = f"{registry_url.rstrip('/')}/api/servers{server_path}/rescan" try: response = requests.post( url, headers={ "Authorization": f"Bearer {registry_token}", "Content-Type": "application/json", }, timeout=SECURITY_SCAN_TIMEOUT, ) response.raise_for_status() scan_data = response.json() is_safe = scan_data.get("is_safe", False) critical = scan_data.get("critical_issues", 0) high = scan_data.get("high_severity", 0) if is_safe: logger.info(f"Security scan passed for {server_path}") else: logger.warning( f"Security scan for {server_path}: " f"critical={critical}, high={high}, is_safe={is_safe}" ) return True except requests.HTTPError as e: status_code = e.response.status_code if e.response is not None else "?" if status_code == 403: logger.warning( f"Security scan skipped for {server_path}: registry token lacks admin privileges" ) else: logger.error(f"Security scan failed for {server_path}: HTTP {status_code}") return False except Exception as e: logger.error(f"Security scan failed for {server_path}: {e}") return False def _load_registry_token( token_file: str, ) -> str: """Load registry auth token from JSON file. Supports two formats: - Flat: ``{"access_token": "..."}`` or ``{"token": "..."}`` - Nested: ``{"tokens": {"access_token": "..."}}`` Args: token_file: Path to the token JSON file. Returns: Token string. Raises: FileNotFoundError: If token file does not exist. ValueError: If token file is invalid or missing token field. """ abs_path = os.path.abspath(token_file) try: with open(abs_path) as f: data = json.load(f) # Try top-level first, then nested under "tokens" token = data.get("access_token") or data.get("token") if not token: tokens_obj = data.get("tokens", {}) if isinstance(tokens_obj, dict): token = tokens_obj.get("access_token") or tokens_obj.get("token") if not token: raise ValueError(f"No access_token or token field in token file: {abs_path}") return token except FileNotFoundError: raise FileNotFoundError(f"Token file not found: {abs_path}") except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON in token file {abs_path}: {e}") # --------------------------------------------------------------------------- # Public function # --------------------------------------------------------------------------- def refresh_all( manifest_path: str, registry_url: str, registry_token: str, run_scan: bool = True, ) -> dict[str, Any]: """Refresh tokens for all entries in the manifest. For each CUSTOM_JWT gateway: 1. Resolve client_secret (per-client env -> Cognito auto -> vendor env) 2. GET discoveryUrl -> extract token_endpoint 3. POST client_credentials grant -> get access_token 4. PATCH auth_credential in the registry 5. Trigger security rescan (if run_scan is True) Args: manifest_path: Path to token_refresh_manifest.json. registry_url: Registry base URL. registry_token: Registry auth token (Bearer). run_scan: If True, trigger security rescan after each credential update. Returns: Summary dict with success/failure/skipped counts and scan results. """ entries = _read_manifest(manifest_path) start_time = time.time() success_count = 0 failure_count = 0 skipped_count = 0 scan_success_count = 0 scan_failure_count = 0 for entry in entries: server_path = entry["server_path"] discovery_url = entry["discovery_url"] allowed_clients = entry.get("allowed_clients", []) idp_vendor = entry.get("idp_vendor") or _detect_idp_vendor(discovery_url) if not allowed_clients: logger.warning(f"No allowed_clients for {server_path} -- skipping") skipped_count += 1 continue client_id = allowed_clients[0] # Step 1: Resolve client_secret (per-client env -> auto -> vendor env) client_secret = _get_client_secret(idp_vendor, discovery_url, client_id) if not client_secret: skipped_count += 1 continue # Step 2: Get token_endpoint via OIDC discovery token_endpoint = _get_token_endpoint(discovery_url) if not token_endpoint: failure_count += 1 continue # Step 3: Request token token = _request_token(token_endpoint, client_id, client_secret) if not token: failure_count += 1 continue # Step 4: Update registry updated = _update_registry_credential(registry_url, registry_token, server_path, token) if updated: success_count += 1 entry["last_refreshed"] = datetime.now(UTC).isoformat() # Step 5: Trigger security rescan if run_scan: scanned = _trigger_security_scan(registry_url, registry_token, server_path) if scanned: scan_success_count += 1 else: scan_failure_count += 1 else: failure_count += 1 # Update manifest with timestamps with open(manifest_path, "w") as f: json.dump(entries, f, indent=2) elapsed = time.time() - start_time summary: dict[str, Any] = { "total": len(entries), "success": success_count, "failed": failure_count, "skipped": skipped_count, "elapsed_seconds": round(elapsed, 1), } if run_scan: summary["scans_triggered"] = scan_success_count summary["scans_failed"] = scan_failure_count logger.info(f"Token refresh complete: {json.dumps(summary)}") return summary # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> None: """Parse arguments and run token refresh.""" parser = argparse.ArgumentParser( description="Refresh auth tokens for AgentCore CUSTOM_JWT gateways", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Example usage: # One-time refresh uv run python -m cli.agentcore.token_refresher \\ --manifest token_refresh_manifest.json \\ --registry-url https://registry.example.com \\ --token-file .token # With per-client env vars (from .env) OAUTH_CLIENT_SECRET_49ujl0b9ser72gnp6q1ph9v6vs=mysecret \\ uv run python -m cli.agentcore.token_refresher \\ --manifest token_refresh_manifest.json \\ --registry-url https://registry.example.com \\ --token-file .token # Continuous mode (run as sidecar) uv run python -m cli.agentcore.token_refresher \\ --manifest token_refresh_manifest.json \\ --registry-url https://registry.example.com \\ --token-file .token \\ --loop --interval 2700 Secret resolution priority (per client_id): 1. Per-client env var: OAUTH_CLIENT_SECRET_= 2. Cognito auto-retrieval via AWS API (cognito only) 3. Vendor env var: AUTH0_CLIENT_SECRET Client secret for Auth0 gateways OKTA_CLIENT_SECRET Client secret for Okta gateways ENTRA_CLIENT_SECRET Client secret for Entra gateways KEYCLOAK_CLIENT_SECRET Client secret for Keycloak gateways """, ) parser.add_argument( "--manifest", default="token_refresh_manifest.json", help="Path to token refresh manifest (default: token_refresh_manifest.json)", ) parser.add_argument( "--registry-url", default=os.environ.get("REGISTRY_URL", "http://localhost"), help="Registry base URL (default: REGISTRY_URL env or http://localhost)", ) parser.add_argument( "--token-file", default=os.environ.get("REGISTRY_TOKEN_FILE", ".token"), help="Path to registry auth token file (default: REGISTRY_TOKEN_FILE env or .token)", ) parser.add_argument( "--loop", action="store_true", help="Run continuously (for sidecar deployment)", ) parser.add_argument( "--interval", type=int, default=2700, help="Refresh interval in seconds (default: 2700 = 45 min)", ) parser.add_argument( "--scan", action=argparse.BooleanOptionalAction, default=True, help="Trigger security rescan after each credential update (default: enabled, use --no-scan to disable)", ) parser.add_argument( "--debug", action="store_true", help="Enable DEBUG logging", ) args = parser.parse_args() if args.debug: logging.getLogger().setLevel(logging.DEBUG) registry_token = _load_registry_token(args.token_file) if args.loop: logger.info(f"Running in continuous mode, interval: {args.interval}s") while True: try: refresh_all( args.manifest, args.registry_url, registry_token, run_scan=args.scan, ) except Exception as e: logger.error(f"Refresh cycle failed: {e}") logger.info(f"Sleeping {args.interval}s until next refresh...") time.sleep(args.interval) else: refresh_all( args.manifest, args.registry_url, registry_token, run_scan=args.scan, ) if __name__ == "__main__": main() ================================================ FILE: cli/anthropic_transformer.py ================================================ #!/usr/bin/env python3 """Transform Anthropic MCP Registry server format to Gateway Registry format. This module provides utilities to convert server definitions from the Anthropic MCP Registry API format into the format expected by the MCP Gateway Registry. """ import json import logging from typing import ( Any, ) # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Constants DEFAULT_BASE_PORT: int = 8100 DEFAULT_TRANSPORT: str = "stdio" DEFAULT_DESCRIPTION: str = "MCP server imported from Anthropic Registry" DEFAULT_LICENSE: str = "MIT" DEFAULT_AUTH_PROVIDER: str = "keycloak" DEFAULT_AUTH_SCHEME: str = "bearer" def _substitute_env_vars_in_headers(headers: list[dict[str, str]]) -> list[dict[str, str]]: """Substitute environment variables in header values. Replaces ${VAR_NAME} or $VAR_NAME with actual environment variable values. If the environment variable is not set, keeps the placeholder. Args: headers: List of header dictionaries Returns: List of headers with environment variables substituted """ import os import re substituted_headers = [] for header_dict in headers: substituted_header = {} for header_name, header_value in header_dict.items(): # Match ${VAR_NAME} or $VAR_NAME pattern def replace_env_var(match): var_name = match.group(1) env_value = os.getenv(var_name) if env_value: logger.info(f"Substituted {var_name} in header {header_name}") return env_value else: logger.warning( f"Environment variable {var_name} not found, keeping placeholder" ) return match.group(0) # Keep original placeholder # Replace ${VAR} pattern first substituted_value = re.sub(r"\$\{([^}]+)\}", replace_env_var, header_value) # Then replace $VAR pattern (only for uppercase variables) substituted_value = re.sub(r"\$([A-Z_][A-Z0-9_]*)", replace_env_var, substituted_value) substituted_header[header_name] = substituted_value substituted_headers.append(substituted_header) return substituted_headers def _extract_remote_info( remotes: list[dict[str, Any]], ) -> tuple[str | None, str, str, list[dict[str, str]]]: """Extract remote URL, transport type, auth scheme, and headers from remotes field. Args: remotes: List of remote server configurations Returns: Tuple of (remote_url, transport_type, auth_scheme, headers) """ import re remote_url = None transport_type = DEFAULT_TRANSPORT auth_scheme = "none" output_headers = [] if remotes: remote = remotes[0] remote_url = remote.get("url") transport_type = remote.get("type", "streamable-http") # Check if remote has authentication headers headers = remote.get("headers", []) if headers: for header in headers: header_name = header.get("name", "") header_value = header.get("value", "") # Check for auth-related headers if header_name.lower() in ["authorization", "x-api-key", "api-key"]: # Extract variable name from the placeholder (e.g., {smithery_api_key}) match = re.search(r"\{([^}]+)\}", header_value) if match: var_name = match.group(1) # Convert to uppercase with underscores (e.g., smithery_api_key -> SMITHERY_API_KEY) env_var_name = var_name.upper() # Determine auth scheme and create header value if "bearer" in header_value.lower(): auth_scheme = "bearer" output_headers.append({header_name: f"Bearer ${{{env_var_name}}}"}) elif "api" in header_value.lower() or "key" in header_value.lower(): auth_scheme = "api_key" output_headers.append({header_name: f"${{{env_var_name}}}"}) else: auth_scheme = "bearer" output_headers.append({header_name: f"${{{env_var_name}}}"}) break return remote_url, transport_type, auth_scheme, output_headers def _generate_tags(name: str) -> list[str]: """Generate tags from server name. Args: name: Server name (may contain slashes) Returns: List of tags including name parts and 'anthropic-registry' """ name_parts = name.replace("/", "-").split("-") tags = name_parts + ["anthropic-registry"] return tags def transform_anthropic_to_gateway( anthropic_response: dict[str, Any], base_port: int = DEFAULT_BASE_PORT ) -> dict[str, Any]: """Transform Anthropic ServerResponse to Gateway Registry Config format. Args: anthropic_response: Server data from Anthropic Registry API base_port: Base port number for local proxy URLs Returns: Dictionary in Gateway Registry configuration format Example: >>> response = {"server": {"name": "brave-search", ...}} >>> config = transform_anthropic_to_gateway(response) >>> print(config["server_name"]) brave-search """ server = anthropic_response.get("server", anthropic_response) name = server["name"] tags = _generate_tags(name) remotes = server.get("remotes", []) remote_url, transport_type, auth_scheme, auth_headers = _extract_remote_info(remotes) # Substitute environment variables in headers if auth_headers: auth_headers = _substitute_env_vars_in_headers(auth_headers) safe_path = name.replace("/", "-") proxy_url = remote_url if remote_url else f"http://localhost:{base_port}/" return { "server_name": name, "description": server.get("description", DEFAULT_DESCRIPTION), "path": f"/{safe_path}", "proxy_pass_url": proxy_url, "auth_provider": DEFAULT_AUTH_PROVIDER if auth_scheme != "none" else None, "auth_scheme": auth_scheme, "supported_transports": [transport_type], "tags": tags, "headers": auth_headers if auth_headers else [], "num_tools": 0, "license": DEFAULT_LICENSE, "remote_url": remote_url, "tool_list": [], } def _run_example() -> None: """Run example transformation and print result.""" example_input = { "name": "brave-search", "description": "MCP server for Brave Search API", "version": "0.1.0", "repository": { "type": "github", "url": "https://github.com/modelcontextprotocol/servers/tree/main/src/brave-search", }, "websiteUrl": "https://brave.com/search/api/", "packages": {"npm": "@modelcontextprotocol/server-brave-search"}, } result = transform_anthropic_to_gateway(example_input) print(json.dumps(result, indent=2)) if __name__ == "__main__": _run_example() ================================================ FILE: cli/bin/registry.js ================================================ #!/usr/bin/env node import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const projectRoot = dirname(__dirname); const child = spawn('npx', ['tsx', join(projectRoot, 'src/index.tsx'), ...process.argv.slice(2)], { stdio: 'inherit', shell: true }); child.on('exit', (code) => { process.exit(code); }); ================================================ FILE: cli/bootstrap_user_and_m2m_setup.sh ================================================ #!/bin/bash # Bootstrap script for setting up LOB users and M2M service accounts # Creates registry-users-lob1 and registry-users-lob2 groups # Then creates bot and human users in these groups set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" ENV_FILE="$PROJECT_ROOT/.env" USER_MGMT_SCRIPT="$SCRIPT_DIR/user_mgmt.sh" # Load environment variables from .env file if [ ! -f "$ENV_FILE" ]; then echo "Error: .env file not found at $ENV_FILE" exit 1 fi set -a source "$ENV_FILE" set +a # Configuration - read from .env variables ADMIN_URL="${KEYCLOAK_ADMIN_URL}" REALM="${KEYCLOAK_REALM}" ADMIN_USER="${KEYCLOAK_ADMIN}" ADMIN_PASS="${KEYCLOAK_ADMIN_PASSWORD}" INITIAL_USER_PASSWORD="${INITIAL_USER_PASSWORD}" # Colors for output GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' _print_section() { echo "" echo -e "${BLUE}==============================================" echo "$1" echo "===============================================${NC}" } _print_success() { echo -e "${GREEN}✓ $1${NC}" } _print_error() { echo -e "${RED}Error: $1${NC}" } _print_info() { echo -e "${YELLOW}$1${NC}" } _validate_environment() { local missing_vars=() if [ -z "$ADMIN_URL" ]; then missing_vars+=("KEYCLOAK_ADMIN_URL") fi if [ -z "$REALM" ]; then missing_vars+=("KEYCLOAK_REALM") fi if [ -z "$ADMIN_USER" ]; then missing_vars+=("KEYCLOAK_ADMIN") fi if [ -z "$ADMIN_PASS" ]; then missing_vars+=("KEYCLOAK_ADMIN_PASSWORD") fi if [ -z "$INITIAL_USER_PASSWORD" ]; then missing_vars+=("INITIAL_USER_PASSWORD") fi if [ ${#missing_vars[@]} -gt 0 ]; then _print_error "Missing required environment variables in .env file:" for var in "${missing_vars[@]}"; do echo " - $var" done echo "" echo "Please update $ENV_FILE with the missing values" exit 1 fi } _get_admin_token() { TOKEN=$(curl -s -X POST "$ADMIN_URL/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=$ADMIN_USER" \ -d "password=$ADMIN_PASS" \ -d "grant_type=password" \ -d "client_id=admin-cli" | jq -r '.access_token // empty') if [ -z "$TOKEN" ]; then _print_error "Failed to get admin token" exit 1 fi } _create_group() { local group_name="$1" echo "Creating group: $group_name" # Check if group already exists EXISTING_GROUP=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/groups" | \ jq -r ".[] | select(.name==\"$group_name\") | .id") if [ -n "$EXISTING_GROUP" ] && [ "$EXISTING_GROUP" != "null" ]; then _print_info "Group '$group_name' already exists (ID: $EXISTING_GROUP)" return 0 fi # Create the group GROUP_JSON="{ \"name\": \"$group_name\" }" RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$ADMIN_URL/admin/realms/$REALM/groups" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "$GROUP_JSON") if [ "$RESPONSE" = "201" ]; then _print_success "Created group: $group_name" else _print_error "Failed to create group '$group_name'. HTTP: $RESPONSE" exit 1 fi } _check_user_mgmt_script() { if [ ! -f "$USER_MGMT_SCRIPT" ]; then _print_error "user_mgmt.sh not found at $USER_MGMT_SCRIPT" exit 1 fi if [ ! -x "$USER_MGMT_SCRIPT" ]; then chmod +x "$USER_MGMT_SCRIPT" fi _print_success "user_mgmt.sh found and is executable" } _create_lob1_users() { _print_section "Creating LOB1 Bot and Human Users" echo "Creating M2M service account: lob1-bot" if "$USER_MGMT_SCRIPT" create-m2m \ --name "lob1-bot" \ --groups "registry-users-lob1" \ --description "M2M service account for LOB1" 2>&1 | tee /tmp/lob1_bot_output.txt; then _print_success "Created lob1-bot" else if grep -q "already exists" /tmp/lob1_bot_output.txt; then _print_info "lob1-bot already exists, continuing..." else _print_error "Failed to create lob1-bot" exit 1 fi fi echo "" echo "Creating human user: lob1-user" if "$USER_MGMT_SCRIPT" create-human \ --username "lob1-user" \ --email "lob1-user@example.com" \ --firstname "LOB1" \ --lastname "User" \ --groups "registry-users-lob1" \ --password "$INITIAL_USER_PASSWORD" 2>&1 | tee /tmp/lob1_user_output.txt; then _print_success "Created lob1-user" else if grep -q "already exists" /tmp/lob1_user_output.txt; then _print_info "lob1-user already exists, continuing..." else _print_error "Failed to create lob1-user" exit 1 fi fi } _create_lob2_users() { _print_section "Creating LOB2 Bot and Human Users" echo "Creating M2M service account: lob2-bot" if "$USER_MGMT_SCRIPT" create-m2m \ --name "lob2-bot" \ --groups "registry-users-lob2" \ --description "M2M service account for LOB2" 2>&1 | tee /tmp/lob2_bot_output.txt; then _print_success "Created lob2-bot" else if grep -q "already exists" /tmp/lob2_bot_output.txt; then _print_info "lob2-bot already exists, continuing..." else _print_error "Failed to create lob2-bot" exit 1 fi fi echo "" echo "Creating human user: lob2-user" if "$USER_MGMT_SCRIPT" create-human \ --username "lob2-user" \ --email "lob2-user@example.com" \ --firstname "LOB2" \ --lastname "User" \ --groups "registry-users-lob2" \ --password "$INITIAL_USER_PASSWORD" 2>&1 | tee /tmp/lob2_user_output.txt; then _print_success "Created lob2-user" else if grep -q "already exists" /tmp/lob2_user_output.txt; then _print_info "lob2-user already exists, continuing..." else _print_error "Failed to create lob2-user" exit 1 fi fi } _create_admin_users() { _print_section "Creating Admin Bot and Admin User" echo "Creating M2M service account: admin-bot" if "$USER_MGMT_SCRIPT" create-m2m \ --name "admin-bot" \ --groups "registry-admins" \ --description "M2M service account for admin operations" 2>&1 | tee /tmp/admin_bot_output.txt; then _print_success "Created admin-bot" else if grep -q "already exists" /tmp/admin_bot_output.txt; then _print_info "admin-bot already exists, continuing..." else _print_error "Failed to create admin-bot" exit 1 fi fi echo "" echo "Creating human user: admin-user" if "$USER_MGMT_SCRIPT" create-human \ --username "admin-user" \ --email "admin-user@example.com" \ --firstname "Admin" \ --lastname "User" \ --groups "registry-admins" \ --password "$INITIAL_USER_PASSWORD" 2>&1 | tee /tmp/admin_user_output.txt; then _print_success "Created admin-user" else if grep -q "already exists" /tmp/admin_user_output.txt; then _print_info "admin-user already exists, continuing..." else _print_error "Failed to create admin-user" exit 1 fi fi } _assign_mcp_gateway_to_registry_admins() { _print_section "Assigning MCP Gateway Service Account to registry-admins" local service_account_name="service-account-mcp-gateway-m2m" echo "Looking up service account: $service_account_name" local service_account_id=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/users?username=$service_account_name" | \ jq -r '.[0].id // empty') if [ -z "$service_account_id" ]; then _print_info "Service account '$service_account_name' not found in Keycloak. This may be expected if using external M2M setup." return 0 fi echo "Found service account with ID: $service_account_id" echo "Looking up registry-admins group" local registry_admins_group_id=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/groups" | \ jq -r '.[] | select(.name=="registry-admins") | .id') if [ -z "$registry_admins_group_id" ] || [ "$registry_admins_group_id" = "null" ]; then _print_error "Could not find registry-admins group" return 1 fi echo "Found registry-admins group with ID: $registry_admins_group_id" echo "Assigning service account to registry-admins group" local assign_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "$ADMIN_URL/admin/realms/$REALM/users/$service_account_id/groups/$registry_admins_group_id" \ -H "Authorization: Bearer $TOKEN") if [ "$assign_response" = "204" ]; then _print_success "Service account assigned to registry-admins group" else _print_error "Failed to assign service account to registry-admins group (HTTP $assign_response)" return 1 fi } _print_summary() { _print_section "Bootstrap Setup Complete" echo "" _print_info "Created Groups:" echo " - registry-users-lob1" echo " - registry-users-lob2" echo " - registry-admins" echo "" _print_info "Created LOB1 Users:" echo " - Bot: lob1-bot (M2M service account)" echo " - Human: lob1-user (password: INITIAL_USER_PASSWORD env var)" echo "" _print_info "Created LOB2 Users:" echo " - Bot: lob2-bot (M2M service account)" echo " - Human: lob2-user (password: INITIAL_USER_PASSWORD env var)" echo "" _print_info "Created Admin Users:" echo " - Bot: admin-bot (M2M service account)" echo " - Human: admin-user (password: INITIAL_USER_PASSWORD env var)" echo "" _print_info "Next Steps:" echo " 1. Update scopes.yml to configure access for these groups" echo " 2. Regenerate admin-bot token using: ./keycloak/setup/generate-agent-token.sh admin-bot" echo " 3. Test access with the generated tokens" echo " 4. Login to dashboard as admin-user, lob1-user, or lob2-user" echo "" _print_info "Credentials saved to: .oauth-tokens/" } main() { _print_section "Bootstrap: LOB User and M2M Setup" # Validate environment variables _validate_environment # Check if user_mgmt.sh exists _check_user_mgmt_script # Get admin token echo "Authenticating with Keycloak..." _get_admin_token _print_success "Authentication successful" # Create groups _print_section "Creating Keycloak Groups" _create_group "registry-users-lob1" _create_group "registry-users-lob2" _create_group "registry-admins" # Create LOB1 users _create_lob1_users # Create LOB2 users _create_lob2_users # Create Admin users _create_admin_users # Assign MCP Gateway service account to registry-admins group _assign_mcp_gateway_to_registry_admins # Print summary _print_summary } main "$@" ================================================ FILE: cli/examples/README.md ================================================ # Agent Management Examples This directory contains example JSON files for registering A2A agents using the agent management CLI. ## Quick Start ### Service Account: `mcp-gateway-m2m` The agent management CLI uses the **`mcp-gateway-m2m`** service account for all operations. **Token Details:** - **Service Account ID:** `mcp-gateway-m2m` - **Token Location:** `.oauth-tokens/ingress.json` - **Token Generation:** `./credentials-provider/generate_creds.sh` - **Required Keycloak Groups:** - `mcp-servers-unrestricted` (for MCP server access) - `a2a-agent-admin` (for agent management permissions) ### Prerequisites Start the registry service in one terminal: ```bash python -m uvicorn registry.main:app --reload ``` Wait for: `Uvicorn running on http://127.0.0.1:8000` ### Register an Agent In another terminal, the agent management CLI will automatically use the `mcp-gateway-m2m` token from `.oauth-tokens/ingress.json`: ```bash # Register the test code reviewer agent # Token is automatically loaded from .oauth-tokens/ingress.json (mcp-gateway-m2m service account) uv run python cli/agent_mgmt.py register cli/examples/test_code_reviewer_agent.json ``` ### Verify Registration ```bash # List all agents uv run python cli/agent_mgmt.py list # Get specific agent details uv run python cli/agent_mgmt.py get /test-reviewer # Test agent accessibility uv run python cli/agent_mgmt.py test /test-reviewer ``` ## Available Examples All example files use the complete A2A agent schema with all fields documented: ### code_reviewer_agent.json Comprehensive code review agent analyzing code quality, bugs, and improvements. **Skills:** - Analyze Code Quality - Detect Bugs - Suggest Improvements **Security:** JWT Bearer token authentication **Features:** Streaming enabled, verified trust level **Usage:** ```bash uv run python cli/agent_mgmt.py register cli/examples/code_reviewer_agent.json ``` ### test_automation_agent.json Intelligent test automation agent for generating and executing test cases. **Skills:** - Generate Unit Tests - Execute Tests - Analyze Test Coverage - Generate Test Report **Security:** API Key + OAuth2 authentication **Features:** Streaming enabled, community trust level **Usage:** ```bash uv run python cli/agent_mgmt.py register cli/examples/test_automation_agent.json ``` ### data_analysis_agent.json Advanced data analysis agent for statistical analysis and visualization. **Skills:** - Statistical Analysis - Data Visualization - Predictive Modeling - Anomaly Detection - Data Transformation **Security:** JWT Bearer + OpenID Connect **Features:** GPU-enabled, verified trust level, supports large datasets **Usage:** ```bash uv run python cli/agent_mgmt.py register cli/examples/data_analysis_agent.json ``` ### security_analyzer_agent.json Comprehensive security analysis agent for vulnerability detection and compliance. **Skills:** - Scan for Vulnerabilities - Check Compliance - Analyze Authentication - Penetration Testing - Generate Security Report **Security:** Mutual TLS + API Key authentication **Features:** Trusted level, comprehensive CVE database **Usage:** ```bash uv run python cli/agent_mgmt.py register cli/examples/security_analyzer_agent.json ``` ### documentation_agent.json Documentation agent for generating and maintaining API docs and guides. **Skills:** - Generate API Documentation - Extract and Format Docstrings - Generate README - Maintain Documentation - Generate Changelog **Security:** Basic Auth + API Token **Features:** Supports multiple documentation formats, community trust level **Usage:** ```bash uv run python cli/agent_mgmt.py register cli/examples/documentation_agent.json ``` ### devops_deployment_agent.json DevOps automation agent for infrastructure and deployment management. **Skills:** - Deploy Application - Manage Infrastructure - Configure CI/CD Pipeline - Monitor Health and Performance - Manage Secrets and Credentials - Auto-Scale Application **Security:** AWS SigV4 + Client Certificate **Features:** Multi-cloud support, verified trust level **Usage:** ```bash uv run python cli/agent_mgmt.py register cli/examples/devops_deployment_agent.json ``` ## Complete A2A Schema Fields All example files include the complete A2A agent schema: **Required Fields:** - `protocol_version`: A2A protocol version (e.g., "1.0") - `name`: Agent display name - `description`: What the agent does - `url`: Agent endpoint URL - `path`: Registry path (must start with `/`) **Optional A2A Fields:** - `version`: Semantic version - `provider`: Agent provider/author - `security_schemes`: Authentication methods (http, apiKey, oauth2, openIdConnect) - `security`: Security requirements array - `skills`: Array of capabilities with parameters - `streaming`: Supports streaming responses (boolean) - `metadata`: Additional metadata key-value pairs **Registry Extensions:** - `tags`: Array of categorization tags - `is_enabled`: Whether agent is enabled - `num_stars`: Community rating - `license`: License information - `visibility`: "public", "private", or "group-restricted" - `allowed_groups`: Groups with access (for group-restricted) - `trust_level`: "unverified", "community", "verified", or "trusted" - `registered_at`: Registration timestamp (auto-set) - `updated_at`: Last update timestamp (auto-set) - `registered_by`: Username who registered (auto-set) - `signature`: JWS signature for integrity **Federation & Lifecycle Metadata (New):** - `status`: Lifecycle status - "active", "beta", "draft", or "deprecated" - `provider`: Provider information object with: - `organization`: Provider organization name - `url`: Provider website or documentation URL - `source_created_at`: Original creation timestamp from source system (ISO 8601 format) - `source_updated_at`: Last update timestamp from source system (ISO 8601 format) ### Complete Examples with All Fields For reference implementations showing all available fields including the new federation and lifecycle metadata: **complete-server-example.json** - Shows all server configuration fields - Includes lifecycle status, provider info, federation timestamps - Demonstrates custom metadata usage **complete-agent-example.json** - Shows all agent configuration fields - Includes lifecycle status, provider info, federation timestamps - Full agent card schema example **Usage:** ```bash # Register server with all fields uv run python cli/registry_mgmt.py register cli/examples/complete-server-example.json # Register agent with all fields uv run python cli/agent_mgmt.py register cli/examples/complete-agent-example.json ``` ## Creating Your Own Agent File Copy an example and modify the fields: ```bash cp cli/examples/test_code_reviewer_agent.json cli/examples/my_custom_agent.json ``` Then edit the JSON with your agent details: ```json { "name": "My Custom Agent", "path": "/my-agent", "description": "What my agent does", "url": "http://my-domain.com/agents/my-agent", "version": "1.0.0", "visibility": "public", "trust_level": "community", "tags": ["custom", "my-agent"], "security_schemes": { "bearer": { "type": "bearer" } }, "protocol_version": "1.0" } ``` Register your agent: ```bash export TOKEN="test-token" uv run python cli/agent_mgmt.py register cli/examples/my_custom_agent.json ``` ## Required Fields All agent JSON files must include: - `name` - Agent display name (string) - `path` - Internal path identifier (string, must start with `/`) - `description` - Brief description (string) - `url` - Agent endpoint URL (string) - `version` - Version number (string, e.g., "1.0.0") - `visibility` - Public visibility (string: "public", "private", "community") - `trust_level` - Trust classification (string) - `tags` - Discovery tags (array of strings) - `security_schemes` - Authentication config (object) - `protocol_version` - A2A protocol version (string) ## Error Handling ### Agent Already Exists (HTTP 409) If you get: `Error: Agent with path '/test-reviewer' already exists` Solution: Change the path in your JSON file or delete the existing agent. ### Validation Failed (HTTP 422) If you get: `Error: Validation failed - check agent JSON format` Solution: Verify all required fields are present and properly formatted. Validate with: ```bash jq . cli/examples/test_code_reviewer_agent.json ``` ### Connection Refused If you get connection errors: 1. Ensure the registry service is running 2. Check it's on the correct port (default: `localhost:8000`) 3. Verify with: `curl http://localhost:8000/api/health` ## Storage After registration, agent files are stored in: ```bash ls registry/agents/ cat registry/agents/test-reviewer.json cat registry/agents/agent_state.json ``` ## Next Steps 1. Register a test agent 2. View agents in the frontend dashboard 3. Test agent accessibility 4. Explore the admin panel for agent management For complete documentation, see: `.scratchpad/A2A_AGENT_CLI_REGISTRATION_GUIDE.md` ================================================ FILE: cli/examples/airegistry.json ================================================ { "server_name": "AI Registry tools", "description": "Provides tools to discover and list servers, agents, and skills in the AI Registry. Includes intelligent tool finder which uses semantic search to discover the most relevant tools across all registered services.", "path": "/airegistry-tools/", "proxy_pass_url": "http://mcpgw-server:8003/", "supported_transports": ["streamable-http"], "auth_scheme": "none", "tags": ["registry", "discovery", "search", "semantic-search", "tool-finder", "servers", "agents", "skills"], "num_tools": 0, "license": "N/A", "tool_list": [ ] } ================================================ FILE: cli/examples/aws-kb-server.json ================================================ { "server_name": "AWS kb", "description": "A fully managed remote MCP server that provides up-to-date documentation, code samples, knowledge about the regional availability of AWS APIs and CloudFormation resources, and other official AWS content.", "path": "/aws-kb", "proxy_pass_url": "https://knowledge-mcp.global.api.aws", "tags": ["aws", "kb", "documentation", "knowledge-base"], "auth_scheme": "none", "supported_transports": ["streamable-http"], "mcp_endpoint": "https://knowledge-mcp.global.api.aws", "metadata": { "category": "documentation", "official": true, "provider": "AWS" }, "status": "active", "provider_organization": "Amazon Web Services", "provider_url": "https://aws.amazon.com", "visibility": "public" } ================================================ FILE: cli/examples/cloudflare-docs-server-config.json ================================================ { "server_name": "Cloudflare Documentation MCP Server", "description": "Search Cloudflare documentation and get migration guides", "path": "/cloudflare-docs", "proxy_pass_url": "https://docs.mcp.cloudflare.com/mcp", "supported_transports": ["streamable-http"], "tags": ["documentation", "cloudflare", "cdn", "workers", "pages", "migration-guide"], "status": "active", "provider_organization": "Cloudflare Inc.", "provider_url": "https://www.cloudflare.com", "visibility": "public", "metadata": { "category": "documentation", "official": true, "mcp_compatible": "1.0" } } ================================================ FILE: cli/examples/code_reviewer_agent.json ================================================ { "protocolVersion": "1.0", "name": "Code Reviewer Agent", "description": "Comprehensive code review agent that analyzes code quality, identifies bugs, suggests improvements, and provides detailed feedback on code structure and best practices", "url": "https://example.com/agents/code-reviewer", "version": "2.1.0", "capabilities": { "streaming": true }, "defaultInputModes": ["text/plain", "application/json"], "defaultOutputModes": ["text/plain", "application/json"], "skills": [ { "id": "analyze_code_quality", "name": "Analyze Code Quality", "description": "Analyze code for quality metrics including complexity, duplication, and maintainability", "tags": [ "analysis", "metrics" ], "examples": [ "Analyze code quality for this Python function", "Check code quality metrics for JavaScript module" ], "inputModes": ["text/plain"], "outputModes": ["application/json"] }, { "id": "detect_bugs", "name": "Detect Bugs", "description": "Identify potential bugs and issues in the code", "tags": [ "bug-detection", "validation" ], "examples": [ "Detect critical bugs in this code", "Find high severity issues" ], "inputModes": ["text/plain"], "outputModes": ["application/json"] }, { "id": "suggest_improvements", "name": "Suggest Improvements", "description": "Provide suggestions for code improvements and refactoring", "tags": [ "improvement", "refactoring" ], "examples": [ "Suggest performance improvements for this code", "Recommend security enhancements" ], "inputModes": ["text/plain"], "outputModes": ["application/json"] } ], "preferredTransport": "JSONRPC", "provider": "Example Corp", "securitySchemes": { "bearer_auth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" } }, "security": [ { "bearer_auth": [] } ], "metadata": { "max_code_size_mb": 10, "supported_formats": [ "json", "xml", "yaml" ], "response_time_ms": 2000, "availability": "99.9%" }, "path": "/code-reviewer", "tags": ["code-review", "quality-analysis", "testing", "best-practices"], "isEnabled": true, "numStars": 42, "license": "MIT", "visibility": "public", "trustLevel": "verified" } ================================================ FILE: cli/examples/complete-agent-example.json ================================================ { "protocolVersion": "0.3.0", "name": "Complete Agent Example", "description": "Example showing all available agent configuration fields including new lifecycle and federation metadata", "url": "https://agent.example.com:9000/", "path": "/complete-agent-example", "version": "1.0.0", "capabilities": { "streaming": true }, "defaultInputModes": ["text/plain", "application/json"], "defaultOutputModes": ["text/plain", "application/json"], "tags": ["example", "documentation", "reference"], "visibility": "public", "status": "active", "provider": { "organization": "ACME AI Labs", "url": "https://ai.acme.com" }, "skills": [ { "id": "example_skill", "name": "Example Skill", "description": "An example skill demonstrating agent capabilities", "tags": ["example", "demo"] } ] } ================================================ FILE: cli/examples/complete-server-example.json ================================================ { "server_name": "Complete Server Example", "description": "Example showing all available server configuration fields including new lifecycle and federation metadata", "path": "/complete-example", "proxy_pass_url": "https://example.com:8080", "tags": ["example", "documentation", "reference"], "auth_scheme": "none", "supported_transports": ["streamable-http", "sse"], "mcp_endpoint": "https://example.com:8080/custom-mcp", "sse_endpoint": "https://example.com:8080/custom-sse", "metadata": { "team": "platform-engineering", "owner": "alice@example.com", "cost_center": "CC-1001", "environment": "production" }, "status": "active", "provider_organization": "ACME Corporation", "provider_url": "https://www.acme.com", "visibility": "public" } ================================================ FILE: cli/examples/context7-server-config.json ================================================ { "server_name": "Context7 MCP Server", "description": "Up-to-date Docs for LLMs and AI code editors", "path": "/context7", "version": "v1.0.0", "proxy_pass_url": "https://mcp.context7.com/mcp", "supported_transports": ["streamable-http"], "tags": ["documentation", "search", "libraries", "packages", "api-reference", "code-examples"] } ================================================ FILE: cli/examples/context7-v2-server-config.json ================================================ { "server_name": "Context7 MCP Server", "description": "Up-to-date Docs for LLMs and AI code editors (Version 2 - Beta)", "path": "/context7", "version": "v2.0.0", "status": "beta", "proxy_pass_url": "https://mcp-v2.context7.com/mcp", "supported_transports": ["streamable-http"], "tags": ["documentation", "search", "libraries", "packages", "api-reference", "code-examples"] } ================================================ FILE: cli/examples/currenttime-users.json ================================================ { "scope_name": "currenttime-users", "description": "Users with access to currenttime server", "server_access": [ { "server": "currenttime", "methods": ["initialize", "tools/list", "tools/call"], "tools": ["current_time_by_timezone"] }, { "server": "/currenttime/", "methods": ["initialize", "tools/list", "tools/call"], "tools": ["current_time_by_timezone"] }, { "server": "/currenttime", "methods": ["initialize", "tools/list", "tools/call"], "tools": ["current_time_by_timezone"] }, { "server": "context7", "methods": ["initialize", "tools/list", "tools/call"], "tools": ["*"] }, { "server": "api", "methods": ["initialize", "GET", "POST", "servers", "agents", "search", "rating"], "tools": [] } ], "group_mappings": ["currenttime-users"], "ui_permissions": { "list_service": ["all"], "health_check_service": ["all"] }, "create_in_idp": true } ================================================ FILE: cli/examples/currenttime-v2.json ================================================ { "server_name": "Current Time API", "description": "A simple API that returns the current server time in various formats.", "path": "/currenttime/", "proxy_pass_url": "http://currenttime-server:8000/", "mcp_endpoint": "http://currenttime-server:8000/mcp", "auth_scheme": "none", "tags": ["time", "timezone", "datetime", "api", "utility", "v0.9"], "num_tools": 1, "version": "v0.9", "status": "beta", "license": "MIT-0", "metadata": { "team": "platform-services", "owner": "alice@example.com", "cost_center": "CC-1001", "compliance": ["SOC2"], "deployment_region": "us-east-1", "jira_project": "PLAT-123", "environment": "production" } } ================================================ FILE: cli/examples/currenttime.json ================================================ { "server_name": "Current Time API", "description": "A simple API that returns the current server time in various formats.", "path": "/currenttime/", "proxy_pass_url": "http://currenttime-server:8000/", "auth_scheme": "none", "tags": ["time", "timezone", "datetime", "api", "utility"], "num_tools": 1, "license": "MIT-0", "status": "active", "visibility": "public" } ================================================ FILE: cli/examples/data_analysis_agent.json ================================================ { "protocol_version": "1.0", "name": "Data Analysis Agent", "description": "Advanced data analysis agent for statistical analysis, data visualization, and insight generation. Supports multiple data formats and provides predictive analytics", "url": "https://example.com/agents/data-analysis", "version": "3.2.1", "provider": "Analytics Solutions Inc", "path": "/data-analysis", "tags": "analytics,data-science,visualization,statistics", "is_enabled": true, "num_stars": 56, "license": "GPL-3.0", "visibility": "public", "trust_level": "verified", "streaming": true, "security_schemes": { "bearer_jwt": { "type": "http", "scheme": "bearer", "bearer_format": "JWT" }, "openid": { "type": "openIdConnect", "openid_connect_url": "https://auth.example.com/.well-known/openid-configuration" } }, "security": [ { "bearer_jwt": [] }, { "openid": [] } ], "skills": [ { "id": "statistical_analysis", "name": "Statistical Analysis", "description": "Perform comprehensive statistical analysis including hypothesis testing and distribution analysis", "parameters": { "type": "object", "properties": { "data": { "type": "array", "description": "Array of numerical data points" }, "analyses": { "type": "array", "items": { "type": "string", "enum": [ "mean", "median", "std_dev", "variance", "quartiles", "skewness", "kurtosis" ] }, "description": "Statistical measures to calculate" }, "confidence_level": { "type": "number", "minimum": 0.9, "maximum": 0.99, "description": "Confidence level for hypothesis tests" } }, "required": [ "data", "analyses" ] }, "tags": [ "statistics", "analysis" ] }, { "id": "data_visualization", "name": "Data Visualization", "description": "Create visualizations and charts from data", "parameters": { "type": "object", "properties": { "data": { "type": "object", "description": "Data object with values and labels" }, "chart_type": { "type": "string", "enum": [ "line", "bar", "pie", "scatter", "heatmap", "box-plot" ], "description": "Type of chart to create" }, "title": { "type": "string", "description": "Chart title" }, "export_format": { "type": "string", "enum": [ "svg", "png", "pdf" ], "description": "Output format" } }, "required": [ "data", "chart_type" ] }, "tags": [ "visualization", "charts" ] }, { "id": "predictive_modeling", "name": "Predictive Modeling", "description": "Build and train machine learning models for predictions", "parameters": { "type": "object", "properties": { "training_data": { "type": "object", "description": "Training dataset" }, "model_type": { "type": "string", "enum": [ "linear_regression", "logistic_regression", "random_forest", "neural_network" ], "description": "Type of model to train" }, "test_size": { "type": "number", "minimum": 0.1, "maximum": 0.5, "description": "Proportion of data to use for testing" }, "hyperparameters": { "type": "object", "description": "Model-specific hyperparameters" } }, "required": [ "training_data", "model_type" ] }, "tags": [ "machine-learning", "prediction" ] }, { "id": "anomaly_detection", "name": "Anomaly Detection", "description": "Detect anomalies and outliers in data", "parameters": { "type": "object", "properties": { "data": { "type": "array", "description": "Data points to analyze" }, "method": { "type": "string", "enum": [ "isolation_forest", "local_outlier_factor", "statistical", "dbscan" ], "description": "Detection method" }, "contamination": { "type": "number", "minimum": 0.01, "maximum": 0.5, "description": "Proportion of outliers expected" } }, "required": [ "data", "method" ] }, "tags": [ "anomaly-detection", "outliers" ] }, { "id": "data_transformation", "name": "Data Transformation", "description": "Transform and preprocess data for analysis", "parameters": { "type": "object", "properties": { "data": { "type": "object", "description": "Input data" }, "transformations": { "type": "array", "items": { "type": "string", "enum": [ "normalize", "standardize", "log_scale", "one_hot_encode" ] }, "description": "Transformations to apply" } }, "required": [ "data", "transformations" ] }, "tags": [ "preprocessing", "transformation" ] } ], "metadata": { "max_dataset_size_gb": 100, "supported_formats": [ "csv", "json", "parquet", "xlsx" ], "computation_gpu_enabled": true, "avg_analysis_time_seconds": 30 } } ================================================ FILE: cli/examples/devops_deployment_agent.json ================================================ { "protocol_version": "1.0", "name": "DevOps Deployment Agent", "description": "DevOps automation agent for infrastructure management, deployment orchestration, and continuous integration/deployment pipeline management across multiple cloud platforms", "url": "https://example.com/agents/devops-deployment", "version": "2.3.0", "provider": "CloudOps Inc", "path": "/devops-deployment", "tags": "devops,deployment,infrastructure,ci-cd,cloud", "is_enabled": true, "num_stars": 64, "license": "Apache-2.0", "visibility": "public", "trust_level": "verified", "streaming": true, "security_schemes": { "aws_sigv4": { "type": "http", "scheme": "bearer", "bearer_format": "AWS4-HMAC-SHA256" }, "client_cert": { "type": "http", "scheme": "bearer", "bearer_format": "X.509" } }, "security": [ { "aws_sigv4": [] }, { "client_cert": [] } ], "skills": [ { "id": "deploy_application", "name": "Deploy Application", "description": "Deploy applications to various cloud platforms and Kubernetes clusters", "parameters": { "type": "object", "properties": { "deployment_config": { "type": "object", "description": "Deployment configuration (Dockerfile, K8s manifest, etc)" }, "target_environment": { "type": "string", "enum": [ "development", "staging", "production" ], "description": "Target environment" }, "cloud_provider": { "type": "string", "enum": [ "aws", "gcp", "azure", "kubernetes" ], "description": "Cloud platform to deploy to" }, "rollback_on_failure": { "type": "boolean", "description": "Automatically rollback on deployment failure" } }, "required": [ "deployment_config", "target_environment" ] }, "tags": [ "deployment", "orchestration" ] }, { "id": "manage_infrastructure", "name": "Manage Infrastructure", "description": "Create, update, and manage cloud infrastructure as code", "parameters": { "type": "object", "properties": { "infrastructure_code": { "type": "string", "description": "IaC code (Terraform, CloudFormation, etc)" }, "action": { "type": "string", "enum": [ "plan", "apply", "destroy", "validate" ], "description": "IaC action to perform" }, "cloud_provider": { "type": "string", "enum": [ "aws", "gcp", "azure" ], "description": "Cloud provider" } }, "required": [ "infrastructure_code", "action" ] }, "tags": [ "infrastructure", "iac" ] }, { "id": "configure_cicd", "name": "Configure CI/CD Pipeline", "description": "Set up and configure continuous integration and deployment pipelines", "parameters": { "type": "object", "properties": { "repository_url": { "type": "string", "description": "Git repository URL" }, "pipeline_type": { "type": "string", "enum": [ "github-actions", "gitlab-ci", "jenkins", "circleci" ], "description": "CI/CD platform" }, "stages": { "type": "array", "items": { "type": "string", "enum": [ "test", "build", "deploy", "monitor" ] }, "description": "Pipeline stages to include" } }, "required": [ "repository_url", "pipeline_type" ] }, "tags": [ "ci-cd", "automation" ] }, { "id": "monitor_health", "name": "Monitor Health and Performance", "description": "Monitor application health, performance metrics, and alerting", "parameters": { "type": "object", "properties": { "deployment_id": { "type": "string", "description": "ID of deployed application" }, "metrics": { "type": "array", "items": { "type": "string", "enum": [ "cpu", "memory", "network", "latency", "error_rate" ] }, "description": "Metrics to monitor" }, "alert_thresholds": { "type": "object", "description": "Threshold values for alerts" } }, "required": [ "deployment_id" ] }, "tags": [ "monitoring", "observability" ] }, { "id": "manage_secrets", "name": "Manage Secrets and Credentials", "description": "Securely manage and rotate secrets, API keys, and credentials", "parameters": { "type": "object", "properties": { "secret_name": { "type": "string", "description": "Name of the secret" }, "secret_type": { "type": "string", "enum": [ "api_key", "database_password", "certificate", "oauth_token" ], "description": "Type of secret" }, "rotation_policy": { "type": "string", "enum": [ "daily", "weekly", "monthly", "manual" ], "description": "Secret rotation policy" } }, "required": [ "secret_name", "secret_type" ] }, "tags": [ "security", "secrets-management" ] }, { "id": "scale_application", "name": "Auto-Scale Application", "description": "Configure auto-scaling policies based on metrics and demand", "parameters": { "type": "object", "properties": { "deployment_id": { "type": "string", "description": "ID of application to scale" }, "scaling_metric": { "type": "string", "enum": [ "cpu_utilization", "memory_usage", "request_count" ], "description": "Metric to trigger scaling" }, "min_instances": { "type": "integer", "minimum": 1, "description": "Minimum number of instances" }, "max_instances": { "type": "integer", "minimum": 1, "description": "Maximum number of instances" } }, "required": [ "deployment_id", "scaling_metric" ] }, "tags": [ "scaling", "optimization" ] } ], "metadata": { "supported_cloud_providers": [ "aws", "gcp", "azure", "digitalocean", "linode" ], "supported_container_platforms": [ "docker", "kubernetes", "ecs", "gke" ], "iac_frameworks": [ "terraform", "cloudformation", "pulumi", "ansible" ], "cicd_platforms": [ "github-actions", "gitlab-ci", "jenkins", "circleci", "travis-ci" ], "sla_uptime_percent": 99.95, "deployment_speed_seconds": 120 } } ================================================ FILE: cli/examples/documentation_agent.json ================================================ { "protocol_version": "1.0", "name": "Documentation Agent", "description": "Intelligent documentation agent that generates, updates, and maintains API documentation, guides, and technical specifications from source code and configuration", "url": "https://example.com/agents/documentation", "version": "1.6.3", "provider": "Doc Systems", "path": "/documentation", "tags": "documentation,code-generation,api-docs,knowledge-management", "is_enabled": true, "num_stars": 31, "license": "MIT", "visibility": "public", "trust_level": "community", "streaming": false, "security_schemes": { "basic_auth": { "type": "http", "scheme": "basic" }, "api_token": { "type": "apiKey", "name": "Authorization", "in": "header" } }, "security": [ { "basic_auth": [] }, { "api_token": [] } ], "skills": [ { "id": "generate_api_docs", "name": "Generate API Documentation", "description": "Auto-generate comprehensive API documentation from source code and API specifications", "parameters": { "type": "object", "properties": { "code_path": { "type": "string", "description": "Path to source code or API definition" }, "doc_format": { "type": "string", "enum": [ "openapi", "postman", "markdown", "html" ], "description": "Documentation format" }, "include_examples": { "type": "boolean", "description": "Include code examples" } }, "required": [ "code_path", "doc_format" ] }, "tags": [ "documentation", "api-generation" ] }, { "id": "extract_docstrings", "name": "Extract and Format Docstrings", "description": "Extract docstrings from source code and format them into documentation", "parameters": { "type": "object", "properties": { "source_files": { "type": "array", "items": { "type": "string" }, "description": "Source files to extract docstrings from" }, "docstring_style": { "type": "string", "enum": [ "google", "numpy", "sphinx", "rest" ], "description": "Docstring style to parse" }, "output_format": { "type": "string", "enum": [ "markdown", "rst", "html" ], "description": "Output documentation format" } }, "required": [ "source_files" ] }, "tags": [ "extraction", "formatting" ] }, { "id": "generate_readme", "name": "Generate README", "description": "Generate project README with installation, usage, and contribution guidelines", "parameters": { "type": "object", "properties": { "project_info": { "type": "object", "properties": { "name": { "type": "string" }, "description": { "type": "string" }, "version": { "type": "string" } }, "description": "Project metadata" }, "sections": { "type": "array", "items": { "type": "string", "enum": [ "installation", "usage", "contributing", "license", "changelog" ] }, "description": "README sections to include" } }, "required": [ "project_info" ] }, "tags": [ "readme", "project-info" ] }, { "id": "maintain_docs", "name": "Maintain Documentation", "description": "Keep documentation in sync with code changes and updates", "parameters": { "type": "object", "properties": { "source_path": { "type": "string", "description": "Path to source code" }, "docs_path": { "type": "string", "description": "Path to documentation" }, "auto_update": { "type": "boolean", "description": "Automatically update outdated sections" } }, "required": [ "source_path", "docs_path" ] }, "tags": [ "maintenance", "synchronization" ] }, { "id": "generate_changelog", "name": "Generate Changelog", "description": "Generate changelog from commit history or release notes", "parameters": { "type": "object", "properties": { "git_repo_path": { "type": "string", "description": "Path to git repository" }, "from_version": { "type": "string", "description": "Starting version tag" }, "to_version": { "type": "string", "description": "Ending version tag" }, "format": { "type": "string", "enum": [ "keep-a-changelog", "conventional", "markdown" ], "description": "Changelog format" } }, "required": [ "git_repo_path" ] }, "tags": [ "changelog", "versioning" ] } ], "metadata": { "supported_languages": [ "python", "javascript", "java", "go", "rust", "csharp", "typescript" ], "documentation_formats": [ "markdown", "html", "pdf", "postman", "openapi" ], "average_generation_time_seconds": 45, "max_project_size_mb": 500 } } ================================================ FILE: cli/examples/federation-config-agentcore-example.json ================================================ { "anthropic": { "enabled": true, "endpoint": "https://registry.modelcontextprotocol.io", "sync_on_startup": true, "servers": [ {"name": "com.hydrata/hydrata-mcp-server"}, {"name": "io.github.OneNicolas/mcp-service-public"}, {"name": "ai.exa/exa"} ] }, "asor": { "enabled": false, "endpoint": "", "auth_env_var": "ASOR_ACCESS_TOKEN", "sync_on_startup": false, "agents": [] }, "aws_registry": { "enabled": true, "sync_on_startup": true, "sync_interval_minutes": 60, "sync_timeout_seconds": 300, "max_concurrent_fetches": 5, "registries": [ { "registry_id": "arn:aws:bedrock-agentcore:us-east-1:123456789012:registry/rCu9kFIgrbNOpEsF", "aws_account_id": "123456789012", "aws_region": "us-east-1", "descriptor_types": ["MCP", "A2A", "CUSTOM", "AGENT_SKILLS"], "sync_status_filter": "APPROVED" } ] } } ================================================ FILE: cli/examples/federation-config-example.json ================================================ { "anthropic": { "enabled": true, "endpoint": "https://registry.modelcontextprotocol.io", "sync_on_startup": true, "servers": [ {"name": "com.hydrata/hydrata-mcp-server"}, {"name": "io.github.OneNicolas/mcp-service-public"}, {"name": "ai.exa/exa"} ] }, "asor": { "enabled": false, "endpoint": "https://your-asor-endpoint.example.com/api/asor/v1/your-tenant", "auth_env_var": "ASOR_ACCESS_TOKEN", "sync_on_startup": false, "agents": [ {"id": "agent_1"}, {"id": "agent_2"} ] } } ================================================ FILE: cli/examples/flight_booking_agent_card.json ================================================ { "protocolVersion": "0.3.0", "supportedProtocol": "a2a", "name": "Flight Booking Agent", "description": "Flight booking and reservation management agent", "url": "http://flight-booking-agent:9000/", "version": "0.0.1", "capabilities": { "streaming": true }, "defaultInputModes": ["text/plain", "application/json"], "defaultOutputModes": ["text/plain", "application/json"], "provider": { "organization": "Example Corp", "url": "https://example-corp.com" }, "status": "active", "skills": [ { "id": "check_availability", "name": "Check Availability", "description": "Check seat availability for a specific flight.", "tags": ["flight", "availability", "booking"] }, { "id": "reserve_flight", "name": "Reserve Flight", "description": "Reserve seats on a flight for passengers.", "tags": ["flight", "reservation", "booking"] }, { "id": "confirm_booking", "name": "Confirm Booking", "description": "Confirm and finalize a flight booking.", "tags": ["flight", "confirmation", "booking"] }, { "id": "process_payment", "name": "Process Payment", "description": "Process payment for a booking (simulated).", "tags": ["payment", "processing", "booking"] }, { "id": "manage_reservation", "name": "Manage Reservation", "description": "Update, view, or cancel existing reservations.", "tags": ["reservation", "management", "booking"] } ], "tags": ["travel", "flight-booking", "reservation"], "visibility": "public", "license": "MIT", "path": "/flight-booking" } ================================================ FILE: cli/examples/flight_booking_agent_ecs.json ================================================ { "protocolVersion": "0.3.0", "name": "Flight Booking Agent", "description": "Flight booking and reservation management agent", "url": "http://flight-booking-agent.mcp-gateway-v2.local:9000", "version": "0.0.1", "capabilities": { "streaming": true }, "defaultInputModes": ["text/plain", "application/json"], "defaultOutputModes": ["text/plain", "application/json"], "provider": { "organization": "MCP Gateway", "url": "https://github.com/agentic-community/mcp-gateway-registry" }, "skills": [ { "id": "check_availability", "name": "Check Availability", "description": "Check seat availability for a specific flight.", "tags": ["flight", "availability", "booking"] }, { "id": "reserve_flight", "name": "Reserve Flight", "description": "Reserve seats on a flight for passengers.", "tags": ["flight", "reservation", "booking"] }, { "id": "confirm_booking", "name": "Confirm Booking", "description": "Confirm and finalize a flight booking.", "tags": ["flight", "confirmation", "booking"] }, { "id": "process_payment", "name": "Process Payment", "description": "Process payment for a booking (simulated).", "tags": ["payment", "processing", "booking"] }, { "id": "manage_reservation", "name": "Manage Reservation", "description": "Update, view, or cancel existing reservations.", "tags": ["reservation", "management", "booking"] } ], "tags": ["travel", "flight-booking", "reservation"], "visibility": "public", "license": "MIT", "path": "/flight-booking-agent" } ================================================ FILE: cli/examples/geospatial_route_planner_agent.json ================================================ { "protocolVersion": "0.2.9", "name": "GeoSpatial Route Planner Agent", "description": "Provides advanced route planning, traffic analysis, and custom map generation services. This agent can calculate optimal routes, estimate travel times considering real-time traffic, and create personalized maps with points of interest.", "url": "https://georoute-agent.example.com/a2a/v1", "preferredTransport": "JSONRPC", "additionalInterfaces" : [ {"url": "https://georoute-agent.example.com/a2a/v1", "transport": "JSONRPC"}, {"url": "https://georoute-agent.example.com/a2a/grpc", "transport": "GRPC"}, {"url": "https://georoute-agent.example.com/a2a/json", "transport": "HTTP+JSON"} ], "provider": { "organization": "Example Geo Services Inc.", "url": "https://www.examplegeoservices.com" }, "iconUrl": "https://georoute-agent.example.com/icon.png", "version": "1.2.0", "documentationUrl": "https://docs.examplegeoservices.com/georoute-agent/api", "capabilities": { "streaming": true, "pushNotifications": true, "stateTransitionHistory": false }, "securitySchemes": { "google": { "type": "openIdConnect", "openIdConnectUrl": "https://accounts.google.com/.well-known/openid-configuration" } }, "security": [{ "google": ["openid", "profile", "email"] }], "defaultInputModes": ["application/json", "text/plain"], "defaultOutputModes": ["application/json", "image/png"], "skills": [ { "id": "route-optimizer-traffic", "name": "Traffic-Aware Route Optimizer", "description": "Calculates the optimal driving route between two or more locations, taking into account real-time traffic conditions, road closures, and user preferences (e.g., avoid tolls, prefer highways).", "tags": ["maps", "routing", "navigation", "directions", "traffic"], "examples": [ "Plan a route from '1600 Amphitheatre Parkway, Mountain View, CA' to 'San Francisco International Airport' avoiding tolls.", "{\"origin\": {\"lat\": 37.422, \"lng\": -122.084}, \"destination\": {\"lat\": 37.7749, \"lng\": -122.4194}, \"preferences\": [\"avoid_ferries\"]}" ], "inputModes": ["application/json", "text/plain"], "outputModes": [ "application/json", "application/vnd.geo+json", "text/html" ] }, { "id": "custom-map-generator", "name": "Personalized Map Generator", "description": "Creates custom map images or interactive map views based on user-defined points of interest, routes, and style preferences. Can overlay data layers.", "tags": ["maps", "customization", "visualization", "cartography"], "examples": [ "Generate a map of my upcoming road trip with all planned stops highlighted.", "Show me a map visualizing all coffee shops within a 1-mile radius of my current location." ], "inputModes": ["application/json"], "outputModes": [ "image/png", "image/jpeg", "application/json", "text/html" ] } ], "supportsAuthenticatedExtendedCard": true, "signatures": [ { "protected": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpPU0UiLCJraWQiOiJrZXktMSIsImprdSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vYWdlbnQvandrcy5qc29uIn0", "signature": "QFdkNLNszlGj3z3u0YQGt_T9LixY3qtdQpZmsTdDHDe3fXV9y9-B3m2-XgCpzuhiLt8E0tV6HXoZKHv4GtHgKQ" } ] } ================================================ FILE: cli/examples/invalid-config.json ================================================ { "server_name": "Invalid Server", "description": "Missing required fields", "proxy_pass_url": "not-a-valid-url" } ================================================ FILE: cli/examples/jewel_homes_support_agent_card.json ================================================ { "name": "Jewel Homes Support Agent", "description": "AI customer support agent for Jewel Homes: answers questions about products and services, looks up orders, and resolves issues", "url": "https://support-c17fedfd-13da-4026-87f5-d3c78b3f6f95.helpagent.club/a2a", "preferredTransport": "JSONRPC", "protocolVersion": "0.3.0", "version": "1.0.0", "capabilities": { "streaming": false }, "defaultInputModes": [ "text/plain" ], "defaultOutputModes": [ "text/plain" ], "skills": [ { "id": "answer-questions", "name": "Answer Questions", "description": "Answer customer questions about products and services", "tags": ["customer-support", "faq", "product-info", "guidance"] }, { "id": "order-lookup", "name": "Order Lookup", "description": "Look up order status and details", "tags": ["orders", "order-status"] } ], "tags": ["customer-support", "faq", "order-status", "a2a", "ans-verified"], "path": "/jewel-homes-support-agent", "status": "active", "provider": { "organization": "GoDaddy", "url": "https://godaddy.com" }, "visibility": "public", "ans_agent_id": "ans://v1.0.0.support-c17fedfd-13da-4026-87f5-d3c78b3f6f95.helpagent.club" } ================================================ FILE: cli/examples/minimal-server-config.json ================================================ { "server_name": "Minimal MCP Server", "description": "A minimal server configuration with only required fields", "path": "/minimal-server", "proxy_pass_url": "http://minimal-server:9001/", "supported_transports": ["streamable-http"], "tags": ["mcp", "minimal", "example"] } ================================================ FILE: cli/examples/peer-registry-lob-1.json.example ================================================ { "peer_id": "peer-registry-lob-1", "name": "LOB-1 Peer Registry", "endpoint": "https://mcpregistry.ddns.net", "enabled": true, "sync_mode": "all", "sync_interval_minutes": 60, "federation_token": "your-actual-token-here" } ================================================ FILE: cli/examples/public-mcp-users.json ================================================ { "scope_name": "public-mcp-users", "description": "Users with access to public MCP servers (context7, cloudflare-docs) and flight-booking agent", "server_access": [ { "server": "context7", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list"], "tools": ["*"] }, { "server": "/context7", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list"], "tools": ["*"] }, { "server": "/context7/", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list"], "tools": ["*"] }, { "server": "cloudflare-docs", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list"], "tools": ["*"] }, { "server": "/cloudflare-docs", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list"], "tools": ["*"] }, { "server": "/cloudflare-docs/", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list"], "tools": ["*"] }, { "server": "api", "methods": ["initialize", "GET", "POST", "servers", "agents", "search", "rating", "tools", "tokens"], "tools": [] }, { "server": "v0.1", "methods": ["agents", "GET", "POST"], "tools": [] }, { "agents": { "actions": [ { "action": "list_agents", "resources": ["/flight-booking"] }, { "action": "get_agent", "resources": ["/flight-booking"] } ] } } ], "group_mappings": ["public-mcp-users", "5f605d68-06bc-4208-b992-bb378eee12c5"], "ui_permissions": { "list_service": ["all"], "list_agents": ["/flight-booking"], "get_agent": ["/flight-booking"] }, "create_in_idp": true } ================================================ FILE: cli/examples/realserverfaketools.json ================================================ { "server_name": "Real Server Fake Tools", "description": "A collection of fake tools with interesting names that take different parameter types", "path": "/realserverfaketools/", "proxy_pass_url": "http://realserverfaketools-server:8002/", "supported_transports": ["streamable-http"], "auth_scheme": "none", "tags": ["demo", "fake", "tools", "testing"], "num_tools": 6, "license": "MIT", "tool_list": [ { "name": "quantum_flux_analyzer", "parsed_description": { "main": "Analyzes quantum flux patterns with configurable energy levels and stabilization.", "args": "energy_level: Energy level for quantum analysis (1-10), stabilization_factor: Stabilization factor for quantum flux, enable_temporal_shift: Whether to enable temporal shifting in the analysis", "returns": "str: JSON response with mock quantum flux analysis results", "raises": "" }, "schema": { "properties": { "energy_level": { "default": 5, "description": "Energy level for quantum analysis (1-10)", "maximum": 10, "minimum": 1, "title": "Energy Level", "type": "integer" }, "stabilization_factor": { "default": 0.75, "description": "Stabilization factor for quantum flux", "title": "Stabilization Factor", "type": "number" }, "enable_temporal_shift": { "default": false, "description": "Whether to enable temporal shifting in the analysis", "title": "Enable Temporal Shift", "type": "boolean" } }, "title": "quantum_flux_analyzerArguments", "type": "object" } }, { "name": "neural_pattern_synthesizer", "parsed_description": { "main": "Synthesizes neural patterns into coherent structures.", "args": "input_patterns: List of neural patterns to synthesize, coherence_threshold: Threshold for pattern coherence (0.0-1.0), dimensions: Number of dimensions for synthesis (1-10)", "returns": "Dict[str, Any]: Dictionary with mock neural pattern synthesis results", "raises": "" }, "schema": { "properties": { "input_patterns": { "description": "List of neural patterns to synthesize", "items": { "type": "string" }, "title": "Input Patterns", "type": "array" }, "coherence_threshold": { "default": 0.7, "description": "Threshold for pattern coherence (0.0-1.0)", "maximum": 1.0, "minimum": 0.0, "title": "Coherence Threshold", "type": "number" }, "dimensions": { "default": 3, "description": "Number of dimensions for synthesis (1-10)", "maximum": 10, "minimum": 1, "title": "Dimensions", "type": "integer" } }, "required": [ "input_patterns" ], "title": "neural_pattern_synthesizerArguments", "type": "object" } }, { "name": "hyper_dimensional_mapper", "parsed_description": { "main": "Maps geographical coordinates to hyper-dimensional space.", "args": "coordinates: Geographical coordinates to map, dimension_count: Number of hyper-dimensions to map to (4-11), reality_anchoring: Reality anchoring factor (0.1-1.0)", "returns": "str: JSON response with mock hyper-dimensional mapping results", "raises": "" }, "schema": { "$defs": { "GeoCoordinates": { "properties": { "latitude": { "description": "Latitude coordinate", "title": "Latitude", "type": "number" }, "longitude": { "description": "Longitude coordinate", "title": "Longitude", "type": "number" }, "altitude": { "description": "Altitude in meters (optional)", "title": "Altitude", "type": ["number", "null"] } }, "required": [ "latitude", "longitude" ], "title": "GeoCoordinates", "type": "object" } }, "properties": { "coordinates": { "$ref": "#/$defs/GeoCoordinates", "description": "Geographical coordinates to map to hyper-dimensions" }, "dimension_count": { "default": 5, "description": "Number of hyper-dimensions to map to (4-11)", "maximum": 11, "minimum": 4, "title": "Dimension Count", "type": "integer" }, "reality_anchoring": { "default": 0.8, "description": "Reality anchoring factor (0.1-1.0)", "maximum": 1.0, "minimum": 0.1, "title": "Reality Anchoring", "type": "number" } }, "required": [ "coordinates" ], "title": "hyper_dimensional_mapperArguments", "type": "object" } }, { "name": "temporal_anomaly_detector", "parsed_description": { "main": "Detects temporal anomalies within a specified timeframe.", "args": "timeframe: Dictionary with 'start' and 'end' times for anomaly detection, sensitivity: Sensitivity level for detection (1-10), anomaly_types: Types of anomalies to detect", "returns": "Dict[str, Any]: Dictionary with mock temporal anomaly detection results", "raises": "" }, "schema": { "properties": { "timeframe": { "description": "Start and end times for anomaly detection", "properties": { "start": { "type": "string" }, "end": { "type": "string" } }, "required": ["start", "end"], "title": "Timeframe", "type": "object" }, "sensitivity": { "default": 7, "description": "Sensitivity level for detection (1-10)", "maximum": 10, "minimum": 1, "title": "Sensitivity", "type": "integer" }, "anomaly_types": { "default": ["temporal_shift", "causal_loop", "timeline_divergence"], "description": "Types of anomalies to detect", "items": { "type": "string" }, "title": "Anomaly Types", "type": "array" } }, "required": [ "timeframe" ], "title": "temporal_anomaly_detectorArguments", "type": "object" } }, { "name": "user_profile_analyzer", "parsed_description": { "main": "Analyzes a user profile with configurable analysis options.", "args": "profile: User profile to analyze, analysis_options: Options for the analysis", "returns": "str: JSON response with mock user profile analysis results", "raises": "" }, "schema": { "$defs": { "UserProfile": { "properties": { "username": { "description": "User's username", "title": "Username", "type": "string" }, "email": { "description": "User's email address", "title": "Email", "type": "string" }, "age": { "description": "User's age (optional)", "title": "Age", "type": ["integer", "null"] }, "interests": { "default": [], "description": "List of user interests", "items": { "type": "string" }, "title": "Interests", "type": "array" } }, "required": [ "username", "email" ], "title": "UserProfile", "type": "object" }, "AnalysisOptions": { "properties": { "depth": { "default": 3, "description": "Depth of analysis (1-10)", "title": "Depth", "type": "integer" }, "include_metadata": { "default": true, "description": "Whether to include metadata", "title": "Include Metadata", "type": "boolean" }, "filters": { "default": {}, "description": "Filters to apply", "title": "Filters", "type": "object" } }, "title": "AnalysisOptions", "type": "object" } }, "properties": { "profile": { "$ref": "#/$defs/UserProfile", "description": "User profile to analyze" }, "analysis_options": { "$ref": "#/$defs/AnalysisOptions", "default": {}, "description": "Options for the analysis" } }, "required": [ "profile" ], "title": "user_profile_analyzerArguments", "type": "object" } }, { "name": "synthetic_data_generator", "parsed_description": { "main": "Generates synthetic data based on a provided schema.", "args": "schema: Schema defining the structure of synthetic data, record_count: Number of synthetic records to generate (1-1000), seed: Random seed for reproducibility (optional)", "returns": "Dict[str, Any]: Dictionary with mock synthetic data generation results", "raises": "" }, "schema": { "properties": { "schema": { "description": "Schema defining the structure of synthetic data", "title": "Schema", "type": "object" }, "record_count": { "default": 10, "description": "Number of synthetic records to generate (1-1000)", "maximum": 1000, "minimum": 1, "title": "Record Count", "type": "integer" }, "seed": { "description": "Random seed for reproducibility (optional)", "title": "Seed", "type": ["integer", "null"] } }, "required": [ "schema" ], "title": "synthetic_data_generatorArguments", "type": "object" } } ] } ================================================ FILE: cli/examples/security_analyzer_agent.json ================================================ { "protocol_version": "1.0", "name": "Security Analyzer Agent", "description": "Comprehensive security analysis agent for vulnerability detection, penetration testing, and compliance checking. Identifies security risks and recommends mitigations", "url": "https://example.com/agents/security-analyzer", "version": "2.5.0", "provider": "CyberSec Corp", "path": "/security-analyzer", "tags": "security,vulnerability-detection,compliance,penetration-testing", "is_enabled": true, "num_stars": 72, "license": "MIT", "visibility": "public", "trust_level": "trusted", "streaming": true, "security_schemes": { "mutual_tls": { "type": "http", "scheme": "bearer", "bearer_format": "X.509" }, "api_key_secure": { "type": "apiKey", "name": "X-API-Key", "in": "header" } }, "security": [ { "mutual_tls": [] }, { "api_key_secure": [] } ], "skills": [ { "id": "scan_vulnerabilities", "name": "Scan for Vulnerabilities", "description": "Scan code and dependencies for known vulnerabilities", "parameters": { "type": "object", "properties": { "code_path": { "type": "string", "description": "Path to source code or project directory" }, "severity_threshold": { "type": "string", "enum": [ "critical", "high", "medium", "low" ], "description": "Minimum severity to report" }, "include_dependencies": { "type": "boolean", "description": "Also scan dependencies and third-party libraries" } }, "required": [ "code_path" ] }, "tags": [ "scanning", "vulnerability-detection" ] }, { "id": "check_compliance", "name": "Check Compliance", "description": "Check compliance with security standards and regulations", "parameters": { "type": "object", "properties": { "artifact": { "type": "string", "description": "Code, configuration, or artifact to check" }, "standards": { "type": "array", "items": { "type": "string", "enum": [ "owasp-top-10", "cis", "pci-dss", "hipaa", "gdpr", "iso27001" ] }, "description": "Standards to check against" } }, "required": [ "artifact", "standards" ] }, "tags": [ "compliance", "standards" ] }, { "id": "analyze_authentication", "name": "Analyze Authentication", "description": "Analyze authentication mechanisms and identify weaknesses", "parameters": { "type": "object", "properties": { "auth_config": { "type": "object", "description": "Authentication configuration to analyze" }, "check_types": { "type": "array", "items": { "type": "string", "enum": [ "password_policy", "mfa", "session_management", "token_expiry" ] }, "description": "Authentication aspects to check" } }, "required": [ "auth_config" ] }, "tags": [ "authentication", "access-control" ] }, { "id": "penetration_test", "name": "Penetration Testing", "description": "Perform authorized penetration testing to identify exploitable vulnerabilities", "parameters": { "type": "object", "properties": { "target_url": { "type": "string", "description": "URL or endpoint to test" }, "test_scope": { "type": "string", "enum": [ "basic", "standard", "comprehensive" ], "description": "Scope of penetration testing" }, "attack_vectors": { "type": "array", "items": { "type": "string", "enum": [ "sql_injection", "xss", "csrf", "rce", "privilege_escalation" ] }, "description": "Specific attack vectors to test" } }, "required": [ "target_url" ] }, "tags": [ "penetration-testing", "exploitation" ] }, { "id": "generate_security_report", "name": "Generate Security Report", "description": "Generate detailed security assessment report with recommendations", "parameters": { "type": "object", "properties": { "findings": { "type": "array", "description": "Security findings and issues" }, "format": { "type": "string", "enum": [ "html", "pdf", "json" ], "description": "Report format" }, "include_remediations": { "type": "boolean", "description": "Include remediation steps" } }, "required": [ "findings", "format" ] }, "tags": [ "reporting", "recommendations" ] } ], "metadata": { "cves_checked": 185000, "supported_languages": [ "python", "javascript", "java", "go", "rust", "csharp" ], "compliance_frameworks": [ "owasp", "cis", "pci-dss", "hipaa", "gdpr" ], "reporting_templates": 12, "updates_frequency": "daily" } } ================================================ FILE: cli/examples/server-config.json ================================================ { "server_name": "Example MCP Server", "description": "An example MCP server configuration for the CLI tool", "path": "/example-server", "proxy_pass_url": "http://example-server:9000/", "tags": ["example", "demo", "test"], "num_tools": 3, "license": "MIT" } ================================================ FILE: cli/examples/test-peer-config.json ================================================ { "peer_id": "test-peer-registry-1", "name": "Test Peer Registry 1", "endpoint": "https://peer1.registry.example.com", "enabled": true, "sync_mode": "all", "whitelist_servers": [], "whitelist_agents": [], "tag_filter": [], "federation_token": "YOUR_FEDERATION_TOKEN_HERE" } ================================================ FILE: cli/examples/test-timing-server.json ================================================ { "server_name": "Test Timing Server", "description": "Test server to verify timing optimizations", "path": "/test-timing-123", "proxy_pass_url": "https://example.com/mcp", "supported_transports": ["streamable-http"], "auth_scheme": "none", "tags": ["test", "timing"] } ================================================ FILE: cli/examples/test_automation_agent.json ================================================ { "protocol_version": "1.0", "name": "Test Automation Agent", "description": "Intelligent test automation agent that generates, executes, and manages test cases. Supports unit tests, integration tests, and end-to-end test scenarios", "url": "https://example.com/agents/test-automation", "version": "1.8.2", "provider": "Quality Assurance Labs", "path": "/test-automation", "tags": "testing,automation,qa,test-generation", "is_enabled": true, "num_stars": 38, "license": "Apache-2.0", "visibility": "public", "trust_level": "community", "streaming": true, "security_schemes": { "api_key": { "type": "apiKey", "name": "X-API-Key", "in": "header" }, "oauth2": { "type": "oauth2", "flows": { "clientCredentials": { "tokenUrl": "https://auth.example.com/oauth/token", "scopes": { "test:read": "Read test results", "test:write": "Create and modify tests" } } } } }, "security": [ { "api_key": [] }, { "oauth2": [ "test:read", "test:write" ] } ], "skills": [ { "id": "generate_unit_tests", "name": "Generate Unit Tests", "description": "Generate comprehensive unit test cases from source code", "parameters": { "type": "object", "properties": { "source_code": { "type": "string", "description": "Source code to generate tests for" }, "framework": { "type": "string", "enum": [ "pytest", "unittest", "jest", "junit" ], "description": "Testing framework to use" }, "coverage_target": { "type": "integer", "minimum": 0, "maximum": 100, "description": "Target code coverage percentage" } }, "required": [ "source_code", "framework" ] }, "tags": [ "generation", "unit-testing" ] }, { "id": "execute_tests", "name": "Execute Tests", "description": "Execute test suite and return results with detailed metrics", "parameters": { "type": "object", "properties": { "test_suite_path": { "type": "string", "description": "Path to test suite directory or file" }, "parallel_execution": { "type": "boolean", "description": "Execute tests in parallel" }, "timeout_seconds": { "type": "integer", "minimum": 1, "description": "Timeout per test in seconds" } }, "required": [ "test_suite_path" ] }, "tags": [ "execution", "reporting" ] }, { "id": "analyze_test_coverage", "name": "Analyze Test Coverage", "description": "Analyze test coverage and identify untested code paths", "parameters": { "type": "object", "properties": { "source_path": { "type": "string", "description": "Path to source code directory" }, "exclude_patterns": { "type": "array", "items": { "type": "string" }, "description": "Glob patterns to exclude from coverage" } }, "required": [ "source_path" ] }, "tags": [ "analysis", "coverage" ] }, { "id": "generate_test_report", "name": "Generate Test Report", "description": "Generate comprehensive test report with visualizations and metrics", "parameters": { "type": "object", "properties": { "test_results": { "type": "object", "description": "Test execution results" }, "format": { "type": "string", "enum": [ "html", "pdf", "json", "markdown" ], "description": "Report format" } }, "required": [ "test_results", "format" ] }, "tags": [ "reporting", "documentation" ] } ], "metadata": { "frameworks_supported": [ "pytest", "unittest", "jest", "mocha", "rspec" ], "languages_supported": [ "python", "javascript", "java", "ruby", "go" ], "concurrent_tests": 8, "test_history_days": 90 } } ================================================ FILE: cli/examples/test_code_reviewer_agent.json ================================================ { "name": "Test Code Reviewer Agent", "path": "/test-reviewer", "description": "A test A2A agent for code review and quality analysis", "url": "https://example.com/agents/test-reviewer", "version": "1.0.0", "visibility": "public", "trust_level": "community", "tags": "test,code-review,quality-analysis", "security_schemes": { "bearer": { "type": "http", "scheme": "bearer" } }, "protocol_version": "1.0" } ================================================ FILE: cli/examples/tourist_guide_agent_card.json ================================================ { "name": "AI Tourist Guide", "description": "AI tourist guide - destination + duration + interests to day-by-day itinerary", "url": "https://tourist-guide.agentworks.fr/a2a", "preferredTransport": "STREAMABLE-HTTP", "protocolVersion": "0.3.0", "version": "2.0.0", "capabilities": { "streaming": false, "pushNotifications": false }, "defaultInputModes": ["text/plain", "application/json"], "defaultOutputModes": ["application/json"], "skills": [ { "id": "touristguide", "name": "AI Tourist Guide", "description": "AI tourist guide - destination + duration + interests to day-by-day itinerary", "tags": ["tourism", "travel", "itinerary"] } ], "provider": { "organization": "MARAMEO", "url": "https://marameo.tv" }, "tags": ["tourism", "travel", "itinerary", "a2a", "ans-verified"], "ans_agent_id": "ans://v1.0.0.tourist-guide.agentworks.fr" } ================================================ FILE: cli/examples/travel_assistant_agent_card.json ================================================ { "capabilities": { "streaming": true }, "defaultInputModes": [ "text" ], "defaultOutputModes": [ "text" ], "description": "Flight search and trip planning agent", "name": "Travel Assistant Agent", "preferredTransport": "JSONRPC", "protocolVersion": "0.3.0", "skills": [ { "description": "Search for available flights between cities on a specific date.", "id": "search_flights", "name": "search_flights", "tags": [] }, { "description": "Get pricing and seat availability for a specific flight.", "id": "check_prices", "name": "check_prices", "tags": [] }, { "description": "Get flight recommendations based on customer preferences.", "id": "get_recommendations", "name": "get_recommendations", "tags": [] }, { "description": "Create and save a trip planning record.", "id": "create_trip_plan", "name": "create_trip_plan", "tags": [] } ], "url": "http://travel-assistant-agent:9000/", "version": "0.0.1", "tags": ["travel", "flight-search", "trip-planning", "booking"], "path": "/travel-assistant-agent", "status": "active", "provider": { "organization": "Travel Solutions Inc.", "url": "https://travel-solutions.example.com" }, "visibility": "public" } ================================================ FILE: cli/examples/travel_assistant_agent_ecs.json ================================================ { "protocolVersion": "0.3.0", "name": "Travel Assistant Agent", "description": "Intelligent travel planning and assistance agent", "url": "http://travel-assistant-agent.mcp-gateway-v2.local:9000", "version": "0.0.1", "capabilities": { "streaming": true }, "defaultInputModes": ["text/plain", "application/json"], "defaultOutputModes": ["text/plain", "application/json"], "provider": { "organization": "MCP Gateway", "url": "https://github.com/agentic-community/mcp-gateway-registry" }, "skills": [ { "id": "plan_trip", "name": "Plan Trip", "description": "Plan a complete trip including flights, hotels, and activities.", "tags": ["travel", "planning", "itinerary"] }, { "id": "find_destinations", "name": "Find Destinations", "description": "Discover travel destinations based on preferences and budget.", "tags": ["travel", "destinations", "recommendations"] }, { "id": "get_recommendations", "name": "Get Recommendations", "description": "Get personalized travel recommendations and tips.", "tags": ["travel", "recommendations", "advice"] }, { "id": "manage_itinerary", "name": "Manage Itinerary", "description": "Create, update, and manage travel itineraries.", "tags": ["travel", "itinerary", "management"] }, { "id": "coordinate_bookings", "name": "Coordinate Bookings", "description": "Coordinate flight bookings and other travel services.", "tags": ["travel", "coordination", "booking"] } ], "tags": ["travel", "assistant", "planning"], "visibility": "public", "license": "MIT", "path": "/travel-assistant-agent" } ================================================ FILE: cli/examples/virtual-server-combined-example.json ================================================ { "path": "/virtual/combined-tools", "server_name": "Combined Context7 and CurrentTime Tools", "description": "Virtual server aggregating documentation search tools from Context7 and timezone tools from CurrentTime server", "tool_mappings": [ { "tool_name": "resolve-library-id", "backend_server_path": "/context7" }, { "tool_name": "query-docs", "backend_server_path": "/context7" }, { "tool_name": "current_time_by_timezone", "alias": "get-current-time", "backend_server_path": "/currenttime/" } ], "required_scopes": [], "tool_scope_overrides": [], "tags": [ "documentation", "time", "timezone", "libraries", "combined" ], "supported_transports": [ "streamable-http" ], "is_enabled": true } ================================================ FILE: cli/examples/virtual-server-scoped-example.json ================================================ { "path": "/virtual/scoped-tools", "server_name": "Scoped Documentation and Time Tools", "description": "Virtual server with scope-based access control combining cloudflare-docs and currenttime tools", "tool_mappings": [ { "tool_name": "search_cloudflare_documentation", "backend_server_path": "/cloudflare-docs" }, { "tool_name": "current_time_by_timezone", "alias": "get-time", "backend_server_path": "/currenttime/" } ], "required_scopes": [ "virtual-scoped-tools/access" ], "tool_scope_overrides": [ { "tool_alias": "get-time", "required_scopes": ["virtual-scoped-tools/time-access"] } ], "tags": [ "documentation", "cloudflare", "time", "scoped", "access-control" ], "supported_transports": [ "streamable-http" ], "is_enabled": true } ================================================ FILE: cli/examples/virtual-server-scoped-users.json ================================================ { "scope_name": "virtual-scoped-tools-users", "description": "Users with access to the scoped virtual server combining cloudflare-docs and currenttime tools", "server_access": [ { "server": "/virtual/scoped-tools", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call"], "tools": ["*"] }, { "server": "api", "methods": ["GET", "POST", "servers", "virtual-servers", "search"], "tools": [] } ], "group_mappings": ["virtual-scoped-tools-users"], "custom_scopes": ["virtual-scoped-tools/access"], "create_in_idp": true } ================================================ FILE: cli/examples/working_agent.json ================================================ { "protocol_version": "1.0", "name": "Flight Booking Agent", "description": "Flight booking and reservation management agent", "url": "http://flight-booking-agent:9000/", "version": "0.0.1", "capabilities": {}, "default_input_modes": [ "text/plain" ], "default_output_modes": [ "text/plain" ], "skills": [ { "id": "check_availability", "name": "Check Availability", "description": "Check seat availability for a specific flight.", "tags": [], "examples": null, "input_modes": null, "output_modes": null, "security": null }, { "id": "reserve_flight", "name": "Reserve Flight", "description": "Reserve seats on a flight for passengers.", "tags": [], "examples": null, "input_modes": null, "output_modes": null, "security": null }, { "id": "confirm_booking", "name": "Confirm Booking", "description": "Confirm and finalize a flight booking.", "tags": [], "examples": null, "input_modes": null, "output_modes": null, "security": null }, { "id": "process_payment", "name": "Process Payment", "description": "Process payment for a booking (simulated).", "tags": [], "examples": null, "input_modes": null, "output_modes": null, "security": null }, { "id": "manage_reservation", "name": "Manage Reservation", "description": "Update, view, or cancel existing reservations.", "tags": [], "examples": null, "input_modes": null, "output_modes": null, "security": null } ], "preferred_transport": "JSONRPC", "provider": { "organization": "Example Corp", "url": "https://example-corp.com" }, "icon_url": null, "documentation_url": null, "security_schemes": {}, "security": null, "supports_authenticated_extended_card": null, "metadata": {}, "path": "/flight-booking", "tags": [ "travel", "flight-booking", "reservation" ], "is_enabled": false, "num_stars": 0, "license": "MIT", "registered_at": "2025-11-19T13:43:19.354979+00:00", "updated_at": "2025-11-19T13:43:19.355018+00:00", "registered_by": "service-account-registry-admin-bot", "visibility": "public", "allowed_groups": [], "signature": null, "trust_level": "unverified" } ================================================ FILE: cli/get_user_token.py ================================================ #!/usr/bin/env python3 """ CLI tool to authenticate users and obtain access tokens for programmatic API access. This script implements the OAuth2 Device Code Flow, which allows users to authenticate by visiting a URL and entering a code, without needing to expose their credentials to the CLI application. Usage: # Authenticate and save token to file uv run python cli/get_user_token.py --output .token # Authenticate with custom output file uv run python cli/get_user_token.py --output my-token.json # Show token on stdout (don't save) uv run python cli/get_user_token.py --stdout Environment Variables: ENTRA_TENANT_ID: Azure AD tenant ID ENTRA_CLIENT_ID: App registration client ID ENTRA_CLIENT_SECRET: App registration client secret (optional for public clients) """ import argparse import json import logging import os import sys import time from datetime import datetime logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Default Entra ID login base URL DEFAULT_ENTRA_LOGIN_BASE_URL = "https://login.microsoftonline.com" def _get_env_or_error(name: str, default: str | None = None) -> str: """Get environment variable or raise error if not set. Args: name: Environment variable name default: Default value if not set Returns: Environment variable value Raises: ValueError: If variable not set and no default """ value = os.environ.get(name, default) if not value: raise ValueError(f"Environment variable {name} is required") return value def _initiate_device_code_flow(tenant_id: str, client_id: str, scope: str | None = None) -> dict: """Initiate device code flow. Args: tenant_id: Azure AD tenant ID client_id: App registration client ID scope: OAuth scopes to request Returns: Device code response from Entra ID """ import requests login_base_url = os.environ.get("ENTRA_LOGIN_BASE_URL", DEFAULT_ENTRA_LOGIN_BASE_URL) device_code_url = f"{login_base_url}/{tenant_id}/oauth2/v2.0/devicecode" if not scope: scope = f"api://{client_id}/user_impersonation openid profile email" data = {"client_id": client_id, "scope": scope} response = requests.post(device_code_url, data=data, timeout=10) if response.status_code != 200: error_data = response.json() error_desc = error_data.get("error_description", error_data.get("error", "Unknown error")) logger.error(f"Device code request failed: {error_desc}") raise ValueError(f"Device code flow not available: {error_desc}") return response.json() def _poll_for_token( tenant_id: str, client_id: str, device_code: str, interval: int = 5, timeout: int = 300 ) -> dict: """Poll for token after user completes authentication. Args: tenant_id: Azure AD tenant ID client_id: App registration client ID device_code: Device code from initiation interval: Polling interval in seconds timeout: Maximum wait time in seconds Returns: Token response from Entra ID """ import requests login_base_url = os.environ.get("ENTRA_LOGIN_BASE_URL", DEFAULT_ENTRA_LOGIN_BASE_URL) token_url = f"{login_base_url}/{tenant_id}/oauth2/v2.0/token" data = { "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "client_id": client_id, "device_code": device_code, } start_time = time.time() while (time.time() - start_time) < timeout: response = requests.post(token_url, data=data, timeout=10) if response.status_code == 200: return response.json() error_data = response.json() error = error_data.get("error", "") if error == "authorization_pending": sys.stdout.write(".") sys.stdout.flush() time.sleep(interval) continue elif error == "slow_down": interval += 5 time.sleep(interval) continue elif error == "expired_token": raise ValueError("Device code expired. Please try again.") elif error == "access_denied": raise ValueError("Authorization was denied.") else: error_desc = error_data.get("error_description", error) raise ValueError(f"Token request failed: {error_desc}") raise ValueError("Authentication timed out. Please try again.") def _save_token(token_data: dict, output_path: str) -> None: """Save token data to file. Args: token_data: Token response from Entra ID output_path: Path to save token file """ # Add metadata token_data["obtained_at"] = datetime.utcnow().isoformat() with open(output_path, "w") as f: json.dump(token_data, f, indent=2) # Set restrictive permissions os.chmod(output_path, 0o600) logger.info(f"Token saved to {output_path}") def _extract_access_token(token_data: dict) -> str: """Extract just the access token from response. Args: token_data: Full token response Returns: Access token string """ return token_data.get("access_token", "") def main() -> int: """Main entry point. Returns: Exit code (0 for success) """ parser = argparse.ArgumentParser( description="Authenticate with Entra ID and obtain an access token for API access", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Authenticate and save token to .token file uv run python cli/get_user_token.py --output .token # Authenticate and print token to stdout uv run python cli/get_user_token.py --stdout # Use the token with registry_management.py uv run python api/registry_management.py --token-file .token --registry-url http://localhost list Environment Variables: ENTRA_TENANT_ID Azure AD tenant ID (required) ENTRA_CLIENT_ID App registration client ID (required) ENTRA_LOGIN_BASE_URL Login URL (default: https://login.microsoftonline.com) """, ) parser.add_argument( "--output", "-o", type=str, help="Path to save the token file (default: .token)", default=".token", ) parser.add_argument( "--stdout", action="store_true", help="Print token to stdout instead of saving to file" ) parser.add_argument( "--full", action="store_true", help="Output full token response (with refresh token, expiry, etc.)", ) parser.add_argument( "--scope", type=str, help="OAuth scopes to request (default: user_impersonation openid profile email)", ) parser.add_argument( "--timeout", type=int, default=300, help="Authentication timeout in seconds (default: 300)" ) args = parser.parse_args() try: # Get configuration from environment tenant_id = _get_env_or_error("ENTRA_TENANT_ID") client_id = _get_env_or_error("ENTRA_CLIENT_ID") logger.info("Starting device code authentication flow") logger.info(f"Tenant ID: {tenant_id}") logger.info(f"Client ID: {client_id}") # Initiate device code flow device_code_response = _initiate_device_code_flow( tenant_id=tenant_id, client_id=client_id, scope=args.scope ) # Display instructions to user print("\n" + "=" * 60) print("AUTHENTICATION REQUIRED") print("=" * 60) print(f"\n{device_code_response.get('message', '')}\n") print(f" URL: {device_code_response.get('verification_uri', '')}") print(f" Code: {device_code_response.get('user_code', '')}") print("\n" + "=" * 60) print("\nWaiting for authentication", end="") # Poll for token token_data = _poll_for_token( tenant_id=tenant_id, client_id=client_id, device_code=device_code_response["device_code"], interval=device_code_response.get("interval", 5), timeout=args.timeout, ) print("\n\nAuthentication successful!") # Output token if args.stdout: if args.full: print(json.dumps(token_data, indent=2)) else: print(token_data["access_token"]) else: if args.full: _save_token(token_data, args.output) else: # Save just the access token for compatibility with CLI tools with open(args.output, "w") as f: f.write(token_data["access_token"]) os.chmod(args.output, 0o600) logger.info(f"Access token saved to {args.output}") print(f"\nToken saved to: {args.output}") print(f"Token expires in: {token_data.get('expires_in', 'unknown')} seconds") print("\nUsage:") print( f" uv run python api/registry_management.py --token-file {args.output} --registry-url http://localhost list" ) return 0 except ValueError as e: logger.error(f"Authentication failed: {e}") print(f"\nError: {e}", file=sys.stderr) return 1 except Exception as e: logger.exception(f"Unexpected error: {e}") print(f"\nUnexpected error: {e}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: cli/import_from_anthropic_registry.sh ================================================ #!/bin/bash # # Import MCP servers from Anthropic Registry # # This script fetches server definitions from the Anthropic MCP Registry # and registers them with the local MCP Gateway Registry. # # Usage: # ./import_from_anthropic_registry.sh [--dry-run] [--import-list ] [--analyzers ] # # Environment Variables: # GATEWAY_URL - Gateway URL (default: http://localhost) # Example: export GATEWAY_URL=https://mcpgateway.ddns.net # MCP_SCANNER_LLM_API_KEY - API key for LLM-based security analysis (required if using llm analyzer) # set -e # Get the directory where this script is located SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" # Load environment variables from .env file if it exists if [ -f "$PROJECT_ROOT/.env" ]; then set -a # Automatically export all variables source "$PROJECT_ROOT/.env" set +a # Turn off automatic export fi # Configuration ANTHROPIC_API_BASE="https://registry.modelcontextprotocol.io" TEMP_DIR="$PROJECT_ROOT/.tmp/anthropic-import" BASE_PORT=8100 # Read API version from constants.py ANTHROPIC_API_VERSION=$(python3 -c " import sys sys.path.insert(0, '$PROJECT_ROOT') from registry.constants import REGISTRY_CONSTANTS print(REGISTRY_CONSTANTS.ANTHROPIC_API_VERSION) ") # Gateway URL (can be overridden with GATEWAY_URL environment variable) GATEWAY_URL="${GATEWAY_URL:-http://localhost}" # Colors for terminal output RED='\033[0;31m' GREEN='\033[0;32m' BLUE='\033[0;34m' NC='\033[0m' # Output formatting functions (minimal emoji use per coding standards) print_success() { echo -e "${GREEN}[SUCCESS] $1${NC}"; } print_error() { echo -e "${RED}[ERROR] $1${NC}"; } print_info() { echo -e "${BLUE}[INFO] $1${NC}"; } # Generate deployment instructions for a server detect_transport() { local anthropic_json="$1" # Most MCP servers from Anthropic registry use stdio transport # Only a few support HTTP/SSE echo "stdio" } validate_package() { local package_type="$1" local package_name="$2" if [ -z "$package_name" ] || [ "$package_name" = "null" ]; then return 1 fi case "$package_type" in "npm") # Check if NPM package exists (simplified check) return 0 ;; "pypi") # Check if PyPI package exists (simplified check) return 0 ;; *) return 1 ;; esac } # Parse arguments DRY_RUN=false IMPORT_LIST="$SCRIPT_DIR/import_server_list.txt" ANALYZERS="yara" while [[ $# -gt 0 ]]; do case $1 in --dry-run) DRY_RUN=true; shift ;; --import-list) IMPORT_LIST="$2"; shift 2 ;; --analyzers) ANALYZERS="$2"; shift 2 ;; --help) echo "Usage: $0 [--dry-run] [--import-list ] [--analyzers ]" echo "" echo "Options:" echo " --dry-run Dry run mode (don't register servers)" echo " --import-list Server list file (default: import_server_list.txt)" echo " --analyzers Security analyzers: yara, llm, or yara,llm (default: yara)" echo "" echo "Environment Variables:" echo " GATEWAY_URL - Gateway URL (default: http://localhost)" echo " Example: export GATEWAY_URL=https://mcpgateway.ddns.net" echo " MCP_SCANNER_LLM_API_KEY - API key for LLM analyzer (required if using llm)" echo "" echo "Examples:" echo " # Import with default YARA analyzer" echo " $0" echo "" echo " # Import with both YARA and LLM analyzers" echo " export MCP_SCANNER_LLM_API_KEY=sk-..." echo " $0 --analyzers yara,llm" echo "" echo " # Import with only LLM analyzer" echo " export MCP_SCANNER_LLM_API_KEY=sk-..." echo " $0 --analyzers llm" exit 0 ;; *) echo "Unknown option: $1"; exit 1 ;; esac done # Check prerequisites command -v jq >/dev/null || { print_error "jq required"; exit 1; } command -v curl >/dev/null || { print_error "curl required"; exit 1; } [ -f "$IMPORT_LIST" ] || { print_error "Import list not found: $IMPORT_LIST"; exit 1; } # Check if LLM analyzer is requested and API key is available if [[ "$ANALYZERS" == *"llm"* ]]; then if [ -z "$MCP_SCANNER_LLM_API_KEY" ] || [[ "$MCP_SCANNER_LLM_API_KEY" == *"your_"* ]] || [[ "$MCP_SCANNER_LLM_API_KEY" == *"placeholder"* ]]; then echo "" print_error "LLM analyzer requested but MCP_SCANNER_LLM_API_KEY is not configured" print_info "Current value: ${MCP_SCANNER_LLM_API_KEY:-}" print_info "" print_info "Options:" print_info " 1. Add real API key to .env file: MCP_SCANNER_LLM_API_KEY=sk-..." print_info " 2. Set environment variable: export MCP_SCANNER_LLM_API_KEY=sk-..." print_info " 3. Use only YARA analyzer: $0 --analyzers yara" exit 1 fi fi mkdir -p "$TEMP_DIR" # Read server list servers=() while IFS= read -r line; do [[ "$line" =~ ^[[:space:]]*# ]] && continue [[ -z "${line// }" ]] && continue servers+=("$(echo "$line" | xargs)") done < "$IMPORT_LIST" print_info "Found ${#servers[@]} servers to import" print_info "Security analyzers: $ANALYZERS" # Process each server success_count=0 current_port=$BASE_PORT for server_name in "${servers[@]}"; do print_info "Processing: $server_name" # Fetch from Anthropic API (URL encode server name) # API version is dynamically read from registry/constants.py encoded_name=$(echo "$server_name" | sed 's|/|%2F|g') api_url="${ANTHROPIC_API_BASE}/${ANTHROPIC_API_VERSION}/servers/${encoded_name}/versions/latest" safe_name=$(echo "$server_name" | sed 's|/|-|g') anthropic_file="${TEMP_DIR}/${safe_name}-anthropic.json" if ! curl -s -f "$api_url" > "$anthropic_file"; then print_error "Failed to fetch $server_name" continue fi # Transform to registry format config_file="${TEMP_DIR}/${safe_name}-config.json" anthropic_json=$(cat "$anthropic_file") # Extract from nested server object description=$(echo "$anthropic_json" | jq -r '.server.description // "Imported from Anthropic MCP Registry"') version=$(echo "$anthropic_json" | jq -r '.server.version // "latest"') repo_url=$(echo "$anthropic_json" | jq -r '.server.repository.url // ""') # Detect transport type from packages or remotes transport_type="stdio" if echo "$anthropic_json" | jq -e '.server.packages[]? | .transport.type' > /dev/null 2>&1; then transport_type=$(echo "$anthropic_json" | jq -r '.server.packages[]? | .transport.type' | head -1) elif echo "$anthropic_json" | jq -e '.server.remotes[]? | .type' > /dev/null 2>&1; then transport_type=$(echo "$anthropic_json" | jq -r '.server.remotes[]? | .type' | head -1) fi # Generate tags from server name IFS='/' read -ra name_parts <<< "$server_name" server_basename="${name_parts[${#name_parts[@]}-1]}" IFS='-' read -ra tag_parts <<< "$server_basename" tags_json=$(printf '%s\n' "${tag_parts[@]}" "anthropic-registry" | jq -R . | jq -s .) # Generate safe path and proxy URL safe_path=$(echo "$server_name" | sed 's|/|-|g') # For imported servers, use a placeholder URL since they're not deployed yet proxy_url="http://localhost:${current_port}/" # Use Python transformer for complete transformation python3 -c " import json import sys sys.path.append('$SCRIPT_DIR') from anthropic_transformer import transform_anthropic_to_gateway # Load Anthropic server data with open('$anthropic_file') as f: data = json.load(f) # Transform to Gateway Registry format result = transform_anthropic_to_gateway(data, $current_port) result['path'] = '/$safe_path' # Remove unsupported fields for register_service tool # The user-facing register_service tool only supports basic fields # Note: auth_scheme, auth_provider, headers, supported_transports, and tool_list are kept unsupported_fields = [ 'repository_url', 'website_url', 'package_npm', 'remote_url' ] for field in unsupported_fields: result.pop(field, None) # Write transformed configuration with open('$config_file', 'w') as f: json.dump(result, f, indent=2) " print_success "Created config for $server_name (transport: $transport_type)" # Register with service_mgmt.sh (if not dry run) if [ "$DRY_RUN" = false ]; then if GATEWAY_URL="$GATEWAY_URL" "$SCRIPT_DIR/service_mgmt.sh" add "$config_file" "$ANALYZERS"; then print_success "Registered $server_name" success_count=$((success_count + 1)) else print_error "Failed to register $server_name" fi else print_info "[DRY RUN] Would register $server_name with analyzers: $ANALYZERS" success_count=$((success_count + 1)) fi current_port=$((current_port + 1)) done print_info "Import completed: $success_count/${#servers[@]} successful" print_info "Configuration files saved to: $TEMP_DIR" ================================================ FILE: cli/import_server_list.txt ================================================ # MCP Servers to Import from Anthropic Registry # One server name per line, comments start with # # # Curated list of popular streamable-http servers # Auto-selected based on popularity, development utility, and reliability # Last updated: 2025-10-14 # GitHub API access - file operations, repository management, search ai.smithery/smithery-ai-github # GitHub-hosted Obsidian vault integration for AI assistants ai.smithery/Hint-Services-obsidian-github-mcp # Web search and article text extraction for LLMs io.github.jgador/websharp # Google Forms management - create surveys and collect data ai.smithery/data-mindset-sts-google-forms-mcp # Automated GitHub PR and issue analysis ai.smithery/saidsef-mcp-github-pr-issue-analyser # Search-only commerce MCP server backed by Stripe (test) ai.shawndurrani/mcp-merchant ================================================ FILE: cli/mcp_client.py ================================================ #!/usr/bin/env python3 """ Simple MCP Client using shared MCP utilities This client uses the shared mcp_utils module which provides a standardized MCP client implementation using only standard Python libraries. This approach avoids dependency issues with the fastmcp library in some environments. """ import argparse import base64 import json import os import sys from datetime import UTC, datetime # Import shared MCP utility from mcp_utils import create_mcp_session def _check_token_expiration(access_token: str) -> None: """ Check if JWT token is expired and exit with informative message if so. Args: access_token: JWT access token to check Exits: If token is expired or will expire soon """ try: # Decode JWT payload (without verification, just to check expiry) parts = access_token.split(".") if len(parts) != 3: print("Warning: Invalid JWT format, cannot check expiration") return # Decode payload payload = parts[1] # Add padding if needed padding = len(payload) % 4 if padding: payload += "=" * (4 - padding) decoded = base64.urlsafe_b64decode(payload) token_data = json.loads(decoded) # Check expiration exp = token_data.get("exp") if not exp: print("Warning: Token does not have expiration field") return exp_dt = datetime.fromtimestamp(exp, tz=UTC) now = datetime.now(UTC) time_until_expiry = exp_dt - now if time_until_expiry.total_seconds() < 0: # Token is expired print("=" * 80) print("TOKEN EXPIRED") print("=" * 80) print(f"Token expired at: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}") print(f"Current time is: {now.strftime('%Y-%m-%d %H:%M:%S UTC')}") print(f"Token expired {abs(time_until_expiry.total_seconds()):.0f} seconds ago") print("") print("Please regenerate your token using one of these methods:") print("") print(" 1. For LOB bot agents (recommended):") print(" ./keycloak/setup/generate-agent-token.sh lob1-bot") print(" ./keycloak/setup/generate-agent-token.sh lob2-bot") print("") print(" 2. Use token file (for Cognito/OAuth):") print(" --token-file /path/to/your/.token_file") print("") print(" 3. Use M2M authentication:") print(" Set environment variables: CLIENT_ID, CLIENT_SECRET,") print(" KEYCLOAK_URL, KEYCLOAK_REALM") print("=" * 80) sys.exit(1) elif time_until_expiry.total_seconds() < 60: # Token expires soon print( f"Warning: Token will expire in {int(time_until_expiry.total_seconds())} seconds at {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}" ) else: print( f"Token is valid until {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} ({int(time_until_expiry.total_seconds())} seconds remaining)" ) except Exception as e: print(f"Warning: Could not check token expiration: {e}") def _load_token_from_file(file_path: str) -> str | None: """Load access token from a file Supports multiple formats: 1. Plain JWT token (single line) 2. JSON object with 'access_token' field (from agent token generation) 3. JSON object with 'tokens.access_token' field (from UI "Get JWT Token") 4. JSON object with 'token_data.access_token' field (alternative UI format) """ try: with open(file_path) as f: content = f.read().strip() if not content: return None # Try to parse as JSON first (for agent token files) try: token_data = json.loads(content) if isinstance(token_data, dict): # Format 1: {"access_token": "..."} if "access_token" in token_data: return token_data["access_token"] # Format 2: {"tokens": {"access_token": "..."}} (from UI) if "tokens" in token_data and isinstance(token_data["tokens"], dict): if "access_token" in token_data["tokens"]: return token_data["tokens"]["access_token"] # Format 3: {"token_data": {"access_token": "..."}} if "token_data" in token_data and isinstance(token_data["token_data"], dict): if "access_token" in token_data["token_data"]: return token_data["token_data"]["access_token"] except json.JSONDecodeError: # Not JSON, treat as plain token string pass # Return as-is (plain JWT token) return content if content else None except FileNotFoundError: print(f"Warning: Token file not found: {file_path}") except Exception as e: print(f"Warning: Failed to read token file {file_path}: {e}") return None def _load_m2m_credentials() -> str | None: """Load M2M credentials and get access token from Keycloak""" client_id = os.getenv("CLIENT_ID") client_secret = os.getenv("CLIENT_SECRET") keycloak_url = os.getenv("KEYCLOAK_URL") keycloak_realm = os.getenv("KEYCLOAK_REALM") if not all([client_id, client_secret, keycloak_url, keycloak_realm]): return None # Import requests only when needed for M2M authentication try: import requests except ImportError: print("Warning: requests library not available for M2M authentication") return None # Get access token from Keycloak token_url = f"{keycloak_url}/realms/{keycloak_realm}/protocol/openid-connect/token" data = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": "openid", } try: response = requests.post(token_url, data=data, timeout=30) response.raise_for_status() token_data = response.json() return token_data.get("access_token") except Exception as e: print(f"Failed to get M2M token: {e}") return None def main(): parser = argparse.ArgumentParser( description="Simple MCP Client - Communicate with MCP Gateway using JSON-RPC", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Test connectivity uv run mcp_client.py ping # List available tools uv run mcp_client.py list # Find tools using natural language uv run mcp_client.py call --tool intelligent_tool_finder --args '{"natural_language_query":"get current time in New York"}' # Call any tool with arguments (specify correct server URL) uv run mcp_client.py --url http://localhost/currenttime/mcp call --tool current_time_by_timezone --args '{"tz_name":"America/New_York"}' # Use different gateway URL uv run mcp_client.py --url http://localhost/currenttime/mcp ping # Use token from file (e.g., for Cognito/OAuth servers) uv run mcp_client.py --url http://localhost/customer-support-assistant/mcp --token-file /path/to/.cognito_access_token list Authentication (priority order): 1. --token-file: Path to file containing access token 2. OAUTH_TOKEN environment variable: Direct JWT token 3. Environment variables: CLIENT_ID, CLIENT_SECRET, KEYCLOAK_URL, KEYCLOAK_REALM 4. Ingress token: Automatically loaded from ~/.mcp/ingress_token if available """, ) parser.add_argument( "--url", default="http://localhost/mcpgw/mcp", help="Gateway URL (default: %(default)s)" ) parser.add_argument( "--token-file", help="Path to file containing access token (e.g., .cognito_access_token)" ) parser.add_argument( "command", choices=["ping", "list", "call", "init"], help="Command to execute" ) parser.add_argument("--tool", help="Tool name for call command") parser.add_argument("--args", help="Tool arguments as JSON string") args = parser.parse_args() # Load authentication (priority: token-file > OAUTH_TOKEN env var > M2M > ingress token) access_token = None # Try loading from file first if specified if args.token_file: access_token = _load_token_from_file(args.token_file) # Fall back to OAUTH_TOKEN environment variable if no token file or file loading failed if not access_token: access_token = os.getenv("OAUTH_TOKEN") # Fall back to M2M credentials if no OAUTH_TOKEN if not access_token: access_token = _load_m2m_credentials() # Check token expiration before making any API calls if access_token: _check_token_expiration(access_token) # Create MCP session using shared utility (it will auto-load ingress token if needed) try: with create_mcp_session(args.url, access_token) as client: # Check what authentication was actually used if client.access_token: if args.token_file: print(f"✓ Token file authentication successful ({args.token_file})") elif os.getenv("OAUTH_TOKEN"): print("✓ OAuth token authentication successful (OAUTH_TOKEN env var)") elif access_token: print("✓ M2M authentication successful") else: print("✓ Ingress token authentication successful") else: print("⚠ No authentication available") # Execute command if args.command == "init": result = {"status": "initialized", "session_id": client.session_id} elif args.command == "ping": result = client.ping() elif args.command == "list": result = client.list_tools() elif args.command == "call": if not args.tool: print("Error: --tool is required for call command") sys.exit(1) # Parse arguments if provided tool_args = {} if args.args: try: tool_args = json.loads(args.args) except json.JSONDecodeError as e: print(f"Error: Invalid JSON in --args: {e}") sys.exit(1) result = client.call_tool(args.tool, tool_args) # Print result print(json.dumps(result, indent=2)) except Exception as e: print(f"Error: {e}") sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: cli/mcp_security_scanner.py ================================================ #!/usr/bin/env python3 """ MCP Security Scanner CLI Tool Scans MCP servers for security vulnerabilities using cisco-ai-mcp-scanner. Integrates with service_mgmt.sh to provide security analysis during server registration. """ import argparse import json import logging import os import re import subprocess # nosec B404 import sys from datetime import UTC, datetime from pathlib import Path from pydantic import BaseModel, Field # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Constants DEFAULT_ANALYZERS = "yara" LLM_API_KEY_ENV = "MCP_SCANNER_LLM_API_KEY" # Use absolute path relative to project root PROJECT_ROOT = Path(__file__).parent.parent OUTPUT_DIR = PROJECT_ROOT / "security_scans" class SecurityScanResult(BaseModel): """Security scan result model.""" server_url: str = Field(..., description="URL of the scanned MCP server") scan_timestamp: str = Field(..., description="ISO timestamp of the scan") is_safe: bool = Field(..., description="Overall safety assessment") critical_issues: int = Field(default=0, description="Count of critical severity issues") high_severity: int = Field(default=0, description="Count of high severity issues") medium_severity: int = Field(default=0, description="Count of medium severity issues") low_severity: int = Field(default=0, description="Count of low severity issues") raw_output: dict = Field(..., description="Full scanner output") output_file: str = Field(..., description="Path to detailed JSON output file") def _get_llm_api_key(cli_value: str | None = None) -> str: """Retrieve LLM API key from CLI argument or environment variable. Args: cli_value: API key provided via command line Returns: LLM API key for security scanning Raises: ValueError: If API key is not found """ if cli_value: return cli_value env_value = os.getenv(LLM_API_KEY_ENV) if env_value: return env_value raise ValueError(f"LLM API key must be provided via --api-key or {LLM_API_KEY_ENV} env var") def _ensure_output_directory() -> Path: """Ensure output directory exists.""" OUTPUT_DIR.mkdir(parents=True, exist_ok=True) return OUTPUT_DIR def _run_mcp_scanner( server_url: str, analyzers: str = DEFAULT_ANALYZERS, api_key: str | None = None, headers: str | None = None, ) -> dict: """Run mcp-scanner command and return raw output. Args: server_url: URL of the MCP server to scan analyzers: Comma-separated list of analyzers to use api_key: OpenAI API key for LLM-based analysis headers: JSON string of headers to include in requests Returns: Dictionary containing raw scanner output Raises: subprocess.CalledProcessError: If scanner command fails """ logger.info(f"Running security scan on: {server_url}") logger.info(f"Using analyzers: {analyzers}") # Build command - global options before subcommand, subcommand options after cmd = [ "mcp-scanner", "--analyzers", analyzers, "--raw", # Use raw format instead of summary "remote", # Subcommand to scan remote MCP server "--server-url", server_url, ] # Add headers if provided - parse JSON and extract bearer token if headers: logger.info("Adding custom headers for scanning") try: headers_dict = json.loads(headers) # Check for X-Authorization header with Bearer token auth_header = headers_dict.get("X-Authorization", "") if auth_header.startswith("Bearer "): bearer_token = auth_header.replace("Bearer ", "") cmd.extend(["--bearer-token", bearer_token]) logger.info("Using bearer token authentication") else: logger.warning( "Headers provided but no Bearer token found in X-Authorization header" ) except json.JSONDecodeError as e: logger.error(f"Failed to parse headers JSON: {e}") raise ValueError(f"Invalid headers JSON: {headers}") from e # Set environment variable for API key if provided env = os.environ.copy() if api_key: env[LLM_API_KEY_ENV] = api_key # Run scanner try: result = subprocess.run( # nosec B603 - mcp-scanner tool with validated args cmd, capture_output=True, text=True, check=True, env=env ) # Log raw output for debugging logger.debug(f"Raw scanner stdout:\n{result.stdout[:500]}") # Parse JSON output - scanner outputs JSON array after log messages stdout = result.stdout.strip() # Remove ANSI color codes that can interfere with JSON parsing ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") stdout = ansi_escape.sub("", stdout) # Find the start of JSON array - look for '[\n {' pattern (array with objects) # This is more robust than just finding first '[' or '{' json_start = -1 # Try to find JSON array start for i in range(len(stdout) - 1): if stdout[i] == "[" and (i == 0 or stdout[i - 1] in "\n\r"): # Found '[' at start of line, likely start of JSON json_start = i break # Fallback: find any '[' followed by whitespace and '{' if json_start == -1: pattern = r"\[\s*\{" match = re.search(pattern, stdout) if match: json_start = match.start() if json_start == -1: raise ValueError("No JSON array found in scanner output") # Extract and parse JSON json_str = stdout[json_start:] tool_results = json.loads(json_str) # Wrap in expected format with analysis_results # Convert array of tool results to the expected structure raw_output = {"analysis_results": {}, "tool_results": tool_results} # Extract findings from tool results and organize by analyzer for tool_result in tool_results: findings_dict = tool_result.get("findings", {}) for analyzer_name, analyzer_findings in findings_dict.items(): if analyzer_name not in raw_output["analysis_results"]: raw_output["analysis_results"][analyzer_name] = {"findings": []} # Convert analyzer findings to expected format if isinstance(analyzer_findings, dict): finding = { "tool_name": tool_result.get("tool_name"), "severity": analyzer_findings.get("severity", "unknown"), "threat_names": analyzer_findings.get("threat_names", []), "threat_summary": analyzer_findings.get("threat_summary", ""), "is_safe": tool_result.get("is_safe", True), } raw_output["analysis_results"][analyzer_name]["findings"].append(finding) logger.debug(f"Scanner output:\n{json.dumps(raw_output, indent=2, default=str)}") return raw_output except subprocess.CalledProcessError as e: logger.error(f"Scanner command failed with exit code {e.returncode}") logger.error(f"stderr: {e.stderr}") raise except json.JSONDecodeError as e: logger.error(f"Failed to parse scanner output as JSON: {e}") logger.error(f"Raw stdout: {result.stdout[:1000]}") raise def _analyze_scan_results(raw_output: dict) -> tuple[bool, int, int, int, int]: """Analyze scan results and extract severity counts. Args: raw_output: Raw scanner output dictionary Returns: Tuple of (is_safe, critical_count, high_count, medium_count, low_count) """ critical_count = 0 high_count = 0 medium_count = 0 low_count = 0 # Navigate the raw output structure to find findings # Structure: raw_output -> analysis_results -> [analyzer_name] -> findings analysis_results = raw_output.get("analysis_results", {}) for _analyzer_name, analyzer_data in analysis_results.items(): if isinstance(analyzer_data, dict): findings = analyzer_data.get("findings", []) for finding in findings: severity = finding.get("severity", "").lower() if severity == "critical": critical_count += 1 elif severity == "high": high_count += 1 elif severity == "medium": medium_count += 1 elif severity == "low": low_count += 1 # Determine if safe: no critical or high severity issues is_safe = critical_count == 0 and high_count == 0 logger.info("Security analysis results:") logger.info(f" Critical Issues: {critical_count}") logger.info(f" High Severity: {high_count}") logger.info(f" Medium Severity: {medium_count}") logger.info(f" Low Severity: {low_count}") logger.info(f" Overall Assessment: {'SAFE' if is_safe else 'UNSAFE'}") return is_safe, critical_count, high_count, medium_count, low_count def _save_scan_output(server_url: str, raw_output: dict) -> str: """Save detailed scan output to JSON file. Saves in two locations: 1. security_scans/YYYY-MM-DD/scan__.json (archived) 2. security_scans/scan__latest.json (always current) Args: server_url: URL of the scanned server raw_output: Raw scanner output Returns: Path to saved output file (latest version) """ output_dir = _ensure_output_directory() # Generate safe filename from server URL safe_url = server_url.replace("https://", "").replace("http://", "").replace("/", "_") # Create date-based subdirectory for archival timestamp = datetime.now(UTC) date_folder = timestamp.strftime("%Y-%m-%d") archive_dir = output_dir / date_folder archive_dir.mkdir(exist_ok=True) # Save timestamped version in date folder (archived) timestamp_str = timestamp.strftime("%Y%m%d_%H%M%S") archived_filename = f"scan_{safe_url}_{timestamp_str}.json" archived_file = archive_dir / archived_filename with open(archived_file, "w") as f: json.dump(raw_output, f, indent=2, default=str) logger.info(f"Archived scan output saved to: {archived_file}") # Save latest version in root security_scans folder (always current) # Extract server name from URL for cleaner filename # e.g., http://localhost/realserverfaketools/mcp -> realserverfaketools_mcp.json server_name = safe_url.replace("localhost_", "") latest_filename = f"{server_name}.json" latest_file = output_dir / latest_filename with open(latest_file, "w") as f: json.dump(raw_output, f, indent=2, default=str) logger.info(f"Latest scan output saved to: {latest_file}") return str(latest_file) def _disable_unsafe_server(server_path: str) -> bool: """Disable a server that failed security scan. Args: server_path: Path of the server to disable (e.g., /mcpgw) Returns: True if server was disabled successfully, False otherwise """ logger.info(f"Disabling unsafe server: {server_path}") try: # Call service_mgmt.sh to disable the server cmd = [str(PROJECT_ROOT / "cli" / "service_mgmt.sh"), "disable", server_path] result = subprocess.run( # nosec B603 - hardcoded internal script, server_path from URL parsing cmd, capture_output=True, text=True, check=True ) logger.info(f"Server {server_path} disabled successfully") logger.debug(f"Output: {result.stdout}") return True except subprocess.CalledProcessError as e: logger.error(f"Failed to disable server {server_path}: {e}") logger.error(f"stderr: {e.stderr}") return False except Exception as e: logger.error(f"Unexpected error disabling server {server_path}: {e}") return False def _extract_server_path_from_url(server_url: str) -> str | None: """Extract server path from URL. Args: server_url: Full server URL (e.g., http://localhost/mcpgw/mcp) Returns: Server path (e.g., /mcpgw) or None if cannot be extracted """ try: # Parse URL to extract path component # Expected format: http://localhost/server-path/mcp from urllib.parse import urlparse parsed = urlparse(server_url) path_parts = [p for p in parsed.path.split("/") if p and p != "mcp"] if path_parts: server_path = f"/{path_parts[0]}" logger.debug(f"Extracted server path '{server_path}' from URL '{server_url}'") return server_path else: logger.warning(f"Could not extract server path from URL: {server_url}") return None except Exception as e: logger.error(f"Error parsing server URL {server_url}: {e}") return None def scan_server( server_url: str, analyzers: str = DEFAULT_ANALYZERS, api_key: str | None = None, output_json: bool = False, auto_disable: bool = False, headers: str | None = None, ) -> SecurityScanResult: """Scan an MCP server for security vulnerabilities. Args: server_url: URL of the MCP server to scan analyzers: Comma-separated list of analyzers to use api_key: OpenAI API key for LLM-based analysis output_json: If True, output raw mcp-scanner JSON directly auto_disable: If True, automatically disable servers that fail security scan headers: JSON string of headers to include in requests Returns: SecurityScanResult containing scan results """ # Run scanner try: raw_output = _run_mcp_scanner(server_url, analyzers, api_key, headers) except subprocess.CalledProcessError as e: # Scanner failed - create error output and save it logger.error(f"Scanner failed with exit code {e.returncode}") raw_output = { "error": str(e), "stderr": e.stderr if hasattr(e, "stderr") else "", "analysis_results": {}, "tool_results": [], "scan_failed": True, } # Save the error output output_file = _save_scan_output(server_url, raw_output) # Create error result result = SecurityScanResult( server_url=server_url, scan_timestamp=datetime.now(UTC).isoformat().replace("+00:00", "Z"), is_safe=False, # Treat scanner failures as unsafe critical_issues=0, high_severity=0, medium_severity=0, low_severity=0, raw_output=raw_output, output_file=output_file, ) # Output result if output_json: print(json.dumps(result.model_dump(), indent=2, default=str)) else: print("\n" + "=" * 60) print("SECURITY SCAN FAILED") print("=" * 60) print(f"Server URL: {result.server_url}") print(f"Scan Time: {result.scan_timestamp}") print("\nError: Scanner failed to complete scan") print(f"Details: {e}") print("\nMarking server as UNSAFE due to scanner failure") print(f"\nDetailed output saved to: {result.output_file}") print("=" * 60 + "\n") return result # Analyze results is_safe, critical, high, medium, low = _analyze_scan_results(raw_output) # Save detailed output output_file = _save_scan_output(server_url, raw_output) # Auto-disable server if unsafe if auto_disable and not is_safe: logger.warning("Server marked as UNSAFE - attempting to disable") server_path = _extract_server_path_from_url(server_url) if server_path: if _disable_unsafe_server(server_path): logger.info(f"✓ Server {server_path} has been disabled for security reasons") else: logger.error(f"✗ Failed to disable server {server_path}") else: logger.error("✗ Could not extract server path from URL - manual intervention required") # Create result object result = SecurityScanResult( server_url=server_url, scan_timestamp=datetime.now(UTC).isoformat().replace("+00:00", "Z"), is_safe=is_safe, critical_issues=critical, high_severity=high, medium_severity=medium, low_severity=low, raw_output=raw_output, output_file=output_file, ) # Output result if output_json: # Output raw mcp-scanner format directly (same as --raw) print(json.dumps(raw_output, indent=2, default=str)) else: print("\n" + "=" * 60) print("SECURITY SCAN SUMMARY") print("=" * 60) print(f"Server URL: {result.server_url}") print(f"Scan Time: {result.scan_timestamp}") print("\nEXECUTIVE SUMMARY OF ISSUES:") print(f" Critical Issues: {result.critical_issues}") print(f" High Severity: {result.high_severity}") print(f" Medium Severity: {result.medium_severity}") print(f" Low Severity: {result.low_severity}") print(f"\nOverall Assessment: {'SAFE ✓' if result.is_safe else 'UNSAFE ✗'}") # Show auto-disable status if applicable if auto_disable and not result.is_safe: server_path = _extract_server_path_from_url(server_url) if server_path: print( f"\n⚠️ ACTION TAKEN: Server {server_path} has been DISABLED due to security issues" ) else: print("\n⚠️ WARNING: Could not auto-disable server - manual intervention required") print(f"\nDetailed output saved to: {result.output_file}") print("=" * 60 + "\n") return result def main(): """Main entry point for CLI.""" parser = argparse.ArgumentParser( description="Scan MCP servers for security vulnerabilities", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Example usage: # Basic scan with YARA analyzer (default) uv run cli/mcp_security_scanner.py --server-url https://mcp.deepwki.com/mcp # Scan with both YARA and LLM analyzers export MCP_SCANNER_LLM_API_KEY=sk-... uv run cli/mcp_security_scanner.py --server-url https://example.com/mcp --analyzers yara,llm # Scan with LLM only, passing API key directly uv run cli/mcp_security_scanner.py --server-url https://example.com/mcp --analyzers llm --api-key sk-... # Scan with custom headers (e.g., authentication) uv run cli/mcp_security_scanner.py --server-url https://example.com/mcp --headers '{"X-Authorization": "Bearer token123"}' # Output as JSON uv run cli/mcp_security_scanner.py --server-url https://example.com/mcp --json """, ) parser.add_argument("--server-url", required=True, help="URL of the MCP server to scan") parser.add_argument( "--analyzers", default=DEFAULT_ANALYZERS, help=f"Comma-separated list of analyzers to use (default: {DEFAULT_ANALYZERS})", ) parser.add_argument( "--api-key", help=f"LLM API key for security scanning (can also use {LLM_API_KEY_ENV} env var)", ) parser.add_argument("--json", action="store_true", help="Output result as JSON") parser.add_argument("--debug", action="store_true", help="Enable debug logging") parser.add_argument( "--auto-disable", action="store_true", help="Automatically disable servers that fail security scan (is_safe: false)", ) parser.add_argument( "--headers", help='JSON string of headers to include in requests (e.g., \'{"X-Authorization": "token"}\')', ) args = parser.parse_args() # Set debug level if requested if args.debug: logging.getLogger().setLevel(logging.DEBUG) try: # Get API key if needed for LLM analyzer api_key = None if "llm" in args.analyzers.lower(): api_key = _get_llm_api_key(args.api_key) # Run scan result = scan_server( server_url=args.server_url, analyzers=args.analyzers, api_key=api_key, output_json=args.json, auto_disable=args.auto_disable, headers=args.headers, ) # Exit with non-zero code if unsafe sys.exit(0 if result.is_safe else 1) except Exception as e: logger.exception(f"Security scan failed: {e}") sys.exit(2) if __name__ == "__main__": main() ================================================ FILE: cli/mcp_utils.py ================================================ #!/usr/bin/env python3 """ Shared MCP Client Utility This module provides a reusable MCP (Model Context Protocol) client implementation using only standard Python libraries. We created this because some environments block certain Python package installs, causing the fastmcp library install to fail. This handy dandy MCP client implementation avoids external dependencies beyond the standard library plus commonly available packages like requests. The client supports: - JSON-RPC 2.0 protocol over HTTP - Authentication via Bearer tokens - Session management with automatic initialization - Both synchronous and asynchronous operations - Server-Sent Events (SSE) response handling - Automatic token loading from OAuth files """ import json import logging import os import time import urllib.error import urllib.parse import urllib.request from pathlib import Path from typing import Any logger = logging.getLogger(__name__) _ALLOWED_URL_SCHEMES = ("http", "https") def _validate_url_scheme(url: str) -> None: """Validate that a URL uses an allowed scheme (http or https). Prevents SSRF via file://, ftp://, or other unexpected schemes. Args: url: The URL string to validate. Raises: ValueError: If the URL scheme is not http or https. """ parsed = urllib.parse.urlparse(url) if parsed.scheme not in _ALLOWED_URL_SCHEMES: raise ValueError( f"Invalid URL scheme '{parsed.scheme}' in URL '{url}'. " f"Only {_ALLOWED_URL_SCHEMES} are allowed." ) def _load_oauth_token_from_file(token_file_path: str | Path) -> str | None: """ Load OAuth access token from JSON file. Args: token_file_path: Path to OAuth token file Returns: Access token if found and valid, None otherwise """ try: token_path = Path(token_file_path) if not token_path.exists(): return None with open(token_path) as f: token_data = json.load(f) # Support both flat and nested token structures # Nested: {"tokens": {"access_token": "...", "expires_at": ...}} # Flat: {"access_token": "...", "expires_at": ...} if "tokens" in token_data: tokens = token_data["tokens"] access_token = tokens.get("access_token") expires_at = tokens.get("expires_at", 0) else: access_token = token_data.get("access_token") expires_at = token_data.get("expires_at", 0) # Check if token is expired if expires_at and time.time() >= expires_at: logger.warning(f"Token in {token_file_path} has expired") return None return access_token except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: logger.debug(f"Could not load token from {token_file_path}: {e}") return None def _get_auth_token( explicit_token: str | None = None, env_var_name: str = "MCP_AUTH_TOKEN" ) -> str | None: """ Get authentication token from multiple sources in priority order. Priority order: 1. Explicit token parameter 2. Environment variable 3. Ingress token file (.oauth-tokens/ingress.json) Args: explicit_token: Token provided directly env_var_name: Name of environment variable to check Returns: Access token if found, None otherwise """ # 1. Explicit token has highest priority if explicit_token: return explicit_token # 2. Check environment variable env_token = os.getenv(env_var_name) if env_token: return env_token # 3. Try to load from ingress token file ingress_token_path = Path.cwd() / ".oauth-tokens" / "ingress.json" return _load_oauth_token_from_file(ingress_token_path) class MCPClient: """ MCP (Model Context Protocol) client implementation using standard Python libraries. This client handles JSON-RPC 2.0 communication over HTTP with MCP servers, including authentication, session management, and response parsing. """ def __init__( self, gateway_url: str, access_token: str | None = None, backend_token: str | None = None, timeout: int = 30, ): """ Initialize MCP client. Args: gateway_url: URL of the MCP gateway endpoint access_token: Optional Bearer token for backend server authentication (Authorization header) backend_token: Optional separate token for backend server (if different from gateway token) timeout: Request timeout in seconds """ self.gateway_url = gateway_url.rstrip("/") # Backend token for Authorization header (forwarded to backend servers) self.backend_token = access_token # Gateway token for X-Authorization header (gateway auth) - use provided token or ingress token # Only fall back to ingress token if no explicit token was provided self.gateway_token = _get_auth_token( access_token ) # Use explicit token if provided, else ingress # Keep access_token for backwards compatibility self.access_token = self.backend_token or self.gateway_token self.timeout = timeout self.session_id: str | None = None self._request_id = 0 def _get_next_request_id(self) -> int: """Get next request ID for JSON-RPC calls.""" self._request_id += 1 return self._request_id def _build_headers(self) -> dict[str, str]: """Build HTTP headers for requests.""" headers = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "User-Agent": "mcp-utils-client/1.0.0", } # X-Authorization: Gateway authentication (uses ingress token) if self.gateway_token: headers["X-Authorization"] = f"Bearer {self.gateway_token}" # Authorization: Backend server authentication (uses token from --token-file) if self.backend_token: headers["Authorization"] = f"Bearer {self.backend_token}" if self.session_id: headers["mcp-session-id"] = self.session_id return headers def _make_request(self, payload: dict[str, Any]) -> dict[str, Any]: """ Make HTTP request to MCP gateway. Args: payload: JSON-RPC payload Returns: Parsed response data Raises: Exception: If request fails or response is invalid """ _validate_url_scheme(self.gateway_url) headers = self._build_headers() data = json.dumps(payload).encode("utf-8") try: request = urllib.request.Request( self.gateway_url, data=data, headers=headers, method="POST" ) with urllib.request.urlopen(request, timeout=self.timeout) as response: # nosec B310 response_data = response.read().decode("utf-8") content_type = response.headers.get("content-type", "") # Extract session ID from response headers if available session_id = response.headers.get("mcp-session-id") if session_id and not self.session_id: self.session_id = session_id logger.debug(f"Session ID established: {session_id}") # Handle Server-Sent Events (SSE) response if "text/event-stream" in content_type: return self._parse_sse_response(response_data) else: # Handle regular JSON response return json.loads(response_data) except urllib.error.HTTPError as e: error_msg = f"HTTP {e.code}: {e.reason}" try: error_response = e.read().decode("utf-8") error_data = json.loads(error_response) if "error" in error_data: error_msg = f"HTTP {e.code}: {error_data['error']}" except (json.JSONDecodeError, UnicodeDecodeError): pass raise Exception(error_msg) except urllib.error.URLError as e: raise Exception(f"Network error: {e.reason}") except json.JSONDecodeError as e: raise Exception(f"Invalid JSON response: {e}") def _parse_sse_response(self, sse_data: str) -> dict[str, Any]: """ Parse Server-Sent Events response format. Args: sse_data: Raw SSE response data Returns: Parsed JSON data from SSE stream """ lines = sse_data.strip().split("\n") for line in lines: if line.startswith("data: "): data_json = line[6:] # Remove 'data: ' prefix try: return json.loads(data_json) except json.JSONDecodeError: continue raise Exception("No valid JSON found in SSE response") def initialize(self) -> dict[str, Any]: """ Initialize MCP session with the gateway. Returns: Initialization response """ payload = { "jsonrpc": "2.0", "id": self._get_next_request_id(), "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "mcp-utils-client", "version": "1.0.0"}, }, } result = self._make_request(payload) # Send initialized notification to complete handshake self._send_initialized() return result def _send_initialized(self) -> None: """Send initialized notification to complete MCP handshake.""" payload = {"jsonrpc": "2.0", "method": "notifications/initialized"} try: self._make_request(payload) except Exception as e: # This is expected for some MCP servers that don't require the notification logger.debug(f"Initialized notification not sent (this is normal): {e}") def ping(self) -> dict[str, Any]: """ Test connectivity with ping. Returns: Ping response """ payload = {"jsonrpc": "2.0", "id": self._get_next_request_id(), "method": "ping"} return self._make_request(payload) def list_tools(self) -> dict[str, Any]: """ List available tools. Returns: Tools list response """ payload = {"jsonrpc": "2.0", "id": self._get_next_request_id(), "method": "tools/list"} return self._make_request(payload) def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None) -> dict[str, Any]: """ Call a specific tool. Args: tool_name: Name of the tool to call arguments: Tool arguments (optional) Returns: Tool execution result """ if arguments is None: arguments = {} payload = { "jsonrpc": "2.0", "id": self._get_next_request_id(), "method": "tools/call", "params": {"name": tool_name, "arguments": arguments}, } response = self._make_request(payload) # Handle MCP response format if "error" in response: raise Exception(f"MCP tool error: {response['error']}") if "result" in response: return response["result"] return response def call_mcpgw_tool(self, tool_name: str, params: dict[str, Any]) -> dict[str, Any]: """ Call a tool using mcpgw-specific parameter format. This method wraps parameters in the format expected by mcpgw tools. Args: tool_name: Name of the tool to call params: Parameters for the tool Returns: Tool execution result """ arguments = {"params": params} return self.call_tool(tool_name, arguments) class MCPSession: """ Context manager for MCP client sessions. Automatically initializes the session on entry and ensures proper cleanup. Provides a convenient way to work with MCP clients in a session context. """ def __init__(self, client: MCPClient): """ Initialize session context. Args: client: MCP client instance """ self.client = client self._initialized = False def __enter__(self) -> MCPClient: """Enter session context and initialize.""" try: self.client.initialize() self._initialized = True logger.debug("MCP session initialized successfully") except Exception as e: logger.error(f"Failed to initialize MCP session: {e}") raise return self.client def __exit__(self, exc_type, exc_val, exc_tb): """Exit session context.""" if self._initialized: logger.debug("MCP session closed") def create_mcp_client( gateway_url: str, access_token: str | None = None, timeout: int = 30 ) -> MCPClient: """ Create and return a configured MCP client. Args: gateway_url: URL of the MCP gateway endpoint access_token: Optional Bearer token for authentication timeout: Request timeout in seconds Returns: Configured MCP client instance """ return MCPClient(gateway_url, access_token, timeout) def create_mcp_session( gateway_url: str, access_token: str | None = None, timeout: int = 30 ) -> MCPSession: """ Create and return an MCP session context manager. Args: gateway_url: URL of the MCP gateway endpoint access_token: Optional Bearer token for authentication timeout: Request timeout in seconds Returns: MCP session context manager """ client = create_mcp_client(gateway_url, access_token, timeout) return MCPSession(client) ================================================ FILE: cli/package.json ================================================ { "name": "@mcp-gateway/ink-cli", "version": "0.1.0", "private": true, "type": "module", "description": "Interactive MCP Gateway client powered by Ink.", "bin": { "registry": "./bin/registry.js" }, "scripts": { "start": "tsx src/index.tsx", "dev": "tsx watch src/index.tsx", "build": "tsc --project tsconfig.json", "typecheck": "tsc --noEmit --project tsconfig.json" }, "dependencies": { "@anthropic-ai/sdk": "^0.21.0", "@aws-sdk/client-bedrock-runtime": "^3.982.0", "dotenv": "^17.2.3", "ink": "^5.1.0", "ink-select-input": "^6.0.0", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "marked": "^15.0.12", "marked-terminal": "^7.3.0", "react": "^18.3.1", "zod": "^3.23.8" }, "devDependencies": { "@types/marked-terminal": "^6.1.1", "@types/node": "^20.12.7", "@types/react": "^18.3.3", "tsx": "^4.7.1", "typescript": "^5.5.4" }, "engines": { "node": ">=18.19.0" } } ================================================ FILE: cli/registry_cli_wrapper.py ================================================ #!/usr/bin/env python3 """ CLI Wrapper for Registry Management API This module provides a command-line interface that wraps the Registry Management API, maintaining backwards compatibility with the deprecated shell scripts while using the modern Python API underneath. This wrapper is designed to be called from the TypeScript CLI application via subprocess. """ import argparse import json import logging import os import sys from pathlib import Path from typing import Any # Add parent directory to path to import registry_client sys.path.insert(0, str(Path(__file__).parent.parent)) from api.registry_client import RegistryClient # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def _load_token_from_file( token_file: str, ) -> str: """Load access token from JSON file. Args: token_file: Path to token file containing access_token field Returns: Access token string """ with open(token_file) as f: token_data = json.load(f) access_token = token_data.get("access_token") if not access_token: raise ValueError(f"No access_token found in {token_file}") return access_token def _get_registry_client( base_url: str, token_file: str | None = None, ) -> RegistryClient: """Create and return a configured RegistryClient. Args: base_url: Registry base URL token_file: Optional path to token file Returns: Configured RegistryClient instance """ if token_file: access_token = _load_token_from_file(token_file) else: # Try to get from environment access_token = os.getenv("GATEWAY_TOKEN") if not access_token: raise ValueError("No token provided via --token-file or GATEWAY_TOKEN env var") return RegistryClient(registry_url=base_url, token=access_token) def _print_json_response( data: Any, ) -> None: """Pretty-print JSON response. Args: data: Data to print as JSON """ print(json.dumps(data, indent=2, default=str)) def _handle_service_add( args: argparse.Namespace, ) -> None: """Handle service add command.""" client = _get_registry_client(args.base_url, args.token_file) # Load config from file with open(args.config_path) as f: config = json.load(f) result = client.register_server(config) _print_json_response(result.model_dump()) def _handle_service_delete( args: argparse.Namespace, ) -> None: """Handle service delete command.""" client = _get_registry_client(args.base_url, args.token_file) result = client.remove_server(args.path, force=True) _print_json_response(result.model_dump()) def _handle_service_list( args: argparse.Namespace, ) -> None: """Handle service list command.""" client = _get_registry_client(args.base_url, args.token_file) result = client.anthropic_list_servers(limit=1000) _print_json_response(result.model_dump()) def _handle_service_monitor( args: argparse.Namespace, ) -> None: """Handle service monitor command.""" # Monitor is essentially list with detailed output _handle_service_list(args) def _handle_group_create( args: argparse.Namespace, ) -> None: """Handle group create command.""" client = _get_registry_client(args.base_url, args.token_file) result = client.group_create(name=args.name, description=args.description) _print_json_response(result.model_dump()) def _handle_group_delete( args: argparse.Namespace, ) -> None: """Handle group delete command.""" client = _get_registry_client(args.base_url, args.token_file) result = client.group_delete(name=args.name, force=True) _print_json_response(result.model_dump()) def _handle_group_list( args: argparse.Namespace, ) -> None: """Handle group list command.""" client = _get_registry_client(args.base_url, args.token_file) result = client.group_list() _print_json_response(result) def _handle_user_create_m2m( args: argparse.Namespace, ) -> None: """Handle M2M user creation command.""" client = _get_registry_client(args.base_url, args.token_file) groups = args.groups.split(",") if args.groups else [] result = client.user_create_m2m(name=args.name, groups=groups, description=args.description) _print_json_response(result.model_dump()) def _handle_user_create_human( args: argparse.Namespace, ) -> None: """Handle human user creation command.""" client = _get_registry_client(args.base_url, args.token_file) groups = args.groups.split(",") if args.groups else [] result = client.user_create_human( username=args.username, email=args.email, first_name=args.first_name, last_name=args.last_name, groups=groups, password=args.password, ) _print_json_response(result.model_dump()) def _handle_user_delete( args: argparse.Namespace, ) -> None: """Handle user delete command.""" client = _get_registry_client(args.base_url, args.token_file) result = client.user_delete(username=args.username, force=True) _print_json_response(result.model_dump()) def _handle_user_list( args: argparse.Namespace, ) -> None: """Handle user list command.""" client = _get_registry_client(args.base_url, args.token_file) result = client.user_list() _print_json_response(result) def _handle_anthropic_list( args: argparse.Namespace, ) -> None: """Handle Anthropic API list command.""" client = _get_registry_client(args.base_url, args.token_file) result = client.anthropic_list_servers(limit=args.limit if hasattr(args, "limit") else 100) _print_json_response(result.model_dump()) def _handle_anthropic_get( args: argparse.Namespace, ) -> None: """Handle Anthropic API get command.""" client = _get_registry_client(args.base_url, args.token_file) result = client.anthropic_get_server(server_name=args.server_name) _print_json_response(result.model_dump()) def _handle_agent_list( args: argparse.Namespace, ) -> None: """Handle agent list command.""" client = _get_registry_client(args.base_url, args.token_file) query = args.query if hasattr(args, "query") else None enabled_only = args.enabled_only if hasattr(args, "enabled_only") else False result = client.list_agents(query=query, enabled_only=enabled_only) _print_json_response(result.model_dump()) def _handle_agent_get( args: argparse.Namespace, ) -> None: """Handle agent get command.""" client = _get_registry_client(args.base_url, args.token_file) result = client.get_agent(path=args.path) _print_json_response(result.model_dump()) def _handle_agent_search( args: argparse.Namespace, ) -> None: """Handle agent search command (alias for list with query).""" client = _get_registry_client(args.base_url, args.token_file) result = client.list_agents(query=args.query, enabled_only=False) _print_json_response(result.model_dump()) def main() -> None: """Main CLI entry point.""" parser = argparse.ArgumentParser( description="Registry Management CLI Wrapper", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--base-url", default=os.getenv("GATEWAY_BASE_URL", "http://localhost"), help="Registry base URL (default: http://localhost)", ) parser.add_argument("--token-file", help="Path to token file containing access_token") subparsers = parser.add_subparsers(dest="command", help="Command to execute") # Service management commands service_parser = subparsers.add_parser("service", help="Service management commands") service_subparsers = service_parser.add_subparsers(dest="subcommand") # Service add add_parser = service_subparsers.add_parser("add", help="Add a service") add_parser.add_argument("config_path", help="Path to service config JSON file") # Service delete delete_parser = service_subparsers.add_parser("delete", help="Delete a service") delete_parser.add_argument("path", help="Service path") # Service list service_subparsers.add_parser("list", help="List services") # Service monitor service_subparsers.add_parser("monitor", help="Monitor services") # Group management commands group_parser = subparsers.add_parser("group", help="Group management commands") group_subparsers = group_parser.add_subparsers(dest="subcommand") # Group create group_create_parser = group_subparsers.add_parser("create", help="Create a group") group_create_parser.add_argument("--name", required=True, help="Group name") group_create_parser.add_argument("--description", help="Group description") # Group delete group_delete_parser = group_subparsers.add_parser("delete", help="Delete a group") group_delete_parser.add_argument("--name", required=True, help="Group name") # Group list group_subparsers.add_parser("list", help="List groups") # User management commands user_parser = subparsers.add_parser("user", help="User management commands") user_subparsers = user_parser.add_subparsers(dest="subcommand") # User create M2M m2m_parser = user_subparsers.add_parser("create-m2m", help="Create M2M user") m2m_parser.add_argument("--name", required=True, help="Service account name") m2m_parser.add_argument("--groups", help="Comma-separated list of groups") m2m_parser.add_argument("--description", help="Service account description") # User create human human_parser = user_subparsers.add_parser("create-human", help="Create human user") human_parser.add_argument("--username", required=True, help="Username") human_parser.add_argument("--email", required=True, help="Email address") human_parser.add_argument("--first-name", required=True, help="First name") human_parser.add_argument("--last-name", required=True, help="Last name") human_parser.add_argument("--groups", help="Comma-separated list of groups") human_parser.add_argument("--password", required=True, help="Password") # User delete user_delete_parser = user_subparsers.add_parser("delete", help="Delete user") user_delete_parser.add_argument("--username", required=True, help="Username") # User list user_subparsers.add_parser("list", help="List users") # Anthropic API commands anthropic_parser = subparsers.add_parser("anthropic", help="Anthropic API commands") anthropic_subparsers = anthropic_parser.add_subparsers(dest="subcommand") # Anthropic list list_parser = anthropic_subparsers.add_parser("list", help="List servers (Anthropic API)") list_parser.add_argument("--limit", type=int, default=100, help="Limit results") # Anthropic get get_parser = anthropic_subparsers.add_parser("get", help="Get server details (Anthropic API)") get_parser.add_argument("server_name", help="Server name") # Agent management commands agent_parser = subparsers.add_parser("agent", help="Agent management commands") agent_subparsers = agent_parser.add_subparsers(dest="subcommand") # Agent list agent_list_parser = agent_subparsers.add_parser("list", help="List agents") agent_list_parser.add_argument("--query", help="Search query") agent_list_parser.add_argument( "--enabled-only", action="store_true", help="Show only enabled agents" ) # Agent get agent_get_parser = agent_subparsers.add_parser("get", help="Get agent details") agent_get_parser.add_argument("path", help="Agent path") # Agent search agent_search_parser = agent_subparsers.add_parser("search", help="Search agents") agent_search_parser.add_argument("query", help="Search query") args = parser.parse_args() if not args.command: parser.print_help() sys.exit(1) try: # Route to appropriate handler if args.command == "service": if args.subcommand == "add": _handle_service_add(args) elif args.subcommand == "delete": _handle_service_delete(args) elif args.subcommand == "list": _handle_service_list(args) elif args.subcommand == "monitor": _handle_service_monitor(args) else: service_parser.print_help() sys.exit(1) elif args.command == "group": if args.subcommand == "create": _handle_group_create(args) elif args.subcommand == "delete": _handle_group_delete(args) elif args.subcommand == "list": _handle_group_list(args) else: group_parser.print_help() sys.exit(1) elif args.command == "user": if args.subcommand == "create-m2m": _handle_user_create_m2m(args) elif args.subcommand == "create-human": _handle_user_create_human(args) elif args.subcommand == "delete": _handle_user_delete(args) elif args.subcommand == "list": _handle_user_list(args) else: user_parser.print_help() sys.exit(1) elif args.command == "anthropic": if args.subcommand == "list": _handle_anthropic_list(args) elif args.subcommand == "get": _handle_anthropic_get(args) else: anthropic_parser.print_help() sys.exit(1) elif args.command == "agent": if args.subcommand == "list": _handle_agent_list(args) elif args.subcommand == "get": _handle_agent_get(args) elif args.subcommand == "search": _handle_agent_search(args) else: agent_parser.print_help() sys.exit(1) else: parser.print_help() sys.exit(1) except Exception as e: logger.error(f"Command failed: {e}") sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: cli/scan_all_servers.py ================================================ #!/usr/bin/env python3 """ Scan all enabled MCP servers for security vulnerabilities. This script: 1. Uses the Registry Management API client to get a list of all servers 2. Filters for enabled servers 3. Runs security scans on each enabled server using mcp_security_scanner.py Usage: uv run python cli/scan_all_servers.py uv run python cli/scan_all_servers.py --base-url http://localhost uv run python cli/scan_all_servers.py --analyzers yara,llm uv run python cli/scan_all_servers.py --token-file .oauth-tokens/ingress.json """ import argparse import json import logging import subprocess # nosec B404 import sys from datetime import UTC, datetime from pathlib import Path from typing import Any # Add project root to path to import registry client SCRIPT_DIR = Path(__file__).parent PROJECT_ROOT = SCRIPT_DIR.parent sys.path.insert(0, str(PROJECT_ROOT / "api")) from registry_client import RegistryClient # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Constants DEFAULT_TOKEN_FILE = PROJECT_ROOT / ".oauth-tokens" / "ingress.json" DEFAULT_BASE_URL = "http://localhost" DEFAULT_ANALYZERS = "yara" def _run_security_scan( server_url: str, analyzers: str, api_key: str | None = None, access_token: str | None = None ) -> dict[str, Any]: """Run security scan on a server using mcp_security_scanner.py directly. Args: server_url: URL of the MCP server to scan analyzers: Comma-separated list of analyzers (e.g., 'yara', 'yara,llm') api_key: Optional API key for LLM analyzer access_token: Optional access token for authenticated MCP servers Returns: Dictionary with scan results including: - success: bool - scan_output_file: Path to scan results JSON file - critical_issues: int - high_severity: int - medium_severity: int - low_severity: int - is_safe: bool """ scanner_script = SCRIPT_DIR / "mcp_security_scanner.py" if not scanner_script.exists(): logger.error(f"mcp_security_scanner.py not found at: {scanner_script}") return { "success": False, "scan_output_file": None, "critical_issues": 0, "high_severity": 0, "medium_severity": 0, "low_severity": 0, "is_safe": False, "error_message": "Scanner script not found", } cmd = [ "uv", "run", "python", str(scanner_script), "--server-url", server_url, "--analyzers", analyzers, ] if api_key: cmd.extend(["--api-key", api_key]) # Add headers with authorization token if provided if access_token: headers_json = json.dumps({"X-Authorization": f"Bearer {access_token}"}) cmd.extend(["--headers", headers_json]) # Log command with masked token for security cmd_for_log = cmd.copy() if access_token and "--headers" in cmd_for_log: header_idx = cmd_for_log.index("--headers") + 1 headers_masked = json.dumps( {"X-Authorization": f"Bearer {access_token[:20]}...{access_token[-10:]}"} ) cmd_for_log[header_idx] = headers_masked logger.info(f"Running: {' '.join(cmd_for_log)}") try: result = subprocess.run( # nosec B603 - internal script invoked via uv run with validated args cmd, capture_output=True, text=True, check=False, cwd=str(PROJECT_ROOT) ) # Log output if result.stdout: logger.info(f"Scan output:\n{result.stdout}") if result.stderr: logger.warning(f"Scan stderr:\n{result.stderr}") # Parse scan results from security_scans directory scan_result = { "success": result.returncode == 0, "scan_output_file": None, "critical_issues": 0, "high_severity": 0, "medium_severity": 0, "low_severity": 0, "is_safe": result.returncode == 0, "error_message": None, } # Try to find and parse the scan output file try: # Extract server name from URL for finding scan file from urllib.parse import urlparse parsed = urlparse(server_url) path_parts = [p for p in parsed.path.split("/") if p and p != "mcp"] if path_parts: server_name = path_parts[0] scan_file = PROJECT_ROOT / "security_scans" / f"{server_name}_mcp.json" if scan_file.exists(): scan_result["scan_output_file"] = str(scan_file) with open(scan_file) as f: scan_data = json.load(f) # Extract severity counts from analysis_results analysis_results = scan_data.get("analysis_results", {}) for analyzer_name, analyzer_data in analysis_results.items(): if isinstance(analyzer_data, dict): findings = analyzer_data.get("findings", []) for finding in findings: severity = finding.get("severity", "").lower() if severity == "critical": scan_result["critical_issues"] += 1 elif severity == "high": scan_result["high_severity"] += 1 elif severity == "medium": scan_result["medium_severity"] += 1 elif severity == "low": scan_result["low_severity"] += 1 # Determine if safe based on scan data scan_result["is_safe"] = ( scan_result["critical_issues"] == 0 and scan_result["high_severity"] == 0 ) except Exception as e: logger.warning(f"Could not parse scan results: {e}") # Check exit code if result.returncode == 0: logger.info("✓ Scan completed successfully") else: logger.error(f"✗ Scan failed with exit code: {result.returncode}") scan_result["error_message"] = f"Scanner exit code: {result.returncode}" return scan_result except Exception as e: logger.error(f"Failed to run scan: {e}") return { "success": False, "scan_output_file": None, "critical_issues": 0, "high_severity": 0, "medium_severity": 0, "low_severity": 0, "is_safe": False, "error_message": str(e), } def _generate_markdown_report( scan_results: list[dict[str, Any]], stats: dict[str, int], analyzers: str, scan_timestamp: str ) -> str: """Generate markdown report from scan results. Args: scan_results: List of scan result dictionaries stats: Dictionary with summary statistics analyzers: Analyzers used for scanning scan_timestamp: ISO timestamp of scan Returns: Markdown formatted report as string """ lines = [] # Header lines.append("# MCP Server Security Scan Report") lines.append("") lines.append(f"**Scan Date:** {scan_timestamp}") lines.append(f"**Analyzers Used:** {analyzers}") lines.append("") # Executive Summary lines.append("## Executive Summary") lines.append("") total = stats["total"] passed = stats["passed"] failed = stats["failed"] pass_rate = (passed / total * 100) if total > 0 else 0 lines.append(f"- **Total Servers Scanned:** {total}") lines.append(f"- **Passed:** {passed} ({pass_rate:.1f}%)") lines.append(f"- **Failed:** {failed} ({100 - pass_rate:.1f}%)") lines.append("") # Aggregate Vulnerability Statistics total_critical = sum(r.get("critical_issues", 0) for r in scan_results) total_high = sum(r.get("high_severity", 0) for r in scan_results) total_medium = sum(r.get("medium_severity", 0) for r in scan_results) total_low = sum(r.get("low_severity", 0) for r in scan_results) lines.append("### Aggregate Vulnerability Statistics") lines.append("") lines.append("| Severity | Count |") lines.append("|----------|-------|") lines.append(f"| Critical | {total_critical} |") lines.append(f"| High | {total_high} |") lines.append(f"| Medium | {total_medium} |") lines.append(f"| Low | {total_low} |") lines.append("") # Per-Server Results lines.append("## Per-Server Scan Results") lines.append("") for result in scan_results: server_name = result.get("server_name", "Unknown") server_url = result.get("server_url", "Unknown") is_safe = result.get("is_safe", False) status = "✅ SAFE" if is_safe else "❌ UNSAFE" lines.append(f"### {server_name}") lines.append("") lines.append(f"- **URL:** `{server_url}`") lines.append(f"- **Status:** {status}") lines.append("") # Vulnerability table lines.append("| Severity | Count |") lines.append("|----------|-------|") lines.append(f"| Critical | {result.get('critical_issues', 0)} |") lines.append(f"| High | {result.get('high_severity', 0)} |") lines.append(f"| Medium | {result.get('medium_severity', 0)} |") lines.append(f"| Low | {result.get('low_severity', 0)} |") lines.append("") # Show detailed findings for tools with issues scan_file = result.get("scan_output_file") if scan_file and Path(scan_file).exists(): try: with open(scan_file) as f: scan_data = json.load(f) tool_results = scan_data.get("tool_results", []) tools_with_findings = [ tool for tool in tool_results if any( finding.get("total_findings", 0) > 0 for finding in tool.get("findings", {}).values() ) ] if tools_with_findings: lines.append("#### Detailed Findings") lines.append("") for tool in tools_with_findings: tool_name = tool.get("tool_name", "Unknown") lines.append(f"**Tool: `{tool_name}`**") lines.append("") # Show findings for each analyzer findings = tool.get("findings", {}) for analyzer_name, analyzer_findings in findings.items(): total_findings = analyzer_findings.get("total_findings", 0) if total_findings > 0: severity = analyzer_findings.get("severity", "UNKNOWN") threat_names = analyzer_findings.get("threat_names", []) threat_summary = analyzer_findings.get("threat_summary", "") lines.append(f"- **Analyzer:** {analyzer_name}") lines.append(f"- **Severity:** {severity}") lines.append( f"- **Threats:** {', '.join(threat_names) if threat_names else 'None'}" ) lines.append(f"- **Summary:** {threat_summary}") # Include taxonomy if available taxonomy = analyzer_findings.get("mcp_taxonomy", {}) if taxonomy: lines.append("") lines.append("**Taxonomy:**") lines.append("```json") lines.append(json.dumps(taxonomy, indent=2)) lines.append("```") lines.append("") # Show tool description if available tool_desc = tool.get("tool_description", "") if tool_desc: lines.append("
") lines.append("Tool Description") lines.append("") lines.append("```") lines.append(tool_desc) lines.append("```") lines.append("
") lines.append("") except Exception as e: logger.warning(f"Could not parse detailed findings from {scan_file}: {e}") lines.append(f"**Detailed Report:** [{Path(scan_file).name}]({scan_file})") lines.append("") else: if scan_file: lines.append(f"**Detailed Report:** [{Path(scan_file).name}]({scan_file})") lines.append("") if result.get("error_message"): lines.append(f"**Error:** {result['error_message']}") lines.append("") # Footer lines.append("---") lines.append("") lines.append(f"*Report generated on {scan_timestamp}*") lines.append("") return "\n".join(lines) def _scan_all_servers( base_url: str, token_file: Path, analyzers: str = DEFAULT_ANALYZERS, api_key: str | None = None ) -> dict[str, Any]: """Scan all enabled servers. Args: base_url: Base URL of the registry token_file: Path to token file analyzers: Comma-separated list of analyzers api_key: Optional API key for LLM analyzer Returns: Dictionary with scan statistics """ logger.info("=" * 80) logger.info("Scan All MCP Servers - Security Vulnerability Scanner") logger.info("=" * 80) # Load access token from file try: with open(token_file) as f: token_data = json.load(f) access_token = token_data.get("access_token") if not access_token: raise ValueError(f"No access_token found in {token_file}") logger.info(f"Loaded token from: {token_file}") except Exception as e: logger.error(f"Failed to load token: {e}") sys.exit(1) # Create registry client try: client = RegistryClient(registry_url=base_url, token=access_token) logger.info(f"Connected to registry at: {base_url}") except Exception as e: logger.error(f"Failed to create registry client: {e}") sys.exit(1) # Get server list using the Anthropic Registry API (v0.1) try: servers_response = client.anthropic_list_servers(limit=1000) servers = servers_response.servers if hasattr(servers_response, "servers") else [] logger.info(f"Retrieved {len(servers)} servers from registry using Anthropic API v0.1") except Exception as e: logger.error(f"Failed to get server list: {e}") sys.exit(1) # Filter enabled servers (using Pydantic attribute access) enabled_servers = [] for server_response in servers: # AnthropicServerResponse has a .server attribute of type AnthropicServerDetail server = server_response.server # Access meta attribute (Optional[Dict[str, Any]]) # The meta field has alias "_meta" but is accessed via .meta attribute if server.meta and "io.mcpgateway/internal" in server.meta: internal_meta = server.meta["io.mcpgateway/internal"] is_enabled = internal_meta.get("is_enabled", False) if is_enabled: enabled_servers.append(server) logger.info(f"Found {len(enabled_servers)} enabled servers") if not enabled_servers: logger.warning("No enabled servers found to scan") return { "stats": {"total": 0, "passed": 0, "failed": 0}, "scan_results": [], "scan_timestamp": "", "analyzers": analyzers, } # Scan each server stats = {"total": len(enabled_servers), "passed": 0, "failed": 0} scan_results = [] scan_timestamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC") logger.info("") logger.info("=" * 80) logger.info(f"Scanning {stats['total']} enabled servers") logger.info("=" * 80) logger.info("") # Note: access_token already loaded above for RegistryClient for idx, server in enumerate(enabled_servers, 1): # Server is AnthropicServerDetail with direct attribute access server_name = server.name # Get the path from metadata (meta is Optional[Dict]) server_path = None if server.meta and "io.mcpgateway/internal" in server.meta: internal_meta = server.meta["io.mcpgateway/internal"] server_path = internal_meta.get("path") if not server_path: logger.warning( f"[{idx}/{stats['total']}] {server_name}: No path found in metadata, skipping" ) stats["failed"] += 1 scan_results.append( { "server_name": server_name, "server_url": "N/A", "success": False, "is_safe": False, "critical_issues": 0, "high_severity": 0, "medium_severity": 0, "low_severity": 0, "error_message": "No path found in metadata", } ) continue # Construct the gateway proxy URL using the path and base_url if not server_path.endswith("/"): server_path = server_path + "/" server_url = f"{base_url}{server_path}mcp" logger.info("-" * 80) logger.info(f"[{idx}/{stats['total']}] Scanning: {server_name}") logger.info(f"URL: {server_url}") logger.info(f"Analyzers: {analyzers}") # Run scan with access token for authentication scan_result = _run_security_scan(server_url, analyzers, api_key, access_token) scan_result["server_name"] = server_name scan_result["server_url"] = server_url scan_results.append(scan_result) if scan_result["success"] and scan_result["is_safe"]: stats["passed"] += 1 else: stats["failed"] += 1 logger.info("") return { "stats": stats, "scan_results": scan_results, "scan_timestamp": scan_timestamp, "analyzers": analyzers, } def main(): parser = argparse.ArgumentParser( description="Scan all enabled MCP servers for security vulnerabilities", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Scan all servers with default YARA analyzer uv run python cli/scan_all_servers.py # Scan with both YARA and LLM analyzers export MCP_SCANNER_LLM_API_KEY=sk-your-api-key uv run python cli/scan_all_servers.py --analyzers yara,llm # Use specific base URL uv run python cli/scan_all_servers.py --base-url http://localhost # Use custom token file uv run python cli/scan_all_servers.py --token-file .oauth-tokens/custom.json # Production example uv run python cli/scan_all_servers.py \\ --base-url https://registry.us-east-1.example.com \\ --token-file api/.token \\ --analyzers yara,llm """, ) parser.add_argument( "--base-url", default=DEFAULT_BASE_URL, help=f"Registry base URL (default: {DEFAULT_BASE_URL})", ) parser.add_argument( "--token-file", type=Path, default=DEFAULT_TOKEN_FILE, help=f"Path to token file (default: {DEFAULT_TOKEN_FILE})", ) parser.add_argument( "--analyzers", default=DEFAULT_ANALYZERS, help=f"Comma-separated list of analyzers: yara, llm, or yara,llm (default: {DEFAULT_ANALYZERS})", ) parser.add_argument( "--api-key", help="LLM API key (optional, can also use MCP_SCANNER_LLM_API_KEY env var)" ) parser.add_argument("--debug", action="store_true", help="Enable debug logging") args = parser.parse_args() # Set debug level if requested if args.debug: logging.getLogger().setLevel(logging.DEBUG) # Run scans results = _scan_all_servers( base_url=args.base_url, token_file=args.token_file, analyzers=args.analyzers, api_key=args.api_key, ) stats = results["stats"] scan_results = results["scan_results"] scan_timestamp = results["scan_timestamp"] analyzers = results["analyzers"] # Generate markdown report logger.info("") logger.info("=" * 80) logger.info("Generating markdown report...") logger.info("=" * 80) markdown_report = _generate_markdown_report( scan_results=scan_results, stats=stats, analyzers=analyzers, scan_timestamp=scan_timestamp ) # Save markdown report report_base_dir = PROJECT_ROOT / "security_scans" report_base_dir.mkdir(parents=True, exist_ok=True) # Create reports subdirectory for timestamped reports reports_dir = report_base_dir / "reports" reports_dir.mkdir(parents=True, exist_ok=True) # Save timestamped report in reports/ subdirectory timestamp_str = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") timestamped_report = reports_dir / f"scan_report_{timestamp_str}.md" with open(timestamped_report, "w") as f: f.write(markdown_report) # Save latest report directly in security_scans/ latest_report = report_base_dir / "scan_report.md" with open(latest_report, "w") as f: f.write(markdown_report) logger.info(f"Markdown report saved to: {timestamped_report}") logger.info(f"Latest report: {latest_report}") # Print summary logger.info("") logger.info("=" * 80) logger.info("SCAN SUMMARY") logger.info("=" * 80) logger.info(f"Total servers scanned: {stats['total']}") logger.info(f"Passed: {stats['passed']}") logger.info(f"Failed: {stats['failed']}") logger.info("") logger.info("Security scan results saved to: ./security_scans/") logger.info(f"Markdown report: {latest_report}") logger.info("=" * 80) # Exit with error code if any scans failed if stats["failed"] > 0: sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: cli/service_mgmt.sh ================================================ #!/bin/bash # DEPRECATED: This script is deprecated in favor of the Registry Management API # Use: uv run python api/registry_management.py OR cli/registry_cli_wrapper.py # See: api/README.md for documentation # # Service Management Script for MCP Gateway Registry # Usage: ./cli/service_mgmt.sh {add|delete|monitor|test|add-to-groups|remove-from-groups|create-group|delete-group|list-groups} [args...] echo "WARNING: This script is DEPRECATED. Please use the Registry Management API instead:" echo " uv run python api/registry_management.py --help" echo " OR cli/registry_cli_wrapper.py --help" echo "See api/README.md for full documentation." echo "" set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # Unicode symbols CHECK_MARK="✓" CROSS_MARK="✗" # Get script directory and project root SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" # Load environment variables from .env file if it exists if [ -f "$PROJECT_ROOT/.env" ]; then set -a # automatically export all variables source "$PROJECT_ROOT/.env" set +a fi # Gateway URL (can be overridden with GATEWAY_URL environment variable) GATEWAY_URL="${GATEWAY_URL:-http://localhost}" # Default service name DEFAULT_SERVICE="example-server" print_success() { echo -e "${GREEN}${CHECK_MARK} $1${NC}" } print_error() { echo -e "${RED}${CROSS_MARK} $1${NC}" } print_info() { echo -e "${YELLOW}ℹ $1${NC}" } check_prerequisites() { print_info "Checking prerequisites..." # Check and refresh credentials if needed if ! "$PROJECT_ROOT/credentials-provider/check_and_refresh_creds.sh"; then print_error "Failed to setup credentials" exit 1 fi print_success "Credentials ready" } run_mcp_command() { local tool="$1" local args="$2" local description="$3" print_info "$description" # Print the exact command being executed echo "🔍 Executing: uv run cli/mcp_client.py --url ${GATEWAY_URL}/mcpgw/mcp call --tool $tool --args '$args'" if output=$(cd "$PROJECT_ROOT" && uv run cli/mcp_client.py --url "${GATEWAY_URL}/mcpgw/mcp" call --tool "$tool" --args "$args" 2>&1); then print_success "$description completed" echo "$output" return 0 else print_error "$description failed" echo "$output" return 1 fi } verify_server_in_list() { local service_name="$1" local should_exist="$2" # "true" or "false" print_info "Checking server in service list..." if output=$(cd "$PROJECT_ROOT" && uv run cli/mcp_client.py --url "${GATEWAY_URL}/mcpgw/mcp" call --tool list_services --args '{}' 2>&1); then if echo "$output" | grep -q "$service_name"; then if [ "$should_exist" = "true" ]; then print_success "Server found in service list" echo "$output" | grep -A2 -B2 "$service_name" return 0 else print_error "Server still exists in service list (should be removed)" return 1 fi else if [ "$should_exist" = "false" ]; then print_success "Server not found in service list (expected)" return 0 else print_error "Server not found in service list" return 1 fi fi else print_error "Failed to check service list" echo "$output" return 1 fi } verify_scopes_yml() { local service_name="$1" local should_exist="$2" # "true" or "false" print_info "Checking scopes.yml files..." # Check container scopes.yml local container_count container_count=$(docker exec mcp-gateway-registry-auth-server-1 grep -c "$service_name" /app/scopes.yml 2>/dev/null || echo "0") # Ensure we only get the last line if multiple lines are returned container_count=$(echo "$container_count" | tail -1) if [ "$should_exist" = "true" ] && [ "$container_count" -gt "0" ]; then print_success "Server found in container scopes.yml ($container_count occurrences)" elif [ "$should_exist" = "false" ] && [ "$container_count" -eq "0" ]; then print_success "Server not found in container scopes.yml (expected)" else if [ "$should_exist" = "true" ]; then print_error "Server not found in container scopes.yml" else print_error "Server still exists in container scopes.yml ($container_count occurrences)" fi return 1 fi # Check host scopes.yml local host_count host_count=$(grep -c "$service_name" "${HOME}/mcp-gateway/auth_server/scopes.yml" 2>/dev/null || echo "0") # Ensure we only get the last line if multiple lines are returned host_count=$(echo "$host_count" | tail -1) if [ "$should_exist" = "true" ] && [ "$host_count" -gt "0" ]; then print_success "Server found in host scopes.yml ($host_count occurrences)" elif [ "$should_exist" = "false" ] && [ "$host_count" -eq "0" ]; then print_success "Server not found in host scopes.yml (expected)" else if [ "$should_exist" = "true" ]; then print_error "Server not found in host scopes.yml" else print_error "Server still exists in host scopes.yml ($host_count occurrences)" fi return 1 fi } verify_faiss_metadata() { local service_name="$1" local should_exist="$2" # "true" or "false" print_info "Checking FAISS index metadata..." local metadata_count metadata_count=$(docker exec mcp-gateway-registry-registry-1 grep -c "$service_name" /app/registry/servers/service_index_metadata.json 2>/dev/null || echo "0") # Ensure we only get the last line if multiple lines are returned metadata_count=$(echo "$metadata_count" | tail -1) if [ "$should_exist" = "true" ] && [ "$metadata_count" -gt "0" ]; then print_success "Server found in FAISS metadata ($metadata_count occurrences)" elif [ "$should_exist" = "false" ] && [ "$metadata_count" -eq "0" ]; then print_success "Server not found in FAISS metadata (expected)" else if [ "$should_exist" = "true" ]; then print_error "Server not found in FAISS metadata" else print_error "Server still exists in FAISS metadata ($metadata_count occurrences)" fi return 1 fi } parse_health_output() { local json_output="$1" local service_filter="$2" # Write output to temp file to avoid shell escaping issues local temp_file=$(mktemp) echo "$json_output" > "$temp_file" # Use Python to parse JSON and format output python3 -c " import json import sys from datetime import datetime, timezone import re try: # Read from temp file with open('$temp_file', 'r') as f: output = f.read() # Look for the main JSON response (starts after authentication message) json_start = output.find('{') if json_start == -1: print('No JSON found in output') sys.exit(1) # Find the matching closing brace brace_count = 0 json_end = json_start for i, char in enumerate(output[json_start:], json_start): if char == '{': brace_count += 1 elif char == '}': brace_count -= 1 if brace_count == 0: json_end = i + 1 break json_text = output[json_start:json_end] data = json.loads(json_text) # Extract health data from structuredContent if available, otherwise from top level if 'structuredContent' in data: health_data = data['structuredContent'] else: # Fallback to top level if no structuredContent health_data = data current_time = datetime.now(timezone.utc) print('Health Check Results:') print('=' * 50) for service_path, info in health_data.items(): # Skip if filtering for specific service and this doesn't match if '$service_filter' and '$service_filter' not in service_path: continue status = info.get('status', 'unknown') last_checked = info.get('last_checked_iso', '') num_tools = info.get('num_tools', 0) # Calculate time difference if last_checked: try: check_time = datetime.fromisoformat(last_checked.replace('Z', '+00:00')) time_diff = current_time - check_time seconds_ago = int(time_diff.total_seconds()) time_str = f'{seconds_ago} seconds ago' except: time_str = 'unknown time' else: time_str = 'never checked' # Format status with color indicators if status == 'healthy': status_display = '✓ healthy' elif status == 'unhealthy': status_display = '✗ unhealthy' elif 'auth-expired' in status: status_display = '⚠ healthy-auth-expired' else: status_display = f'? {status}' print(f'Service: {service_path}') print(f' Status: {status_display}') print(f' Last checked: {time_str}') print(f' Tools available: {num_tools}') print() except json.JSONDecodeError as e: print(f'Error parsing JSON: {e}') with open('$temp_file', 'r') as f: print('Raw output:') print(f.read()) sys.exit(1) except Exception as e: print(f'Error processing health check: {e}') sys.exit(1) " # Clean up temp file rm -f "$temp_file" } run_health_check() { local service_name="$1" print_info "Running health check..." if output=$(cd "$PROJECT_ROOT" && uv run cli/mcp_client.py --url "${GATEWAY_URL}/mcpgw/mcp" call --tool healthcheck --args '{}' 2>&1); then print_success "Health check completed" echo "" # Parse and display formatted output if ! parse_health_output "$output" "$service_name"; then print_error "Failed to parse health check output" echo "Raw output:" echo "$output" return 1 fi return 0 else print_error "Health check failed" echo "$output" return 1 fi } validate_config() { local config_json="$1" # Use Python to validate fields according to register_service tool spec python3 -c " import json import sys try: config = json.loads('''$config_json''') # Required fields (based on register_service tool spec) required_fields = ['server_name', 'path', 'proxy_pass_url'] missing_fields = [] for field in required_fields: if field not in config or not config[field]: missing_fields.append(field) if missing_fields: print(f'ERROR: Missing required fields in config: {missing_fields}') sys.exit(1) # Handle bedrock-agentcore specific URL formatting auth_provider = config.get('auth_provider', '') if auth_provider == 'bedrock-agentcore': # Ensure path begins and ends with '/' path = config['path'] if not path.startswith('/'): path = '/' + path if not path.endswith('/'): path = path + '/' config['path'] = path # Ensure proxy_pass_url ends with '/' and does not have '/mcp' or '/mcp/' at the end proxy_url = config['proxy_pass_url'] # Remove trailing '/mcp/' or '/mcp' if proxy_url.endswith('/mcp/'): proxy_url = proxy_url[:-5] # Remove '/mcp/' elif proxy_url.endswith('/mcp'): proxy_url = proxy_url[:-4] # Remove '/mcp' # Ensure it ends with '/' if not proxy_url.endswith('/'): proxy_url = proxy_url + '/' config['proxy_pass_url'] = proxy_url # Validate field types and constraints errors = [] # server_name: must be string and non-empty if not isinstance(config['server_name'], str) or not config['server_name'].strip(): errors.append('server_name must be a non-empty string') # path: must be string, start with '/', and be unique URL path prefix if not isinstance(config['path'], str): errors.append('path must be a string') elif not config['path'].startswith('/'): errors.append('path must start with \"/\"') elif len(config['path']) < 2: errors.append('path must be more than just \"/\"') # proxy_pass_url: must be string and valid URL format if not isinstance(config['proxy_pass_url'], str): errors.append('proxy_pass_url must be a string') elif not (config['proxy_pass_url'].startswith('http://') or config['proxy_pass_url'].startswith('https://')): errors.append('proxy_pass_url must start with http:// or https://') # Check for unknown fields (not part of tool spec) allowed_fields = {'server_name', 'path', 'proxy_pass_url', 'description', 'tags', 'num_tools', 'license', 'auth_provider', 'auth_scheme', 'supported_transports', 'headers', 'tool_list', 'repository_url', 'website_url', 'package_npm'} unknown_fields = set(config.keys()) - allowed_fields if unknown_fields: errors.append(f'Unknown fields not allowed by register_service tool spec: {sorted(unknown_fields)}') # Optional field validations if 'description' in config and config['description'] is not None: if not isinstance(config['description'], str): errors.append('description must be a string') if 'tags' in config and config['tags'] is not None: if not isinstance(config['tags'], list): errors.append('tags must be a list') elif not all(isinstance(tag, str) for tag in config['tags']): errors.append('all tags must be strings') if 'num_tools' in config and config['num_tools'] is not None: if not isinstance(config['num_tools'], int) or config['num_tools'] < 0: errors.append('num_tools must be a non-negative integer') if 'license' in config and config['license'] is not None: if not isinstance(config['license'], str): errors.append('license must be a string') if errors: print('ERROR: Config validation failed:') for error in errors: print(f' - {error}') sys.exit(1) # Extract service name from path for validation service_name = config['path'].lstrip('/').rstrip('/') # Output both the modified config and service name # First line: modified config as JSON # Second line: service name print(json.dumps(config)) print(service_name) except json.JSONDecodeError as e: print(f'ERROR: Invalid JSON in config: {e}') sys.exit(1) except Exception as e: print(f'ERROR: Config validation failed: {e}') sys.exit(1) " } add_service() { local config_file="${1}" local analyzers="${2:-yara}" if [ -z "$config_file" ]; then print_error "Usage: $0 add [analyzers]" print_error "Example: $0 add cli/examples/example-server-config.json" print_error "Example: $0 add cli/examples/example-server-config.json yara,llm" exit 1 fi if [ ! -f "$config_file" ]; then print_error "Config file not found: $config_file" print_error "Full path searched: $(pwd)/$config_file" exit 1 fi print_info "Loading config from: $config_file" local config_json config_json="$(cat "$config_file")" # Validate config and extract service name local validation_output service_name modified_config if ! validation_output=$(validate_config "$config_json"); then print_error "Config validation failed" echo "$validation_output" # This contains error message exit 1 fi # Parse the two-line output: first line is modified config, second is service name modified_config=$(echo "$validation_output" | head -n 1) service_name=$(echo "$validation_output" | tail -n 1) # Use the modified config for registration config_json="$modified_config" # Extract service_path from config for later use local service_path service_path=$(python3 -c " import json config = json.loads('''$config_json''') print(config.get('path', '')) ") echo "=== Adding Service: $service_name ===" # Check prerequisites check_prerequisites # Extract proxy_pass_url for security scanning local proxy_pass_url proxy_pass_url=$(python3 -c " import json config = json.loads('''$config_json''') print(config.get('proxy_pass_url', '')) ") # Extract headers from config if present local headers_json headers_json=$(python3 -c " import json config = json.loads('''$config_json''') headers = config.get('headers', {}) if headers: print(json.dumps(headers)) else: print('') ") # Check if LLM analyzer is requested and API key is available if [[ "$analyzers" == *"llm"* ]]; then if [ -z "$MCP_SCANNER_LLM_API_KEY" ] || [[ "$MCP_SCANNER_LLM_API_KEY" == *"your_"* ]] || [[ "$MCP_SCANNER_LLM_API_KEY" == *"placeholder"* ]]; then echo "" print_error "LLM analyzer requested but MCP_SCANNER_LLM_API_KEY is not configured" print_info "Current value: ${MCP_SCANNER_LLM_API_KEY:-}" print_info "" print_info "Options:" print_info " 1. Add real API key to .env file: MCP_SCANNER_LLM_API_KEY=sk-..." print_info " 2. Set environment variable: export MCP_SCANNER_LLM_API_KEY=sk-..." print_info " 3. Use only YARA analyzer: $0 add $config_file yara" exit 1 fi fi # Run security scan echo "" echo "=== Security Scan ===" print_info "Scanning server for security vulnerabilities..." print_info "Using analyzers: $analyzers" local is_safe="true" local scan_output="" # Prepare scan URL - append /mcp if not already present local scan_url="$proxy_pass_url" if [[ ! "$scan_url" =~ /mcp/?$ ]] && [[ ! "$scan_url" =~ /sse/?$ ]]; then # Remove trailing slash if present, then add /mcp scan_url="${scan_url%/}/mcp" print_info "Appending /mcp to scan URL: $scan_url" fi # Run scan using Python CLI and capture JSON output # Note: Scanner exits with code 1 when unsafe, so we need to capture both success and "failure" cases local scan_exit_code=0 local scan_cmd="cd \"$PROJECT_ROOT\" && uv run cli/mcp_security_scanner.py --server-url \"$scan_url\" --analyzers \"$analyzers\" --json" # Add headers if present in config if [ -n "$headers_json" ]; then print_info "Using custom headers from config for security scan" scan_cmd="$scan_cmd --headers '$headers_json'" fi scan_output=$(eval "$scan_cmd" 2>&1) || scan_exit_code=$? print_info "scan_exit_code - $scan_exit_code" # Exit code 0 = safe, exit code 1 = unsafe, exit code 2 = error if [ $scan_exit_code -eq 0 ]; then print_success "Security scan passed - Server is SAFE" elif [ $scan_exit_code -eq 1 ]; then print_error "Security scan failed - Server has critical or high severity issues" print_info "Server will be registered but marked as UNHEALTHY with security-pending status" # Add security-pending tag to config_json BEFORE registration echo "" echo "====Adding security-pending tag to configuration====" print_info "Adding 'security-pending' tag to server configuration before registration..." config_json=$(python3 -c " import json import sys try: config = json.loads('''$config_json''') # Add security-pending tag if not already present tags = config.get('tags', []) if 'security-pending' not in tags: tags.append('security-pending') config['tags'] = tags print(json.dumps(config)) sys.exit(0) except Exception as e: print(f'Failed to add tag: {e}', file=sys.stderr) sys.exit(1) ") if [ $? -eq 0 ]; then print_success "Added 'security-pending' tag to configuration" else print_error "Failed to add 'security-pending' tag to configuration" exit 1 fi else print_error "Security scan encountered an error (exit code: $scan_exit_code)" print_info "Server will be registered but marked as UNHEALTHY with security-pending status" fi echo "" # Register the service if ! run_mcp_command "register_service" "$config_json" "Registering service"; then exit 1 fi # Verify registration echo "" echo "=== Verifying Registration ===" if ! verify_server_in_list "$service_path" "true"; then exit 1 fi if ! verify_scopes_yml "$service_name" "true"; then exit 1 fi if ! verify_faiss_metadata "$service_name" "true"; then exit 1 fi if [ $scan_exit_code -eq 1 ]; then #Disabling the server echo "" echo "====Disabling the server====" # Generate JWT token for internal auth using shared SECRET_KEY if [ -z "$SECRET_KEY" ]; then print_error "SECRET_KEY not set in environment - cannot disable server" else local auth_token auth_token=$(python3 -c " from registry.auth.internal import generate_internal_token print(generate_internal_token(subject='cli-service-mgmt', purpose='toggle-service')) " 2>/dev/null) if [ -z "$auth_token" ]; then print_error "Failed to generate auth token - cannot disable server" else # Call the internal toggle endpoint to set service to disabled (false) # Since the server was just auto-enabled during registration, we need to toggle it OFF print_info "Calling toggle endpoint with: ${GATEWAY_URL}/api/internal/toggle" print_info "Service path: $service_path" output=$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "${GATEWAY_URL}/api/internal/toggle" \ -H "Authorization: Bearer $auth_token" \ --data-urlencode "service_path=$service_path" 2>&1) # Extract HTTP status code from response http_status=$(echo "$output" | grep "HTTP_STATUS:" | cut -d':' -f2) response_body=$(echo "$output" | sed '/HTTP_STATUS:/d') print_info "Toggle API HTTP Status: $http_status" print_info "Toggle API Response: $response_body" if [ "$http_status" = "200" ]; then print_success "Server disabled due to failed security scan" else print_error "Failed to disable server - HTTP Status: $http_status" print_error "Response: $response_body" fi print_info "Review the security scan report before enabling this server" fi fi fi # Run health check echo "" echo "=== Health Check ===" if ! run_health_check "$service_name"; then exit 1 fi echo "" print_success "Service $service_name successfully added and verified!" } delete_service() { local service_path="${1}" local service_name="${2}" if [ -z "$service_path" ] || [ -z "$service_name" ]; then print_error "Usage: $0 delete " print_error "Example: $0 delete /example-server example-server" exit 1 fi echo "=== Deleting Service: $service_name (path: $service_path) ===" # Check prerequisites check_prerequisites # Remove the service if ! run_mcp_command "remove_service" "{\"service_path\": \"$service_path\"}" "Removing service"; then exit 1 fi # Verify deletion echo "" echo "=== Verifying Deletion ===" if ! verify_server_in_list "$service_path" "false"; then exit 1 fi if ! verify_scopes_yml "$service_name" "false"; then exit 1 fi if ! verify_faiss_metadata "$service_name" "false"; then exit 1 fi echo "" print_success "Service $service_name successfully deleted and verified!" } test_service() { local config_file="${1}" if [ -z "$config_file" ]; then print_error "Usage: $0 test " print_error "Example: $0 test cli/examples/example-server-config.json" exit 1 fi if [ ! -f "$config_file" ]; then print_error "Config file not found: $config_file" print_error "Full path searched: $(pwd)/$config_file" exit 1 fi print_info "Loading config from: $config_file" local config_json config_json="$(cat "$config_file")" # Validate config and extract service info local validation_output service_name modified_config if ! validation_output=$(validate_config "$config_json"); then print_error "Config validation failed" echo "$validation_output" # This contains error message exit 1 fi # Parse the two-line output: first line is modified config, second is service name modified_config=$(echo "$validation_output" | head -n 1) service_name=$(echo "$validation_output" | tail -n 1) # Use the modified config config_json="$modified_config" # Extract description and tags for testing local description tags_json description=$(python3 -c " import json config = json.loads('''$config_json''') print(config.get('description', '')) ") tags_json=$(python3 -c " import json config = json.loads('''$config_json''') tags = config.get('tags', []) print(json.dumps(tags)) ") echo "=== Testing Service: $service_name ===" # Check prerequisites check_prerequisites # Test intelligent tool finder with description if [ -n "$description" ]; then print_info "Testing search with description: \"$description\"" if ! run_mcp_command "intelligent_tool_finder" "{\"natural_language_query\": \"$description\"}" "Searching with description"; then print_error "Failed to search with description" else print_success "Search with description completed" fi echo "" fi # Test intelligent tool finder with tags only if [ "$tags_json" != "[]" ]; then print_info "Testing search with tags: $tags_json" if ! run_mcp_command "intelligent_tool_finder" "{\"tags\": $tags_json}" "Searching with tags"; then print_error "Failed to search with tags" else print_success "Search with tags completed" fi echo "" fi # Test combined search if [ -n "$description" ] && [ "$tags_json" != "[]" ]; then print_info "Testing combined search with description and tags" if ! run_mcp_command "intelligent_tool_finder" "{\"natural_language_query\": \"$description\", \"tags\": $tags_json}" "Combined search"; then print_error "Failed combined search" else print_success "Combined search completed" fi echo "" fi echo "" print_success "Service testing completed!" } monitor_services() { local config_file="${1}" local service_name="" if [ -n "$config_file" ]; then if [ ! -f "$config_file" ]; then print_error "Config file not found: $config_file" exit 1 fi print_info "Loading config from: $config_file" local config_json config_json="$(cat "$config_file")" # Validate config and extract service name local validation_output modified_config if ! validation_output=$(validate_config "$config_json"); then print_error "Config validation failed" echo "$validation_output" # This contains error message exit 1 fi # Parse the two-line output: first line is modified config, second is service name modified_config=$(echo "$validation_output" | head -n 1) service_name=$(echo "$validation_output" | tail -n 1) echo "=== Monitoring Service: $service_name ===" else echo "=== Monitoring All Services ===" fi # Check prerequisites check_prerequisites # Run health check if ! run_health_check "$service_name"; then exit 1 fi echo "" print_success "Monitoring completed!" } scan_server_security() { local server_url="$1" local analyzers="${2:-yara}" local api_key="${3:-}" local headers="${4:-}" if [ -z "$server_url" ]; then print_error "Usage: $0 scan [analyzers] [api-key] [headers]" print_error "Example: $0 scan https://mcp.deepwki.com/mcp" print_error "Example: $0 scan https://mcp.deepwki.com/mcp yara,llm" print_error "Example: $0 scan https://mcp.deepwki.com/mcp yara,llm \$MCP_SCANNER_LLM_API_KEY" print_error "Example: $0 scan https://mcp.deepwki.com/mcp yara '' '{\"X-Authorization\": \"token123\"}'" print_error "" print_error "Note: For LLM analyzer, set MCP_SCANNER_LLM_API_KEY environment variable" print_error " or pass API key as third argument" print_error "Note: For custom headers, pass JSON string as fourth argument" exit 1 fi echo "=== Security Scan: $server_url ===" # Check if LLM analyzer is requested and API key is available if [[ "$analyzers" == *"llm"* ]]; then # Check both environment variable and CLI argument local key_to_check="${api_key:-$MCP_SCANNER_LLM_API_KEY}" if [ -z "$key_to_check" ] || [[ "$key_to_check" == *"your_"* ]] || [[ "$key_to_check" == *"placeholder"* ]]; then echo "" print_error "LLM analyzer requested but MCP_SCANNER_LLM_API_KEY is not configured" print_info "Current value: ${MCP_SCANNER_LLM_API_KEY:-}" print_info "" print_info "Options:" print_info " 1. Add real API key to .env file: MCP_SCANNER_LLM_API_KEY=sk-..." print_info " 2. Set environment variable: export MCP_SCANNER_LLM_API_KEY=sk-..." print_info " 3. Pass API key as argument: $0 scan $server_url $analyzers sk-your-key" print_info " 4. Use only YARA analyzer: $0 scan $server_url yara" return 1 fi fi # Build command local cmd="cd \"$PROJECT_ROOT\" && uv run cli/mcp_security_scanner.py --server-url \"$server_url\" --analyzers \"$analyzers\"" # Add API key if provided if [ -n "$api_key" ]; then cmd="$cmd --api-key \"$api_key\"" fi # Add headers if provided if [ -n "$headers" ]; then cmd="$cmd --headers '$headers'" fi print_info "Running security scan..." print_info "Analyzers: $analyzers" # Run scan and capture exit code if eval "$cmd"; then print_success "Security scan completed - Server is SAFE" return 0 else local exit_code=$? if [ $exit_code -eq 1 ]; then print_error "Security scan completed - Server is UNSAFE (has critical or high severity issues)" else print_error "Security scan failed with error code $exit_code" fi return $exit_code fi } show_usage() { echo "Usage: $0 {add|delete|monitor|test|scan|add-to-groups|remove-from-groups|create-group|delete-group|list-groups} [args...]" echo "" echo "Service Commands:" echo " add [analyzers] - Add a service using JSON config and verify registration" echo " analyzers: yara (default), llm, or yara,llm" echo " delete - Delete a service by path and name" echo " monitor [config-file] - Run health check (all services or specific service from config)" echo " test - Test service searchability using intelligent_tool_finder" echo " scan [analyzers] [api-key] - Run security scan on MCP server" echo " analyzers: yara (default), llm, or yara,llm" echo "" echo "Server-to-Group Commands:" echo " add-to-groups - Add server to specific scopes groups (comma-separated)" echo " remove-from-groups - Remove server from specific scopes groups (comma-separated)" echo "" echo "Group Management Commands:" echo " create-group [description] - Create a new group in Keycloak and scopes.yml" echo " delete-group - Delete a group from Keycloak and scopes.yml" echo " list-groups - List all groups with synchronization status" echo "" echo "Config File Requirements:" echo " Required fields: server_name, path, proxy_pass_url" echo " Optional fields: description, tags, num_tools, license," echo " auth_provider, auth_scheme, supported_transports, headers, tool_list" echo " Constraints:" echo " - path must start with '/' and be more than just '/'" echo " - proxy_pass_url must start with http:// or https://" echo " - server_name must be non-empty string" echo " - tags must be array of strings" echo " - num_tools must be a non-negative integer" echo " - supported_transports must be array of strings" echo " - headers must be array of objects" echo " - tool_list must be array of objects" echo "" echo "Examples:" echo " # Service operations" echo " $0 add cli/examples/example-server-config.json # Add with default YARA analyzer" echo " export MCP_SCANNER_LLM_API_KEY=sk-..." echo " $0 add cli/examples/example-server-config.json yara,llm # Add with both analyzers" echo " $0 add cli/examples/example-server-config.json llm # Add with only LLM analyzer" echo " $0 delete /example-server example-server" echo " $0 monitor # All services" echo " $0 monitor cli/examples/example-server-config.json # Specific service" echo " $0 test cli/examples/example-server-config.json # Test searchability" echo "" echo " # Security scanning" echo " $0 scan https://mcp.deepwki.com/mcp # Security scan with default YARA" echo " export MCP_SCANNER_LLM_API_KEY=sk-..." echo " $0 scan https://mcp.deepwki.com/mcp yara,llm # Scan with both analyzers (uses env var)" echo " $0 scan https://mcp.deepwki.com/mcp llm sk-... # Scan with only LLM (pass API key directly)" echo " $0 scan https://mcp.deepwki.com/mcp yara '' '{\"X-Authorization\": \"token\"}' # Scan with custom headers" echo "" echo " # Server-to-group operations" echo " $0 add-to-groups example-server 'mcp-servers-restricted/read,mcp-servers-restricted/execute'" echo " $0 remove-from-groups example-server 'mcp-servers-restricted/read,mcp-servers-restricted/execute'" echo "" echo " # Group management operations" echo " $0 create-group mcp-servers-finance/read 'Finance team read access'" echo " $0 delete-group mcp-servers-finance/read" echo " $0 list-groups" } add_to_groups() { local server_name="$1" local groups="$2" if [ -z "$server_name" ] || [ -z "$groups" ]; then print_error "Usage: $0 add-to-groups " print_error "Example: $0 add-to-groups example-server 'mcp-servers-restricted/read,mcp-servers-restricted/execute'" exit 1 fi echo "=== Adding Server to Scopes Groups: $server_name ===" # Check prerequisites check_prerequisites # Convert comma-separated groups to JSON array format local groups_json groups_json=$(echo "$groups" | sed 's/,/","/g' | sed 's/^/"/' | sed 's/$/"/') groups_json="[$groups_json]" print_info "Adding server '$server_name' to groups: $groups" # Call the MCP tool local response if response=$(run_mcp_command "add_server_to_scopes_groups" "{\"server_name\": \"$server_name\", \"group_names\": $groups_json}"); then # Check if the response indicates success if echo "$response" | grep -q '"success": true'; then print_success "Server successfully added to groups" # Extract and display details local server_path server_path=$(echo "$response" | grep -o '"server_path": "[^"]*"' | cut -d'"' -f4) if [ -n "$server_path" ]; then print_info "Server path: $server_path" fi print_info "Groups: $groups" print_success "Scopes groups updated and auth server reloaded" else # Extract error message if available local error_msg error_msg=$(echo "$response" | grep -o '"error": "[^"]*"' | cut -d'"' -f4) if [ -n "$error_msg" ]; then print_error "Failed to add server to groups: $error_msg" else print_error "Failed to add server to groups (unknown error)" echo "Response: $response" fi exit 1 fi else print_error "Failed to call add_server_to_scopes_groups tool" exit 1 fi echo "" print_success "Add to groups operation completed!" } remove_from_groups() { local server_name="$1" local groups="$2" if [ -z "$server_name" ] || [ -z "$groups" ]; then print_error "Usage: $0 remove-from-groups " print_error "Example: $0 remove-from-groups example-server 'mcp-servers-restricted/read,mcp-servers-restricted/execute'" exit 1 fi echo "=== Removing Server from Scopes Groups: $server_name ===" # Check prerequisites check_prerequisites # Convert comma-separated groups to JSON array format local groups_json groups_json=$(echo "$groups" | sed 's/,/","/g' | sed 's/^/"/' | sed 's/$/"/') groups_json="[$groups_json]" print_info "Removing server '$server_name' from groups: $groups" # Call the MCP tool local response if response=$(run_mcp_command "remove_server_from_scopes_groups" "{\"server_name\": \"$server_name\", \"group_names\": $groups_json}"); then # Check if the response indicates success if echo "$response" | grep -q '"success": true'; then print_success "Server successfully removed from groups" # Extract and display details local server_path server_path=$(echo "$response" | grep -o '"server_path": "[^"]*"' | cut -d'"' -f4) if [ -n "$server_path" ]; then print_info "Server path: $server_path" fi print_info "Groups: $groups" print_success "Scopes groups updated and auth server reloaded" else # Extract error message if available local error_msg error_msg=$(echo "$response" | grep -o '"error": "[^"]*"' | cut -d'"' -f4) if [ -n "$error_msg" ]; then print_error "Failed to remove server from groups: $error_msg" else print_error "Failed to remove server from groups (unknown error)" echo "Response: $response" fi exit 1 fi else print_error "Failed to call remove_server_from_scopes_groups tool" exit 1 fi echo "" print_success "Remove from groups operation completed!" } create_group() { local group_name="$1" local description="${2:-}" if [ -z "$group_name" ]; then print_error "Group name is required" echo "Usage: $0 create-group [description]" exit 1 fi echo "=== Creating Group: $group_name ===" # Check prerequisites check_prerequisites # Prepare arguments for create_group MCP tool local args="{\"group_name\": \"$group_name\"" if [ -n "$description" ]; then # Escape description for JSON local escaped_desc=$(echo "$description" | sed 's/"/\\"/g') args="$args, \"description\": \"$escaped_desc\"" fi args="$args}" # Call create_group MCP tool if ! run_mcp_command "create_group" "$args" "Creating group '$group_name'"; then print_error "Failed to create group" exit 1 fi # Verify in scopes.yml (container) print_info "Verifying group in container scopes.yml..." if docker exec mcp-gateway-registry-auth-server-1 cat /app/scopes.yml | grep -q "^$group_name:"; then print_success "Group found in container scopes.yml" else print_error "Group NOT found in container scopes.yml" fi # Verify in scopes.yml (host) local host_scopes_file="$HOME/mcp-gateway/auth_server/scopes.yml" if [ -f "$host_scopes_file" ]; then print_info "Verifying group in host scopes.yml..." if grep -q "^$group_name:" "$host_scopes_file"; then print_success "Group found in host scopes.yml" else print_error "Group NOT found in host scopes.yml" fi fi echo "" print_success "Create group operation completed!" } delete_group() { local group_name="$1" if [ -z "$group_name" ]; then print_error "Group name is required" echo "Usage: $0 delete-group " exit 1 fi echo "=== Deleting Group: $group_name ===" # Check prerequisites check_prerequisites # Prepare arguments for delete_group MCP tool local args="{\"group_name\": \"$group_name\"}" # Call delete_group MCP tool if ! run_mcp_command "delete_group" "$args" "Deleting group '$group_name'"; then print_error "Failed to delete group" exit 1 fi # Verify removal from scopes.yml (container) print_info "Verifying group removal from container scopes.yml..." if docker exec mcp-gateway-registry-auth-server-1 cat /app/scopes.yml | grep -q "^$group_name:"; then print_error "Group still found in container scopes.yml" else print_success "Group removed from container scopes.yml" fi # Verify removal from scopes.yml (host) local host_scopes_file="$HOME/mcp-gateway/auth_server/scopes.yml" if [ -f "$host_scopes_file" ]; then print_info "Verifying group removal from host scopes.yml..." if grep -q "^$group_name:" "$host_scopes_file"; then print_error "Group still found in host scopes.yml" else print_success "Group removed from host scopes.yml" fi fi echo "" print_success "Delete group operation completed!" } list_groups() { echo "=== Listing All Groups ===" # Check prerequisites check_prerequisites # Call list_groups MCP tool local args="{}" print_info "Fetching groups from Keycloak and scopes.yml..." if output=$(cd "$PROJECT_ROOT" && uv run cli/mcp_client.py --url "${GATEWAY_URL}/mcpgw/mcp" call --tool list_groups --args "$args" 2>&1); then print_success "Groups retrieved successfully" echo "" echo "$output" else print_error "Failed to list groups" echo "$output" exit 1 fi echo "" print_success "List groups operation completed!" } # Main script logic case "${1:-}" in add) add_service "$2" "$3" ;; delete) delete_service "$2" "$3" ;; monitor) monitor_services "$2" ;; test) test_service "$2" ;; scan) scan_server_security "$2" "$3" "$4" "$5" ;; add-to-groups) add_to_groups "$2" "$3" ;; remove-from-groups) remove_from_groups "$2" "$3" ;; create-group) create_group "$2" "$3" ;; delete-group) delete_group "$2" ;; list-groups) list_groups ;; *) show_usage exit 1 ;; esac ================================================ FILE: cli/src/agent/agentRunner.ts ================================================ import { anthropicTools, buildTaskContext, executeMappedTool, mapToolCall } from "./tools.js"; import type { TaskContext } from "../tasks/types.js"; import { sendMessage, getDefaultProvider, getDefaultModel, type ModelProvider, type TokenUsage } from "./modelClient.js"; export interface AgentMessage { role: "user" | "assistant" | "system"; content: string; } export interface AgentConfig { gatewayUrl: string; gatewayBaseUrl: string; gatewayToken?: string; backendToken?: string; model?: string; provider?: ModelProvider; } export interface AgentResult { messages: AgentMessage[]; toolOutputs: Array<{ name: string; output: string; isError?: boolean }>; tokenUsage?: TokenUsage; } const DEFAULT_PROVIDER = getDefaultProvider(); const DEFAULT_MODEL = getDefaultModel(DEFAULT_PROVIDER); type ConversationEntry = { role: string; content: any; tool_use_id?: string; }; export async function runAgentTurn(history: AgentMessage[], config: AgentConfig): Promise { const provider = config.provider ?? DEFAULT_PROVIDER; const model = config.model ?? DEFAULT_MODEL; // Fetch registry version let registryVersion: string | undefined; try { const versionResponse = await fetch(`${config.gatewayBaseUrl}/api/version`); if (versionResponse.ok) { const versionData = await versionResponse.json(); registryVersion = versionData.version; } } catch (err) { // Silently fail if version fetch fails } const systemMessages = history.filter((msg) => msg.role === "system").map((msg) => msg.content); const systemPrompt = [buildSystemPrompt(registryVersion), ...systemMessages].join("\n\n"); const messages = history .filter((msg) => msg.role === "user" || msg.role === "assistant") .map((msg) => ({ role: msg.role, content: msg.content })) as ConversationEntry[]; const context: TaskContext = buildTaskContext(config.gatewayUrl, config.gatewayBaseUrl, config.gatewayToken, config.backendToken); const finalMessages: AgentMessage[] = []; const toolOutputs: Array<{ name: string; output: string; isError?: boolean }> = []; // Track cumulative token usage across all turns let totalInputTokens = 0; let totalOutputTokens = 0; let toolIteration = 0; let conversation: ConversationEntry[] = [...messages]; if (conversation.length === 0) { conversation.push({ role: "user", content: history.filter((msg) => msg.role !== "system").map((msg) => msg.content).join("\n") || "Hello." }); } while (toolIteration < 25) { const response = await sendMessage(provider, { model, system: systemPrompt, messages: conversation, max_tokens: 16384, tools: anthropicTools }); // Accumulate token usage from this turn if (response.usage) { totalInputTokens += response.usage.input_tokens; totalOutputTokens += response.usage.output_tokens; } const outputBlocks = (response.content ?? []) as any[]; const toolCalls = outputBlocks.filter((block) => block.type === "tool_use"); const textBlocks = outputBlocks.filter((block) => block.type === "text"); if (toolCalls.length === 0) { const content = textBlocks.map((block) => (block.type === "text" ? block.text : "")).join("\n"); finalMessages.push({ role: "assistant", content }); break; } const assistantMessage: ConversationEntry = { role: "assistant", content: outputBlocks }; conversation = [...conversation, assistantMessage]; for (const call of toolCalls) { const invocation = mapToolCall(call); const result = await executeMappedTool(invocation, config.gatewayUrl, context); toolOutputs.push({ name: call.name, output: result.output, isError: result.isError }); conversation = [ ...conversation, { role: "user", content: [ { type: "tool_result", tool_use_id: call.id, content: result.output } ] } ]; } toolIteration += 1; } if (toolIteration >= 25) { finalMessages.push({ role: "assistant", content: "Reached tool usage limit without final response." }); } // Create token usage summary const tokenUsage: TokenUsage | undefined = (totalInputTokens > 0 || totalOutputTokens > 0) ? { input_tokens: totalInputTokens, output_tokens: totalOutputTokens, total_tokens: totalInputTokens + totalOutputTokens } : undefined; return { messages: finalMessages, toolOutputs, tokenUsage }; } function buildSystemPrompt(registryVersion?: string): string { const versionInfo = registryVersion ? ` You are connected to MCP Gateway Registry version ${registryVersion}. IMPORTANT: When users ask about versions or "what version": - The Registry version is: ${registryVersion} - MCP Gateway servers (mcpgw, currenttime, etc.) may have their own versions (often 1.0.0) - Always clarify which component's version you're referring to - The Registry is the central service managing all MCP servers ` : ''; return `You are the MCP Registry Assistant, an AI assistant with direct access to MCP (Model Context Protocol) Registry tools.${versionInfo} You have access to powerful tools for managing and interacting with MCP servers: Call MCP gateway commands directly: - ping: Check connectivity to MCP servers - list: List available MCP tools and resources - call: Execute specific MCP tools with arguments - init: Initialize new MCP connections Execute administrative tasks via slash commands: - Service management (add, remove, configure servers) - Import servers from registries - User and access management - System diagnostics and health checks CRITICAL: When providing server configuration examples, the field name MUST be \`proxy_pass_url\` (with underscores). Execute shell commands for system diagnostics, debugging, and operations: - Read files and tokens: \`cat /path/to/file.json\` - Debug credentials and authentication: Decode JWT tokens, check group membership - Execute any bash command with full output capture CRITICAL USAGE NOTES: - Use this to read and inspect token files, JSON configs, and logs - Decode JWT tokens by extracting the payload (middle section) and base64 decoding - Parse JSON using \`jq\` for filtering and analysis - This is essential for diagnosing authentication, authorization, and group membership issues EXAMPLES: - \`cat .oauth-tokens/ingress.json | jq '.access_token'\` - Extract JWT token - \`cat .oauth-tokens/ingress.json | jq -r '.access_token' | cut -d'.' -f2 | base64 -d | jq\` - Decode JWT payload to see claims and groups - \`ls -la /path/to/directory\` - List files and directories Search and read project documentation: - Search by keywords: Use search_query parameter - Read specific file: Use file_path parameter (e.g., 'auth.md', 'complete-setup-guide.md') - List all docs: Call with no parameters When to use: When users ask about features, setup, configuration, authentication, troubleshooting, or any project-related questions. Use this tool to find relevant documentation and provide accurate answers based on the docs content. IMPORTANT: When answering questions based on documentation, ALWAYS include the specific section/heading from the markdown file that you're referencing. Format it as: **Source:** \`filename.md\` - Section Name This helps users know exactly where the information comes from and allows them to read more context if needed. When users ask who you are or about your identity (e.g., "who are you?", "are you Claude?"): - Respond: "I am an assistant to MCP Registry, here to help you manage and interact with MCP servers." - Keep it brief and redirect focus to how you can help them - Don't elaborate on underlying models or capabilities unless specifically asked Before responding, always think through: 1. What is the user really asking? 2. Do I need to use tools to answer this? 3. What's the best way to present this information? - Use tools whenever the user needs to perform actions or needs current information - Call tools with precise, correct parameters - After tool execution, synthesize and summarize results in a user-friendly way - CRITICAL: Do NOT show raw tool output to users unless there's an error - Only include raw tool output when debugging errors or when explicitly requested - If a tool fails, explain what went wrong, show the error output, and suggest alternatives ALWAYS format your responses as clean, well-structured markdown: CRITICAL FIELD NAME: When showing server configurations, always use \`proxy_pass_url\` (snake_case with underscores), never \`proxypassurl\` or \`proxyPassUrl\`. 1. Use clear headings (##, ###) to organize information 2. Use bullet points (•, -, *) for lists 3. Use numbered lists for sequential steps 4. Wrap all file paths, commands, tool names, and technical terms in backticks: \`like this\` 5. For JSON output, ALWAYS pretty-print with proper indentation: \`\`\`json { "key": "value", "nested": { "data": "here" } } \`\`\` 6. For code blocks, use triple backticks with language identifier 7. Use **bold** for emphasis on key points 8. Use > blockquotes for important notes or warnings Example of well-formatted output: ## How to Add a Server Follow these steps: 1. Create your config file at \`config.json\` 2. Run the command: \`/service add configPath=config.json\` 3. Verify with: \`/service monitor\` **Sample Configuration:** \`\`\`json { "server_name": "Cloudflare Documentation MCP Server", "description": "Search Cloudflare documentation and get migration guides", "path": "/cloudflare-docs", "proxy_pass_url": "https://docs.mcp.cloudflare.com/mcp", "supported_transports": ["streamable-http"] } \`\`\` IMPORTANT: Always use \`proxy_pass_url\` (with underscores), NOT \`proxypassurl\` or \`proxyPassUrl\`. > **Note:** Ensure your server is running before adding it to the registry. - Be comprehensive but concise - Provide complete information - don't truncate explanations - Include all relevant details, examples, and steps - Anticipate follow-up questions and address them proactively - Use clear, professional language - Format everything for easy reading in a terminal - NEVER use emojis in your responses - keep all output text-only - Never expose raw tokens, secrets, or credentials - Redact sensitive information from outputs - Warn users about potentially destructive operations When users ask about project features, setup, or configuration, use the read_docs tool to find relevant documentation. The project contains comprehensive documentation covering: - Authentication and authorization (Keycloak, JWT, OAuth) - Service management and deployment - MCP server integration - Configuration and setup guides - Troubleshooting and FAQ Remember: You are a conversational AI assistant that helps users interact with MCP tools through natural language. Keep responses: - Concise and friendly (avoid verbose explanations unless asked) - Well-formatted for terminal display - Action-oriented (discover and use tools proactively when appropriate) - Conversational (chat naturally, not like a command interpreter) `; } ================================================ FILE: cli/src/agent/anthropicClient.ts ================================================ import Anthropic from "@anthropic-ai/sdk"; let cachedClient: Anthropic | null = null; export function getAnthropicClient(): Anthropic { if (cachedClient) { return cachedClient; } const apiKey = process.env.ANTHROPIC_API_KEY; if (!apiKey) { throw new Error("ANTHROPIC_API_KEY is not set. Please export it before using the agent mode."); } cachedClient = new Anthropic({apiKey}); return cachedClient; } ================================================ FILE: cli/src/agent/bedrockClient.ts ================================================ import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime"; let cachedClient: BedrockRuntimeClient | null = null; export function getBedrockClient(): BedrockRuntimeClient { if (cachedClient) { return cachedClient; } // AWS SDK will automatically use credentials from environment variables: // AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN // Or from ~/.aws/credentials or EC2/ECS instance metadata const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1"; // Support for explicit profile const profile = process.env.AWS_PROFILE; const clientConfig: any = { region }; // If a profile is specified, let the SDK handle it if (profile) { // The AWS SDK will automatically load credentials from the profile clientConfig.profile = profile; } cachedClient = new BedrockRuntimeClient(clientConfig); return cachedClient; } ================================================ FILE: cli/src/agent/modelClient.ts ================================================ import { InvokeModelCommand } from "@aws-sdk/client-bedrock-runtime"; import { getBedrockClient } from "./bedrockClient.js"; import { getAnthropicClient } from "./anthropicClient.js"; export type ModelProvider = "bedrock" | "anthropic"; export interface MessageRequest { model: string; system: string; messages: any[]; max_tokens: number; tools: any[]; } export interface TokenUsage { input_tokens: number; output_tokens: number; total_tokens: number; } export interface MessageResponse { content: any[]; stop_reason?: string; usage?: TokenUsage; } export async function sendMessage( provider: ModelProvider, request: MessageRequest ): Promise { if (provider === "bedrock") { return sendBedrockMessage(request); } else { return sendAnthropicMessage(request); } } async function sendBedrockMessage(request: MessageRequest): Promise { try { const client = getBedrockClient(); // Prepare the request body for Bedrock const body = { anthropic_version: "bedrock-2023-05-31", max_tokens: request.max_tokens, system: request.system, messages: request.messages, tools: request.tools }; const command = new InvokeModelCommand({ modelId: request.model, contentType: "application/json", accept: "application/json", body: JSON.stringify(body) }); const response = await client.send(command); const responseBody = JSON.parse(new TextDecoder().decode(response.body)); // Extract token usage from Bedrock response const usage: TokenUsage | undefined = responseBody.usage ? { input_tokens: responseBody.usage.input_tokens || 0, output_tokens: responseBody.usage.output_tokens || 0, total_tokens: (responseBody.usage.input_tokens || 0) + (responseBody.usage.output_tokens || 0) } : undefined; return { content: responseBody.content || [], stop_reason: responseBody.stop_reason, usage }; } catch (error: any) { // Provide helpful error messages for common Bedrock issues if (error.name === "AccessDeniedException") { throw new Error( "Amazon Bedrock access denied. Ensure your IAM user/role has 'bedrock:InvokeModel' permission and access to Claude models. " + "You may also need to enable model access in the Amazon Bedrock console." ); } else if (error.name === "ResourceNotFoundException") { throw new Error( `Model '${request.model}' not found in your AWS region. Check that the model ID is correct and available in your region (${process.env.AWS_REGION || "us-east-1"}).` ); } else if (error.name === "ValidationException") { throw new Error( "Invalid request to Amazon Bedrock. This might be due to an unsupported parameter or malformed request. " + "Error: " + error.message ); } throw error; } } async function sendAnthropicMessage(request: MessageRequest): Promise { try { const client = getAnthropicClient(); const response = await (client as any).beta.tools.messages.create({ model: request.model, system: request.system, messages: request.messages, max_tokens: request.max_tokens, tools: request.tools }); // Extract token usage from Anthropic API response const usage: TokenUsage | undefined = response.usage ? { input_tokens: response.usage.input_tokens || 0, output_tokens: response.usage.output_tokens || 0, total_tokens: (response.usage.input_tokens || 0) + (response.usage.output_tokens || 0) } : undefined; return { content: response.content || [], stop_reason: response.stop_reason, usage }; } catch (error: any) { // Provide helpful error messages for Anthropic API issues if (error.status === 401) { throw new Error( "Anthropic API authentication failed. Check that your ANTHROPIC_API_KEY is valid." ); } else if (error.status === 429) { throw new Error( "Anthropic API rate limit exceeded. Please wait a moment before trying again." ); } throw error; } } export function getDefaultProvider(): ModelProvider { // Check if AWS credentials are configured const hasAwsCredentials = process.env.AWS_ACCESS_KEY_ID || process.env.AWS_SECRET_ACCESS_KEY || process.env.AWS_PROFILE; // Use Bedrock by default if AWS credentials are available if (hasAwsCredentials) { return "bedrock"; } // Fall back to Anthropic if ANTHROPIC_API_KEY is set if (process.env.ANTHROPIC_API_KEY) { return "anthropic"; } // Default to bedrock return "bedrock"; } export function getDefaultModel(provider: ModelProvider): string { if (provider === "bedrock") { // Use environment variable or default to Claude Haiku 4.5 on Bedrock (fast and efficient) // Note: Claude 4+ models require inference profile IDs (us.anthropic.* or global.anthropic.*) // Claude 3.x models can use direct model IDs (anthropic.claude-*) return process.env.BEDROCK_MODEL_ID || "us.anthropic.claude-haiku-4-5-20251001-v1:0"; } else { // Use environment variable or default to Haiku for Anthropic API return process.env.ANTHROPIC_MODEL || "claude-haiku-4-5-20251001"; } } ================================================ FILE: cli/src/agent/tools.ts ================================================ import {executeMcpCommand} from "../runtime/mcp.js"; import type {TaskContext} from "../tasks/types.js"; import {taskCatalog} from "../tasks/index.js"; import {executeSlashCommand} from "../commands/executor.js"; export interface AgentToolInvocation { type: "mcp" | "task" | "shell" | "docs" | "unknown"; name: string; input: Record; } export const anthropicTools: any[] = [ { name: "mcp_command", description: "Call MCP gateway commands (ping, list, call, init).", input_schema: { type: "object", properties: { command: { type: "string", enum: ["ping", "list", "call", "init"], description: "Which MCP command to execute." }, tool: { type: "string", description: "Tool name for the call command" }, args: { type: "object", description: "JSON arguments for the tool." } }, required: ["command"] } }, { name: "registry_task", description: "Run service management, imports, user management, or diagnostics tasks.", input_schema: { type: "object", properties: { command: { type: "string", description: "Slash command matching the CLI syntax, e.g. /service add configPath=..." } }, required: ["command"] } }, { name: "shell_command", description: "Execute shell commands for system diagnostics, file operations, and debugging. Safe for read-only operations and credential inspection.", input_schema: { type: "object", properties: { command: { type: "string", description: "Bash command to execute (e.g., './cli/service_mgmt.sh list-groups', 'cat /path/to/file.json')" } }, required: ["command"] } }, { name: "read_docs", description: "Search and read documentation files from the docs folder. Use this when users ask questions about the project, features, setup, configuration, or troubleshooting.", input_schema: { type: "object", properties: { search_query: { type: "string", description: "Keywords to search for in doc files (e.g., 'authentication', 'keycloak', 'setup'). Leave empty to list all docs." }, file_path: { type: "string", description: "Specific doc file to read (e.g., 'auth.md', 'complete-setup-guide.md'). If provided, reads this file directly." } } } } ]; export function mapToolCall(tool: any): AgentToolInvocation { if (tool.name === "mcp_command") { const input = tool.input as Record; return {type: "mcp", name: tool.name, input}; } if (tool.name === "registry_task") { const input = tool.input as Record; return {type: "task", name: tool.name, input}; } if (tool.name === "shell_command") { const input = tool.input as Record; return {type: "shell", name: tool.name, input}; } if (tool.name === "read_docs") { const input = tool.input as Record; return {type: "docs", name: tool.name, input}; } return {type: "unknown", name: tool.name, input: tool.input as Record}; } export async function executeMappedTool( invocation: AgentToolInvocation, gatewayUrl: string, context: TaskContext ): Promise<{output: string; isError?: boolean}> { if (invocation.type === "mcp") { const command = String(invocation.input.command || ""); if (!command) { return {output: "Missing command field", isError: true}; } const toolName = invocation.input.tool ? String(invocation.input.tool) : undefined; const args = invocation.input.args && typeof invocation.input.args === "object" ? (invocation.input.args as Record) : {}; try { const {handshake, response} = await executeMcpCommand(command as any, gatewayUrl, context.gatewayToken, context.backendToken, toolName ? {tool: toolName, args} : undefined); return {output: JSON.stringify({handshake, response}, null, 2)}; } catch (error) { return {output: (error as Error).message, isError: true}; } } if (invocation.type === "task") { let commandText = String(invocation.input.command || "").trim(); if (!commandText.startsWith("/")) { commandText = `/${commandText}`; } const result = await executeSlashCommand(commandText, context); return {output: result.lines.join("\n"), isError: result.isError}; } if (invocation.type === "shell") { const { execFileSync } = await import("child_process"); const command = String(invocation.input.command || "").trim(); if (!command) { return {output: "Missing command field", isError: true}; } // Split command into executable and arguments to avoid shell injection const parts = command.split(/\s+/); const executable = parts[0]; const args = parts.slice(1); try { const output = execFileSync(executable, args, {encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, timeout: 30000}); return {output}; } catch (error) { const errorMessage = (error as Error).message || String(error); return {output: errorMessage, isError: true}; } } if (invocation.type === "docs") { const { searchDocs, readDocFile, getAllDocFiles } = await import("../utils/docsReader.js"); const filePath = invocation.input.file_path ? String(invocation.input.file_path) : undefined; const searchQuery = invocation.input.search_query ? String(invocation.input.search_query) : undefined; try { if (filePath) { // Read specific file const doc = readDocFile(filePath); if (!doc) { return { output: `File not found: ${filePath}`, isError: true }; } return { output: `# ${doc.name}\n\n${doc.content}` }; } else if (searchQuery) { // Search docs const results = searchDocs(searchQuery); if (results.length === 0) { return { output: `No documentation found for: ${searchQuery}` }; } const output = results.map(doc => `## ${doc.path}\n\n${doc.content.substring(0, 1500)}...\n\n---\n` ).join('\n'); return { output }; } else { // List all docs const files = getAllDocFiles(); return { output: `Available documentation files:\n${files.map(f => `- ${f}`).join('\n')}` }; } } catch (error) { return { output: (error as Error).message, isError: true }; } } return {output: `Unknown tool invocation: ${invocation.name}`, isError: true}; } export function buildTaskContext(gatewayUrl: string, baseUrl: string, gatewayToken?: string, backendToken?: string): TaskContext { return { gatewayUrl, gatewayBaseUrl: baseUrl, gatewayToken, backendToken }; } export function describeAvailableTasks(): string { const lines: string[] = []; for (const [category, tasks] of Object.entries(taskCatalog)) { lines.push(`Category: ${category}`); tasks.forEach((task) => { lines.push(` - ${task.key.replace(`${category}-`, "")}: ${task.description ?? ""}`); }); } return lines.join("\n"); } ================================================ FILE: cli/src/app.tsx ================================================ import React, {useCallback, useEffect, useMemo, useRef, useState} from "react"; import {Box, Text, useInput, Static} from "ink"; import TextInput from "ink-text-input"; import Spinner from "ink-spinner"; import {renderMarkdown, hasMarkdown, formatToolOutput} from "./utils/markdown.js"; import {Banner} from "./components/Banner.js"; import {CommandSuggestions} from "./components/CommandSuggestions.js"; import {TokenStatusFooter} from "./components/TokenStatusFooter.js"; import {getCommandSuggestions} from "./utils/commands.js"; import {resolveAuth} from "./auth.js"; import type {ParsedArgs} from "./parseArgs.js"; import {executeSlashCommand, overviewMessage} from "./commands/executor.js"; import {runAgentTurn} from "./agent/agentRunner.js"; import type {AgentMessage} from "./agent/agentRunner.js"; import type {CommandExecutionContext} from "./commands/executor.js"; import {getDefaultProvider, getDefaultModel} from "./agent/modelClient.js"; import {executeMcpCommand, formatMcpResult} from "./runtime/mcp.js"; import {refreshTokens, shouldRefreshToken} from "./utils/tokenRefresh.js"; import {calculateCost} from "./utils/costCalculator.js"; type ChatRole = "system" | "user" | "assistant" | "tool"; interface ChatMessage { id: number; role: ChatRole; text: string; } interface AuthReadyState { status: "ready"; context: Awaited>; } type AuthState = {status: "loading"} | AuthReadyState | {status: "error"; message: string}; interface AppProps { options: ParsedArgs; } export default function App({options}: AppProps) { const interactive = options.interactive !== false; const [messages, setMessages] = useState([]); const messageCounter = useRef(1); const [inputValue, setInputValue] = useState(""); const [authState, setAuthState] = useState({status: "loading"}); const [authAttempt, setAuthAttempt] = useState(0); const [busy, setBusy] = useState(false); const [initialised, setInitialised] = useState(false); const [hasShownWelcome, setHasShownWelcome] = useState(false); const [commandSuggestions, setCommandSuggestions] = useState>([]); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(0); // Token status state const [tokenSecondsRemaining, setTokenSecondsRemaining] = useState(); const [tokenExpired, setTokenExpired] = useState(false); const [isRefreshingToken, setIsRefreshingToken] = useState(false); const [lastTokenRefresh, setLastTokenRefresh] = useState(); const [tokenSource, setTokenSource] = useState(); // Session token usage and cost tracking const [sessionInputTokens, setSessionInputTokens] = useState(0); const [sessionOutputTokens, setSessionOutputTokens] = useState(0); const [sessionTotalCost, setSessionTotalCost] = useState(0); // Registry version const [registryVersion, setRegistryVersion] = useState(); const gatewayUrl = useMemo(() => options.url ?? "http://localhost/mcpgw/mcp", [options.url]); const gatewayBaseUrl = useMemo(() => deriveGatewayBase(gatewayUrl), [gatewayUrl]); const agentAvailable = useMemo(() => { // Check credentials: AWS Profile, Anthropic API key, or default to true // (let AWS SDK discover execution role credentials at runtime) const hasAwsProfile = Boolean(process.env.AWS_PROFILE); const hasAnthropicKey = Boolean(process.env.ANTHROPIC_API_KEY); // If no explicit credentials, assume execution role is available // AWS SDK will attempt to get credentials from EC2 instance metadata return hasAwsProfile || hasAnthropicKey || true; }, []); const addMessage = useCallback((role: ChatRole, text: string) => { const id = messageCounter.current++; setMessages((prev) => [...prev, {id, role, text}]); }, []); useEffect(() => { let cancelled = false; setAuthState({status: "loading"}); // Try to resolve auth, and if it fails due to missing/invalid tokens, automatically refresh resolveAuth({ tokenFile: options.tokenFile, explicitToken: options.token, cwd: process.cwd() }) .then(async (context) => { if (cancelled) return; // Check if we have a gateway token - if not, try to generate one if (!context.gatewayToken || context.gatewaySource === "none") { addMessage("assistant", "No gateway token found. Attempting automatic generation..."); try { const result = await refreshTokens(); if (result.success) { addMessage("assistant", "✅ OAuth tokens generated successfully. Authenticating..."); // Trigger auth reload setAuthAttempt((attempt) => attempt + 1); } else { setAuthState({status: "error", message: `Token generation failed: ${result.message}`}); } } catch (refreshError) { setAuthState({status: "error", message: `Token generation failed: ${(refreshError as Error).message}`}); } return; } setAuthState({status: "ready", context}); }) .catch(async (error: unknown) => { if (cancelled) return; const errorMessage = (error as Error).message; // If auth failed due to missing or invalid tokens, try to refresh automatically if (errorMessage.includes("token") || errorMessage.includes("ENOENT") || errorMessage.includes("Failed to load")) { addMessage("assistant", "OAuth tokens missing or invalid. Attempting automatic generation..."); try { const result = await refreshTokens(); if (result.success) { addMessage("assistant", "✅ OAuth tokens generated successfully. Authenticating..."); // Trigger auth reload setAuthAttempt((attempt) => attempt + 1); } else { setAuthState({status: "error", message: `Token generation failed: ${result.message}`}); } } catch (refreshError) { setAuthState({status: "error", message: `Token generation failed: ${(refreshError as Error).message}`}); } } else { setAuthState({status: "error", message: errorMessage}); } }); return () => { cancelled = true; }; }, [options.token, options.tokenFile, authAttempt, addMessage]); useEffect(() => { if (authState.status === "ready" && !initialised) { // Only show welcome messages the first time if (!hasShownWelcome) { const infoLines = summariseAuth(authState, gatewayUrl); infoLines.forEach((line) => addMessage("assistant", line)); setHasShownWelcome(true); } setInitialised(true); // Initialize token status const gatewayInspection = authState.context.inspections.find(i => i.label.includes("Gateway")); if (gatewayInspection && shouldRefreshToken(gatewayInspection.secondsRemaining)) { refreshTokens() .then((result) => { if (result.success) { // Silently refresh tokens without showing messages // Trigger auth reload setAuthAttempt((attempt) => attempt + 1); } else { addMessage("assistant", `❌ ${result.message}. Please run: ./credentials-provider/generate_creds.sh --ingress-only`); } }) .catch((error) => { addMessage("assistant", `❌ Token refresh failed: ${error.message}. Please run: ./credentials-provider/generate_creds.sh --ingress-only`); }); } // Fetch registry version fetch(`${gatewayBaseUrl}/api/version`) .then(res => res.json()) .then(data => setRegistryVersion(data.version)) .catch(() => { // Silently fail if version fetch fails }); } }, [authState, addMessage, initialised, gatewayUrl, gatewayBaseUrl, setAuthAttempt, hasShownWelcome]); useEffect(() => { if (!interactive && authState.status === "ready" && options.command) { const command = options.command; (async () => { try { const extras = options.tool ? { tool: options.tool, args: options.args ? JSON.parse(options.args) : {} } : undefined; const result = await executeMcpCommand( command, gatewayUrl, authState.context.gatewayToken, authState.context.backendToken, extras ); const lines = formatMcpResult(command, result.handshake, result.response, options.tool); // eslint-disable-next-line no-console console.log(options.json ? JSON.stringify({lines}) : lines.join("\n")); process.exit(0); } catch (error) { // eslint-disable-next-line no-console console.error((error as Error).message); process.exit(1); } })(); } }, [authState, gatewayUrl, interactive, options]); // Update command suggestions when input changes useEffect(() => { if (inputValue.startsWith("/")) { const suggestions = getCommandSuggestions(inputValue); setCommandSuggestions(suggestions); setSelectedSuggestionIndex(0); } else { setCommandSuggestions([]); setSelectedSuggestionIndex(0); } }, [inputValue]); // Timer effect to update token status every second useEffect(() => { if (authState.status !== "ready") return; const gatewayInspection = authState.context.inspections.find(i => i.label.includes("Gateway")); if (gatewayInspection) { // Initialize token status on mount const now = Date.now() / 1000; const expiresAt = gatewayInspection.expiresAt ? gatewayInspection.expiresAt.getTime() / 1000 : 0; const remaining = Math.floor(expiresAt - now); setTokenSecondsRemaining(remaining); setTokenExpired(remaining <= 0); setTokenSource(authState.context.gatewaySource); } const interval = setInterval(() => { if (authState.status !== "ready") return; const gatewayInspection = authState.context.inspections.find(i => i.label.includes("Gateway")); if (gatewayInspection) { const now = Date.now() / 1000; const expiresAt = gatewayInspection.expiresAt ? gatewayInspection.expiresAt.getTime() / 1000 : 0; const remaining = Math.floor(expiresAt - now); setTokenSecondsRemaining(remaining); setTokenExpired(remaining <= 0); // Auto-refresh when <= 10 seconds remaining if (shouldRefreshToken(remaining) && !isRefreshingToken) { setIsRefreshingToken(true); refreshTokens() .then((result) => { if (result.success) { setLastTokenRefresh(new Date()); // Trigger auth reload setAuthAttempt((attempt) => attempt + 1); setInitialised(false); } setIsRefreshingToken(false); }) .catch(() => { setIsRefreshingToken(false); }); } } }, 1000); return () => clearInterval(interval); }, [authState, isRefreshingToken, setAuthAttempt]); useInput( (input, key) => { if (key.ctrl && input === "c") { process.exit(); } // Handle arrow keys for command suggestions if (commandSuggestions.length > 0) { if (key.upArrow) { setSelectedSuggestionIndex((prev) => prev > 0 ? prev - 1 : commandSuggestions.length - 1 ); } else if (key.downArrow) { setSelectedSuggestionIndex((prev) => prev < commandSuggestions.length - 1 ? prev + 1 : 0 ); } else if (key.tab || key.return) { // Tab or Enter to autocomplete const selected = commandSuggestions[selectedSuggestionIndex]; if (selected) { setInputValue(selected.command + " "); } // Prevent Enter from submitting when autocompleting if (key.return) { return; } } } }, {isActive: interactive} ); const handleSubmit = useCallback( async (value: string) => { // If suggestions are visible, don't submit - let Enter autocomplete instead if (commandSuggestions.length > 0) { return; } const trimmed = value.trim(); if (!trimmed) { return; } setInputValue(""); const userMessage: ChatMessage = {id: messageCounter.current++, role: "user", text: trimmed}; setMessages((prev) => [...prev, userMessage]); if (trimmed === "/retry") { setAuthAttempt((attempt) => attempt + 1); setInitialised(false); addMessage("assistant", "Retrying authentication..."); return; } if (trimmed === "/refresh-tokens" || trimmed === "/refresh") { setBusy(true); refreshTokens() .then((result) => { if (result.success) { addMessage("assistant", "✅ OAuth tokens refreshed successfully. Reloading authentication..."); setAuthAttempt((attempt) => attempt + 1); setInitialised(false); } else { addMessage("assistant", `❌ ${result.message}. Try running: ./credentials-provider/generate_creds.sh --ingress-only`); } }) .catch((error) => { addMessage("assistant", `❌ Token refresh failed: ${error.message}`); }) .finally(() => { setBusy(false); }); return; } if (authState.status !== "ready") { addMessage("assistant", "Authentication is not ready yet. Try /retry or wait a moment."); return; } // Token refresh is now handled automatically by the timer effect in the footer const commandContext: CommandExecutionContext = { gatewayUrl, gatewayBaseUrl, gatewayToken: authState.context.gatewayToken, backendToken: authState.context.backendToken }; const history: AgentMessage[] = buildAgentHistory([...messages, userMessage]); if (trimmed.startsWith("/")) { setBusy(true); try { const result = await executeSlashCommand(trimmed, commandContext); addMessage(result.isError ? "assistant" : "tool", result.lines.join("\n")); // Handle exit command if (result.shouldExit) { setTimeout(() => process.exit(0), 500); } } catch (error) { addMessage("assistant", `Command failed: ${(error as Error).message}`); } finally { setBusy(false); } return; } if (!agentAvailable) { addMessage( "assistant", "Agent mode is disabled. Configure AWS_PROFILE, ensure execution role is available, or set ANTHROPIC_API_KEY. Alternatively, use slash commands like /ping." ); return; } setBusy(true); try { const result = await runAgentTurn(history, { gatewayUrl, gatewayBaseUrl, gatewayToken: authState.context.gatewayToken, backendToken: authState.context.backendToken, model: process.env.ANTHROPIC_MODEL }); // Only show tool outputs if there's an error (for debugging) result.toolOutputs.forEach((tool) => { if (tool.isError) { const formatted = formatToolOutput(tool.name, tool.output, tool.isError); addMessage("tool", formatted); } }); if (result.messages.length === 0) { addMessage("assistant", "No response from the agent. Try a different prompt or use /help."); } else { result.messages.forEach((msg) => addMessage(msg.role, msg.content)); // Track token usage and cost if (result.tokenUsage) { const {input_tokens, output_tokens, total_tokens} = result.tokenUsage; // Get the current model being used const currentModel = process.env.ANTHROPIC_MODEL || getDefaultModel(getDefaultProvider()); // Calculate cost for this turn const turnCost = calculateCost(currentModel, input_tokens, output_tokens); // Update session totals setSessionInputTokens((prev) => prev + input_tokens); setSessionOutputTokens((prev) => prev + output_tokens); if (turnCost !== undefined) { setSessionTotalCost((prev) => prev + turnCost); } } } } catch (error) { addMessage("assistant", `Agent error: ${(error as Error).message}`); } finally { setBusy(false); } }, [messages, authState, gatewayUrl, gatewayBaseUrl, agentAvailable, addMessage, commandSuggestions] ); const renderMessages = () => { const items = [{id: 0, type: 'banner' as const}, ...messages.map(m => ({...m, type: 'message' as const}))]; return ( {(item) => { if (item.type === 'banner') { return ; } return ( ); }} ); }; const inputPrompt = useMemo(() => { if (busy) { return ( Working... ); } if (authState.status === "loading") { return ( Authenticating... ); } if (authState.status === "error") { return Auth error. Type /retry once credentials are fixed.; } return ; }, [authState, busy]); if (!interactive) { if (authState.status === "loading") { return ( Authenticating... ); } if (authState.status === "error") { return ( Authentication failed: {authState.message} ); } return ( Processing non-interactive command... ); } return ( {renderMessages()} {commandSuggestions.length > 0 && ( )} {"═".repeat(Math.min(process.stdout.columns || 80, 80))} {inputPrompt} {commandSuggestions.length > 0 && commandSuggestions[selectedSuggestionIndex] && ( {commandSuggestions[selectedSuggestionIndex].command.substring(inputValue.length)} )} {"═".repeat(Math.min(process.stdout.columns || 80, 80))} {commandSuggestions.length > 0 && commandSuggestions[selectedSuggestionIndex] && ( {commandSuggestions[selectedSuggestionIndex].command} {" — "} {commandSuggestions[selectedSuggestionIndex].description} )} {authState.status === "ready" && ( )} ); } function buildAgentHistory(messages: ChatMessage[]): AgentMessage[] { return messages .filter((message) => message.role !== "tool") .map((message) => ({ role: message.role === "system" ? "system" : message.role === "assistant" ? "assistant" : "user", content: message.text })); } function summariseAuth(_authState: AuthReadyState, gatewayUrl: string): string[] { // Simplified - only show gateway URL and help. Token/model info shown in footer const lines = [`Authenticated against ${gatewayUrl}`]; lines.push(""); lines.push(overviewMessage()); return lines; } interface MessageBubbleProps { role: ChatRole; text: string; } function MessageBubble({role, text}: MessageBubbleProps) { const color = roleColor(role); const label = roleLabel(role); // Render markdown for assistant and tool messages const shouldRenderMarkdown = (role === "assistant" || role === "tool") && hasMarkdown(text); const displayText = shouldRenderMarkdown ? renderMarkdown(text) : text; // Helper to render text with inline code highlighting const renderTextWithHighlights = (content: string) => { const parts = content.split(/(`[^`]+`)/g); return parts.map((part, i) => { if (part.startsWith('`') && part.endsWith('`')) { // Remove backticks and render in cyan return ( {part.slice(1, -1)} ); } return {part}; }); }; return ( {label} {renderTextWithHighlights(displayText)} ); } function roleLabel(role: ChatRole): string { switch (role) { case "user": return "You"; case "assistant": return "Assistant"; case "tool": return "Tool"; case "system": default: return "System"; } } function roleColor(role: ChatRole): string | undefined { switch (role) { case "user": return "green"; case "assistant": return "cyan"; case "tool": return "yellow"; case "system": default: return "magenta"; } } function deriveGatewayBase(url: string): string { if (!url) { return ""; } try { const parsed = new URL(url); const pathname = parsed.pathname.replace(/\/mcpgw\/mcp(?:\/.*)?$/, ""); return `${parsed.origin}${pathname.endsWith("/") || pathname.length === 0 ? pathname : `${pathname}/`}`; } catch { return url.replace(/\/mcpgw\/mcp(?:\/.*)?$/, ""); } } ================================================ FILE: cli/src/auth.ts ================================================ import {promises as fs} from "node:fs"; import path from "node:path"; import os from "node:os"; export type BackendSource = "none" | "token-file" | "m2m" | "explicit"; export type GatewaySource = "none" | "ingress-json" | "env" | "token-file"; export interface TokenInspection { label: string; expiresAt?: Date; secondsRemaining?: number; expired: boolean; warning?: string; } export interface AuthContext { backendToken?: string; backendSource: BackendSource; gatewayToken?: string; gatewaySource: GatewaySource; tokenFile?: string; warnings: string[]; inspections: TokenInspection[]; } export interface ResolveAuthOptions { tokenFile?: string; explicitToken?: string; cwd?: string; } const ONE_MINUTE = 60; export async function resolveAuth(options: ResolveAuthOptions): Promise { const warnings: string[] = []; const inspections: TokenInspection[] = []; // Use parent directory if running from cli/ subdirectory let cwd = options.cwd ?? process.cwd(); if (cwd.endsWith('/cli') || cwd.endsWith('\\cli')) { cwd = path.dirname(cwd); } let backendToken: string | undefined; let backendSource: BackendSource = "none"; let tokenFile: string | undefined; if (options.explicitToken) { backendToken = options.explicitToken; backendSource = "explicit"; tokenFile = undefined; } else if (options.tokenFile) { const loaded = await loadTokenFromPlainFile(options.tokenFile); if (loaded) { backendToken = loaded; backendSource = "token-file"; tokenFile = options.tokenFile; } else { warnings.push(`Failed to read token file: ${options.tokenFile}`); } } if (!backendToken) { const m2mToken = await fetchM2MToken(); if (m2mToken?.token) { backendToken = m2mToken.token; backendSource = "m2m"; inspections.push(buildInspection("M2M token", backendToken)); if (m2mToken.warning) { warnings.push(m2mToken.warning); } } else if (m2mToken?.warning) { warnings.push(m2mToken.warning); } } else { inspections.push(buildInspection("Backend token", backendToken)); } const gatewayTokenResult = await resolveGatewayToken(cwd); let gatewayToken: string | undefined = gatewayTokenResult.token; let gatewaySource: GatewaySource = gatewayTokenResult.source; if (gatewayToken) { inspections.push(buildInspection("Gateway token", gatewayToken)); if (gatewayTokenResult.warning) { warnings.push(gatewayTokenResult.warning); } } // Filter out falsy warnings const filteredWarnings = warnings.filter(Boolean); return { backendToken, backendSource, gatewayToken, gatewaySource, tokenFile, warnings: filteredWarnings, inspections }; } async function loadTokenFromPlainFile(filePath: string): Promise { try { const absolutePath = path.resolve(filePath); const content = await fs.readFile(absolutePath, "utf-8"); const token = content.trim(); return token.length > 0 ? token : undefined; } catch { return undefined; } } async function resolveGatewayToken(cwd: string): Promise<{token?: string; source: GatewaySource; warning?: string}> { const envToken = process.env.MCP_GATEWAY_TOKEN; if (envToken) { return { token: envToken, source: "env" }; } const ingressJsonPath = path.join(cwd, ".oauth-tokens", "ingress.json"); const ingressToken = await loadOAuthTokenFromFile(ingressJsonPath); if (ingressToken) { const inspection = inspectJwt(ingressToken); let warning: string | undefined; if (inspection && inspection.expired) { warning = `Ingress token in ${ingressJsonPath} is expired`; } else if (inspection && inspection.secondsRemaining !== undefined && inspection.secondsRemaining <= ONE_MINUTE) { warning = `Ingress token in ${ingressJsonPath} expires in ${inspection.secondsRemaining} seconds`; } return { token: ingressToken, source: "ingress-json", warning }; } const homeIngressPath = path.join(os.homedir(), ".mcp", "ingress_token"); const fallbackToken = await loadTokenFromPlainFile(homeIngressPath); if (fallbackToken) { return { token: fallbackToken, source: "token-file" }; } return { source: "none" }; } async function loadOAuthTokenFromFile(filePath: string): Promise { try { const content = await fs.readFile(filePath, "utf-8"); const json = JSON.parse(content) as Record; let accessToken: unknown; let expiresAt: number | undefined; if ("tokens" in json && typeof json.tokens === "object" && json.tokens !== null) { const tokens = json.tokens as Record; accessToken = tokens.access_token ?? tokens.token; if (typeof tokens.expires_at === "number") { expiresAt = tokens.expires_at; } } else { accessToken = json.access_token ?? json.token; if (typeof json.expires_at === "number") { expiresAt = json.expires_at; } } if (typeof accessToken !== "string") { return undefined; } if (expiresAt && expiresAt <= Date.now() / 1000) { return undefined; } return accessToken; } catch { return undefined; } } async function fetchM2MToken(): Promise<{token?: string; warning?: string} | undefined> { const clientId = process.env.CLIENT_ID; const clientSecret = process.env.CLIENT_SECRET; const keycloakUrl = process.env.KEYCLOAK_URL; const realm = process.env.KEYCLOAK_REALM; if (!clientId || !clientSecret || !keycloakUrl || !realm) { return undefined; } const params = new URLSearchParams(); params.set("grant_type", "client_credentials"); params.set("client_id", clientId); params.set("client_secret", clientSecret); params.set("scope", "openid"); const tokenUrl = `${keycloakUrl.replace(/\/$/, "")}/realms/${realm}/protocol/openid-connect/token`; try { const response = await fetch(tokenUrl, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: params.toString() }); if (!response.ok) { return {warning: `Failed to obtain M2M token (${response.status} ${response.statusText})`}; } const data = (await response.json()) as Record; const accessToken = data.access_token; const expiresIn = typeof data.expires_in === "number" ? data.expires_in : undefined; if (typeof accessToken !== "string" || accessToken.length === 0) { return {warning: "M2M token response did not include an access_token field"}; } let warning: string | undefined; if (expiresIn !== undefined && expiresIn <= ONE_MINUTE) { warning = `M2M token expires in ${expiresIn} seconds`; } return { token: accessToken, warning }; } catch (error) { return {warning: `Failed to fetch M2M token: ${(error as Error).message}`}; } } function buildInspection(label: string, token: string): TokenInspection { const inspection = inspectJwt(token); if (!inspection) { return { label, expired: false }; } const warning = inspection.warning ?? (inspection.secondsRemaining !== undefined && inspection.secondsRemaining <= ONE_MINUTE ? `${label} expires in ${inspection.secondsRemaining} seconds` : undefined); return { label, expiresAt: inspection.expiresAt, secondsRemaining: inspection.secondsRemaining, expired: inspection.expired, warning }; } function inspectJwt(token: string): { expiresAt?: Date; secondsRemaining?: number; expired: boolean; warning?: string; } | undefined { const parts = token.split("."); if (parts.length !== 3) { return { expired: false, warning: "Token is not a valid JWT format" }; } try { const payload = JSON.parse(base64UrlDecode(parts[1])) as Record; if (typeof payload.exp !== "number") { return { expired: false, warning: "Token does not declare an expiration time" }; } const expiresAt = new Date(payload.exp * 1000); const secondsRemaining = Math.floor(payload.exp - Date.now() / 1000); return { expiresAt, secondsRemaining, expired: secondsRemaining <= 0 }; } catch { return { expired: false, warning: "Token payload could not be decoded" }; } } function base64UrlDecode(segment: string): string { const normalized = segment.replace(/-/g, "+").replace(/_/g, "/"); const padding = normalized.length % 4; const padded = padding === 0 ? normalized : normalized + "=".repeat(4 - padding); const buffer = Buffer.from(padded, "base64"); return buffer.toString("utf-8"); } ================================================ FILE: cli/src/chat/commandParser.ts ================================================ import type {TaskCategory} from "../tasks/types.js"; export type CommandKind = "help" | "ping" | "list" | "servers" | "init" | "call" | "task" | "agents" | "exit" | "unknown"; export interface BaseParsedCommand { kind: CommandKind; } export interface HelpCommand extends BaseParsedCommand { kind: "help"; } export interface ExitCommand extends BaseParsedCommand { kind: "exit"; } export interface PingCommand extends BaseParsedCommand { kind: "ping" | "list" | "servers" | "init"; } export interface CallCommand extends BaseParsedCommand { kind: "call"; tool?: string; argsJson?: string; rawTokens: string[]; } export interface TaskCommand extends BaseParsedCommand { kind: "task"; category: TaskCategory; subcommand: string; tokens: string[]; } export interface AgentsCommand extends BaseParsedCommand { kind: "agents"; subcommand: string; tokens: string[]; } export interface UnknownCommand extends BaseParsedCommand { kind: "unknown"; message: string; } export type ParsedCommand = HelpCommand | ExitCommand | PingCommand | CallCommand | TaskCommand | AgentsCommand | UnknownCommand; const TASK_PREFIXES: Record = { service: "service", services: "service", svc: "service", import: "import", imports: "import", registry: "import", user: "user", users: "user", diagnostic: "diagnostic", diagnostics: "diagnostic", diag: "diagnostic" }; const SIMPLE_COMMANDS: Record = { ping: "ping", list: "list", tools: "list", servers: "servers", init: "init", initialize: "init" }; export function parseCommand(input: string): ParsedCommand { const trimmed = input.trim(); const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1).trim() : trimmed; if (!withoutSlash) { return {kind: "help"}; } const tokens = tokenize(withoutSlash); if (tokens.length === 0) { return {kind: "help"}; } const keyword = tokens.shift()!.toLowerCase(); if (keyword === "help" || keyword === "?") { return {kind: "help"}; } if (keyword === "exit" || keyword === "quit" || keyword === "q") { return {kind: "exit"}; } if (keyword === "call") { return parseCall(tokens); } if (keyword === "agents" || keyword === "agent") { if (tokens.length === 0) { return { kind: "unknown", message: `I need a subcommand for agents. Try "/agents help" or "/help".` }; } const subcommand = tokens.shift()!.toLowerCase(); if (subcommand === "help") { return { kind: "agents", subcommand: "help", tokens: [] }; } return { kind: "agents", subcommand, tokens }; } const simpleKind = SIMPLE_COMMANDS[keyword]; if (simpleKind) { return {kind: simpleKind}; } const category = TASK_PREFIXES[keyword]; if (category) { if (tokens.length === 0) { return { kind: "unknown", message: `I need a subcommand for ${category} tasks. Try "/${category} help" or "/help".` }; } const subcommand = tokens.shift()!.toLowerCase(); if (subcommand === "help") { return { kind: "unknown", message: describeCategory(category) }; } return { kind: "task", category, subcommand, tokens }; } return { kind: "unknown", message: `I don't recognise the command "${keyword}". Try "/help" to see what I can do.` }; } function parseCall(tokens: string[]): CallCommand { let tool: string | undefined; let argsJson: string | undefined; if (tokens.length > 0 && !tokens[0].includes("=")) { tool = tokens.shift(); } for (const token of tokens) { const [key, value] = splitToken(token); if (!key || value === undefined) { continue; } if (key === "tool" && !tool) { tool = value; } if (key === "args" || key === "json") { argsJson = value; } } return { kind: "call", tool, argsJson, rawTokens: tokens }; } function describeCategory(category: TaskCategory): string { switch (category) { case "service": return "Service toolkit commands: /service add, /service delete, /service monitor, /service test, /service add-groups, /service remove-groups, /service create-group, /service delete-group, /service list-groups."; case "import": return "Registry import commands: /import dry, /import apply (optional import-list=)."; case "user": return "User management commands: /user create-m2m, /user create-human, /user delete, /user list, /user list-groups."; case "diagnostic": return "Diagnostics commands: /diagnostic run-suite, /diagnostic run-test."; default: return "Unknown category."; } } export function tokenize(text: string): string[] { const tokens: string[] = []; const regex = /"([^"\\]*(\\.[^"\\]*)*)"|'([^'\\]*(\\.[^'\\]*)*)'|[^\s]+/g; let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { const token = match[0]; tokens.push(unquote(token)); } return tokens; } function unquote(token: string): string { if (token.length >= 2) { const first = token[0]; const last = token[token.length - 1]; if ((first === '"' && last === '"') || (first === "'" && last === "'")) { const inner = token.slice(1, -1); return inner.replace(/\\(["'\\])/g, "$1").replace(/\\n/g, "\n").replace(/\\t/g, "\t"); } } return token; } export function splitToken(token: string): [string | undefined, string | undefined] { const index = token.indexOf("="); if (index === -1) { return [undefined, token]; } const key = token.slice(0, index).toLowerCase(); const value = token.slice(index + 1); return [key, value]; } ================================================ FILE: cli/src/chat/taskInterpreter.ts ================================================ import {getTaskByKey, resolveDefaultValues, taskCatalog} from "../tasks/index.js"; import type {ScriptTask, TaskCategory, TaskField} from "../tasks/types.js"; import type {TaskCommand} from "./commandParser.js"; import {splitToken} from "./commandParser.js"; interface TaskResolutionSuccess { task: ScriptTask; values: Record; } interface TaskResolutionError { error: string; } export type TaskResolution = TaskResolutionSuccess | TaskResolutionError; export function resolveTaskCommand(command: TaskCommand): TaskResolution { const {category, subcommand} = command; const taskKey = resolveTaskKey(category, subcommand); if (!taskKey) { const available = taskCatalog[category].map((task) => task.key.replace(`${category}-`, "")).join(", "); return { error: `I don't recognise "/${category} ${subcommand}". Available subcommands: ${available}.` }; } const task = getTaskByKey(category, taskKey); if (!task) { return { error: `Task "${taskKey}" is not available.` }; } const values = resolveDefaultValues(task); const assignments: Record = {...values}; const positionalFields = task.fields.filter((field) => !field.optional && !(field.name in assignments)); let positionalIndex = 0; for (const token of command.tokens) { const [key, value] = splitToken(token); if (key) { const field = findField(task.fields, key); if (!field) { return {error: `Unknown option "${key}" for "/${category} ${subcommand}".`}; } assignments[field.name] = value ?? ""; } else { if (positionalIndex >= task.fields.length) { return {error: `Too many positional values for "/${category} ${subcommand}".`}; } let field = positionalFields[positionalIndex]; while (field && field.name in assignments && assignments[field.name]) { positionalIndex += 1; field = positionalFields[positionalIndex]; } if (!field) { return {error: `Unexpected extra value "${token}" for "/${category} ${subcommand}".`}; } assignments[field.name] = token; positionalIndex += 1; } } for (const field of task.fields) { if (!field.optional) { const value = assignments[field.name]; if (!value || value.trim().length === 0) { return { error: `Missing required option "${field.name}" for "/${category} ${subcommand}".` }; } } } return { task, values: assignments }; } function resolveTaskKey(category: TaskCategory, subcommand: string): string | undefined { const normalized = subcommand.toLowerCase().replace(/_/g, "-"); const candidate = `${category}-${normalized}`; const hasTask = taskCatalog[category].some((task) => task.key === candidate); if (hasTask) { return candidate; } // Attempt to add common suffixes/prefixes const alt = taskCatalog[category].find((task) => { const suffix = task.key.replace(`${category}-`, ""); return suffix === normalized; }); return alt?.key; } function findField(fields: TaskField[], inputKey: string): TaskField | undefined { const lower = inputKey.toLowerCase(); return fields.find((field) => field.name.toLowerCase() === lower); } ================================================ FILE: cli/src/commands/executor.ts ================================================ import {parseCommand, type CallCommand, type TaskCommand, type AgentsCommand} from "../chat/commandParser.js"; import {resolveTaskCommand} from "../chat/taskInterpreter.js"; import {executeMcpCommand, formatMcpResult} from "../runtime/mcp.js"; import {runScriptTaskToString} from "../runtime/script.js"; import type {TaskContext} from "../tasks/types.js"; import {spawn} from "node:child_process"; import {REGISTRY_CLI_WRAPPER, REPO_ROOT} from "../paths.js"; export interface CommandExecutionContext extends TaskContext {} // Helper function to call the registry CLI wrapper async function callRegistryWrapper(args: string[], context: CommandExecutionContext): Promise<{stdout: string; stderr: string; exitCode: number}> { const baseArgs = [ "run", "python", REGISTRY_CLI_WRAPPER, "--base-url", context.gatewayBaseUrl, ...args ]; // Use backendToken if available, otherwise fall back to gatewayToken const token = context.backendToken || context.gatewayToken; const env = token ? {...process.env, GATEWAY_TOKEN: token} : process.env; return new Promise((resolve) => { const child = spawn("uv", baseArgs, { cwd: REPO_ROOT, env: env as NodeJS.ProcessEnv, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("close", (code) => { resolve({stdout, stderr, exitCode: code ?? -1}); }); child.on("error", (error) => { resolve({ stdout, stderr: `${stderr}\nFailed to start process: ${(error as Error).message}`, exitCode: -1 }); }); }); } export async function executeSlashCommand( input: string, context: CommandExecutionContext ): Promise<{lines: string[]; isError?: boolean; shouldExit?: boolean}> { const parsed = parseCommand(input); switch (parsed.kind) { case "help": return {lines: [detailedHelpMessage()]}; case "exit": return {lines: ["Goodbye!"], shouldExit: true}; case "ping": case "list": case "init": return await executeMcp(parsed.kind, context); case "servers": return await executeServers(context); case "call": return await executeCall(parsed, context); case "agents": return await executeAgents(parsed as AgentsCommand, context); case "task": { const resolution = resolveTaskCommand(parsed as TaskCommand); if ("error" in resolution) { return {lines: [resolution.error], isError: true}; } const result = await runScriptTaskToString(parsed.category, resolution.task, resolution.values, context); const lines = [ `$ ${result.command.command} ${result.command.args.join(" ")}`, result.stdout.trim(), result.stderr ? `stderr:\n${result.stderr.trim()}` : "", `exitCode: ${result.exitCode ?? 0}` ] .filter((line) => line && line.trim().length > 0) .join("\n\n"); return {lines: [lines]}; } case "unknown": default: return {lines: [(parsed as any).message], isError: true}; } } async function executeMcp(command: "ping" | "list" | "init", context: CommandExecutionContext) { const {handshake, response} = await executeMcpCommand( command, context.gatewayUrl, context.gatewayToken, context.backendToken ); const lines = formatMcpResult(command, handshake, response); return {lines}; } async function executeServers(context: CommandExecutionContext) { // Use the registry client to list servers instead of MCP call const result = await callRegistryWrapper(["anthropic", "list", "--limit", "1000"], context); if (result.exitCode !== 0) { return { lines: [`Error listing servers:`, result.stderr || result.stdout], isError: true }; } try { const data = JSON.parse(result.stdout); const servers = data.servers || []; if (servers.length === 0) { return {lines: ["No servers found."]}; } const lines: string[] = [`Found ${servers.length} MCP servers:\n`]; servers.forEach((serverResponse: any, index: number) => { const server = serverResponse.server || serverResponse; const meta = server._meta || server.meta || {}; const internalMeta = meta['io.mcpgateway/internal'] || {}; lines.push(`${index + 1}. ${server.name || 'Unknown'}`); lines.push(` Path: ${internalMeta.path || 'N/A'}`); lines.push(` Status: ${internalMeta.is_enabled ? 'enabled' : 'disabled'}`); if (server.description) { const desc = server.description.length > 80 ? server.description.substring(0, 80) + '...' : server.description; lines.push(` Description: ${desc}`); } if (server.tags && server.tags.length > 0) { lines.push(` Tags: ${server.tags.slice(0, 5).join(', ')}${server.tags.length > 5 ? '...' : ''}`); } if (server.tools && server.tools.length > 0) { lines.push(` Tools: ${server.tools.length}`); } lines.push(''); }); lines.push(`Total: ${servers.length} servers\n`); lines.push('Tip: Ask "tell me more about server X" for detailed info'); return {lines}; } catch (error) { return {lines: [`Error parsing server list: ${(error as Error).message}`], isError: true}; } } async function executeCall(parsed: CallCommand, context: CommandExecutionContext) { if (!parsed.tool) { return {lines: ["Tool name is required for /call."], isError: true}; } let args: Record = {}; if (parsed.argsJson) { try { args = JSON.parse(parsed.argsJson); } catch (error) { return {lines: [`Invalid JSON for args: ${(error as Error).message}`], isError: true}; } } const {handshake, response} = await executeMcpCommand( "call", context.gatewayUrl, context.gatewayToken, context.backendToken, {tool: parsed.tool, args} ); const lines = formatMcpResult("call", handshake, response, parsed.tool); return {lines}; } async function executeAgents(parsed: AgentsCommand, context: CommandExecutionContext) { const subcommand = parsed.subcommand.toLowerCase(); switch (subcommand) { case "help": return {lines: [describeAgents()]}; case "list": return await executeAgentsList(context); case "get": if (parsed.tokens.length === 0) { return {lines: ["Agent path required. Usage: /agents get /agent-path"], isError: true}; } return await executeAgentsGet(parsed.tokens[0], context); case "search": if (parsed.tokens.length === 0) { return {lines: ["Search query required. Usage: /agents search "], isError: true}; } return await executeAgentsSearch(parsed.tokens.join(" "), context); case "test": if (parsed.tokens.length === 0) { return {lines: ["Agent path required. Usage: /agents test /agent-path"], isError: true}; } return await executeAgentsTest(parsed.tokens[0], context); case "test-all": return await executeAgentsTestAll(context); default: return {lines: [`Unknown agent subcommand: ${subcommand}. Try "/agents help".`], isError: true}; } } async function executeAgentsList(context: CommandExecutionContext) { const result = await callRegistryWrapper(["agent", "list"], context); if (result.exitCode !== 0) { return { lines: [`Error listing agents:`, result.stderr || result.stdout], isError: true }; } return {lines: [result.stdout]}; } async function executeAgentsGet(agentPath: string, context: CommandExecutionContext) { const result = await callRegistryWrapper(["agent", "get", agentPath], context); if (result.exitCode !== 0) { return { lines: [`Error getting agent:`, result.stderr || result.stdout], isError: true }; } return {lines: [result.stdout]}; } async function executeAgentsSearch(query: string, context: CommandExecutionContext) { const result = await callRegistryWrapper(["agent", "search", query], context); if (result.exitCode !== 0) { return { lines: [`Error searching agents:`, result.stderr || result.stdout], isError: true }; } return {lines: [result.stdout]}; } async function executeAgentsTest(agentPath: string, context: CommandExecutionContext) { const result = await callRegistryWrapper(["agent", "get", agentPath], context); if (result.exitCode !== 0) { return { lines: [`Error testing agent:`, result.stderr || result.stdout], isError: true }; } try { const agent = JSON.parse(result.stdout); const lines: string[] = []; lines.push(`Testing agent: ${agent.name || agentPath}`); lines.push(`✓ Agent registered`); lines.push(`✓ Endpoint accessible`); if (agent.is_enabled) { lines.push(`✓ Agent enabled`); } else { lines.push(`⚠ Agent is disabled`); } return {lines}; } catch (error) { return {lines: [`Error parsing agent data: ${(error as Error).message}`], isError: true}; } } async function executeAgentsTestAll(context: CommandExecutionContext) { const result = await callRegistryWrapper(["agent", "list"], context); if (result.exitCode !== 0) { return { lines: [`Error testing agents:`, result.stderr || result.stdout], isError: true }; } try { const data = JSON.parse(result.stdout); const agents = Array.isArray(data.agents) ? data.agents : []; if (agents.length === 0) { return {lines: ["No agents to test."]}; } const lines: string[] = [`Testing ${agents.length} agent(s)...\n`]; let healthy = 0; let unhealthy = 0; agents.forEach((agent: any) => { if (agent.is_enabled) { lines.push(`✓ ${agent.name || agent.path} - operational`); healthy++; } else { lines.push(`✗ ${agent.name || agent.path} - disabled`); unhealthy++; } }); lines.push(""); lines.push(`Summary: ${healthy}/${agents.length} agents operational`); if (unhealthy > 0) { lines.push(`Issue detected: ${unhealthy} agent(s) disabled or unavailable`); } return {lines}; } catch (error) { return {lines: [`Error parsing agent data: ${(error as Error).message}`], isError: true}; } } function describeAgents(): string { return [ "Agent Registry Commands", "", "Discover and interact with registered A2A agents:", "", " /agents list List all available agents", " /agents get Get details about a specific agent", " /agents search Search agents by capability", " /agents test Test agent availability", " /agents test-all Test all agents", "", "Examples:", " /agents list", " /agents get /code-reviewer", " /agents search \"code review\"", " /agents test /code-reviewer", "", "For more information, see the Agent CLI Guide: docs/agents-cli-guide.md" ].join("\n"); } export function overviewMessage(): string { return [ "Chat with me using natural language - I can discover and use MCP tools for you!", "", "Essential commands:", " /help Show help message", " /exit Exit the CLI", " /ping Test gateway connectivity", " /list List available tools", " /servers List all MCP servers", " /agents Discover and use A2A agents", "", "Examples:", " \"How do I import servers from the Anthropic registry?\"", " \"What authentication methods are supported by the servers?\"", " \"What transport types do the servers support (stdio, SSE, HTTP)?\"", " \"What agents are available?\"", " \"Can you review my code?\"", "" ].join("\n"); } export function detailedHelpMessage(): string { const basicCommands = [ { cmd: "/help", desc: "Show this help message" }, { cmd: "/servers", desc: "List all MCP servers" }, { cmd: "/exit", desc: "Exit the CLI (aliases: /quit, /q)" } ]; const advancedCommands = [ { cmd: "/ping", desc: "Check MCP gateway connectivity" }, { cmd: "/list", desc: "List MCP tools from current server" }, { cmd: "/call", args: "tool= args=''", desc: "Invoke a tool directly" }, { cmd: "/refresh", desc: "Refresh OAuth tokens" }, { cmd: "/retry", desc: "Retry authentication" } ]; const agentCommands = [ { cmd: "/agents", desc: "Agent registry help" }, { cmd: "/agents list", desc: "List all available agents" }, { cmd: "/agents get", args: "", desc: "Get details about an agent" }, { cmd: "/agents search", args: "", desc: "Search agents by capability" }, { cmd: "/agents test", args: "", desc: "Test agent availability" }, { cmd: "/agents test-all", desc: "Test all registered agents" } ]; const registryCommands = [ { cmd: "/service", desc: "Service management (add, delete, monitor, test, groups)" }, { cmd: "/import", desc: "Import from registry (dry, apply)" }, { cmd: "/user", desc: "User management (create-m2m, create-human, delete, list)" }, { cmd: "/diagnostic", desc: "Run diagnostics (run-suite, run-test)" } ]; const formatCommands = (cmds: Array<{cmd: string; args?: string; desc: string}>) => { const maxLength = Math.max(...cmds.map(c => (c.cmd + (c.args ? " " + c.args : "")).length)); return cmds.map(({cmd, args, desc}) => { const full = cmd + (args ? " " + args : ""); const padding = " ".repeat(maxLength - full.length + 2); return ` ${full}${padding}${desc}`; }); }; return [ "MCP Gateway CLI - Natural Language Interface", "", "PREFERRED: Use natural language to interact with MCP tools", "Examples:", " \"What tools are available?\"", " \"Check the current time in New York\"", " \"Find tools for weather information\"", " \"What agents are available?\"", " \"Can you find an agent for code review?\"", "", "Basic Commands:", ...formatCommands(basicCommands), "", "Advanced Commands (for debugging):", ...formatCommands(advancedCommands), "", "Agent Management:", ...formatCommands(agentCommands), "", "Registry Management:", ...formatCommands(registryCommands) ].join("\n"); } ================================================ FILE: cli/src/components/Banner.tsx ================================================ import React from "react"; import { Box, Text } from "ink"; export function Banner() { return ( {"███╗ ███╗ ██████╗██████╗ "} {"██████╗ ███████╗ ██████╗ ██╗███████╗████████╗██████╗ ██╗ ██╗"} {" ██████╗██╗ ██╗"} {"████╗ ████║██╔════╝██╔══██╗"} {"██╔══██╗██╔════╝██╔════╝ ██║██╔════╝╚══██╔══╝██╔══██╗╚██╗ ██╔╝"} {" ██╔════╝██║ ██║"} {"██╔████╔██║██║ ██████╔╝"} {"██████╔╝█████╗ ██║ ███╗██║███████╗ ██║ ██████╔╝ ╚████╔╝ "} {" ██║ ██║ ██║"} {"██║╚██╔╝██║██║ ██╔═══╝ "} {"██╔══██╗██╔══╝ ██║ ██║██║╚════██║ ██║ ██╔══██╗ ╚██╔╝ "} {" ██║ ██║ ██║"} {"██║ ╚═╝ ██║╚██████╗██║ "} {"██║ ██║███████╗╚██████╔╝██║███████║ ██║ ██║ ██║ ██║ "} {" ╚██████╗███████╗██║"} {"╚═╝ ╚═╝ ╚═════╝╚═╝ "} {"╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ "} {" ╚═════╝╚══════╝╚═╝"} ); } ================================================ FILE: cli/src/components/CallToolForm.tsx ================================================ import React, {useState} from "react"; import {Box, Text, useInput} from "ink"; import TextInput from "ink-text-input"; export interface CallToolPayload { tool: string; args: string; } interface CallToolFormProps { initialTool?: string; initialArgs?: string; onSubmit: (payload: CallToolPayload) => void; onCancel: () => void; } const DEFAULT_ARGS = "{}"; export function CallToolForm({initialTool, initialArgs, onSubmit, onCancel}: CallToolFormProps) { const [step, setStep] = useState<"tool" | "args">("tool"); const [tool, setTool] = useState(initialTool ?? ""); const [args, setArgs] = useState(initialArgs ?? DEFAULT_ARGS); useInput((_input, key) => { if (key.escape) { onCancel(); } }); const handleToolSubmit = (value: string) => { const trimmed = value.trim(); if (trimmed.length === 0) { return; } setTool(trimmed); setStep("args"); }; const handleArgsSubmit = (value: string) => { onSubmit({ tool, args: value.trim().length === 0 ? DEFAULT_ARGS : value.trim() }); }; return ( Tool name {step === "args" && ( Tool arguments (JSON) Press ↵ to run, Esc to cancel. Leave blank to send {DEFAULT_ARGS}. )} ); } ================================================ FILE: cli/src/components/CommandSuggestions.tsx ================================================ import React from "react"; import { Box, Text } from "ink"; import type { CommandOption } from "../utils/commands.js"; interface CommandSuggestionsProps { suggestions: CommandOption[]; selectedIndex: number; } export function CommandSuggestions({ suggestions, selectedIndex }: CommandSuggestionsProps) { if (suggestions.length === 0) { return null; } // Calculate max command length for alignment const maxCommandLength = Math.max(...suggestions.map(s => s.command.length)); return ( {suggestions.map((suggestion, index) => { const isSelected = index === selectedIndex; const padding = " ".repeat(maxCommandLength - suggestion.command.length); return ( {isSelected ? "› " : " "} {suggestion.command} {padding} {suggestion.description} ); })} ); } ================================================ FILE: cli/src/components/JsonViewer.tsx ================================================ import React from "react"; import {Box, Text} from "ink"; interface JsonViewerProps { data: unknown; label?: string; raw?: boolean; } export function JsonViewer({data, label, raw}: JsonViewerProps) { const json = stringify(data, raw); return ( {label ? ( {label} ) : null} {json} ); } function stringify(data: unknown, raw = false): string { if (raw) { return typeof data === "string" ? data : JSON.stringify(data); } return JSON.stringify(data, null, 2); } ================================================ FILE: cli/src/components/MultiStepForm.tsx ================================================ import React, {useEffect, useMemo, useState} from "react"; import {Box, Text, useInput} from "ink"; import TextInput from "ink-text-input"; import type {TaskField} from "../tasks/types.js"; interface MultiStepFormProps { fields: TaskField[]; initialValues?: Record; onSubmit: (values: Record) => void; onCancel: () => void; heading: string; } export function MultiStepForm({fields, initialValues = {}, onSubmit, onCancel, heading}: MultiStepFormProps) { const [stepIndex, setStepIndex] = useState(0); const [values, setValues] = useState>({...initialValues}); const [inputValue, setInputValue] = useState(""); const [error, setError] = useState(); const currentField = fields[stepIndex]; useEffect(() => { if (fields.length === 0) { onSubmit(values); } }, [fields, onSubmit, values]); useEffect(() => { if (currentField) { setInputValue(values[currentField.name] ?? currentField.defaultValue ?? ""); } }, [currentField, values]); useInput((input, key) => { if (key.escape) { onCancel(); } if (!currentField) { if (key.return) { onSubmit(values); } return; } if (input === "\u0017") { // ctrl+w clears input setInputValue(""); } }); const instructions = useMemo(() => { if (!currentField) { return "Press ↵ to continue or Esc to cancel."; } return currentField.optional ? "Enter a value or leave blank, ↵ to accept, Esc to cancel." : "Enter a value, ↵ to accept, Esc to cancel."; }, [currentField]); const handleSubmit = (value: string) => { if (!currentField) { onSubmit(values); return; } const trimmed = value.trim(); if (!currentField.optional && trimmed.length === 0 && !(currentField.defaultValue && currentField.defaultValue.length > 0)) { setError("This field is required."); return; } setError(undefined); const nextValues = { ...values, [currentField.name]: trimmed.length === 0 ? "" : trimmed }; setValues(nextValues); if (stepIndex + 1 >= fields.length) { onSubmit(nextValues); return; } setStepIndex((index) => index + 1); }; if (!currentField && fields.length > 0) { return null; } return ( {heading} {currentField ? ( {currentField.label} {currentField.optional ? (optional) : null} {currentField.placeholder ? {currentField.placeholder} : null} ) : ( All fields captured. Press ↵ to continue. )} {instructions} {error ? {error} : null} ); } ================================================ FILE: cli/src/components/StatusMessage.tsx ================================================ import React from "react"; import {Text} from "ink"; interface StatusMessageProps { variant: "info" | "warning" | "error"; message: string; } export function StatusMessage({variant, message}: StatusMessageProps) { if (variant === "warning") { return {message}; } if (variant === "error") { return {`❌ ${message}`}; } return {message}; } ================================================ FILE: cli/src/components/TaskRunner.tsx ================================================ import React, {useEffect, useRef, useState} from "react"; import {Box, Text, useInput} from "ink"; import {spawn} from "node:child_process"; import {REPO_ROOT} from "../paths.js"; import type {ScriptCommand} from "../tasks/types.js"; type RunnerStatus = "running" | "success" | "error"; interface LogEntry { id: number; type: "stdout" | "stderr"; text: string; } interface TaskRunnerProps { title: string; description?: string; command: ScriptCommand; onDone: (exitCode: number | null) => void; } export function TaskRunner({title, description, command, onDone}: TaskRunnerProps) { const [status, setStatus] = useState("running"); const [exitCode, setExitCode] = useState(null); const [logs, setLogs] = useState([]); const nextId = useRef(0); const processRef = useRef | null>(null); useEffect(() => { const env = command.env ? {...process.env, ...command.env} : process.env; const child = spawn(command.command, command.args, { cwd: REPO_ROOT, env, stdio: ["ignore", "pipe", "pipe"] }); processRef.current = child; const handleData = (type: LogEntry["type"]) => (chunk: Buffer) => { const text = chunk.toString(); const lines = text.replace(/\r\n/g, "\n").split("\n"); setLogs((prev) => [ ...prev, ...lines .filter((line) => line.length > 0) .map((line) => ({ id: nextId.current++, type, text: line })) ]); }; child.stdout?.on("data", handleData("stdout")); child.stderr?.on("data", handleData("stderr")); child.on("close", (code) => { setExitCode(code); setStatus(code === 0 ? "success" : "error"); }); child.on("error", (error) => { setLogs((prev) => [ ...prev, { id: nextId.current++, type: "stderr", text: `Failed to start process: ${error.message}` } ]); setExitCode(-1); setStatus("error"); }); return () => { if (processRef.current && status === "running") { processRef.current.kill("SIGTERM"); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useInput((input, key) => { if (status === "running") { if (key.escape || (key.ctrl && input === "c")) { processRef.current?.kill("SIGINT"); } return; } if (key.return || input === "q") { onDone(exitCode); } }); return ( {title} {description ? {description} : null} Command:  {command.command} {command.args.join(" ")} {logs.length === 0 ? No output yet... : null} {logs.map((entry) => ( {entry.text} ))} {status === "running" ? ( Running… (Esc to cancel) ) : status === "success" ? ( ✓ Completed with exit code {exitCode ?? 0}. Press ↵ to return or q to quit this view. ) : ( ✗ Failed with exit code {exitCode ?? -1}. Press ↵ to return or q to quit this view. )} ); } ================================================ FILE: cli/src/components/TokenFileEditor.tsx ================================================ import React, {useState} from "react"; import {Box, Text, useInput} from "ink"; import TextInput from "ink-text-input"; interface TokenFileEditorProps { initialPath?: string; onSubmit: (value?: string) => void; onCancel: () => void; } export function TokenFileEditor({initialPath, onSubmit, onCancel}: TokenFileEditorProps) { const [value, setValue] = useState(initialPath ?? ""); useInput((_input, key) => { if (key.escape) { onCancel(); } }); const handleSubmit = (input: string) => { const trimmed = input.trim(); onSubmit(trimmed.length > 0 ? trimmed : undefined); }; return ( Token file path Enter a path to use, leave blank to clear, Esc to cancel. ); } ================================================ FILE: cli/src/components/TokenStatusFooter.tsx ================================================ import {Box, Text} from "ink"; interface TokenStatusFooterProps { secondsRemaining?: number; expired: boolean; isRefreshing: boolean; lastRefresh?: Date; source?: string; model?: string; inputTokens?: number; outputTokens?: number; cost?: number; registryVersion?: string; } export function TokenStatusFooter({ secondsRemaining, expired, isRefreshing, lastRefresh, source, model, inputTokens, outputTokens, cost, registryVersion }: TokenStatusFooterProps) { const formatTime = (seconds: number): string => { if (seconds < 0) return "expired"; if (seconds < 60) return `${seconds}s`; const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}m ${secs}s`; }; const getStatusText = (): string => { if (isRefreshing) return "Refreshing..."; if (expired || (secondsRemaining !== undefined && secondsRemaining <= 0)) return "Expired"; if (secondsRemaining !== undefined) return `Valid for ${formatTime(secondsRemaining)}`; return "Unknown"; }; const getStatusColor = (): string => { if (isRefreshing) return "cyan"; if (expired || (secondsRemaining !== undefined && secondsRemaining <= 0)) return "red"; if (secondsRemaining !== undefined && secondsRemaining < 60) return "yellow"; return "green"; }; const lastRefreshText = lastRefresh ? lastRefresh.toLocaleTimeString("en-US", {hour12: false}) : "N/A"; const formatCost = (costValue: number): string => { if (costValue >= 0.01) { return `$${costValue.toFixed(2)}`; } else if (costValue >= 0.001) { return `$${costValue.toFixed(4)}`; } else if (costValue > 0) { return `$${costValue.toFixed(6)}`; } else { return "$0.00"; } }; return ( Token: {getStatusText()} {source && ( | Source: {source} )} | Last refresh: {lastRefreshText} {model && ( | Model: {model} )} {(inputTokens !== undefined || outputTokens !== undefined) && (inputTokens! > 0 || outputTokens! > 0) && ( | Tokens: In: {(inputTokens || 0).toLocaleString()} | Out: {(outputTokens || 0).toLocaleString()} )} {cost !== undefined && cost > 0 && ( | Cost: {formatCost(cost)} )} {registryVersion && ( | Registry: {registryVersion} )} ); } ================================================ FILE: cli/src/components/UrlEditor.tsx ================================================ import React, {useState} from "react"; import {Box, Text, useInput} from "ink"; import TextInput from "ink-text-input"; interface UrlEditorProps { initialUrl: string; onSubmit: (value: string) => void; onCancel: () => void; } export function UrlEditor({initialUrl, onSubmit, onCancel}: UrlEditorProps) { const [value, setValue] = useState(initialUrl); useInput((_input, key) => { if (key.escape) { onCancel(); } }); const handleSubmit = (url: string) => { const trimmed = url.trim(); if (trimmed.length > 0) { onSubmit(trimmed); } else { onCancel(); } }; return ( Gateway URL Press ↵ to confirm or Esc to keep the current URL. ); } ================================================ FILE: cli/src/index.tsx ================================================ #!/usr/bin/env node import "dotenv/config"; import React from "react"; import {render} from "ink"; import App from "./app.js"; import {HELP_TEXT, parseArgs} from "./parseArgs.js"; const parsed = parseArgs(process.argv.slice(2)); if (parsed.helpRequested) { // eslint-disable-next-line no-console console.log(HELP_TEXT); process.exit(0); } if (parsed.unknown.length > 0) { // eslint-disable-next-line no-console console.warn(`Ignoring unknown arguments: ${parsed.unknown.join(", ")}`); } render(); ================================================ FILE: cli/src/parseArgs.ts ================================================ export type CommandName = "ping" | "list" | "call" | "init"; export interface ParsedArgs { url?: string; tokenFile?: string; token?: string; command?: CommandName; tool?: string; args?: string; json?: boolean; interactive: boolean; helpRequested?: boolean; unknown: string[]; } const COMMANDS = new Set(["ping", "list", "call", "init"]); export const HELP_TEXT = ` Usage mcp-ink [options] [command] Commands ping Test connectivity with the configured MCP gateway list List available tools for the current session call Invoke a specific tool (use --tool and --args) init Initialize the session and print the handshake payload Options --url, -u Override the MCP gateway URL (default: http://localhost/mcpgw/mcp) --token-file, -t Path to a file containing a bearer token --token Explicit bearer token (overrides token file) --command Run a command non-interactively (alias for specifying the command positionally) --tool Tool name for the call command --args JSON string with tool arguments for the call command --json Print raw JSON responses without formatting --interactive Force interactive mode even when a command is provided --no-interactive Force non-interactive mode --help, -h Show this help message `.trim(); export function parseArgs(argv: string[]): ParsedArgs { const result: ParsedArgs = { interactive: true, unknown: [] }; const consumeValue = (index: number): string | undefined => { const value = argv[index + 1]; if (value === undefined) { return undefined; } return value; }; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; switch (arg) { case "--url": case "-u": { const value = consumeValue(i); if (value !== undefined) { result.url = value; i += 1; } break; } case "--token-file": case "-t": { const value = consumeValue(i); if (value !== undefined) { result.tokenFile = value; i += 1; } break; } case "--token": { const value = consumeValue(i); if (value !== undefined) { result.token = value; i += 1; } break; } case "--command": { const value = consumeValue(i); if (value !== undefined && isCommand(value)) { result.command = value; result.interactive = false; i += 1; } break; } case "--tool": { const value = consumeValue(i); if (value !== undefined) { result.tool = value; i += 1; } break; } case "--args": { const value = consumeValue(i); if (value !== undefined) { result.args = value; i += 1; } break; } case "--json": { result.json = true; break; } case "--interactive": { result.interactive = true; break; } case "--no-interactive": { result.interactive = false; break; } case "--help": case "-h": { result.helpRequested = true; result.interactive = false; break; } default: { if (arg.startsWith("--")) { result.unknown.push(arg); break; } if (!result.command && isCommand(arg)) { result.command = arg; result.interactive = false; break; } if (result.command === "call") { if (!result.tool) { result.tool = arg; break; } if (!result.args) { result.args = arg; break; } } result.unknown.push(arg); } } } return result; } function isCommand(value: string): value is CommandName { return COMMANDS.has(value as CommandName); } ================================================ FILE: cli/src/paths.ts ================================================ import path from "node:path"; import {fileURLToPath} from "node:url"; const SRC_DIR = fileURLToPath(new URL(".", import.meta.url)); export const CLI_ROOT = path.resolve(SRC_DIR, ".."); export const REPO_ROOT = path.resolve(CLI_ROOT, ".."); // Modern Python API wrapper (replaces deprecated shell scripts) export const REGISTRY_CLI_WRAPPER = path.join(CLI_ROOT, "registry_cli_wrapper.py"); // Legacy scripts (deprecated - kept for backwards compatibility) export const SERVICE_MANAGEMENT_SCRIPT = path.join(CLI_ROOT, "service_mgmt.sh"); export const IMPORT_ANTHROPIC_SCRIPT = path.join(CLI_ROOT, "import_from_anthropic_registry.sh"); export const USER_MANAGEMENT_SCRIPT = path.join(CLI_ROOT, "user_mgmt.sh"); export const TEST_ANTHROPIC_SCRIPT = path.join(CLI_ROOT, "test_anthropic_api.py"); export const DEFAULT_IMPORT_LIST = path.join(CLI_ROOT, "import_server_list.txt"); ================================================ FILE: cli/src/runtime/mcp.ts ================================================ import type {CommandName} from "../parseArgs.js"; import type {JsonRpcResponse} from "../types/mcp.js"; import {executePythonMcpCommand} from "./pythonClient.js"; export interface McpExecutionResult { handshake: JsonRpcResponse; response: JsonRpcResponse; } /** * Execute MCP command using the Python client backend. * * This function bridges the TypeScript CLI to the Python mcp_client.py, * eliminating duplicate client implementations while maintaining the Ink UI. */ export async function executeMcpCommand( command: CommandName, gatewayUrl: string, gatewayToken?: string, backendToken?: string, callOptions?: {tool: string; args: Record} ): Promise { // Delegate to Python client return executePythonMcpCommand( command, gatewayUrl, gatewayToken, backendToken, callOptions ); } export function formatMcpResult( command: "ping" | "list" | "init" | "call", handshake: JsonRpcResponse, response: JsonRpcResponse, tool?: string ): string[] { const lines: string[] = []; const sessionId = (handshake as {result?: {sessionId?: string}}).result?.sessionId; if (sessionId) { lines.push(`Session established: ${sessionId}`); } if (command === "ping") { lines.push("Ping response:"); lines.push(JSON.stringify(response, null, 2)); } else if (command === "list") { lines.push("Available tools:"); lines.push(JSON.stringify(response, null, 2)); } else if (command === "call") { lines.push(`Tool "${tool}" response:`); lines.push(JSON.stringify(response, null, 2)); } else if (command === "init") { lines.push("Initialization payload:"); lines.push(JSON.stringify(handshake, null, 2)); } return lines; } ================================================ FILE: cli/src/runtime/pythonClient.ts ================================================ import {spawn, type ChildProcess} from "child_process"; import {resolve, join, dirname} from "path"; import {writeFileSync, unlinkSync, mkdtempSync} from "fs"; import {tmpdir} from "os"; import {fileURLToPath} from "url"; import type {JsonRpcResponse} from "../types/mcp.js"; // ES module compatibility for __dirname const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); export interface PythonMcpExecutionResult { handshake: JsonRpcResponse; response: JsonRpcResponse; } /** * Execute Python MCP client command * * This bridges the TypeScript CLI to the Python mcp_client.py, * eliminating duplicate client code while maintaining the Ink UI. */ export async function executePythonMcpCommand( command: "ping" | "list" | "init" | "call", gatewayUrl: string, gatewayToken?: string, backendToken?: string, callOptions?: {tool: string; args: Record} ): Promise { // Build Python command arguments const pythonScript = resolve(__dirname, "../../mcp_client.py"); const args = ["--url", gatewayUrl]; let tokenFile: string | undefined; // Add authentication if available // Priority: gatewayToken (for MCP gateway) > backendToken (for specific servers) const tokenToUse = gatewayToken || backendToken; if (tokenToUse) { // Use a temporary file to pass the token // Write ONLY the token string (not JSON) as Python client expects plain token const tmpDir = mkdtempSync(join(tmpdir(), "mcp-token-")); tokenFile = join(tmpDir, ".mcp_token"); try { // Ensure we write just the token string, not any JSON wrapper const tokenString = tokenToUse.trim(); writeFileSync(tokenFile, tokenString); args.push("--token-file", tokenFile); } catch (error) { // Clean up temp file try { if (tokenFile) { unlinkSync(tokenFile); } } catch { // Ignore cleanup errors } throw error; } } // Add command args.push(command); // Add tool call parameters if needed if (command === "call" && callOptions) { args.push("--tool", callOptions.tool); if (callOptions.args && Object.keys(callOptions.args).length > 0) { args.push("--args", JSON.stringify(callOptions.args)); } } // Execute Python script return new Promise((promiseResolve, promiseReject) => { let stdout = ""; let stderr = ""; // Use uv run to execute the Python script const proc: ChildProcess = spawn("uv", ["run", pythonScript, ...args], { cwd: resolve(__dirname, "../.."), env: process.env }); if (proc.stdout) { proc.stdout.on("data", (data: Buffer) => { stdout += data.toString(); }); } if (proc.stderr) { proc.stderr.on("data", (data: Buffer) => { stderr += data.toString(); }); } proc.on("error", (error: Error) => { // Clean up temp file if (tokenFile) { try { unlinkSync(tokenFile); } catch { // Ignore cleanup errors } } promiseReject(new Error(`Failed to execute Python client: ${error.message}`)); }); proc.on("close", (code: number | null) => { // Clean up temp file if (tokenFile) { try { unlinkSync(tokenFile); } catch { // Ignore cleanup errors } } if (code !== 0) { promiseReject(new Error(`Python client exited with code ${code}: ${stderr}`)); return; } try { // Parse the JSON output from Python client const lines = stdout.trim().split("\n"); // Find the JSON response (skip authentication success messages) // The JSON may span multiple lines, so we need to collect all lines from the first { to the last } let jsonStartIndex = -1; let jsonEndIndex = -1; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (jsonStartIndex === -1 && (line.startsWith("{") || line.startsWith("["))) { jsonStartIndex = i; } if (jsonStartIndex !== -1 && (line.endsWith("}") || line.endsWith("]"))) { jsonEndIndex = i; // Continue to find the last closing brace } } if (jsonStartIndex === -1 || jsonEndIndex === -1) { promiseReject(new Error(`No JSON output from Python client: ${stdout}`)); return; } // Collect all lines from start to end of JSON const jsonOutput = lines.slice(jsonStartIndex, jsonEndIndex + 1).join("\n"); const result = JSON.parse(jsonOutput); // Transform Python response to match TypeScript interface let handshake: JsonRpcResponse; let response: JsonRpcResponse; if (command === "init") { // For init, both are the same handshake = result; response = result; } else { // For other commands, create a basic handshake response handshake = { jsonrpc: "2.0", result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "mcp-gateway", version: "1.0.0" } } }; response = result; } promiseResolve({handshake, response}); } catch (error) { promiseReject(new Error(`Failed to parse Python client output: ${(error as Error).message}\nOutput: ${stdout}`)); } }); }); } ================================================ FILE: cli/src/runtime/script.ts ================================================ import {spawn} from "node:child_process"; import {REPO_ROOT} from "../paths.js"; import {taskCatalog} from "../tasks/index.js"; import type {ScriptCommand, ScriptTask, TaskCategory, TaskContext} from "../tasks/types.js"; export interface ScriptRunResult { stdout: string; stderr: string; exitCode: number | null; command: ScriptCommand; task: ScriptTask; } export function resolveTask(category: TaskCategory, key: string): ScriptTask | undefined { return taskCatalog[category].find((task) => task.key === key); } export async function runScriptTaskToString( category: TaskCategory, task: ScriptTask, values: Record, context: TaskContext ): Promise { const command = task.build(values, context); const env = command.env ? {...process.env, ...command.env} : process.env; return new Promise((resolve) => { const child = spawn(command.command, command.args, { cwd: REPO_ROOT, env, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("close", (code) => { resolve({stdout, stderr, exitCode: code, command, task}); }); child.on("error", (error) => { resolve({ stdout, stderr: `${stderr}\nFailed to start process: ${(error as Error).message}`, exitCode: -1, command, task }); }); }); } ================================================ FILE: cli/src/tasks/index.ts ================================================ import path from "node:path"; import { DEFAULT_IMPORT_LIST, IMPORT_ANTHROPIC_SCRIPT, REGISTRY_CLI_WRAPPER, SERVICE_MANAGEMENT_SCRIPT, TEST_ANTHROPIC_SCRIPT, USER_MANAGEMENT_SCRIPT } from "../paths.js"; import type {ScriptCommand, ScriptTask, TaskCategory, TaskContext} from "./types.js"; const trim = (value: string | undefined): string => value?.trim() ?? ""; const buildBashCommand = (scriptPath: string, args: string[], env?: Record): ScriptCommand => ({ command: "bash", args: [scriptPath, ...args], env }); const buildUvPythonCommand = (scriptPath: string, args: string[], env?: Record): ScriptCommand => ({ command: "uv", args: ["run", "python", scriptPath, ...args], env }); // Build command for Registry Management API wrapper const buildRegistryCommand = (args: string[], context: TaskContext): ScriptCommand => { const baseArgs = [ "run", "python", REGISTRY_CLI_WRAPPER, "--base-url", context.gatewayBaseUrl ]; // Add token from context if available (backend token takes precedence) if (context.backendToken) { // Token is already a string, pass it as environment variable // since the wrapper can read from GATEWAY_TOKEN env var return { command: "uv", args: [...baseArgs, ...args], env: { ...process.env, GATEWAY_TOKEN: context.backendToken } }; } return { command: "uv", args: [...baseArgs, ...args], env: process.env as Record }; }; const computeGatewayEnv = (context: TaskContext): Record => ({ ...process.env, GATEWAY_URL: context.gatewayBaseUrl }); const serviceTasks: ScriptTask[] = [ { key: "service-add", label: "Add service from config", description: "Validate the config and register the service via MCP gateway tools.", fields: [ { name: "configPath", label: "Config file path", placeholder: "cli/examples/server-config.json" } ], build(values, context) { const configPath = trim(values.configPath); if (!configPath) { throw new Error("Config file path is required."); } return buildRegistryCommand( ["service", "add", configPath], context ); } }, { key: "service-delete", label: "Delete service", description: "Remove a service by path and name and clean up group assignments.", fields: [ { name: "servicePath", label: "Service path (e.g. /example-server)", placeholder: "/example-server" }, { name: "serviceName", label: "Service name", placeholder: "example-server" } ], build(values, context) { const servicePath = trim(values.servicePath); const serviceName = trim(values.serviceName); if (!servicePath || !serviceName) { throw new Error("Service path and name are required."); } return buildRegistryCommand( ["service", "delete", servicePath], context ); } }, { key: "service-monitor", label: "Monitor services", description: "Run health checks for all services or a specific config.", fields: [ { name: "configPath", label: "Optional config file path", placeholder: "(leave blank for all services)", optional: true } ], build(values, context) { // Monitor is essentially list with detailed output return buildRegistryCommand( ["service", "list"], context ); } }, { key: "service-create-group", label: "Create group", description: "Create a Keycloak group for MCP servers.", fields: [ { name: "groupName", label: "Group name", placeholder: "mcp-servers-team-x" }, { name: "description", label: "Description", placeholder: "Team X access", optional: true } ], build(values, context) { const groupName = trim(values.groupName); if (!groupName) { throw new Error("Group name is required."); } const description = trim(values.description); const args = description ? ["group", "create", "--name", groupName, "--description", description] : ["group", "create", "--name", groupName]; return buildRegistryCommand(args, context); } }, { key: "service-delete-group", label: "Delete group", description: "Delete a Keycloak group.", fields: [ { name: "groupName", label: "Group name", placeholder: "mcp-servers-team-x" } ], build(values, context) { const groupName = trim(values.groupName); if (!groupName) { throw new Error("Group name is required."); } return buildRegistryCommand( ["group", "delete", "--name", groupName], context ); } }, { key: "service-list-groups", label: "List groups", description: "List Keycloak groups.", fields: [], build(_values, context) { return buildRegistryCommand( ["group", "list"], context ); } } ]; const importTasks: ScriptTask[] = [ { key: "import-anthropic-dry", label: "Anthropic import (dry run)", description: "Preview the servers that would be imported from the Anthropic registry.", fields: [ { name: "importList", label: "Import list file", placeholder: DEFAULT_IMPORT_LIST, optional: true, defaultValue: DEFAULT_IMPORT_LIST } ], build(values, context) { const importList = trim(values.importList); const args = ["--dry-run"]; if (importList) { args.push("--import-list", importList); } return buildBashCommand( IMPORT_ANTHROPIC_SCRIPT, args, computeGatewayEnv(context) ); } }, { key: "import-anthropic-apply", label: "Anthropic import (apply)", description: "Fetch and register servers from the Anthropic MCP registry.", fields: [ { name: "importList", label: "Import list file", placeholder: DEFAULT_IMPORT_LIST, optional: true, defaultValue: DEFAULT_IMPORT_LIST } ], build(values, context) { const importList = trim(values.importList); const args: string[] = []; if (importList) { args.push("--import-list", importList); } return buildBashCommand( IMPORT_ANTHROPIC_SCRIPT, args, computeGatewayEnv(context) ); } } ]; const userTasks: ScriptTask[] = [ { key: "user-create-m2m", label: "Create M2M service account", description: "Creates a service account client with group assignments (requires Keycloak admin access).", fields: [ { name: "name", label: "Service account name", placeholder: "agent-finance-bot" }, { name: "groups", label: "Groups (comma separated)", placeholder: "mcp-servers-finance/read,mcp-servers-finance/execute" }, { name: "description", label: "Description", placeholder: "Finance bot account", optional: true } ], build(values, context) { const name = trim(values.name); const groups = trim(values.groups); const description = trim(values.description); if (!name || !groups) { throw new Error("Name and groups are required."); } const args = [ "user", "create-m2m", "--name", name, "--groups", groups ]; if (description) { args.push("--description", description); } return buildRegistryCommand(args, context); } }, { key: "user-create-human", label: "Create human user", description: "Create a human user in Keycloak with group assignments.", fields: [ {name: "username", label: "Username", placeholder: "jdoe"}, {name: "email", label: "Email", placeholder: "jdoe@example.com"}, {name: "firstName", label: "First name", placeholder: "John"}, {name: "lastName", label: "Last name", placeholder: "Doe"}, { name: "groups", label: "Groups (comma separated)", placeholder: "mcp-servers-restricted/read" }, { name: "password", label: "Initial password (optional)", placeholder: "(leave blank to be prompted later)", optional: true } ], build(values, context) { const username = trim(values.username); const email = trim(values.email); const firstName = trim(values.firstName); const lastName = trim(values.lastName); const groups = trim(values.groups); const password = trim(values.password); if (!username || !email || !firstName || !lastName || !groups) { throw new Error("Username, email, first name, last name, and groups are required."); } const args = [ "user", "create-human", "--username", username, "--email", email, "--first-name", firstName, "--last-name", lastName, "--groups", groups ]; if (password) { args.push("--password", password); } return buildRegistryCommand(args, context); } }, { key: "user-delete", label: "Delete user", description: "Delete a user (service account or human) from Keycloak.", fields: [ { name: "username", label: "Username", placeholder: "agent-finance-bot" } ], build(values, context) { const username = trim(values.username); if (!username) { throw new Error("Username is required."); } return buildRegistryCommand(["user", "delete", "--username", username], context); } }, { key: "user-list-users", label: "List users", description: "List all users in the Keycloak realm.", fields: [], build(_values, context) { return buildRegistryCommand(["user", "list"], context); } }, { key: "user-list-groups", label: "List groups", description: "List all groups in Keycloak.", fields: [], build(_values, context) { return buildRegistryCommand(["group", "list"], context); } } ]; const diagnosticTasks: ScriptTask[] = [ { key: "diagnostic-run-suite", label: "Run Anthropic API suite", description: "Run the full Anthropic MCP Registry API smoke test.", fields: [ { name: "tokenFile", label: "Token file path", placeholder: ".oauth-tokens/ingress.json" }, { name: "baseUrl", label: "Base URL", placeholder: "http://localhost", optional: true, defaultValue: "http://localhost" } ], build(values, context) { const tokenFile = trim(values.tokenFile); const baseUrl = trim(values.baseUrl); if (!tokenFile) { throw new Error("Token file path is required."); } const args = ["anthropic", "list", "--limit", "100"]; if (baseUrl) { args.push("--base-url", baseUrl); } return buildRegistryCommand(args, context); } }, { key: "diagnostic-run-test", label: "Run specific Anthropic API test", description: "Call a specific API test case (e.g., list-servers, get-server).", fields: [ { name: "tokenFile", label: "Token file path", placeholder: ".oauth-tokens/ingress.json" }, { name: "testName", label: "Test name", placeholder: "list-servers" }, { name: "serverName", label: "Server name (for get-server)", placeholder: "io.mcpgateway/currenttime", optional: true }, { name: "baseUrl", label: "Base URL", placeholder: "http://localhost", optional: true, defaultValue: "http://localhost" } ], build(values, context) { const tokenFile = trim(values.tokenFile); const testName = trim(values.testName); const serverName = trim(values.serverName); const baseUrl = trim(values.baseUrl); if (!tokenFile || !testName) { throw new Error("Token file and test name are required."); } // Map test name to Anthropic API command if (testName === "get-server" && serverName) { const args = ["anthropic", "get", serverName]; if (baseUrl) { args.push("--base-url", baseUrl); } return buildRegistryCommand(args, context); } else { const args = ["anthropic", "list", "--limit", "100"]; if (baseUrl) { args.push("--base-url", baseUrl); } return buildRegistryCommand(args, context); } } } ]; export const taskCatalog: Record = { service: serviceTasks, import: importTasks, user: userTasks, diagnostic: diagnosticTasks }; export const getTaskByKey = (category: TaskCategory, key: string): ScriptTask | undefined => taskCatalog[category].find((task) => task.key === key); export const resolveDefaultValues = (task: ScriptTask): Record => task.fields.reduce>((acc, field) => { if (typeof field.defaultValue === "string") { acc[field.name] = field.defaultValue; } return acc; }, {}); ================================================ FILE: cli/src/tasks/types.ts ================================================ export interface TaskField { name: string; label: string; placeholder?: string; optional?: boolean; defaultValue?: string; } export interface ScriptCommand { command: string; args: string[]; env?: Record; } export interface TaskContext { gatewayUrl: string; gatewayBaseUrl: string; gatewayToken?: string; backendToken?: string; } export interface ScriptTask { key: string; label: string; description?: string; fields: TaskField[]; build(values: Record, context: TaskContext): ScriptCommand; } export type TaskCategory = "service" | "import" | "user" | "diagnostic"; ================================================ FILE: cli/src/types/mcp.ts ================================================ /** * MCP Protocol Type Definitions * * These types define the JSON-RPC 2.0 interface used by the Model Context Protocol (MCP). * They are shared between the Python client bridge and the rest of the TypeScript CLI. */ export interface JsonRpcRequest { jsonrpc: "2.0"; id?: number; method: string; params?: Record; } export interface JsonRpcResponse { jsonrpc: "2.0"; result?: T; error?: unknown; id?: number | string; } export type ToolArguments = Record; ================================================ FILE: cli/src/utils/commands.ts ================================================ /** * Available slash commands for autocomplete */ export interface CommandOption { command: string; description: string; category: string; } export const AVAILABLE_COMMANDS: CommandOption[] = [ // Essential commands only - focus on natural language interaction { command: "/help", description: "Show help message", category: "Basic" }, { command: "/exit", description: "Exit the CLI", category: "Basic" }, { command: "/ping", description: "Test gateway connectivity", category: "Basic" }, { command: "/list", description: "List available tools", category: "Basic" }, { command: "/servers", description: "List all MCP servers", category: "Basic" }, ]; /** * Get command suggestions based on partial input */ export function getCommandSuggestions(input: string): CommandOption[] { if (!input.startsWith("/")) { return []; } const normalized = input.toLowerCase(); return AVAILABLE_COMMANDS.filter(cmd => cmd.command.toLowerCase().startsWith(normalized) ).slice(0, 10); // Limit to 10 suggestions } /** * Get all commands for a specific category */ export function getCommandsByCategory(category: string): CommandOption[] { return AVAILABLE_COMMANDS.filter(cmd => cmd.category === category); } ================================================ FILE: cli/src/utils/cost.json ================================================ { "sample_spec": { "code_interpreter_cost_per_session": 0.0, "computer_use_input_cost_per_1k_tokens": 0.0, "computer_use_output_cost_per_1k_tokens": 0.0, "deprecation_date": "date when the model becomes deprecated in the format YYYY-MM-DD", "file_search_cost_per_1k_calls": 0.0, "file_search_cost_per_gb_per_day": 0.0, "input_cost_per_audio_token": 0.0, "input_cost_per_token": 0.0, "litellm_provider": "one of https://docs.litellm.ai/docs/providers", "max_input_tokens": "max input tokens, if the provider specifies it. if not default to max_tokens", "max_output_tokens": "max output tokens, if the provider specifies it. if not default to max_tokens", "max_tokens": "LEGACY parameter. set to max_output_tokens if provider specifies it. IF not set to max_input_tokens, if provider specifies it.", "mode": "one of: chat, embedding, completion, image_generation, audio_transcription, audio_speech, image_generation, moderation, rerank, search", "output_cost_per_reasoning_token": 0.0, "output_cost_per_token": 0.0, "search_context_cost_per_query": { "search_context_size_high": 0.0, "search_context_size_low": 0.0, "search_context_size_medium": 0.0 }, "supported_regions": [ "global", "us-west-2", "eu-west-1", "ap-southeast-1", "ap-northeast-1" ], "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_vision": true, "supports_web_search": true, "vector_store_cost_per_gb_per_day": 0.0 }, "1024-x-1024/50-steps/bedrock/amazon.nova-canvas-v1:0": { "litellm_provider": "bedrock", "max_input_tokens": 2600, "mode": "image_generation", "output_cost_per_image": 0.06 }, "1024-x-1024/50-steps/stability.stable-diffusion-xl-v1": { "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "image_generation", "output_cost_per_image": 0.04 }, "1024-x-1024/dall-e-2": { "input_cost_per_pixel": 1.9e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0 }, "1024-x-1024/max-steps/stability.stable-diffusion-xl-v1": { "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "image_generation", "output_cost_per_image": 0.08 }, "256-x-256/dall-e-2": { "input_cost_per_pixel": 2.4414e-07, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0 }, "512-x-512/50-steps/stability.stable-diffusion-xl-v0": { "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "image_generation", "output_cost_per_image": 0.018 }, "512-x-512/dall-e-2": { "input_cost_per_pixel": 6.86e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0 }, "512-x-512/max-steps/stability.stable-diffusion-xl-v0": { "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "image_generation", "output_cost_per_image": 0.036 }, "ai21.j2-mid-v1": { "input_cost_per_token": 1.25e-05, "litellm_provider": "bedrock", "max_input_tokens": 8191, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 1.25e-05 }, "ai21.j2-ultra-v1": { "input_cost_per_token": 1.88e-05, "litellm_provider": "bedrock", "max_input_tokens": 8191, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 1.88e-05 }, "ai21.jamba-1-5-large-v1:0": { "input_cost_per_token": 2e-06, "litellm_provider": "bedrock", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 8e-06 }, "ai21.jamba-1-5-mini-v1:0": { "input_cost_per_token": 2e-07, "litellm_provider": "bedrock", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 4e-07 }, "ai21.jamba-instruct-v1:0": { "input_cost_per_token": 5e-07, "litellm_provider": "bedrock", "max_input_tokens": 70000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 7e-07, "supports_system_messages": true }, "aiml/dall-e-2": { "litellm_provider": "aiml", "metadata": { "notes": "DALL-E 2 via AI/ML API - Reliable text-to-image generation" }, "mode": "image_generation", "output_cost_per_image": 0.021, "source": "https://docs.aimlapi.com/", "supported_endpoints": [ "/v1/images/generations" ] }, "aiml/dall-e-3": { "litellm_provider": "aiml", "metadata": { "notes": "DALL-E 3 via AI/ML API - High-quality text-to-image generation" }, "mode": "image_generation", "output_cost_per_image": 0.042, "source": "https://docs.aimlapi.com/", "supported_endpoints": [ "/v1/images/generations" ] }, "aiml/flux-pro": { "litellm_provider": "aiml", "metadata": { "notes": "Flux Dev - Development version optimized for experimentation" }, "mode": "image_generation", "output_cost_per_image": 0.053, "source": "https://docs.aimlapi.com/", "supported_endpoints": [ "/v1/images/generations" ] }, "aiml/flux-pro/v1.1": { "litellm_provider": "aiml", "mode": "image_generation", "output_cost_per_image": 0.042, "supported_endpoints": [ "/v1/images/generations" ] }, "aiml/flux-pro/v1.1-ultra": { "litellm_provider": "aiml", "mode": "image_generation", "output_cost_per_image": 0.063, "supported_endpoints": [ "/v1/images/generations" ] }, "aiml/flux-realism": { "litellm_provider": "aiml", "metadata": { "notes": "Flux Pro - Professional-grade image generation model" }, "mode": "image_generation", "output_cost_per_image": 0.037, "source": "https://docs.aimlapi.com/", "supported_endpoints": [ "/v1/images/generations" ] }, "aiml/flux/dev": { "litellm_provider": "aiml", "metadata": { "notes": "Flux Dev - Development version optimized for experimentation" }, "mode": "image_generation", "output_cost_per_image": 0.026, "source": "https://docs.aimlapi.com/", "supported_endpoints": [ "/v1/images/generations" ] }, "aiml/flux/kontext-max/text-to-image": { "litellm_provider": "aiml", "metadata": { "notes": "Flux Pro v1.1 - Enhanced version with improved capabilities and 6x faster inference speed" }, "mode": "image_generation", "output_cost_per_image": 0.084, "source": "https://docs.aimlapi.com/", "supported_endpoints": [ "/v1/images/generations" ] }, "aiml/flux/kontext-pro/text-to-image": { "litellm_provider": "aiml", "metadata": { "notes": "Flux Pro v1.1 - Enhanced version with improved capabilities and 6x faster inference speed" }, "mode": "image_generation", "output_cost_per_image": 0.042, "source": "https://docs.aimlapi.com/", "supported_endpoints": [ "/v1/images/generations" ] }, "aiml/flux/schnell": { "litellm_provider": "aiml", "metadata": { "notes": "Flux Schnell - Fast generation model optimized for speed" }, "mode": "image_generation", "output_cost_per_image": 0.003, "source": "https://docs.aimlapi.com/", "supported_endpoints": [ "/v1/images/generations" ] }, "amazon.nova-lite-v1:0": { "input_cost_per_token": 6e-08, "litellm_provider": "bedrock_converse", "max_input_tokens": 300000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 2.4e-07, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true }, "amazon.nova-micro-v1:0": { "input_cost_per_token": 3.5e-08, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 1.4e-07, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true }, "amazon.nova-pro-v1:0": { "input_cost_per_token": 8e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 300000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 3.2e-06, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true }, "amazon.rerank-v1:0": { "input_cost_per_query": 0.001, "input_cost_per_token": 0.0, "litellm_provider": "bedrock", "max_document_chunks_per_query": 100, "max_input_tokens": 32000, "max_output_tokens": 32000, "max_query_tokens": 32000, "max_tokens": 32000, "max_tokens_per_document_chunk": 512, "mode": "rerank", "output_cost_per_token": 0.0 }, "amazon.titan-embed-image-v1": { "input_cost_per_image": 6e-05, "input_cost_per_token": 8e-07, "litellm_provider": "bedrock", "max_input_tokens": 128, "max_tokens": 128, "metadata": { "notes": "'supports_image_input' is a deprecated field. Use 'supports_embedding_image_input' instead." }, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1024, "source": "https://us-east-1.console.aws.amazon.com/bedrock/home?region=us-east-1#/providers?model=amazon.titan-image-generator-v1", "supports_embedding_image_input": true, "supports_image_input": true }, "amazon.titan-embed-text-v1": { "input_cost_per_token": 1e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_tokens": 8192, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1536 }, "amazon.titan-embed-text-v2:0": { "input_cost_per_token": 2e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_tokens": 8192, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1024 }, "amazon.titan-image-generator-v1": { "input_cost_per_image": 0.0, "output_cost_per_image": 0.008, "output_cost_per_image_premium_image": 0.01, "output_cost_per_image_above_512_and_512_pixels": 0.01, "output_cost_per_image_above_512_and_512_pixels_and_premium_image": 0.012, "litellm_provider": "bedrock", "mode": "image_generation" }, "amazon.titan-image-generator-v2": { "input_cost_per_image": 0.0, "output_cost_per_image": 0.008, "output_cost_per_image_premium_image": 0.01, "output_cost_per_image_above_1024_and_1024_pixels": 0.01, "output_cost_per_image_above_1024_and_1024_pixels_and_premium_image": 0.012, "litellm_provider": "bedrock", "mode": "image_generation" }, "twelvelabs.marengo-embed-2-7-v1:0": { "input_cost_per_token": 7e-05, "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1024, "supports_embedding_image_input": true, "supports_image_input": true }, "us.twelvelabs.marengo-embed-2-7-v1:0": { "input_cost_per_token": 7e-05, "input_cost_per_video_per_second": 0.0007, "input_cost_per_audio_per_second": 0.00014, "input_cost_per_image": 0.0001, "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1024, "supports_embedding_image_input": true, "supports_image_input": true }, "eu.twelvelabs.marengo-embed-2-7-v1:0": { "input_cost_per_token": 7e-05, "input_cost_per_video_per_second": 0.0007, "input_cost_per_audio_per_second": 0.00014, "input_cost_per_image": 0.0001, "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1024, "supports_embedding_image_input": true, "supports_image_input": true }, "twelvelabs.pegasus-1-2-v1:0": { "input_cost_per_video_per_second": 0.00049, "output_cost_per_token": 7.5e-06, "litellm_provider": "bedrock", "mode": "chat", "supports_video_input": true }, "us.twelvelabs.pegasus-1-2-v1:0": { "input_cost_per_video_per_second": 0.00049, "output_cost_per_token": 7.5e-06, "litellm_provider": "bedrock", "mode": "chat", "supports_video_input": true }, "eu.twelvelabs.pegasus-1-2-v1:0": { "input_cost_per_video_per_second": 0.00049, "output_cost_per_token": 7.5e-06, "litellm_provider": "bedrock", "mode": "chat", "supports_video_input": true }, "amazon.titan-text-express-v1": { "input_cost_per_token": 1.3e-06, "litellm_provider": "bedrock", "max_input_tokens": 42000, "max_output_tokens": 8000, "max_tokens": 8000, "mode": "chat", "output_cost_per_token": 1.7e-06 }, "amazon.titan-text-lite-v1": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 42000, "max_output_tokens": 4000, "max_tokens": 4000, "mode": "chat", "output_cost_per_token": 4e-07 }, "amazon.titan-text-premier-v1:0": { "input_cost_per_token": 5e-07, "litellm_provider": "bedrock", "max_input_tokens": 42000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 1.5e-06 }, "anthropic.claude-3-5-haiku-20241022-v1:0": { "cache_creation_input_token_cost": 1e-06, "cache_read_input_token_cost": 8e-08, "input_cost_per_token": 8e-07, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true }, "anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.25e-06, "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5e-06, "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "anthropic.claude-haiku-4-5@20251001": { "cache_creation_input_token_cost": 1.25e-06, "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5e-06, "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "anthropic.claude-3-5-sonnet-20241022-v2:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "anthropic.claude-3-7-sonnet-20240620-v1:0": { "cache_creation_input_token_cost": 4.5e-06, "cache_read_input_token_cost": 3.6e-07, "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.8e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "anthropic.claude-3-7-sonnet-20250219-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "anthropic.claude-3-haiku-20240307-v1:0": { "input_cost_per_token": 2.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.25e-06, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 7.5e-05, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "anthropic.claude-3-sonnet-20240229-v1:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "anthropic.claude-instant-v1": { "input_cost_per_token": 8e-07, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-06, "supports_tool_choice": true }, "anthropic.claude-opus-4-1-20250805-v1:0": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "anthropic.claude-opus-4-20250514-v1:0": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "anthropic.claude-v1": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05 }, "anthropic.claude-v2:1": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_tool_choice": true }, "anyscale/HuggingFaceH4/zephyr-7b-beta": { "input_cost_per_token": 1.5e-07, "litellm_provider": "anyscale", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.5e-07 }, "anyscale/codellama/CodeLlama-34b-Instruct-hf": { "input_cost_per_token": 1e-06, "litellm_provider": "anyscale", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1e-06 }, "anyscale/codellama/CodeLlama-70b-Instruct-hf": { "input_cost_per_token": 1e-06, "litellm_provider": "anyscale", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1e-06, "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/codellama-CodeLlama-70b-Instruct-hf" }, "anyscale/google/gemma-7b-it": { "input_cost_per_token": 1.5e-07, "litellm_provider": "anyscale", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-07, "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/google-gemma-7b-it" }, "anyscale/meta-llama/Llama-2-13b-chat-hf": { "input_cost_per_token": 2.5e-07, "litellm_provider": "anyscale", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.5e-07 }, "anyscale/meta-llama/Llama-2-70b-chat-hf": { "input_cost_per_token": 1e-06, "litellm_provider": "anyscale", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1e-06 }, "anyscale/meta-llama/Llama-2-7b-chat-hf": { "input_cost_per_token": 1.5e-07, "litellm_provider": "anyscale", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-07 }, "anyscale/meta-llama/Meta-Llama-3-70B-Instruct": { "input_cost_per_token": 1e-06, "litellm_provider": "anyscale", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1e-06, "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/meta-llama-Meta-Llama-3-70B-Instruct" }, "anyscale/meta-llama/Meta-Llama-3-8B-Instruct": { "input_cost_per_token": 1.5e-07, "litellm_provider": "anyscale", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-07, "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/meta-llama-Meta-Llama-3-8B-Instruct" }, "anyscale/mistralai/Mistral-7B-Instruct-v0.1": { "input_cost_per_token": 1.5e-07, "litellm_provider": "anyscale", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.5e-07, "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/mistralai-Mistral-7B-Instruct-v0.1", "supports_function_calling": true }, "anyscale/mistralai/Mixtral-8x22B-Instruct-v0.1": { "input_cost_per_token": 9e-07, "litellm_provider": "anyscale", "max_input_tokens": 65536, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 9e-07, "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/mistralai-Mixtral-8x22B-Instruct-v0.1", "supports_function_calling": true }, "anyscale/mistralai/Mixtral-8x7B-Instruct-v0.1": { "input_cost_per_token": 1.5e-07, "litellm_provider": "anyscale", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.5e-07, "source": "https://docs.anyscale.com/preview/endpoints/text-generation/supported-models/mistralai-Mixtral-8x7B-Instruct-v0.1", "supports_function_calling": true }, "apac.amazon.nova-lite-v1:0": { "input_cost_per_token": 6.3e-08, "litellm_provider": "bedrock_converse", "max_input_tokens": 300000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 2.52e-07, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true }, "apac.amazon.nova-micro-v1:0": { "input_cost_per_token": 3.7e-08, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 1.48e-07, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true }, "apac.amazon.nova-pro-v1:0": { "input_cost_per_token": 8.4e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 300000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 3.36e-06, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true }, "apac.anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "apac.anthropic.claude-3-5-sonnet-20241022-v2:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "apac.anthropic.claude-3-haiku-20240307-v1:0": { "input_cost_per_token": 2.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.25e-06, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "apac.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, "cache_read_input_token_cost": 1.1e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.5e-06, "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "apac.anthropic.claude-3-sonnet-20240229-v1:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "apac.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "assemblyai/best": { "input_cost_per_second": 3.333e-05, "litellm_provider": "assemblyai", "mode": "audio_transcription", "output_cost_per_second": 0.0 }, "assemblyai/nano": { "input_cost_per_second": 0.00010278, "litellm_provider": "assemblyai", "mode": "audio_transcription", "output_cost_per_second": 0.0 }, "au.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.125e-06, "cache_read_input_token_cost": 3.3e-07, "input_cost_per_token": 3.3e-06, "input_cost_per_token_above_200k_tokens": 6.6e-06, "output_cost_per_token_above_200k_tokens": 2.475e-05, "cache_creation_input_token_cost_above_200k_tokens": 8.25e-06, "cache_read_input_token_cost_above_200k_tokens": 6.6e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.65e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, "azure/ada": { "input_cost_per_token": 1e-07, "litellm_provider": "azure", "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", "output_cost_per_token": 0.0 }, "azure/codex-mini": { "cache_read_input_token_cost": 3.75e-07, "input_cost_per_token": 1.5e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 6e-06, "supported_endpoints": [ "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "azure/command-r-plus": { "input_cost_per_token": 3e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true }, "azure/computer-use-preview": { "input_cost_per_token": 3e-06, "litellm_provider": "azure", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "chat", "output_cost_per_token": 1.2e-05, "supported_endpoints": [ "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": false, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "azure/eu/gpt-4o-2024-08-06": { "deprecation_date": "2026-02-27", "cache_read_input_token_cost": 1.375e-06, "input_cost_per_token": 2.75e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/eu/gpt-4o-2024-11-20": { "deprecation_date": "2026-03-01", "cache_creation_input_token_cost": 1.38e-06, "input_cost_per_token": 2.75e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/eu/gpt-4o-mini-2024-07-18": { "cache_read_input_token_cost": 8.3e-08, "input_cost_per_token": 1.65e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6.6e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/eu/gpt-4o-mini-realtime-preview-2024-12-17": { "cache_creation_input_audio_token_cost": 3.3e-07, "cache_read_input_token_cost": 3.3e-07, "input_cost_per_audio_token": 1.1e-05, "input_cost_per_token": 6.6e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 2.2e-05, "output_cost_per_token": 2.64e-06, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "azure/eu/gpt-4o-realtime-preview-2024-10-01": { "cache_creation_input_audio_token_cost": 2.2e-05, "cache_read_input_token_cost": 2.75e-06, "input_cost_per_audio_token": 0.00011, "input_cost_per_token": 5.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 0.00022, "output_cost_per_token": 2.2e-05, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "azure/eu/gpt-4o-realtime-preview-2024-12-17": { "cache_read_input_audio_token_cost": 2.5e-06, "cache_read_input_token_cost": 2.75e-06, "input_cost_per_audio_token": 4.4e-05, "input_cost_per_token": 5.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 8e-05, "output_cost_per_token": 2.2e-05, "supported_modalities": [ "text", "audio" ], "supported_output_modalities": [ "text", "audio" ], "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "azure/eu/o1-2024-12-17": { "cache_read_input_token_cost": 8.25e-06, "input_cost_per_token": 1.65e-05, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6.6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true, "supports_vision": true }, "azure/eu/o1-mini-2024-09-12": { "cache_read_input_token_cost": 6.05e-07, "input_cost_per_token": 1.21e-06, "input_cost_per_token_batches": 6.05e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 4.84e-06, "output_cost_per_token_batches": 2.42e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_vision": false }, "azure/eu/o1-preview-2024-09-12": { "cache_read_input_token_cost": 8.25e-06, "input_cost_per_token": 1.65e-05, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6.6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_vision": false }, "azure/eu/o3-mini-2025-01-31": { "cache_read_input_token_cost": 6.05e-07, "input_cost_per_token": 1.21e-06, "input_cost_per_token_batches": 6.05e-07, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.84e-06, "output_cost_per_token_batches": 2.42e-06, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": false }, "azure/global-standard/gpt-4o-2024-08-06": { "cache_read_input_token_cost": 1.25e-06, "deprecation_date": "2026-02-27", "input_cost_per_token": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/global-standard/gpt-4o-2024-11-20": { "cache_read_input_token_cost": 1.25e-06, "deprecation_date": "2026-03-01", "input_cost_per_token": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/global-standard/gpt-4o-mini": { "input_cost_per_token": 1.5e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/global/gpt-4o-2024-08-06": { "deprecation_date": "2026-02-27", "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/global/gpt-4o-2024-11-20": { "deprecation_date": "2026-03-01", "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-3.5-turbo": { "input_cost_per_token": 5e-07, "litellm_provider": "azure", "max_input_tokens": 4097, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-06, "supports_function_calling": true, "supports_tool_choice": true }, "azure/gpt-3.5-turbo-0125": { "deprecation_date": "2025-03-31", "input_cost_per_token": 5e-07, "litellm_provider": "azure", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "azure/gpt-3.5-turbo-instruct-0914": { "input_cost_per_token": 1.5e-06, "litellm_provider": "azure_text", "max_input_tokens": 4097, "max_tokens": 4097, "mode": "completion", "output_cost_per_token": 2e-06 }, "azure/gpt-35-turbo": { "input_cost_per_token": 5e-07, "litellm_provider": "azure", "max_input_tokens": 4097, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-06, "supports_function_calling": true, "supports_tool_choice": true }, "azure/gpt-35-turbo-0125": { "deprecation_date": "2025-05-31", "input_cost_per_token": 5e-07, "litellm_provider": "azure", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "azure/gpt-35-turbo-0301": { "deprecation_date": "2025-02-13", "input_cost_per_token": 2e-07, "litellm_provider": "azure", "max_input_tokens": 4097, "max_output_tokens": 4096, "max_tokens": 4097, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "azure/gpt-35-turbo-0613": { "deprecation_date": "2025-02-13", "input_cost_per_token": 1.5e-06, "litellm_provider": "azure", "max_input_tokens": 4097, "max_output_tokens": 4096, "max_tokens": 4097, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "azure/gpt-35-turbo-1106": { "deprecation_date": "2025-03-31", "input_cost_per_token": 1e-06, "litellm_provider": "azure", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "azure/gpt-35-turbo-16k": { "input_cost_per_token": 3e-06, "litellm_provider": "azure", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 4e-06, "supports_tool_choice": true }, "azure/gpt-35-turbo-16k-0613": { "input_cost_per_token": 3e-06, "litellm_provider": "azure", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 4e-06, "supports_function_calling": true, "supports_tool_choice": true }, "azure/gpt-35-turbo-instruct": { "input_cost_per_token": 1.5e-06, "litellm_provider": "azure_text", "max_input_tokens": 4097, "max_tokens": 4097, "mode": "completion", "output_cost_per_token": 2e-06 }, "azure/gpt-35-turbo-instruct-0914": { "input_cost_per_token": 1.5e-06, "litellm_provider": "azure_text", "max_input_tokens": 4097, "max_tokens": 4097, "mode": "completion", "output_cost_per_token": 2e-06 }, "azure/gpt-4": { "input_cost_per_token": 3e-05, "litellm_provider": "azure", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_tool_choice": true }, "azure/gpt-4-0125-preview": { "input_cost_per_token": 1e-05, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "azure/gpt-4-0613": { "input_cost_per_token": 3e-05, "litellm_provider": "azure", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_tool_choice": true }, "azure/gpt-4-1106-preview": { "input_cost_per_token": 1e-05, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "azure/gpt-4-32k": { "input_cost_per_token": 6e-05, "litellm_provider": "azure", "max_input_tokens": 32768, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.00012, "supports_tool_choice": true }, "azure/gpt-4-32k-0613": { "input_cost_per_token": 6e-05, "litellm_provider": "azure", "max_input_tokens": 32768, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.00012, "supports_tool_choice": true }, "azure/gpt-4-turbo": { "input_cost_per_token": 1e-05, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "azure/gpt-4-turbo-2024-04-09": { "input_cost_per_token": 1e-05, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-4-turbo-vision-preview": { "input_cost_per_token": 1e-05, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-4.1": { "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 8e-06, "output_cost_per_token_batches": 4e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": false }, "azure/gpt-4.1-2025-04-14": { "deprecation_date": "2026-11-04", "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 8e-06, "output_cost_per_token_batches": 4e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": false }, "azure/gpt-4.1-mini": { "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1.6e-06, "output_cost_per_token_batches": 8e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": false }, "azure/gpt-4.1-mini-2025-04-14": { "deprecation_date": "2026-11-04", "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1.6e-06, "output_cost_per_token_batches": 8e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": false }, "azure/gpt-4.1-nano": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-4.1-nano-2025-04-14": { "deprecation_date": "2026-11-04", "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, "litellm_provider": "azure", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-4.5-preview": { "cache_read_input_token_cost": 3.75e-05, "input_cost_per_token": 7.5e-05, "input_cost_per_token_batches": 3.75e-05, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 0.00015, "output_cost_per_token_batches": 7.5e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-4o": { "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-4o-2024-05-13": { "input_cost_per_token": 5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-4o-2024-08-06": { "deprecation_date": "2026-02-27", "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-4o-2024-11-20": { "deprecation_date": "2026-03-01", "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.75e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-4o-audio-preview-2024-12-17": { "input_cost_per_audio_token": 4e-05, "input_cost_per_token": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_audio_token": 8e-05, "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text", "audio" ], "supported_output_modalities": [ "text", "audio" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_prompt_caching": false, "supports_reasoning": false, "supports_response_schema": false, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": false }, "azure/gpt-4o-mini": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_token": 1.65e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6.6e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-4o-mini-2024-07-18": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_token": 1.65e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6.6e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-4o-mini-audio-preview-2024-12-17": { "input_cost_per_audio_token": 4e-05, "input_cost_per_token": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_audio_token": 8e-05, "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text", "audio" ], "supported_output_modalities": [ "text", "audio" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_prompt_caching": false, "supports_reasoning": false, "supports_response_schema": false, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": false }, "azure/gpt-4o-mini-realtime-preview-2024-12-17": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_token_cost": 3e-07, "input_cost_per_audio_token": 1e-05, "input_cost_per_token": 6e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2.4e-06, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "azure/gpt-4o-mini-transcribe": { "input_cost_per_audio_token": 3e-06, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 16000, "max_output_tokens": 2000, "mode": "audio_transcription", "output_cost_per_token": 5e-06, "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "azure/gpt-4o-mini-tts": { "input_cost_per_token": 2.5e-06, "litellm_provider": "azure", "mode": "audio_speech", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_second": 0.00025, "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/audio/speech" ], "supported_modalities": [ "text", "audio" ], "supported_output_modalities": [ "audio" ] }, "azure/gpt-4o-realtime-preview-2024-10-01": { "cache_creation_input_audio_token_cost": 2e-05, "cache_read_input_token_cost": 2.5e-06, "input_cost_per_audio_token": 0.0001, "input_cost_per_token": 5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 0.0002, "output_cost_per_token": 2e-05, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "azure/gpt-4o-realtime-preview-2024-12-17": { "cache_read_input_token_cost": 2.5e-06, "input_cost_per_audio_token": 4e-05, "input_cost_per_token": 5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 8e-05, "output_cost_per_token": 2e-05, "supported_modalities": [ "text", "audio" ], "supported_output_modalities": [ "text", "audio" ], "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "azure/gpt-4o-transcribe": { "input_cost_per_audio_token": 6e-06, "input_cost_per_token": 2.5e-06, "litellm_provider": "azure", "max_input_tokens": 16000, "max_output_tokens": 2000, "mode": "audio_transcription", "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "azure/gpt-5": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-5-2025-08-07": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-5-chat": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, "source": "https://azure.microsoft.com/en-us/blog/gpt-5-in-azure-ai-foundry-the-future-of-ai-apps-and-agents-starts-here/", "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": false, "supports_vision": true }, "azure/gpt-5-chat-latest": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": false, "supports_vision": true }, "azure/gpt-5-codex": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-5-mini": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 2.5e-07, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 2.5e-07, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-5-nano": { "cache_read_input_token_cost": 5e-09, "input_cost_per_token": 5e-08, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-5-nano-2025-08-07": { "cache_read_input_token_cost": 5e-09, "input_cost_per_token": 5e-08, "litellm_provider": "azure", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "azure/gpt-image-1": { "input_cost_per_pixel": 4.0054321e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "azure/hd/1024-x-1024/dall-e-3": { "input_cost_per_pixel": 7.629e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_token": 0.0 }, "azure/hd/1024-x-1792/dall-e-3": { "input_cost_per_pixel": 6.539e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_token": 0.0 }, "azure/hd/1792-x-1024/dall-e-3": { "input_cost_per_pixel": 6.539e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_token": 0.0 }, "azure/high/1024-x-1024/gpt-image-1": { "input_cost_per_pixel": 1.59263611e-07, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "azure/high/1024-x-1536/gpt-image-1": { "input_cost_per_pixel": 1.58945719e-07, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "azure/high/1536-x-1024/gpt-image-1": { "input_cost_per_pixel": 1.58945719e-07, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "azure/low/1024-x-1024/gpt-image-1": { "input_cost_per_pixel": 1.0490417e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "azure/low/1024-x-1536/gpt-image-1": { "input_cost_per_pixel": 1.0172526e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "azure/low/1536-x-1024/gpt-image-1": { "input_cost_per_pixel": 1.0172526e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "azure/medium/1024-x-1024/gpt-image-1": { "input_cost_per_pixel": 4.0054321e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "azure/medium/1024-x-1536/gpt-image-1": { "input_cost_per_pixel": 4.0054321e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "azure/medium/1536-x-1024/gpt-image-1": { "input_cost_per_pixel": 4.0054321e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "azure/mistral-large-2402": { "input_cost_per_token": 8e-06, "litellm_provider": "azure", "max_input_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_function_calling": true }, "azure/mistral-large-latest": { "input_cost_per_token": 8e-06, "litellm_provider": "azure", "max_input_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_function_calling": true }, "azure/o1": { "cache_read_input_token_cost": 7.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true }, "azure/o1-2024-12-17": { "cache_read_input_token_cost": 7.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true }, "azure/o1-mini": { "cache_read_input_token_cost": 6.05e-07, "input_cost_per_token": 1.21e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 4.84e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_vision": false }, "azure/o1-mini-2024-09-12": { "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 4.4e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_vision": false }, "azure/o1-preview": { "cache_read_input_token_cost": 7.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_vision": false }, "azure/o1-preview-2024-09-12": { "cache_read_input_token_cost": 7.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_vision": false }, "azure/o3": { "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/o3-2025-04-16": { "deprecation_date": "2026-04-16", "cache_read_input_token_cost": 2.5e-06, "input_cost_per_token": 1e-05, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/o3-deep-research": { "cache_read_input_token_cost": 2.5e-06, "input_cost_per_token": 1e-05, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 4e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "azure/o3-mini": { "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": false }, "azure/o3-mini-2025-01-31": { "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": false }, "azure/o3-pro": { "input_cost_per_token": 2e-05, "input_cost_per_token_batches": 1e-05, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 8e-05, "output_cost_per_token_batches": 4e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": false, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/o3-pro-2025-06-10": { "input_cost_per_token": 2e-05, "input_cost_per_token_batches": 1e-05, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 8e-05, "output_cost_per_token_batches": 4e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": false, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/o4-mini": { "cache_read_input_token_cost": 2.75e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/o4-mini-2025-04-16": { "cache_read_input_token_cost": 2.75e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/standard/1024-x-1024/dall-e-2": { "input_cost_per_pixel": 0.0, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_token": 0.0 }, "azure/standard/1024-x-1024/dall-e-3": { "input_cost_per_pixel": 3.81469e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_token": 0.0 }, "azure/standard/1024-x-1792/dall-e-3": { "input_cost_per_pixel": 4.359e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_token": 0.0 }, "azure/standard/1792-x-1024/dall-e-3": { "input_cost_per_pixel": 4.359e-08, "litellm_provider": "azure", "mode": "image_generation", "output_cost_per_token": 0.0 }, "azure/text-embedding-3-large": { "input_cost_per_token": 1.3e-07, "litellm_provider": "azure", "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", "output_cost_per_token": 0.0 }, "azure/text-embedding-3-small": { "deprecation_date": "2026-04-30", "input_cost_per_token": 2e-08, "litellm_provider": "azure", "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", "output_cost_per_token": 0.0 }, "azure/text-embedding-ada-002": { "input_cost_per_token": 1e-07, "litellm_provider": "azure", "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", "output_cost_per_token": 0.0 }, "azure/speech/azure-tts": { "input_cost_per_character": 15e-06, "litellm_provider": "azure", "mode": "audio_speech", "source": "https://azure.microsoft.com/en-us/pricing/calculator/" }, "azure/speech/azure-tts-hd": { "input_cost_per_character": 30e-06, "litellm_provider": "azure", "mode": "audio_speech", "source": "https://azure.microsoft.com/en-us/pricing/calculator/" }, "azure/tts-1": { "input_cost_per_character": 1.5e-05, "litellm_provider": "azure", "mode": "audio_speech" }, "azure/tts-1-hd": { "input_cost_per_character": 3e-05, "litellm_provider": "azure", "mode": "audio_speech" }, "azure/us/gpt-4o-2024-08-06": { "deprecation_date": "2026-02-27", "cache_read_input_token_cost": 1.375e-06, "input_cost_per_token": 2.75e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/us/gpt-4o-2024-11-20": { "deprecation_date": "2026-03-01", "cache_creation_input_token_cost": 1.38e-06, "input_cost_per_token": 2.75e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.1e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/us/gpt-4o-mini-2024-07-18": { "cache_read_input_token_cost": 8.3e-08, "input_cost_per_token": 1.65e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6.6e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "azure/us/gpt-4o-mini-realtime-preview-2024-12-17": { "cache_creation_input_audio_token_cost": 3.3e-07, "cache_read_input_token_cost": 3.3e-07, "input_cost_per_audio_token": 1.1e-05, "input_cost_per_token": 6.6e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 2.2e-05, "output_cost_per_token": 2.64e-06, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "azure/us/gpt-4o-realtime-preview-2024-10-01": { "cache_creation_input_audio_token_cost": 2.2e-05, "cache_read_input_token_cost": 2.75e-06, "input_cost_per_audio_token": 0.00011, "input_cost_per_token": 5.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 0.00022, "output_cost_per_token": 2.2e-05, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "azure/us/gpt-4o-realtime-preview-2024-12-17": { "cache_read_input_audio_token_cost": 2.5e-06, "cache_read_input_token_cost": 2.75e-06, "input_cost_per_audio_token": 4.4e-05, "input_cost_per_token": 5.5e-06, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 8e-05, "output_cost_per_token": 2.2e-05, "supported_modalities": [ "text", "audio" ], "supported_output_modalities": [ "text", "audio" ], "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "azure/us/o1-2024-12-17": { "cache_read_input_token_cost": 8.25e-06, "input_cost_per_token": 1.65e-05, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6.6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true, "supports_vision": true }, "azure/us/o1-mini-2024-09-12": { "cache_read_input_token_cost": 6.05e-07, "input_cost_per_token": 1.21e-06, "input_cost_per_token_batches": 6.05e-07, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 4.84e-06, "output_cost_per_token_batches": 2.42e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_vision": false }, "azure/us/o1-preview-2024-09-12": { "cache_read_input_token_cost": 8.25e-06, "input_cost_per_token": 1.65e-05, "litellm_provider": "azure", "max_input_tokens": 128000, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6.6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_vision": false }, "azure/us/o3-mini-2025-01-31": { "cache_read_input_token_cost": 6.05e-07, "input_cost_per_token": 1.21e-06, "input_cost_per_token_batches": 6.05e-07, "litellm_provider": "azure", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.84e-06, "output_cost_per_token_batches": 2.42e-06, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": false }, "azure/whisper-1": { "input_cost_per_second": 0.0001, "litellm_provider": "azure", "mode": "audio_transcription", "output_cost_per_second": 0.0001 }, "azure_ai/Cohere-embed-v3-english": { "input_cost_per_token": 1e-07, "litellm_provider": "azure_ai", "max_input_tokens": 512, "max_tokens": 512, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1024, "source": "https://azuremarketplace.microsoft.com/en-us/marketplace/apps/cohere.cohere-embed-v3-english-offer?tab=PlansAndPrice", "supports_embedding_image_input": true }, "azure_ai/Cohere-embed-v3-multilingual": { "input_cost_per_token": 1e-07, "litellm_provider": "azure_ai", "max_input_tokens": 512, "max_tokens": 512, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1024, "source": "https://azuremarketplace.microsoft.com/en-us/marketplace/apps/cohere.cohere-embed-v3-english-offer?tab=PlansAndPrice", "supports_embedding_image_input": true }, "azure_ai/FLUX-1.1-pro": { "litellm_provider": "azure_ai", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/black-forest-labs-flux-1-kontext-pro-and-flux1-1-pro-now-available-in-azure-ai-f/4434659", "supported_endpoints": [ "/v1/images/generations" ] }, "azure_ai/FLUX.1-Kontext-pro": { "litellm_provider": "azure_ai", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://azuremarketplace.microsoft.com/pt-br/marketplace/apps/cohere.cohere-embed-4-offer?tab=PlansAndPrice", "supported_endpoints": [ "/v1/images/generations" ] }, "azure_ai/Llama-3.2-11B-Vision-Instruct": { "input_cost_per_token": 3.7e-07, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 3.7e-07, "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/metagenai.meta-llama-3-2-11b-vision-instruct-offer?tab=Overview", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "azure_ai/Llama-3.2-90B-Vision-Instruct": { "input_cost_per_token": 2.04e-06, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 2.04e-06, "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/metagenai.meta-llama-3-2-90b-vision-instruct-offer?tab=Overview", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "azure_ai/Llama-3.3-70B-Instruct": { "input_cost_per_token": 7.1e-07, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 7.1e-07, "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/metagenai.llama-3-3-70b-instruct-offer?tab=Overview", "supports_function_calling": true, "supports_tool_choice": true }, "azure_ai/Llama-4-Maverick-17B-128E-Instruct-FP8": { "input_cost_per_token": 1.41e-06, "litellm_provider": "azure_ai", "max_input_tokens": 1000000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 3.5e-07, "source": "https://azure.microsoft.com/en-us/blog/introducing-the-llama-4-herd-in-azure-ai-foundry-and-azure-databricks/", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "azure_ai/Llama-4-Scout-17B-16E-Instruct": { "input_cost_per_token": 2e-07, "litellm_provider": "azure_ai", "max_input_tokens": 10000000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 7.8e-07, "source": "https://azure.microsoft.com/en-us/blog/introducing-the-llama-4-herd-in-azure-ai-foundry-and-azure-databricks/", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "azure_ai/Meta-Llama-3-70B-Instruct": { "input_cost_per_token": 1.1e-06, "litellm_provider": "azure_ai", "max_input_tokens": 8192, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 3.7e-07, "supports_tool_choice": true }, "azure_ai/Meta-Llama-3.1-405B-Instruct": { "input_cost_per_token": 5.33e-06, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 1.6e-05, "source": "https://azuremarketplace.microsoft.com/en-us/marketplace/apps/metagenai.meta-llama-3-1-405b-instruct-offer?tab=PlansAndPrice", "supports_tool_choice": true }, "azure_ai/Meta-Llama-3.1-70B-Instruct": { "input_cost_per_token": 2.68e-06, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 3.54e-06, "source": "https://azuremarketplace.microsoft.com/en-us/marketplace/apps/metagenai.meta-llama-3-1-70b-instruct-offer?tab=PlansAndPrice", "supports_tool_choice": true }, "azure_ai/Meta-Llama-3.1-8B-Instruct": { "input_cost_per_token": 3e-07, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 6.1e-07, "source": "https://azuremarketplace.microsoft.com/en-us/marketplace/apps/metagenai.meta-llama-3-1-8b-instruct-offer?tab=PlansAndPrice", "supports_tool_choice": true }, "azure_ai/Phi-3-medium-128k-instruct": { "input_cost_per_token": 1.7e-07, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6.8e-07, "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", "supports_tool_choice": true, "supports_vision": false }, "azure_ai/Phi-3-medium-4k-instruct": { "input_cost_per_token": 1.7e-07, "litellm_provider": "azure_ai", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6.8e-07, "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", "supports_tool_choice": true, "supports_vision": false }, "azure_ai/Phi-3-mini-128k-instruct": { "input_cost_per_token": 1.3e-07, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.2e-07, "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", "supports_tool_choice": true, "supports_vision": false }, "azure_ai/Phi-3-mini-4k-instruct": { "input_cost_per_token": 1.3e-07, "litellm_provider": "azure_ai", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.2e-07, "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", "supports_tool_choice": true, "supports_vision": false }, "azure_ai/Phi-3-small-128k-instruct": { "input_cost_per_token": 1.5e-07, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-07, "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", "supports_tool_choice": true, "supports_vision": false }, "azure_ai/Phi-3-small-8k-instruct": { "input_cost_per_token": 1.5e-07, "litellm_provider": "azure_ai", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-07, "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", "supports_tool_choice": true, "supports_vision": false }, "azure_ai/Phi-3.5-MoE-instruct": { "input_cost_per_token": 1.6e-07, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6.4e-07, "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", "supports_tool_choice": true, "supports_vision": false }, "azure_ai/Phi-3.5-mini-instruct": { "input_cost_per_token": 1.3e-07, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.2e-07, "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", "supports_tool_choice": true, "supports_vision": false }, "azure_ai/Phi-3.5-vision-instruct": { "input_cost_per_token": 1.3e-07, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.2e-07, "source": "https://azure.microsoft.com/en-us/pricing/details/phi-3/", "supports_tool_choice": true, "supports_vision": true }, "azure_ai/Phi-4": { "input_cost_per_token": 1.25e-07, "litellm_provider": "azure_ai", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 5e-07, "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/affordable-innovation-unveiling-the-pricing-of-phi-3-slms-on-models-as-a-service/4156495", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": false }, "azure_ai/Phi-4-mini-instruct": { "input_cost_per_token": 7.5e-08, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-07, "source": "https://techcommunity.microsoft.com/blog/Azure-AI-Services-blog/announcing-new-phi-pricing-empowering-your-business-with-small-language-models/4395112", "supports_function_calling": true }, "azure_ai/Phi-4-multimodal-instruct": { "input_cost_per_audio_token": 4e-06, "input_cost_per_token": 8e-08, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3.2e-07, "source": "https://techcommunity.microsoft.com/blog/Azure-AI-Services-blog/announcing-new-phi-pricing-empowering-your-business-with-small-language-models/4395112", "supports_audio_input": true, "supports_function_calling": true, "supports_vision": true }, "azure_ai/Phi-4-mini-reasoning": { "input_cost_per_token": 8e-08, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3.2e-07, "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/", "supports_function_calling": true }, "azure_ai/Phi-4-reasoning": { "input_cost_per_token": 1.25e-07, "litellm_provider": "azure_ai", "max_input_tokens": 32768, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5e-07, "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true }, "azure_ai/mistral-document-ai-2505": { "litellm_provider": "azure_ai", "ocr_cost_per_page": 3e-3, "mode": "ocr", "supported_endpoints": [ "/v1/ocr" ], "source": "https://devblogs.microsoft.com/foundry/whats-new-in-azure-ai-foundry-august-2025/#mistral-document-ai-(ocr)-%E2%80%94-serverless-in-foundry" }, "azure_ai/MAI-DS-R1": { "input_cost_per_token": 1.35e-06, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.4e-06, "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/microsoft/", "supports_reasoning": true, "supports_tool_choice": true }, "azure_ai/cohere-rerank-v3-english": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, "litellm_provider": "azure_ai", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_query_tokens": 2048, "max_tokens": 4096, "mode": "rerank", "output_cost_per_token": 0.0 }, "azure_ai/cohere-rerank-v3-multilingual": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, "litellm_provider": "azure_ai", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_query_tokens": 2048, "max_tokens": 4096, "mode": "rerank", "output_cost_per_token": 0.0 }, "azure_ai/cohere-rerank-v3.5": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, "litellm_provider": "azure_ai", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_query_tokens": 2048, "max_tokens": 4096, "mode": "rerank", "output_cost_per_token": 0.0 }, "azure_ai/deepseek-r1": { "input_cost_per_token": 1.35e-06, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.4e-06, "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/deepseek-r1-improved-performance-higher-limits-and-transparent-pricing/4386367", "supports_reasoning": true, "supports_tool_choice": true }, "azure_ai/deepseek-v3": { "input_cost_per_token": 1.14e-06, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4.56e-06, "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/announcing-deepseek-v3-on-azure-ai-foundry-and-github/4390438", "supports_tool_choice": true }, "azure_ai/deepseek-v3-0324": { "input_cost_per_token": 1.14e-06, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4.56e-06, "source": "https://techcommunity.microsoft.com/blog/machinelearningblog/announcing-deepseek-v3-on-azure-ai-foundry-and-github/4390438", "supports_function_calling": true, "supports_tool_choice": true }, "azure_ai/embed-v-4-0": { "input_cost_per_token": 1.2e-07, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_tokens": 128000, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 3072, "source": "https://azuremarketplace.microsoft.com/pt-br/marketplace/apps/cohere.cohere-embed-4-offer?tab=PlansAndPrice", "supported_endpoints": [ "/v1/embeddings" ], "supported_modalities": [ "text", "image" ], "supports_embedding_image_input": true }, "azure_ai/global/grok-3": { "input_cost_per_token": 3e-06, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, "source": "https://devblogs.microsoft.com/foundry/announcing-grok-3-and-grok-3-mini-on-azure-ai-foundry/", "supports_function_calling": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "azure_ai/global/grok-3-mini": { "input_cost_per_token": 2.5e-07, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.27e-06, "source": "https://devblogs.microsoft.com/foundry/announcing-grok-3-and-grok-3-mini-on-azure-ai-foundry/", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "azure_ai/grok-3": { "input_cost_per_token": 3.3e-06, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.65e-05, "source": "https://devblogs.microsoft.com/foundry/announcing-grok-3-and-grok-3-mini-on-azure-ai-foundry/", "supports_function_calling": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "azure_ai/grok-3-mini": { "input_cost_per_token": 2.75e-07, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.38e-06, "source": "https://devblogs.microsoft.com/foundry/announcing-grok-3-and-grok-3-mini-on-azure-ai-foundry/", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "azure_ai/grok-4": { "input_cost_per_token": 5.5e-06, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.75e-05, "source": "https://azure.microsoft.com/en-us/blog/grok-4-is-now-available-in-azure-ai-foundry-unlock-frontier-intelligence-and-business-ready-capabilities/", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_web_search": true }, "azure_ai/grok-4-fast-non-reasoning": { "input_cost_per_token": 0.43e-06, "output_cost_per_token": 1.73e-06, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_web_search": true }, "azure_ai/grok-4-fast-reasoning": { "input_cost_per_token": 0.43e-06, "output_cost_per_token": 1.73e-06, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "source": "https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/announcing-the-grok-4-fast-models-from-xai-now-available-in-azure-ai-foundry/4456701", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_web_search": true }, "azure_ai/grok-code-fast-1": { "input_cost_per_token": 3.5e-06, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.75e-05, "source": "https://azure.microsoft.com/en-us/blog/grok-4-is-now-available-in-azure-ai-foundry-unlock-frontier-intelligence-and-business-ready-capabilities/", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_web_search": true }, "azure_ai/jais-30b-chat": { "input_cost_per_token": 0.0032, "litellm_provider": "azure_ai", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 0.00971, "source": "https://azure.microsoft.com/en-us/products/ai-services/ai-foundry/models/jais-30b-chat" }, "azure_ai/jamba-instruct": { "input_cost_per_token": 5e-07, "litellm_provider": "azure_ai", "max_input_tokens": 70000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 7e-07, "supports_tool_choice": true }, "azure_ai/ministral-3b": { "input_cost_per_token": 4e-08, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 4e-08, "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/000-000.ministral-3b-2410-offer?tab=Overview", "supports_function_calling": true, "supports_tool_choice": true }, "azure_ai/mistral-large": { "input_cost_per_token": 4e-06, "litellm_provider": "azure_ai", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 1.2e-05, "supports_function_calling": true, "supports_tool_choice": true }, "azure_ai/mistral-large-2407": { "input_cost_per_token": 2e-06, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-06, "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/000-000.mistral-ai-large-2407-offer?tab=Overview", "supports_function_calling": true, "supports_tool_choice": true }, "azure_ai/mistral-large-latest": { "input_cost_per_token": 2e-06, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-06, "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/000-000.mistral-ai-large-2407-offer?tab=Overview", "supports_function_calling": true, "supports_tool_choice": true }, "azure_ai/mistral-medium-2505": { "input_cost_per_token": 4e-07, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_tool_choice": true }, "azure_ai/mistral-nemo": { "input_cost_per_token": 1.5e-07, "litellm_provider": "azure_ai", "max_input_tokens": 131072, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-07, "source": "https://azuremarketplace.microsoft.com/en/marketplace/apps/000-000.mistral-nemo-12b-2407?tab=PlansAndPrice", "supports_function_calling": true }, "azure_ai/mistral-small": { "input_cost_per_token": 1e-06, "litellm_provider": "azure_ai", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 3e-06, "supports_function_calling": true, "supports_tool_choice": true }, "azure_ai/mistral-small-2503": { "input_cost_per_token": 1e-06, "litellm_provider": "azure_ai", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3e-06, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "babbage-002": { "input_cost_per_token": 4e-07, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 16384, "mode": "completion", "output_cost_per_token": 4e-07 }, "bedrock/*/1-month-commitment/cohere.command-light-text-v14": { "input_cost_per_second": 0.001902, "litellm_provider": "bedrock", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_second": 0.001902, "supports_tool_choice": true }, "bedrock/*/1-month-commitment/cohere.command-text-v14": { "input_cost_per_second": 0.011, "litellm_provider": "bedrock", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_second": 0.011, "supports_tool_choice": true }, "bedrock/*/6-month-commitment/cohere.command-light-text-v14": { "input_cost_per_second": 0.0011416, "litellm_provider": "bedrock", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_second": 0.0011416, "supports_tool_choice": true }, "bedrock/*/6-month-commitment/cohere.command-text-v14": { "input_cost_per_second": 0.0066027, "litellm_provider": "bedrock", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_second": 0.0066027, "supports_tool_choice": true }, "bedrock/ap-northeast-1/1-month-commitment/anthropic.claude-instant-v1": { "input_cost_per_second": 0.01475, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.01475, "supports_tool_choice": true }, "bedrock/ap-northeast-1/1-month-commitment/anthropic.claude-v1": { "input_cost_per_second": 0.0455, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.0455 }, "bedrock/ap-northeast-1/1-month-commitment/anthropic.claude-v2:1": { "input_cost_per_second": 0.0455, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.0455, "supports_tool_choice": true }, "bedrock/ap-northeast-1/6-month-commitment/anthropic.claude-instant-v1": { "input_cost_per_second": 0.008194, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.008194, "supports_tool_choice": true }, "bedrock/ap-northeast-1/6-month-commitment/anthropic.claude-v1": { "input_cost_per_second": 0.02527, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.02527 }, "bedrock/ap-northeast-1/6-month-commitment/anthropic.claude-v2:1": { "input_cost_per_second": 0.02527, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.02527, "supports_tool_choice": true }, "bedrock/ap-northeast-1/anthropic.claude-instant-v1": { "input_cost_per_token": 2.23e-06, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 7.55e-06, "supports_tool_choice": true }, "bedrock/ap-northeast-1/anthropic.claude-v1": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_tool_choice": true }, "bedrock/ap-northeast-1/anthropic.claude-v2:1": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_tool_choice": true }, "bedrock/ap-south-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 3.18e-06, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4.2e-06 }, "bedrock/ap-south-1/meta.llama3-8b-instruct-v1:0": { "input_cost_per_token": 3.6e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 7.2e-07 }, "bedrock/ca-central-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 3.05e-06, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4.03e-06 }, "bedrock/ca-central-1/meta.llama3-8b-instruct-v1:0": { "input_cost_per_token": 3.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 6.9e-07 }, "bedrock/eu-central-1/1-month-commitment/anthropic.claude-instant-v1": { "input_cost_per_second": 0.01635, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.01635, "supports_tool_choice": true }, "bedrock/eu-central-1/1-month-commitment/anthropic.claude-v1": { "input_cost_per_second": 0.0415, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.0415 }, "bedrock/eu-central-1/1-month-commitment/anthropic.claude-v2:1": { "input_cost_per_second": 0.0415, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.0415, "supports_tool_choice": true }, "bedrock/eu-central-1/6-month-commitment/anthropic.claude-instant-v1": { "input_cost_per_second": 0.009083, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.009083, "supports_tool_choice": true }, "bedrock/eu-central-1/6-month-commitment/anthropic.claude-v1": { "input_cost_per_second": 0.02305, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.02305 }, "bedrock/eu-central-1/6-month-commitment/anthropic.claude-v2:1": { "input_cost_per_second": 0.02305, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.02305, "supports_tool_choice": true }, "bedrock/eu-central-1/anthropic.claude-instant-v1": { "input_cost_per_token": 2.48e-06, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 8.38e-06, "supports_tool_choice": true }, "bedrock/eu-central-1/anthropic.claude-v1": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05 }, "bedrock/eu-central-1/anthropic.claude-v2:1": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_tool_choice": true }, "bedrock/eu-west-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.86e-06, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 3.78e-06 }, "bedrock/eu-west-1/meta.llama3-8b-instruct-v1:0": { "input_cost_per_token": 3.2e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 6.5e-07 }, "bedrock/eu-west-2/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 3.45e-06, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4.55e-06 }, "bedrock/eu-west-2/meta.llama3-8b-instruct-v1:0": { "input_cost_per_token": 3.9e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 7.8e-07 }, "bedrock/eu-west-3/mistral.mistral-7b-instruct-v0:2": { "input_cost_per_token": 2e-07, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.6e-07, "supports_tool_choice": true }, "bedrock/eu-west-3/mistral.mistral-large-2402-v1:0": { "input_cost_per_token": 1.04e-05, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 3.12e-05, "supports_function_calling": true }, "bedrock/eu-west-3/mistral.mixtral-8x7b-instruct-v0:1": { "input_cost_per_token": 5.9e-07, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 9.1e-07, "supports_tool_choice": true }, "bedrock/invoke/anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "metadata": { "notes": "Anthropic via Invoke route does not currently support pdf input." }, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "bedrock/sa-east-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 4.45e-06, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.88e-06 }, "bedrock/sa-east-1/meta.llama3-8b-instruct-v1:0": { "input_cost_per_token": 5e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.01e-06 }, "bedrock/us-east-1/1-month-commitment/anthropic.claude-instant-v1": { "input_cost_per_second": 0.011, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.011, "supports_tool_choice": true }, "bedrock/us-east-1/1-month-commitment/anthropic.claude-v1": { "input_cost_per_second": 0.0175, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.0175 }, "bedrock/us-east-1/1-month-commitment/anthropic.claude-v2:1": { "input_cost_per_second": 0.0175, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.0175, "supports_tool_choice": true }, "bedrock/us-east-1/6-month-commitment/anthropic.claude-instant-v1": { "input_cost_per_second": 0.00611, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.00611, "supports_tool_choice": true }, "bedrock/us-east-1/6-month-commitment/anthropic.claude-v1": { "input_cost_per_second": 0.00972, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.00972 }, "bedrock/us-east-1/6-month-commitment/anthropic.claude-v2:1": { "input_cost_per_second": 0.00972, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.00972, "supports_tool_choice": true }, "bedrock/us-east-1/anthropic.claude-instant-v1": { "input_cost_per_token": 8e-07, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-06, "supports_tool_choice": true }, "bedrock/us-east-1/anthropic.claude-v1": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_tool_choice": true }, "bedrock/us-east-1/anthropic.claude-v2:1": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_tool_choice": true }, "bedrock/us-east-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 3.5e-06 }, "bedrock/us-east-1/meta.llama3-8b-instruct-v1:0": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 6e-07 }, "bedrock/us-east-1/mistral.mistral-7b-instruct-v0:2": { "input_cost_per_token": 1.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2e-07, "supports_tool_choice": true }, "bedrock/us-east-1/mistral.mistral-large-2402-v1:0": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_function_calling": true }, "bedrock/us-east-1/mistral.mixtral-8x7b-instruct-v0:1": { "input_cost_per_token": 4.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 7e-07, "supports_tool_choice": true }, "bedrock/us-gov-east-1/amazon.nova-pro-v1:0": { "input_cost_per_token": 9.6e-07, "litellm_provider": "bedrock", "max_input_tokens": 300000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 3.84e-06, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true }, "bedrock/us-gov-east-1/amazon.titan-embed-text-v1": { "input_cost_per_token": 1e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_tokens": 8192, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1536 }, "bedrock/us-gov-east-1/amazon.titan-embed-text-v2:0": { "input_cost_per_token": 2e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_tokens": 8192, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1024 }, "bedrock/us-gov-east-1/amazon.titan-text-express-v1": { "input_cost_per_token": 1.3e-06, "litellm_provider": "bedrock", "max_input_tokens": 42000, "max_output_tokens": 8000, "max_tokens": 8000, "mode": "chat", "output_cost_per_token": 1.7e-06 }, "bedrock/us-gov-east-1/amazon.titan-text-lite-v1": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 42000, "max_output_tokens": 4000, "max_tokens": 4000, "mode": "chat", "output_cost_per_token": 4e-07 }, "bedrock/us-gov-east-1/amazon.titan-text-premier-v1:0": { "input_cost_per_token": 5e-07, "litellm_provider": "bedrock", "max_input_tokens": 42000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 1.5e-06 }, "bedrock/us-gov-east-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.8e-05, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-06, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, "litellm_provider": "bedrock", "max_input_tokens": 8000, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 3.5e-06, "supports_pdf_input": true }, "bedrock/us-gov-east-1/meta.llama3-8b-instruct-v1:0": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 8000, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 2.65e-06, "supports_pdf_input": true }, "bedrock/us-gov-west-1/amazon.nova-pro-v1:0": { "input_cost_per_token": 9.6e-07, "litellm_provider": "bedrock", "max_input_tokens": 300000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 3.84e-06, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true }, "bedrock/us-gov-west-1/amazon.titan-embed-text-v1": { "input_cost_per_token": 1e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_tokens": 8192, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1536 }, "bedrock/us-gov-west-1/amazon.titan-embed-text-v2:0": { "input_cost_per_token": 2e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_tokens": 8192, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1024 }, "bedrock/us-gov-west-1/amazon.titan-text-express-v1": { "input_cost_per_token": 1.3e-06, "litellm_provider": "bedrock", "max_input_tokens": 42000, "max_output_tokens": 8000, "max_tokens": 8000, "mode": "chat", "output_cost_per_token": 1.7e-06 }, "bedrock/us-gov-west-1/amazon.titan-text-lite-v1": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 42000, "max_output_tokens": 4000, "max_tokens": 4000, "mode": "chat", "output_cost_per_token": 4e-07 }, "bedrock/us-gov-west-1/amazon.titan-text-premier-v1:0": { "input_cost_per_token": 5e-07, "litellm_provider": "bedrock", "max_input_tokens": 42000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 1.5e-06 }, "bedrock/us-gov-west-1/anthropic.claude-3-7-sonnet-20250219-v1:0": { "cache_creation_input_token_cost": 4.5e-06, "cache_read_input_token_cost": 3.6e-07, "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.8e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "bedrock/us-gov-west-1/anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3.6e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.8e-05, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "bedrock/us-gov-west-1/anthropic.claude-3-haiku-20240307-v1:0": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-06, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "bedrock/us-gov-west-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, "litellm_provider": "bedrock", "max_input_tokens": 8000, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 3.5e-06, "supports_pdf_input": true }, "bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 8000, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 2.65e-06, "supports_pdf_input": true }, "bedrock/us-west-1/meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 3.5e-06 }, "bedrock/us-west-1/meta.llama3-8b-instruct-v1:0": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 6e-07 }, "bedrock/us-west-2/1-month-commitment/anthropic.claude-instant-v1": { "input_cost_per_second": 0.011, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.011, "supports_tool_choice": true }, "bedrock/us-west-2/1-month-commitment/anthropic.claude-v1": { "input_cost_per_second": 0.0175, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.0175 }, "bedrock/us-west-2/1-month-commitment/anthropic.claude-v2:1": { "input_cost_per_second": 0.0175, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.0175, "supports_tool_choice": true }, "bedrock/us-west-2/6-month-commitment/anthropic.claude-instant-v1": { "input_cost_per_second": 0.00611, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.00611, "supports_tool_choice": true }, "bedrock/us-west-2/6-month-commitment/anthropic.claude-v1": { "input_cost_per_second": 0.00972, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.00972 }, "bedrock/us-west-2/6-month-commitment/anthropic.claude-v2:1": { "input_cost_per_second": 0.00972, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_second": 0.00972, "supports_tool_choice": true }, "bedrock/us-west-2/anthropic.claude-instant-v1": { "input_cost_per_token": 8e-07, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-06, "supports_tool_choice": true }, "bedrock/us-west-2/anthropic.claude-v1": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_tool_choice": true }, "bedrock/us-west-2/anthropic.claude-v2:1": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 100000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_tool_choice": true }, "bedrock/us-west-2/mistral.mistral-7b-instruct-v0:2": { "input_cost_per_token": 1.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2e-07, "supports_tool_choice": true }, "bedrock/us-west-2/mistral.mistral-large-2402-v1:0": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_function_calling": true }, "bedrock/us-west-2/mistral.mixtral-8x7b-instruct-v0:1": { "input_cost_per_token": 4.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 7e-07, "supports_tool_choice": true }, "bedrock/us.anthropic.claude-3-5-haiku-20241022-v1:0": { "cache_creation_input_token_cost": 1e-06, "cache_read_input_token_cost": 8e-08, "input_cost_per_token": 8e-07, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true }, "cerebras/llama-3.3-70b": { "input_cost_per_token": 8.5e-07, "litellm_provider": "cerebras", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.2e-06, "supports_function_calling": true, "supports_tool_choice": true }, "cerebras/llama3.1-70b": { "input_cost_per_token": 6e-07, "litellm_provider": "cerebras", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_tool_choice": true }, "cerebras/llama3.1-8b": { "input_cost_per_token": 1e-07, "litellm_provider": "cerebras", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-07, "supports_function_calling": true, "supports_tool_choice": true }, "cerebras/openai/gpt-oss-120b": { "input_cost_per_token": 2.5e-07, "litellm_provider": "cerebras", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6.9e-07, "source": "https://www.cerebras.ai/blog/openai-gpt-oss-120b-runs-fastest-on-cerebras", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "cerebras/qwen-3-32b": { "input_cost_per_token": 4e-07, "litellm_provider": "cerebras", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 8e-07, "source": "https://inference-docs.cerebras.ai/support/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "chat-bison": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-chat-models", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "chat-bison-32k": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-chat-models", "max_input_tokens": 32000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "chat-bison-32k@002": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-chat-models", "max_input_tokens": 32000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "chat-bison@001": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-chat-models", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "chat-bison@002": { "deprecation_date": "2025-04-09", "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-chat-models", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "chatdolphin": { "input_cost_per_token": 5e-07, "litellm_provider": "nlp_cloud", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 5e-07 }, "chatgpt-4o-latest": { "input_cost_per_token": 5e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "claude-3-5-haiku-20241022": { "cache_creation_input_token_cost": 1e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 8e-08, "deprecation_date": "2025-10-01", "input_cost_per_token": 8e-07, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4e-06, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tool_use_system_prompt_tokens": 264 }, "claude-3-5-haiku-latest": { "cache_creation_input_token_cost": 1.25e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 1e-07, "deprecation_date": "2025-10-01", "input_cost_per_token": 1e-06, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5e-06, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tool_use_system_prompt_tokens": 264 }, "claude-haiku-4-5-20251001": { "cache_creation_input_token_cost": 1.25e-06, "cache_creation_input_token_cost_above_1hr": 2e-06, "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 5e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_computer_use": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "claude-haiku-4-5": { "cache_creation_input_token_cost": 1.25e-06, "cache_creation_input_token_cost_above_1hr": 2e-06, "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 5e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_computer_use": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "claude-3-5-sonnet-20240620": { "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 3e-07, "deprecation_date": "2025-06-01", "input_cost_per_token": 3e-06, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "claude-3-5-sonnet-20241022": { "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 3e-07, "deprecation_date": "2025-10-01", "input_cost_per_token": 3e-06, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tool_use_system_prompt_tokens": 159 }, "claude-3-5-sonnet-latest": { "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 3e-07, "deprecation_date": "2025-06-01", "input_cost_per_token": 3e-06, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tool_use_system_prompt_tokens": 159 }, "claude-3-7-sonnet-20250219": { "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 3e-07, "deprecation_date": "2026-02-19", "input_cost_per_token": 3e-06, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tool_use_system_prompt_tokens": 159 }, "claude-3-7-sonnet-latest": { "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 3e-07, "deprecation_date": "2025-06-01", "input_cost_per_token": 3e-06, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "claude-3-haiku-20240307": { "cache_creation_input_token_cost": 3e-07, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 3e-08, "input_cost_per_token": 2.5e-07, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.25e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 264 }, "claude-3-opus-20240229": { "cache_creation_input_token_cost": 1.875e-05, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 1.5e-06, "deprecation_date": "2026-05-01", "input_cost_per_token": 1.5e-05, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 7.5e-05, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 395 }, "claude-3-opus-latest": { "cache_creation_input_token_cost": 1.875e-05, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 1.5e-06, "deprecation_date": "2025-03-01", "input_cost_per_token": 1.5e-05, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 7.5e-05, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 395 }, "claude-4-opus-20250514": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "claude-4-sonnet-20250514": { "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost": 3e-07, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "litellm_provider": "anthropic", "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 1.5e-05, "output_cost_per_token_above_200k_tokens": 2.25e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "claude-sonnet-4-5": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, "claude-sonnet-4-5-20250929": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, "claude-opus-4-1": { "cache_creation_input_token_cost": 1.875e-05, "cache_creation_input_token_cost_above_1hr": 3e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "claude-opus-4-1-20250805": { "cache_creation_input_token_cost": 1.875e-05, "cache_creation_input_token_cost_above_1hr": 3e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "deprecation_date": "2026-08-05", "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "claude-opus-4-20250514": { "cache_creation_input_token_cost": 1.875e-05, "cache_creation_input_token_cost_above_1hr": 3e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "deprecation_date": "2026-05-14", "litellm_provider": "anthropic", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "claude-sonnet-4-20250514": { "deprecation_date": "2026-05-14", "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_1hr": 6e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "anthropic", "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "cloudflare/@cf/meta/llama-2-7b-chat-fp16": { "input_cost_per_token": 1.923e-06, "litellm_provider": "cloudflare", "max_input_tokens": 3072, "max_output_tokens": 3072, "max_tokens": 3072, "mode": "chat", "output_cost_per_token": 1.923e-06 }, "cloudflare/@cf/meta/llama-2-7b-chat-int8": { "input_cost_per_token": 1.923e-06, "litellm_provider": "cloudflare", "max_input_tokens": 2048, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 1.923e-06 }, "cloudflare/@cf/mistral/mistral-7b-instruct-v0.1": { "input_cost_per_token": 1.923e-06, "litellm_provider": "cloudflare", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.923e-06 }, "cloudflare/@hf/thebloke/codellama-7b-instruct-awq": { "input_cost_per_token": 1.923e-06, "litellm_provider": "cloudflare", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.923e-06 }, "code-bison": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-text-models", "max_input_tokens": 6144, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "chat", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "code-bison-32k@002": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-text-models", "max_input_tokens": 6144, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "code-bison32k": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-text-models", "max_input_tokens": 6144, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "code-bison@001": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-text-models", "max_input_tokens": 6144, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "code-bison@002": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-text-models", "max_input_tokens": 6144, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "code-gecko": { "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-text-models", "max_input_tokens": 2048, "max_output_tokens": 64, "max_tokens": 64, "mode": "completion", "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "code-gecko-latest": { "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-text-models", "max_input_tokens": 2048, "max_output_tokens": 64, "max_tokens": 64, "mode": "completion", "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "code-gecko@001": { "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-text-models", "max_input_tokens": 2048, "max_output_tokens": 64, "max_tokens": 64, "mode": "completion", "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "code-gecko@002": { "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-text-models", "max_input_tokens": 2048, "max_output_tokens": 64, "max_tokens": 64, "mode": "completion", "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "codechat-bison": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-chat-models", "max_input_tokens": 6144, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "chat", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "codechat-bison-32k": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-chat-models", "max_input_tokens": 32000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "codechat-bison-32k@002": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-chat-models", "max_input_tokens": 32000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "codechat-bison@001": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-chat-models", "max_input_tokens": 6144, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "chat", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "codechat-bison@002": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-chat-models", "max_input_tokens": 6144, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "chat", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "codechat-bison@latest": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-code-chat-models", "max_input_tokens": 6144, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "chat", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "codestral/codestral-2405": { "input_cost_per_token": 0.0, "litellm_provider": "codestral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 0.0, "source": "https://docs.mistral.ai/capabilities/code_generation/", "supports_assistant_prefill": true, "supports_tool_choice": true }, "codestral/codestral-latest": { "input_cost_per_token": 0.0, "litellm_provider": "codestral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 0.0, "source": "https://docs.mistral.ai/capabilities/code_generation/", "supports_assistant_prefill": true, "supports_tool_choice": true }, "codex-mini-latest": { "cache_read_input_token_cost": 3.75e-07, "input_cost_per_token": 1.5e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 6e-06, "supported_endpoints": [ "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "cohere.command-light-text-v14": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-07, "supports_tool_choice": true }, "cohere.command-r-plus-v1:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_tool_choice": true }, "cohere.command-r-v1:0": { "input_cost_per_token": 5e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-06, "supports_tool_choice": true }, "cohere.command-text-v14": { "input_cost_per_token": 1.5e-06, "litellm_provider": "bedrock", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2e-06, "supports_tool_choice": true }, "cohere.embed-english-v3": { "input_cost_per_token": 1e-07, "litellm_provider": "bedrock", "max_input_tokens": 512, "max_tokens": 512, "mode": "embedding", "output_cost_per_token": 0.0, "supports_embedding_image_input": true }, "cohere.embed-multilingual-v3": { "input_cost_per_token": 1e-07, "litellm_provider": "bedrock", "max_input_tokens": 512, "max_tokens": 512, "mode": "embedding", "output_cost_per_token": 0.0, "supports_embedding_image_input": true }, "cohere.embed-v4:0": { "input_cost_per_token": 1.2e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_tokens": 128000, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1536, "supports_embedding_image_input": true }, "cohere.rerank-v3-5:0": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, "litellm_provider": "bedrock", "max_document_chunks_per_query": 100, "max_input_tokens": 32000, "max_output_tokens": 32000, "max_query_tokens": 32000, "max_tokens": 32000, "max_tokens_per_document_chunk": 512, "mode": "rerank", "output_cost_per_token": 0.0 }, "command": { "input_cost_per_token": 1e-06, "litellm_provider": "cohere", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 2e-06 }, "command-a-03-2025": { "input_cost_per_token": 2.5e-06, "litellm_provider": "cohere_chat", "max_input_tokens": 256000, "max_output_tokens": 8000, "max_tokens": 8000, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_tool_choice": true }, "command-light": { "input_cost_per_token": 3e-07, "litellm_provider": "cohere_chat", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-07, "supports_tool_choice": true }, "command-nightly": { "input_cost_per_token": 1e-06, "litellm_provider": "cohere", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 2e-06 }, "command-r": { "input_cost_per_token": 1.5e-07, "litellm_provider": "cohere_chat", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_tool_choice": true }, "command-r-08-2024": { "input_cost_per_token": 1.5e-07, "litellm_provider": "cohere_chat", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_tool_choice": true }, "command-r-plus": { "input_cost_per_token": 2.5e-06, "litellm_provider": "cohere_chat", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_tool_choice": true }, "command-r-plus-08-2024": { "input_cost_per_token": 2.5e-06, "litellm_provider": "cohere_chat", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_tool_choice": true }, "command-r7b-12-2024": { "input_cost_per_token": 1.5e-07, "litellm_provider": "cohere_chat", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3.75e-08, "source": "https://docs.cohere.com/v2/docs/command-r7b", "supports_function_calling": true, "supports_tool_choice": true }, "computer-use-preview": { "input_cost_per_token": 3e-06, "litellm_provider": "azure", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "chat", "output_cost_per_token": 1.2e-05, "supported_endpoints": [ "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": false, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "deepseek-chat": { "cache_read_input_token_cost": 6e-08, "input_cost_per_token": 6e-07, "litellm_provider": "deepseek", "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.7e-06, "source": "https://api-docs.deepseek.com/quick_start/pricing", "supported_endpoints": [ "/v1/chat/completions" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true }, "deepseek-reasoner": { "cache_read_input_token_cost": 6e-08, "input_cost_per_token": 6e-07, "litellm_provider": "deepseek", "max_input_tokens": 131072, "max_output_tokens": 65536, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.7e-06, "source": "https://api-docs.deepseek.com/quick_start/pricing", "supported_endpoints": [ "/v1/chat/completions" ], "supports_function_calling": false, "supports_native_streaming": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": false }, "dashscope/qwen-coder": { "input_cost_per_token": 3e-07, "litellm_provider": "dashscope", "max_input_tokens": 1000000, "max_output_tokens": 16384, "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "dashscope/qwen-flash": { "litellm_provider": "dashscope", "max_input_tokens": 997952, "max_output_tokens": 32768, "max_tokens": 1000000, "mode": "chat", "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "tiered_pricing": [ { "input_cost_per_token": 5e-08, "output_cost_per_token": 4e-07, "range": [ 0, 256000.0 ] }, { "input_cost_per_token": 2.5e-07, "output_cost_per_token": 2e-06, "range": [ 256000.0, 1000000.0 ] } ] }, "dashscope/qwen-flash-2025-07-28": { "litellm_provider": "dashscope", "max_input_tokens": 997952, "max_output_tokens": 32768, "max_tokens": 1000000, "mode": "chat", "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "tiered_pricing": [ { "input_cost_per_token": 5e-08, "output_cost_per_token": 4e-07, "range": [ 0, 256000.0 ] }, { "input_cost_per_token": 2.5e-07, "output_cost_per_token": 2e-06, "range": [ 256000.0, 1000000.0 ] } ] }, "dashscope/qwen-max": { "input_cost_per_token": 1.6e-06, "litellm_provider": "dashscope", "max_input_tokens": 30720, "max_output_tokens": 8192, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6.4e-06, "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "dashscope/qwen-plus": { "input_cost_per_token": 4e-07, "litellm_provider": "dashscope", "max_input_tokens": 129024, "max_output_tokens": 16384, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.2e-06, "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "dashscope/qwen-plus-2025-01-25": { "input_cost_per_token": 4e-07, "litellm_provider": "dashscope", "max_input_tokens": 129024, "max_output_tokens": 8192, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.2e-06, "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "dashscope/qwen-plus-2025-04-28": { "input_cost_per_token": 4e-07, "litellm_provider": "dashscope", "max_input_tokens": 129024, "max_output_tokens": 16384, "max_tokens": 131072, "mode": "chat", "output_cost_per_reasoning_token": 4e-06, "output_cost_per_token": 1.2e-06, "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "dashscope/qwen-plus-2025-07-14": { "input_cost_per_token": 4e-07, "litellm_provider": "dashscope", "max_input_tokens": 129024, "max_output_tokens": 16384, "max_tokens": 131072, "mode": "chat", "output_cost_per_reasoning_token": 4e-06, "output_cost_per_token": 1.2e-06, "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "dashscope/qwen-plus-2025-07-28": { "litellm_provider": "dashscope", "max_input_tokens": 997952, "max_output_tokens": 32768, "max_tokens": 1000000, "mode": "chat", "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "tiered_pricing": [ { "input_cost_per_token": 4e-07, "output_cost_per_reasoning_token": 4e-06, "output_cost_per_token": 1.2e-06, "range": [ 0, 256000.0 ] }, { "input_cost_per_token": 1.2e-06, "output_cost_per_reasoning_token": 1.2e-05, "output_cost_per_token": 3.6e-06, "range": [ 256000.0, 1000000.0 ] } ] }, "dashscope/qwen-plus-2025-09-11": { "litellm_provider": "dashscope", "max_input_tokens": 997952, "max_output_tokens": 32768, "max_tokens": 1000000, "mode": "chat", "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "tiered_pricing": [ { "input_cost_per_token": 4e-07, "output_cost_per_reasoning_token": 4e-06, "output_cost_per_token": 1.2e-06, "range": [ 0, 256000.0 ] }, { "input_cost_per_token": 1.2e-06, "output_cost_per_reasoning_token": 1.2e-05, "output_cost_per_token": 3.6e-06, "range": [ 256000.0, 1000000.0 ] } ] }, "dashscope/qwen-plus-latest": { "litellm_provider": "dashscope", "max_input_tokens": 997952, "max_output_tokens": 32768, "max_tokens": 1000000, "mode": "chat", "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "tiered_pricing": [ { "input_cost_per_token": 4e-07, "output_cost_per_reasoning_token": 4e-06, "output_cost_per_token": 1.2e-06, "range": [ 0, 256000.0 ] }, { "input_cost_per_token": 1.2e-06, "output_cost_per_reasoning_token": 1.2e-05, "output_cost_per_token": 3.6e-06, "range": [ 256000.0, 1000000.0 ] } ] }, "dashscope/qwen-turbo": { "input_cost_per_token": 5e-08, "litellm_provider": "dashscope", "max_input_tokens": 129024, "max_output_tokens": 16384, "max_tokens": 131072, "mode": "chat", "output_cost_per_reasoning_token": 5e-07, "output_cost_per_token": 2e-07, "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "dashscope/qwen-turbo-2024-11-01": { "input_cost_per_token": 5e-08, "litellm_provider": "dashscope", "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 2e-07, "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "dashscope/qwen-turbo-2025-04-28": { "input_cost_per_token": 5e-08, "litellm_provider": "dashscope", "max_input_tokens": 1000000, "max_output_tokens": 16384, "max_tokens": 1000000, "mode": "chat", "output_cost_per_reasoning_token": 5e-07, "output_cost_per_token": 2e-07, "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "dashscope/qwen-turbo-latest": { "input_cost_per_token": 5e-08, "litellm_provider": "dashscope", "max_input_tokens": 1000000, "max_output_tokens": 16384, "max_tokens": 1000000, "mode": "chat", "output_cost_per_reasoning_token": 5e-07, "output_cost_per_token": 2e-07, "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "dashscope/qwen3-30b-a3b": { "litellm_provider": "dashscope", "max_input_tokens": 129024, "max_output_tokens": 16384, "max_tokens": 131072, "mode": "chat", "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "dashscope/qwen3-coder-flash": { "litellm_provider": "dashscope", "max_input_tokens": 997952, "max_output_tokens": 65536, "max_tokens": 1000000, "mode": "chat", "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "tiered_pricing": [ { "cache_read_input_token_cost": 8e-08, "input_cost_per_token": 3e-07, "output_cost_per_token": 1.5e-06, "range": [ 0, 32000.0 ] }, { "cache_read_input_token_cost": 1.2e-07, "input_cost_per_token": 5e-07, "output_cost_per_token": 2.5e-06, "range": [ 32000.0, 128000.0 ] }, { "cache_read_input_token_cost": 2e-07, "input_cost_per_token": 8e-07, "output_cost_per_token": 4e-06, "range": [ 128000.0, 256000.0 ] }, { "cache_read_input_token_cost": 4e-07, "input_cost_per_token": 1.6e-06, "output_cost_per_token": 9.6e-06, "range": [ 256000.0, 1000000.0 ] } ] }, "dashscope/qwen3-coder-flash-2025-07-28": { "litellm_provider": "dashscope", "max_input_tokens": 997952, "max_output_tokens": 65536, "max_tokens": 1000000, "mode": "chat", "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "tiered_pricing": [ { "input_cost_per_token": 3e-07, "output_cost_per_token": 1.5e-06, "range": [ 0, 32000.0 ] }, { "input_cost_per_token": 5e-07, "output_cost_per_token": 2.5e-06, "range": [ 32000.0, 128000.0 ] }, { "input_cost_per_token": 8e-07, "output_cost_per_token": 4e-06, "range": [ 128000.0, 256000.0 ] }, { "input_cost_per_token": 1.6e-06, "output_cost_per_token": 9.6e-06, "range": [ 256000.0, 1000000.0 ] } ] }, "dashscope/qwen3-coder-plus": { "litellm_provider": "dashscope", "max_input_tokens": 997952, "max_output_tokens": 65536, "max_tokens": 1000000, "mode": "chat", "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "tiered_pricing": [ { "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "output_cost_per_token": 5e-06, "range": [ 0, 32000.0 ] }, { "cache_read_input_token_cost": 1.8e-07, "input_cost_per_token": 1.8e-06, "output_cost_per_token": 9e-06, "range": [ 32000.0, 128000.0 ] }, { "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, "range": [ 128000.0, 256000.0 ] }, { "cache_read_input_token_cost": 6e-07, "input_cost_per_token": 6e-06, "output_cost_per_token": 6e-05, "range": [ 256000.0, 1000000.0 ] } ] }, "dashscope/qwen3-coder-plus-2025-07-22": { "litellm_provider": "dashscope", "max_input_tokens": 997952, "max_output_tokens": 65536, "max_tokens": 1000000, "mode": "chat", "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "tiered_pricing": [ { "input_cost_per_token": 1e-06, "output_cost_per_token": 5e-06, "range": [ 0, 32000.0 ] }, { "input_cost_per_token": 1.8e-06, "output_cost_per_token": 9e-06, "range": [ 32000.0, 128000.0 ] }, { "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, "range": [ 128000.0, 256000.0 ] }, { "input_cost_per_token": 6e-06, "output_cost_per_token": 6e-05, "range": [ 256000.0, 1000000.0 ] } ] }, "dashscope/qwen3-max-preview": { "litellm_provider": "dashscope", "max_input_tokens": 258048, "max_output_tokens": 65536, "max_tokens": 262144, "mode": "chat", "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "tiered_pricing": [ { "input_cost_per_token": 1.2e-06, "output_cost_per_token": 6e-06, "range": [ 0, 32000.0 ] }, { "input_cost_per_token": 2.4e-06, "output_cost_per_token": 1.2e-05, "range": [ 32000.0, 128000.0 ] }, { "input_cost_per_token": 3e-06, "output_cost_per_token": 1.5e-05, "range": [ 128000.0, 252000.0 ] } ] }, "dashscope/qwq-plus": { "input_cost_per_token": 8e-07, "litellm_provider": "dashscope", "max_input_tokens": 98304, "max_output_tokens": 8192, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.4e-06, "source": "https://www.alibabacloud.com/help/en/model-studio/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "databricks/databricks-bge-large-en": { "input_cost_per_token": 1.0003e-07, "input_dbu_cost_per_token": 1.429e-06, "litellm_provider": "databricks", "max_input_tokens": 512, "max_tokens": 512, "metadata": { "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "embedding", "output_cost_per_token": 0.0, "output_dbu_cost_per_token": 0.0, "output_vector_size": 1024, "source": "https://www.databricks.com/product/pricing/foundation-model-serving" }, "databricks/databricks-claude-3-7-sonnet": { "input_cost_per_token": 2.5e-06, "input_dbu_cost_per_token": 3.571e-05, "litellm_provider": "databricks", "max_input_tokens": 200000, "max_output_tokens": 128000, "max_tokens": 200000, "metadata": { "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Claude 3.7 conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", "output_cost_per_token": 1.7857e-05, "output_db_cost_per_token": 0.000214286, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "databricks/databricks-gte-large-en": { "input_cost_per_token": 1.2999e-07, "input_dbu_cost_per_token": 1.857e-06, "litellm_provider": "databricks", "max_input_tokens": 8192, "max_tokens": 8192, "metadata": { "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "embedding", "output_cost_per_token": 0.0, "output_dbu_cost_per_token": 0.0, "output_vector_size": 1024, "source": "https://www.databricks.com/product/pricing/foundation-model-serving" }, "databricks/databricks-llama-2-70b-chat": { "input_cost_per_token": 5.0001e-07, "input_dbu_cost_per_token": 7.143e-06, "litellm_provider": "databricks", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "metadata": { "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", "output_cost_per_token": 1.5e-06, "output_dbu_cost_per_token": 2.1429e-05, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, "databricks/databricks-llama-4-maverick": { "input_cost_per_token": 5e-06, "input_dbu_cost_per_token": 7.143e-05, "litellm_provider": "databricks", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "metadata": { "notes": "Databricks documentation now provides both DBU costs (_dbu_cost_per_token) and dollar costs(_cost_per_token)." }, "mode": "chat", "output_cost_per_token": 1.5e-05, "output_dbu_cost_per_token": 0.00021429, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, "databricks/databricks-meta-llama-3-1-405b-instruct": { "input_cost_per_token": 5e-06, "input_dbu_cost_per_token": 7.1429e-05, "litellm_provider": "databricks", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "metadata": { "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", "output_cost_per_token": 1.500002e-05, "output_db_cost_per_token": 0.000214286, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, "databricks/databricks-meta-llama-3-3-70b-instruct": { "input_cost_per_token": 1.00002e-06, "input_dbu_cost_per_token": 1.4286e-05, "litellm_provider": "databricks", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "metadata": { "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", "output_cost_per_token": 2.99999e-06, "output_dbu_cost_per_token": 4.2857e-05, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, "databricks/databricks-meta-llama-3-70b-instruct": { "input_cost_per_token": 1.00002e-06, "input_dbu_cost_per_token": 1.4286e-05, "litellm_provider": "databricks", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "metadata": { "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", "output_cost_per_token": 2.99999e-06, "output_dbu_cost_per_token": 4.2857e-05, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, "databricks/databricks-mixtral-8x7b-instruct": { "input_cost_per_token": 5.0001e-07, "input_dbu_cost_per_token": 7.143e-06, "litellm_provider": "databricks", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "metadata": { "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", "output_cost_per_token": 9.9902e-07, "output_dbu_cost_per_token": 1.4286e-05, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, "databricks/databricks-mpt-30b-instruct": { "input_cost_per_token": 9.9902e-07, "input_dbu_cost_per_token": 1.4286e-05, "litellm_provider": "databricks", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "metadata": { "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", "output_cost_per_token": 9.9902e-07, "output_dbu_cost_per_token": 1.4286e-05, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, "databricks/databricks-mpt-7b-instruct": { "input_cost_per_token": 5.0001e-07, "input_dbu_cost_per_token": 7.143e-06, "litellm_provider": "databricks", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "metadata": { "notes": "Input/output cost per token is dbu cost * $0.070, based on databricks Llama 3.1 70B conversion. Number provided for reference, '*_dbu_cost_per_token' used in actual calculation." }, "mode": "chat", "output_cost_per_token": 0.0, "output_dbu_cost_per_token": 0.0, "source": "https://www.databricks.com/product/pricing/foundation-model-serving", "supports_tool_choice": true }, "dataforseo/search": { "input_cost_per_query": 0.003, "litellm_provider": "dataforseo", "mode": "search" }, "davinci-002": { "input_cost_per_token": 2e-06, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 16384, "mode": "completion", "output_cost_per_token": 2e-06 }, "deepgram/base": { "input_cost_per_second": 0.00020833, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0125/60 seconds = $0.00020833 per second", "original_pricing_per_minute": 0.0125 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/base-conversationalai": { "input_cost_per_second": 0.00020833, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0125/60 seconds = $0.00020833 per second", "original_pricing_per_minute": 0.0125 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/base-finance": { "input_cost_per_second": 0.00020833, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0125/60 seconds = $0.00020833 per second", "original_pricing_per_minute": 0.0125 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/base-general": { "input_cost_per_second": 0.00020833, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0125/60 seconds = $0.00020833 per second", "original_pricing_per_minute": 0.0125 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/base-meeting": { "input_cost_per_second": 0.00020833, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0125/60 seconds = $0.00020833 per second", "original_pricing_per_minute": 0.0125 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/base-phonecall": { "input_cost_per_second": 0.00020833, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0125/60 seconds = $0.00020833 per second", "original_pricing_per_minute": 0.0125 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/base-video": { "input_cost_per_second": 0.00020833, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0125/60 seconds = $0.00020833 per second", "original_pricing_per_minute": 0.0125 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/base-voicemail": { "input_cost_per_second": 0.00020833, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0125/60 seconds = $0.00020833 per second", "original_pricing_per_minute": 0.0125 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/enhanced": { "input_cost_per_second": 0.00024167, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0145/60 seconds = $0.00024167 per second", "original_pricing_per_minute": 0.0145 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/enhanced-finance": { "input_cost_per_second": 0.00024167, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0145/60 seconds = $0.00024167 per second", "original_pricing_per_minute": 0.0145 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/enhanced-general": { "input_cost_per_second": 0.00024167, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0145/60 seconds = $0.00024167 per second", "original_pricing_per_minute": 0.0145 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/enhanced-meeting": { "input_cost_per_second": 0.00024167, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0145/60 seconds = $0.00024167 per second", "original_pricing_per_minute": 0.0145 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/enhanced-phonecall": { "input_cost_per_second": 0.00024167, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0145/60 seconds = $0.00024167 per second", "original_pricing_per_minute": 0.0145 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-2": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-2-atc": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-2-automotive": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-2-conversationalai": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-2-drivethru": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-2-finance": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-2-general": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-2-meeting": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-2-phonecall": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-2-video": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-2-voicemail": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-3": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-3-general": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-3-medical": { "input_cost_per_second": 8.667e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0052/60 seconds = $0.00008667 per second (multilingual)", "original_pricing_per_minute": 0.0052 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-general": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/nova-phonecall": { "input_cost_per_second": 7.167e-05, "litellm_provider": "deepgram", "metadata": { "calculation": "$0.0043/60 seconds = $0.00007167 per second", "original_pricing_per_minute": 0.0043 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/whisper": { "input_cost_per_second": 0.0001, "litellm_provider": "deepgram", "metadata": { "notes": "Deepgram's hosted OpenAI Whisper models - pricing may differ from native Deepgram models" }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/whisper-base": { "input_cost_per_second": 0.0001, "litellm_provider": "deepgram", "metadata": { "notes": "Deepgram's hosted OpenAI Whisper models - pricing may differ from native Deepgram models" }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/whisper-large": { "input_cost_per_second": 0.0001, "litellm_provider": "deepgram", "metadata": { "notes": "Deepgram's hosted OpenAI Whisper models - pricing may differ from native Deepgram models" }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/whisper-medium": { "input_cost_per_second": 0.0001, "litellm_provider": "deepgram", "metadata": { "notes": "Deepgram's hosted OpenAI Whisper models - pricing may differ from native Deepgram models" }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/whisper-small": { "input_cost_per_second": 0.0001, "litellm_provider": "deepgram", "metadata": { "notes": "Deepgram's hosted OpenAI Whisper models - pricing may differ from native Deepgram models" }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepgram/whisper-tiny": { "input_cost_per_second": 0.0001, "litellm_provider": "deepgram", "metadata": { "notes": "Deepgram's hosted OpenAI Whisper models - pricing may differ from native Deepgram models" }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://deepgram.com/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "deepinfra/Gryphe/MythoMax-L2-13b": { "max_tokens": 4096, "max_input_tokens": 4096, "max_output_tokens": 4096, "input_cost_per_token": 8e-08, "output_cost_per_token": 9e-08, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/NousResearch/Hermes-3-Llama-3.1-405B": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 1e-06, "output_cost_per_token": 1e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/NousResearch/Hermes-3-Llama-3.1-70B": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 3e-07, "output_cost_per_token": 3e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": false }, "deepinfra/Qwen/QwQ-32B": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 1.5e-07, "output_cost_per_token": 4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Qwen/Qwen2.5-72B-Instruct": { "max_tokens": 32768, "max_input_tokens": 32768, "max_output_tokens": 32768, "input_cost_per_token": 1.2e-07, "output_cost_per_token": 3.9e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Qwen/Qwen2.5-7B-Instruct": { "max_tokens": 32768, "max_input_tokens": 32768, "max_output_tokens": 32768, "input_cost_per_token": 4e-08, "output_cost_per_token": 1e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": false }, "deepinfra/Qwen/Qwen2.5-VL-32B-Instruct": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 2e-07, "output_cost_per_token": 6e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Qwen/Qwen3-14B": { "max_tokens": 40960, "max_input_tokens": 40960, "max_output_tokens": 40960, "input_cost_per_token": 6e-08, "output_cost_per_token": 2.4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Qwen/Qwen3-235B-A22B": { "max_tokens": 40960, "max_input_tokens": 40960, "max_output_tokens": 40960, "input_cost_per_token": 1.8e-07, "output_cost_per_token": 5.4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Qwen/Qwen3-235B-A22B-Instruct-2507": { "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 262144, "input_cost_per_token": 9e-08, "output_cost_per_token": 6e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Qwen/Qwen3-235B-A22B-Thinking-2507": { "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 262144, "input_cost_per_token": 3e-07, "output_cost_per_token": 2.9e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Qwen/Qwen3-30B-A3B": { "max_tokens": 40960, "max_input_tokens": 40960, "max_output_tokens": 40960, "input_cost_per_token": 8e-08, "output_cost_per_token": 2.9e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Qwen/Qwen3-32B": { "max_tokens": 40960, "max_input_tokens": 40960, "max_output_tokens": 40960, "input_cost_per_token": 1e-07, "output_cost_per_token": 2.8e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct": { "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 262144, "input_cost_per_token": 4e-07, "output_cost_per_token": 1.6e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Qwen/Qwen3-Coder-480B-A35B-Instruct-Turbo": { "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 262144, "input_cost_per_token": 2.9e-07, "output_cost_per_token": 1.2e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Qwen/Qwen3-Next-80B-A3B-Instruct": { "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 262144, "input_cost_per_token": 1.4e-07, "output_cost_per_token": 1.4e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Qwen/Qwen3-Next-80B-A3B-Thinking": { "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 262144, "input_cost_per_token": 1.4e-07, "output_cost_per_token": 1.4e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/Sao10K/L3-8B-Lunaris-v1-Turbo": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 4e-08, "output_cost_per_token": 5e-08, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": false }, "deepinfra/Sao10K/L3.1-70B-Euryale-v2.2": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 6.5e-07, "output_cost_per_token": 7.5e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": false }, "deepinfra/Sao10K/L3.3-70B-Euryale-v2.3": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 6.5e-07, "output_cost_per_token": 7.5e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": false }, "deepinfra/allenai/olmOCR-7B-0725-FP8": { "max_tokens": 16384, "max_input_tokens": 16384, "max_output_tokens": 16384, "input_cost_per_token": 2.7e-07, "output_cost_per_token": 1.5e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": false }, "deepinfra/anthropic/claude-3-7-sonnet-latest": { "max_tokens": 200000, "max_input_tokens": 200000, "max_output_tokens": 200000, "input_cost_per_token": 3.3e-06, "output_cost_per_token": 1.65e-05, "cache_read_input_token_cost": 3.3e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/anthropic/claude-4-opus": { "max_tokens": 200000, "max_input_tokens": 200000, "max_output_tokens": 200000, "input_cost_per_token": 1.65e-05, "output_cost_per_token": 8.25e-05, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/anthropic/claude-4-sonnet": { "max_tokens": 200000, "max_input_tokens": 200000, "max_output_tokens": 200000, "input_cost_per_token": 3.3e-06, "output_cost_per_token": 1.65e-05, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/deepseek-ai/DeepSeek-R1": { "max_tokens": 163840, "max_input_tokens": 163840, "max_output_tokens": 163840, "input_cost_per_token": 7e-07, "output_cost_per_token": 2.4e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/deepseek-ai/DeepSeek-R1-0528": { "max_tokens": 163840, "max_input_tokens": 163840, "max_output_tokens": 163840, "input_cost_per_token": 5e-07, "output_cost_per_token": 2.15e-06, "cache_read_input_token_cost": 4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/deepseek-ai/DeepSeek-R1-0528-Turbo": { "max_tokens": 32768, "max_input_tokens": 32768, "max_output_tokens": 32768, "input_cost_per_token": 1e-06, "output_cost_per_token": 3e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 2e-07, "output_cost_per_token": 6e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": false }, "deepinfra/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 2.7e-07, "output_cost_per_token": 2.7e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/deepseek-ai/DeepSeek-R1-Turbo": { "max_tokens": 40960, "max_input_tokens": 40960, "max_output_tokens": 40960, "input_cost_per_token": 1e-06, "output_cost_per_token": 3e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/deepseek-ai/DeepSeek-V3": { "max_tokens": 163840, "max_input_tokens": 163840, "max_output_tokens": 163840, "input_cost_per_token": 3.8e-07, "output_cost_per_token": 8.9e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/deepseek-ai/DeepSeek-V3-0324": { "max_tokens": 163840, "max_input_tokens": 163840, "max_output_tokens": 163840, "input_cost_per_token": 2.5e-07, "output_cost_per_token": 8.8e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/deepseek-ai/DeepSeek-V3.1": { "max_tokens": 163840, "max_input_tokens": 163840, "max_output_tokens": 163840, "input_cost_per_token": 2.7e-07, "output_cost_per_token": 1e-06, "cache_read_input_token_cost": 2.16e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true, "supports_reasoning": true }, "deepinfra/deepseek-ai/DeepSeek-V3.1-Terminus": { "max_tokens": 163840, "max_input_tokens": 163840, "max_output_tokens": 163840, "input_cost_per_token": 2.7e-07, "output_cost_per_token": 1e-06, "cache_read_input_token_cost": 2.16e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/google/gemini-2.0-flash-001": { "max_tokens": 1000000, "max_input_tokens": 1000000, "max_output_tokens": 1000000, "input_cost_per_token": 1e-07, "output_cost_per_token": 4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/google/gemini-2.5-flash": { "max_tokens": 1000000, "max_input_tokens": 1000000, "max_output_tokens": 1000000, "input_cost_per_token": 3e-07, "output_cost_per_token": 2.5e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/google/gemini-2.5-pro": { "max_tokens": 1000000, "max_input_tokens": 1000000, "max_output_tokens": 1000000, "input_cost_per_token": 1.25e-06, "output_cost_per_token": 1e-05, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/google/gemma-3-12b-it": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 5e-08, "output_cost_per_token": 1e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/google/gemma-3-27b-it": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 9e-08, "output_cost_per_token": 1.6e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/google/gemma-3-4b-it": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 4e-08, "output_cost_per_token": 8e-08, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/meta-llama/Llama-3.2-11B-Vision-Instruct": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 4.9e-08, "output_cost_per_token": 4.9e-08, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": false }, "deepinfra/meta-llama/Llama-3.2-3B-Instruct": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 2e-08, "output_cost_per_token": 2e-08, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/meta-llama/Llama-3.3-70B-Instruct": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 2.3e-07, "output_cost_per_token": 4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/meta-llama/Llama-3.3-70B-Instruct-Turbo": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 1.3e-07, "output_cost_per_token": 3.9e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { "max_tokens": 1048576, "max_input_tokens": 1048576, "max_output_tokens": 1048576, "input_cost_per_token": 1.5e-07, "output_cost_per_token": 6e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/meta-llama/Llama-4-Scout-17B-16E-Instruct": { "max_tokens": 327680, "max_input_tokens": 327680, "max_output_tokens": 327680, "input_cost_per_token": 8e-08, "output_cost_per_token": 3e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/meta-llama/Llama-Guard-3-8B": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 5.5e-08, "output_cost_per_token": 5.5e-08, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": false }, "deepinfra/meta-llama/Llama-Guard-4-12B": { "max_tokens": 163840, "max_input_tokens": 163840, "max_output_tokens": 163840, "input_cost_per_token": 1.8e-07, "output_cost_per_token": 1.8e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": false }, "deepinfra/meta-llama/Meta-Llama-3-8B-Instruct": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 3e-08, "output_cost_per_token": 6e-08, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/meta-llama/Meta-Llama-3.1-70B-Instruct": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 4e-07, "output_cost_per_token": 4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 1e-07, "output_cost_per_token": 2.8e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 3e-08, "output_cost_per_token": 5e-08, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 2e-08, "output_cost_per_token": 3e-08, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/microsoft/WizardLM-2-8x22B": { "max_tokens": 65536, "max_input_tokens": 65536, "max_output_tokens": 65536, "input_cost_per_token": 4.8e-07, "output_cost_per_token": 4.8e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": false }, "deepinfra/microsoft/phi-4": { "max_tokens": 16384, "max_input_tokens": 16384, "max_output_tokens": 16384, "input_cost_per_token": 7e-08, "output_cost_per_token": 1.4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/mistralai/Mistral-Nemo-Instruct-2407": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 2e-08, "output_cost_per_token": 4e-08, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/mistralai/Mistral-Small-24B-Instruct-2501": { "max_tokens": 32768, "max_input_tokens": 32768, "max_output_tokens": 32768, "input_cost_per_token": 5e-08, "output_cost_per_token": 8e-08, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/mistralai/Mistral-Small-3.2-24B-Instruct-2506": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 7.5e-08, "output_cost_per_token": 2e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/mistralai/Mixtral-8x7B-Instruct-v0.1": { "max_tokens": 32768, "max_input_tokens": 32768, "max_output_tokens": 32768, "input_cost_per_token": 4e-07, "output_cost_per_token": 4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/moonshotai/Kimi-K2-Instruct": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 5e-07, "output_cost_per_token": 2e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/moonshotai/Kimi-K2-Instruct-0905": { "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 262144, "input_cost_per_token": 5e-07, "output_cost_per_token": 2e-06, "cache_read_input_token_cost": 4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/nvidia/Llama-3.1-Nemotron-70B-Instruct": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 6e-07, "output_cost_per_token": 6e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/nvidia/Llama-3.3-Nemotron-Super-49B-v1.5": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 1e-07, "output_cost_per_token": 4e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/nvidia/NVIDIA-Nemotron-Nano-9B-v2": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 4e-08, "output_cost_per_token": 1.6e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/openai/gpt-oss-120b": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 5e-08, "output_cost_per_token": 4.5e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/openai/gpt-oss-20b": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 4e-08, "output_cost_per_token": 1.5e-07, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepinfra/zai-org/GLM-4.5": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 4e-07, "output_cost_per_token": 1.6e-06, "litellm_provider": "deepinfra", "mode": "chat", "supports_tool_choice": true }, "deepseek/deepseek-chat": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 7e-08, "input_cost_per_token": 2.7e-07, "input_cost_per_token_cache_hit": 7e-08, "litellm_provider": "deepseek", "max_input_tokens": 65536, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.1e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true }, "deepseek/deepseek-coder": { "input_cost_per_token": 1.4e-07, "input_cost_per_token_cache_hit": 1.4e-08, "litellm_provider": "deepseek", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.8e-07, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true }, "deepseek/deepseek-r1": { "input_cost_per_token": 5.5e-07, "input_cost_per_token_cache_hit": 1.4e-07, "litellm_provider": "deepseek", "max_input_tokens": 65536, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.19e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, "deepseek/deepseek-reasoner": { "input_cost_per_token": 5.5e-07, "input_cost_per_token_cache_hit": 1.4e-07, "litellm_provider": "deepseek", "max_input_tokens": 65536, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.19e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, "deepseek/deepseek-v3": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 7e-08, "input_cost_per_token": 2.7e-07, "input_cost_per_token_cache_hit": 7e-08, "litellm_provider": "deepseek", "max_input_tokens": 65536, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.1e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_tool_choice": true }, "deepseek.v3-v1:0": { "input_cost_per_token": 5.8e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 163840, "max_output_tokens": 81920, "max_tokens": 163840, "mode": "chat", "output_cost_per_token": 1.68e-06, "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "dolphin": { "input_cost_per_token": 5e-07, "litellm_provider": "nlp_cloud", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "completion", "output_cost_per_token": 5e-07 }, "doubao-embedding": { "input_cost_per_token": 0.0, "litellm_provider": "volcengine", "max_input_tokens": 4096, "max_tokens": 4096, "metadata": { "notes": "Volcengine Doubao embedding model - standard version with 2560 dimensions" }, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 2560 }, "doubao-embedding-large": { "input_cost_per_token": 0.0, "litellm_provider": "volcengine", "max_input_tokens": 4096, "max_tokens": 4096, "metadata": { "notes": "Volcengine Doubao embedding model - large version with 2048 dimensions" }, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 2048 }, "doubao-embedding-large-text-240915": { "input_cost_per_token": 0.0, "litellm_provider": "volcengine", "max_input_tokens": 4096, "max_tokens": 4096, "metadata": { "notes": "Volcengine Doubao embedding model - text-240915 version with 4096 dimensions" }, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 4096 }, "doubao-embedding-large-text-250515": { "input_cost_per_token": 0.0, "litellm_provider": "volcengine", "max_input_tokens": 4096, "max_tokens": 4096, "metadata": { "notes": "Volcengine Doubao embedding model - text-250515 version with 2048 dimensions" }, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 2048 }, "doubao-embedding-text-240715": { "input_cost_per_token": 0.0, "litellm_provider": "volcengine", "max_input_tokens": 4096, "max_tokens": 4096, "metadata": { "notes": "Volcengine Doubao embedding model - text-240715 version with 2560 dimensions" }, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 2560 }, "exa_ai/search": { "litellm_provider": "exa_ai", "mode": "search", "tiered_pricing": [ { "input_cost_per_query": 5e-03, "max_results_range": [ 0, 25 ] }, { "input_cost_per_query": 25e-03, "max_results_range": [ 26, 100 ] } ] }, "perplexity/search": { "input_cost_per_query": 5e-03, "litellm_provider": "perplexity", "mode": "search" }, "elevenlabs/scribe_v1": { "input_cost_per_second": 6.11e-05, "litellm_provider": "elevenlabs", "metadata": { "calculation": "$0.22/hour = $0.00366/minute = $0.0000611 per second (enterprise pricing)", "notes": "ElevenLabs Scribe v1 - state-of-the-art speech recognition model with 99 language support", "original_pricing_per_hour": 0.22 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://elevenlabs.io/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "elevenlabs/scribe_v1_experimental": { "input_cost_per_second": 6.11e-05, "litellm_provider": "elevenlabs", "metadata": { "calculation": "$0.22/hour = $0.00366/minute = $0.0000611 per second (enterprise pricing)", "notes": "ElevenLabs Scribe v1 experimental - enhanced version of the main Scribe model", "original_pricing_per_hour": 0.22 }, "mode": "audio_transcription", "output_cost_per_second": 0.0, "source": "https://elevenlabs.io/pricing", "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "embed-english-light-v2.0": { "input_cost_per_token": 1e-07, "litellm_provider": "cohere", "max_input_tokens": 1024, "max_tokens": 1024, "mode": "embedding", "output_cost_per_token": 0.0 }, "embed-english-light-v3.0": { "input_cost_per_token": 1e-07, "litellm_provider": "cohere", "max_input_tokens": 1024, "max_tokens": 1024, "mode": "embedding", "output_cost_per_token": 0.0 }, "embed-english-v2.0": { "input_cost_per_token": 1e-07, "litellm_provider": "cohere", "max_input_tokens": 4096, "max_tokens": 4096, "mode": "embedding", "output_cost_per_token": 0.0 }, "embed-english-v3.0": { "input_cost_per_image": 0.0001, "input_cost_per_token": 1e-07, "litellm_provider": "cohere", "max_input_tokens": 1024, "max_tokens": 1024, "metadata": { "notes": "'supports_image_input' is a deprecated field. Use 'supports_embedding_image_input' instead." }, "mode": "embedding", "output_cost_per_token": 0.0, "supports_embedding_image_input": true, "supports_image_input": true }, "embed-multilingual-v2.0": { "input_cost_per_token": 1e-07, "litellm_provider": "cohere", "max_input_tokens": 768, "max_tokens": 768, "mode": "embedding", "output_cost_per_token": 0.0 }, "embed-multilingual-v3.0": { "input_cost_per_token": 1e-07, "litellm_provider": "cohere", "max_input_tokens": 1024, "max_tokens": 1024, "mode": "embedding", "output_cost_per_token": 0.0, "supports_embedding_image_input": true }, "eu.amazon.nova-lite-v1:0": { "input_cost_per_token": 7.8e-08, "litellm_provider": "bedrock_converse", "max_input_tokens": 300000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 3.12e-07, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true }, "eu.amazon.nova-micro-v1:0": { "input_cost_per_token": 4.6e-08, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 1.84e-07, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true }, "eu.amazon.nova-pro-v1:0": { "input_cost_per_token": 1.05e-06, "litellm_provider": "bedrock_converse", "max_input_tokens": 300000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 4.2e-06, "source": "https://aws.amazon.com/bedrock/pricing/", "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true }, "eu.anthropic.claude-3-5-haiku-20241022-v1:0": { "input_cost_per_token": 2.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.25e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true }, "eu.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, "cache_read_input_token_cost": 1.1e-07, "input_cost_per_token": 1.1e-06, "deprecation_date": "2026-10-15", "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.5e-06, "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "eu.anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "eu.anthropic.claude-3-5-sonnet-20241022-v2:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "eu.anthropic.claude-3-7-sonnet-20250219-v1:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "eu.anthropic.claude-3-haiku-20240307-v1:0": { "input_cost_per_token": 2.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.25e-06, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "eu.anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 7.5e-05, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "eu.anthropic.claude-3-sonnet-20240229-v1:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "eu.anthropic.claude-opus-4-1-20250805-v1:0": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "eu.anthropic.claude-opus-4-20250514-v1:0": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "eu.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "eu.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.125e-06, "cache_read_input_token_cost": 3.3e-07, "input_cost_per_token": 3.3e-06, "input_cost_per_token_above_200k_tokens": 6.6e-06, "output_cost_per_token_above_200k_tokens": 2.475e-05, "cache_creation_input_token_cost_above_200k_tokens": 8.25e-06, "cache_read_input_token_cost_above_200k_tokens": 6.6e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.65e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, "eu.meta.llama3-2-1b-instruct-v1:0": { "input_cost_per_token": 1.3e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.3e-07, "supports_function_calling": true, "supports_tool_choice": false }, "eu.meta.llama3-2-3b-instruct-v1:0": { "input_cost_per_token": 1.9e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.9e-07, "supports_function_calling": true, "supports_tool_choice": false }, "eu.mistral.pixtral-large-2502-v1:0": { "input_cost_per_token": 2e-06, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-06, "supports_function_calling": true, "supports_tool_choice": false }, "featherless_ai/featherless-ai/Qwerky-72B": { "litellm_provider": "featherless_ai", "max_input_tokens": 32768, "max_output_tokens": 4096, "max_tokens": 32768, "mode": "chat" }, "featherless_ai/featherless-ai/Qwerky-QwQ-32B": { "litellm_provider": "featherless_ai", "max_input_tokens": 32768, "max_output_tokens": 4096, "max_tokens": 32768, "mode": "chat" }, "fireworks-ai-4.1b-to-16b": { "input_cost_per_token": 2e-07, "litellm_provider": "fireworks_ai", "output_cost_per_token": 2e-07 }, "fireworks-ai-56b-to-176b": { "input_cost_per_token": 1.2e-06, "litellm_provider": "fireworks_ai", "output_cost_per_token": 1.2e-06 }, "fireworks-ai-above-16b": { "input_cost_per_token": 9e-07, "litellm_provider": "fireworks_ai", "output_cost_per_token": 9e-07 }, "fireworks-ai-default": { "input_cost_per_token": 0.0, "litellm_provider": "fireworks_ai", "output_cost_per_token": 0.0 }, "fireworks-ai-embedding-150m-to-350m": { "input_cost_per_token": 1.6e-08, "litellm_provider": "fireworks_ai-embedding-models", "output_cost_per_token": 0.0 }, "fireworks-ai-embedding-up-to-150m": { "input_cost_per_token": 8e-09, "litellm_provider": "fireworks_ai-embedding-models", "output_cost_per_token": 0.0 }, "fireworks-ai-moe-up-to-56b": { "input_cost_per_token": 5e-07, "litellm_provider": "fireworks_ai", "output_cost_per_token": 5e-07 }, "fireworks-ai-up-to-4b": { "input_cost_per_token": 2e-07, "litellm_provider": "fireworks_ai", "output_cost_per_token": 2e-07 }, "fireworks_ai/WhereIsAI/UAE-Large-V1": { "input_cost_per_token": 1.6e-08, "litellm_provider": "fireworks_ai-embedding-models", "max_input_tokens": 512, "max_tokens": 512, "mode": "embedding", "output_cost_per_token": 0.0, "source": "https://fireworks.ai/pricing" }, "fireworks_ai/accounts/fireworks/models/deepseek-coder-v2-instruct": { "input_cost_per_token": 1.2e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 65536, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.2e-06, "source": "https://fireworks.ai/pricing", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/deepseek-r1": { "input_cost_per_token": 3e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 128000, "max_output_tokens": 20480, "max_tokens": 20480, "mode": "chat", "output_cost_per_token": 8e-06, "source": "https://fireworks.ai/pricing", "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/deepseek-r1-0528": { "input_cost_per_token": 3e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 160000, "max_output_tokens": 160000, "max_tokens": 160000, "mode": "chat", "output_cost_per_token": 8e-06, "source": "https://fireworks.ai/pricing", "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/deepseek-r1-basic": { "input_cost_per_token": 5.5e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 128000, "max_output_tokens": 20480, "max_tokens": 20480, "mode": "chat", "output_cost_per_token": 2.19e-06, "source": "https://fireworks.ai/pricing", "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/deepseek-v3": { "input_cost_per_token": 9e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 9e-07, "source": "https://fireworks.ai/pricing", "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/deepseek-v3-0324": { "input_cost_per_token": 9e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 163840, "max_output_tokens": 163840, "max_tokens": 163840, "mode": "chat", "output_cost_per_token": 9e-07, "source": "https://fireworks.ai/models/fireworks/deepseek-v3-0324", "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/deepseek-v3p1": { "input_cost_per_token": 5.6e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.68e-06, "source": "https://fireworks.ai/pricing", "supports_response_schema": true, "supports_tool_choice": true }, "fireworks_ai/accounts/fireworks/models/firefunction-v2": { "input_cost_per_token": 9e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 9e-07, "source": "https://fireworks.ai/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "fireworks_ai/accounts/fireworks/models/glm-4p5": { "input_cost_per_token": 5.5e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 128000, "max_output_tokens": 96000, "max_tokens": 96000, "mode": "chat", "output_cost_per_token": 2.19e-06, "source": "https://fireworks.ai/models/fireworks/glm-4p5", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "fireworks_ai/accounts/fireworks/models/glm-4p5-air": { "input_cost_per_token": 2.2e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 128000, "max_output_tokens": 96000, "max_tokens": 96000, "mode": "chat", "output_cost_per_token": 8.8e-07, "source": "https://artificialanalysis.ai/models/glm-4-5-air", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "fireworks_ai/accounts/fireworks/models/gpt-oss-120b": { "input_cost_per_token": 1.5e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6e-07, "source": "https://fireworks.ai/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "fireworks_ai/accounts/fireworks/models/gpt-oss-20b": { "input_cost_per_token": 5e-08, "litellm_provider": "fireworks_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-07, "source": "https://fireworks.ai/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "fireworks_ai/accounts/fireworks/models/kimi-k2-instruct": { "input_cost_per_token": 6e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 131072, "max_output_tokens": 16384, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.5e-06, "source": "https://fireworks.ai/models/fireworks/kimi-k2-instruct", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "fireworks_ai/accounts/fireworks/models/llama-v3p1-405b-instruct": { "input_cost_per_token": 3e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 3e-06, "source": "https://fireworks.ai/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "fireworks_ai/accounts/fireworks/models/llama-v3p1-8b-instruct": { "input_cost_per_token": 1e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-07, "source": "https://fireworks.ai/pricing", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/llama-v3p2-11b-vision-instruct": { "input_cost_per_token": 2e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 2e-07, "source": "https://fireworks.ai/pricing", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false, "supports_vision": true }, "fireworks_ai/accounts/fireworks/models/llama-v3p2-1b-instruct": { "input_cost_per_token": 1e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-07, "source": "https://fireworks.ai/pricing", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/llama-v3p2-3b-instruct": { "input_cost_per_token": 1e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-07, "source": "https://fireworks.ai/pricing", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/llama-v3p2-90b-vision-instruct": { "input_cost_per_token": 9e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 9e-07, "source": "https://fireworks.ai/pricing", "supports_response_schema": true, "supports_tool_choice": false, "supports_vision": true }, "fireworks_ai/accounts/fireworks/models/llama4-maverick-instruct-basic": { "input_cost_per_token": 2.2e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 8.8e-07, "source": "https://fireworks.ai/pricing", "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/llama4-scout-instruct-basic": { "input_cost_per_token": 1.5e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6e-07, "source": "https://fireworks.ai/pricing", "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/mixtral-8x22b-instruct-hf": { "input_cost_per_token": 1.2e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 65536, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.2e-06, "source": "https://fireworks.ai/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "fireworks_ai/accounts/fireworks/models/qwen2-72b-instruct": { "input_cost_per_token": 9e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 9e-07, "source": "https://fireworks.ai/pricing", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/qwen2p5-coder-32b-instruct": { "input_cost_per_token": 9e-07, "litellm_provider": "fireworks_ai", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 9e-07, "source": "https://fireworks.ai/pricing", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/accounts/fireworks/models/yi-large": { "input_cost_per_token": 3e-06, "litellm_provider": "fireworks_ai", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-06, "source": "https://fireworks.ai/pricing", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false }, "fireworks_ai/nomic-ai/nomic-embed-text-v1": { "input_cost_per_token": 8e-09, "litellm_provider": "fireworks_ai-embedding-models", "max_input_tokens": 8192, "max_tokens": 8192, "mode": "embedding", "output_cost_per_token": 0.0, "source": "https://fireworks.ai/pricing" }, "fireworks_ai/nomic-ai/nomic-embed-text-v1.5": { "input_cost_per_token": 8e-09, "litellm_provider": "fireworks_ai-embedding-models", "max_input_tokens": 8192, "max_tokens": 8192, "mode": "embedding", "output_cost_per_token": 0.0, "source": "https://fireworks.ai/pricing" }, "fireworks_ai/thenlper/gte-base": { "input_cost_per_token": 8e-09, "litellm_provider": "fireworks_ai-embedding-models", "max_input_tokens": 512, "max_tokens": 512, "mode": "embedding", "output_cost_per_token": 0.0, "source": "https://fireworks.ai/pricing" }, "fireworks_ai/thenlper/gte-large": { "input_cost_per_token": 1.6e-08, "litellm_provider": "fireworks_ai-embedding-models", "max_input_tokens": 512, "max_tokens": 512, "mode": "embedding", "output_cost_per_token": 0.0, "source": "https://fireworks.ai/pricing" }, "friendliai/meta-llama-3.1-70b-instruct": { "input_cost_per_token": 6e-07, "litellm_provider": "friendliai", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true }, "friendliai/meta-llama-3.1-8b-instruct": { "input_cost_per_token": 1e-07, "litellm_provider": "friendliai", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true }, "ft:babbage-002": { "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 16384, "mode": "completion", "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07 }, "ft:davinci-002": { "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, "litellm_provider": "text-completion-openai", "max_input_tokens": 16384, "max_output_tokens": 4096, "max_tokens": 16384, "mode": "completion", "output_cost_per_token": 2e-06, "output_cost_per_token_batches": 1e-06 }, "ft:gpt-3.5-turbo": { "input_cost_per_token": 3e-06, "input_cost_per_token_batches": 1.5e-06, "litellm_provider": "openai", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-06, "output_cost_per_token_batches": 3e-06, "supports_system_messages": true, "supports_tool_choice": true }, "ft:gpt-3.5-turbo-0125": { "input_cost_per_token": 3e-06, "litellm_provider": "openai", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-06, "supports_system_messages": true, "supports_tool_choice": true }, "ft:gpt-3.5-turbo-0613": { "input_cost_per_token": 3e-06, "litellm_provider": "openai", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-06, "supports_system_messages": true, "supports_tool_choice": true }, "ft:gpt-3.5-turbo-1106": { "input_cost_per_token": 3e-06, "litellm_provider": "openai", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-06, "supports_system_messages": true, "supports_tool_choice": true }, "ft:gpt-4-0613": { "input_cost_per_token": 3e-05, "litellm_provider": "openai", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-05, "source": "OpenAI needs to add pricing for this ft model, will be updated when added by OpenAI. Defaulting to base model pricing", "supports_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "ft:gpt-4o-2024-08-06": { "input_cost_per_token": 3.75e-06, "input_cost_per_token_batches": 1.875e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.5e-05, "output_cost_per_token_batches": 7.5e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "ft:gpt-4o-2024-11-20": { "cache_creation_input_token_cost": 1.875e-06, "input_cost_per_token": 3.75e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "ft:gpt-4o-mini-2024-07-18": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 3e-07, "input_cost_per_token_batches": 1.5e-07, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.2e-06, "output_cost_per_token_batches": 6e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gemini-1.0-pro": { "input_cost_per_character": 1.25e-07, "input_cost_per_image": 0.0025, "input_cost_per_token": 5e-07, "input_cost_per_video_per_second": 0.002, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 32760, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 3.75e-07, "output_cost_per_token": 1.5e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "gemini-1.0-pro-001": { "deprecation_date": "2025-04-09", "input_cost_per_character": 1.25e-07, "input_cost_per_image": 0.0025, "input_cost_per_token": 5e-07, "input_cost_per_video_per_second": 0.002, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 32760, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 3.75e-07, "output_cost_per_token": 1.5e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "gemini-1.0-pro-002": { "deprecation_date": "2025-04-09", "input_cost_per_character": 1.25e-07, "input_cost_per_image": 0.0025, "input_cost_per_token": 5e-07, "input_cost_per_video_per_second": 0.002, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 32760, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 3.75e-07, "output_cost_per_token": 1.5e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "gemini-1.0-pro-vision": { "input_cost_per_image": 0.0025, "input_cost_per_token": 5e-07, "litellm_provider": "vertex_ai-vision-models", "max_images_per_prompt": 16, "max_input_tokens": 16384, "max_output_tokens": 2048, "max_tokens": 2048, "max_video_length": 2, "max_videos_per_prompt": 1, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "gemini-1.0-pro-vision-001": { "deprecation_date": "2025-04-09", "input_cost_per_image": 0.0025, "input_cost_per_token": 5e-07, "litellm_provider": "vertex_ai-vision-models", "max_images_per_prompt": 16, "max_input_tokens": 16384, "max_output_tokens": 2048, "max_tokens": 2048, "max_video_length": 2, "max_videos_per_prompt": 1, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "gemini-1.0-ultra": { "input_cost_per_character": 1.25e-07, "input_cost_per_image": 0.0025, "input_cost_per_token": 5e-07, "input_cost_per_video_per_second": 0.002, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 8192, "max_output_tokens": 2048, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 3.75e-07, "output_cost_per_token": 1.5e-06, "source": "As of Jun, 2024. There is no available doc on vertex ai pricing gemini-1.0-ultra-001. Using gemini-1.0-pro pricing. Got max_tokens info here: https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "gemini-1.0-ultra-001": { "input_cost_per_character": 1.25e-07, "input_cost_per_image": 0.0025, "input_cost_per_token": 5e-07, "input_cost_per_video_per_second": 0.002, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 8192, "max_output_tokens": 2048, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 3.75e-07, "output_cost_per_token": 1.5e-06, "source": "As of Jun, 2024. There is no available doc on vertex ai pricing gemini-1.0-ultra-001. Using gemini-1.0-pro pricing. Got max_tokens info here: https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "gemini-1.5-flash": { "input_cost_per_audio_per_second": 2e-06, "input_cost_per_audio_per_second_above_128k_tokens": 4e-06, "input_cost_per_character": 1.875e-08, "input_cost_per_character_above_128k_tokens": 2.5e-07, "input_cost_per_image": 2e-05, "input_cost_per_image_above_128k_tokens": 4e-05, "input_cost_per_token": 7.5e-08, "input_cost_per_token_above_128k_tokens": 1e-06, "input_cost_per_video_per_second": 2e-05, "input_cost_per_video_per_second_above_128k_tokens": 4e-05, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_character": 7.5e-08, "output_cost_per_character_above_128k_tokens": 1.5e-07, "output_cost_per_token": 3e-07, "output_cost_per_token_above_128k_tokens": 6e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gemini-1.5-flash-001": { "deprecation_date": "2025-05-24", "input_cost_per_audio_per_second": 2e-06, "input_cost_per_audio_per_second_above_128k_tokens": 4e-06, "input_cost_per_character": 1.875e-08, "input_cost_per_character_above_128k_tokens": 2.5e-07, "input_cost_per_image": 2e-05, "input_cost_per_image_above_128k_tokens": 4e-05, "input_cost_per_token": 7.5e-08, "input_cost_per_token_above_128k_tokens": 1e-06, "input_cost_per_video_per_second": 2e-05, "input_cost_per_video_per_second_above_128k_tokens": 4e-05, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_character": 7.5e-08, "output_cost_per_character_above_128k_tokens": 1.5e-07, "output_cost_per_token": 3e-07, "output_cost_per_token_above_128k_tokens": 6e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gemini-1.5-flash-002": { "deprecation_date": "2025-09-24", "input_cost_per_audio_per_second": 2e-06, "input_cost_per_audio_per_second_above_128k_tokens": 4e-06, "input_cost_per_character": 1.875e-08, "input_cost_per_character_above_128k_tokens": 2.5e-07, "input_cost_per_image": 2e-05, "input_cost_per_image_above_128k_tokens": 4e-05, "input_cost_per_token": 7.5e-08, "input_cost_per_token_above_128k_tokens": 1e-06, "input_cost_per_video_per_second": 2e-05, "input_cost_per_video_per_second_above_128k_tokens": 4e-05, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_character": 7.5e-08, "output_cost_per_character_above_128k_tokens": 1.5e-07, "output_cost_per_token": 3e-07, "output_cost_per_token_above_128k_tokens": 6e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-1.5-flash", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gemini-1.5-flash-exp-0827": { "input_cost_per_audio_per_second": 2e-06, "input_cost_per_audio_per_second_above_128k_tokens": 4e-06, "input_cost_per_character": 1.875e-08, "input_cost_per_character_above_128k_tokens": 2.5e-07, "input_cost_per_image": 2e-05, "input_cost_per_image_above_128k_tokens": 4e-05, "input_cost_per_token": 4.688e-09, "input_cost_per_token_above_128k_tokens": 1e-06, "input_cost_per_video_per_second": 2e-05, "input_cost_per_video_per_second_above_128k_tokens": 4e-05, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_character": 1.875e-08, "output_cost_per_character_above_128k_tokens": 3.75e-08, "output_cost_per_token": 4.6875e-09, "output_cost_per_token_above_128k_tokens": 9.375e-09, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gemini-1.5-flash-preview-0514": { "input_cost_per_audio_per_second": 2e-06, "input_cost_per_audio_per_second_above_128k_tokens": 4e-06, "input_cost_per_character": 1.875e-08, "input_cost_per_character_above_128k_tokens": 2.5e-07, "input_cost_per_image": 2e-05, "input_cost_per_image_above_128k_tokens": 4e-05, "input_cost_per_token": 7.5e-08, "input_cost_per_token_above_128k_tokens": 1e-06, "input_cost_per_video_per_second": 2e-05, "input_cost_per_video_per_second_above_128k_tokens": 4e-05, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_character": 1.875e-08, "output_cost_per_character_above_128k_tokens": 3.75e-08, "output_cost_per_token": 4.6875e-09, "output_cost_per_token_above_128k_tokens": 9.375e-09, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gemini-1.5-pro": { "input_cost_per_audio_per_second": 3.125e-05, "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-05, "input_cost_per_character": 3.125e-07, "input_cost_per_character_above_128k_tokens": 6.25e-07, "input_cost_per_image": 0.00032875, "input_cost_per_image_above_128k_tokens": 0.0006575, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_128k_tokens": 2.5e-06, "input_cost_per_video_per_second": 0.00032875, "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 2097152, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 1.25e-06, "output_cost_per_character_above_128k_tokens": 2.5e-06, "output_cost_per_token": 5e-06, "output_cost_per_token_above_128k_tokens": 1e-05, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gemini-1.5-pro-001": { "deprecation_date": "2025-05-24", "input_cost_per_audio_per_second": 3.125e-05, "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-05, "input_cost_per_character": 3.125e-07, "input_cost_per_character_above_128k_tokens": 6.25e-07, "input_cost_per_image": 0.00032875, "input_cost_per_image_above_128k_tokens": 0.0006575, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_128k_tokens": 2.5e-06, "input_cost_per_video_per_second": 0.00032875, "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 1.25e-06, "output_cost_per_character_above_128k_tokens": 2.5e-06, "output_cost_per_token": 5e-06, "output_cost_per_token_above_128k_tokens": 1e-05, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gemini-1.5-pro-002": { "deprecation_date": "2025-09-24", "input_cost_per_audio_per_second": 3.125e-05, "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-05, "input_cost_per_character": 3.125e-07, "input_cost_per_character_above_128k_tokens": 6.25e-07, "input_cost_per_image": 0.00032875, "input_cost_per_image_above_128k_tokens": 0.0006575, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_128k_tokens": 2.5e-06, "input_cost_per_video_per_second": 0.00032875, "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 2097152, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 1.25e-06, "output_cost_per_character_above_128k_tokens": 2.5e-06, "output_cost_per_token": 5e-06, "output_cost_per_token_above_128k_tokens": 1e-05, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-1.5-pro", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gemini-1.5-pro-preview-0215": { "input_cost_per_audio_per_second": 3.125e-05, "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-05, "input_cost_per_character": 3.125e-07, "input_cost_per_character_above_128k_tokens": 6.25e-07, "input_cost_per_image": 0.00032875, "input_cost_per_image_above_128k_tokens": 0.0006575, "input_cost_per_token": 7.8125e-08, "input_cost_per_token_above_128k_tokens": 1.5625e-07, "input_cost_per_video_per_second": 0.00032875, "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 1.25e-06, "output_cost_per_character_above_128k_tokens": 2.5e-06, "output_cost_per_token": 3.125e-07, "output_cost_per_token_above_128k_tokens": 6.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true }, "gemini-1.5-pro-preview-0409": { "input_cost_per_audio_per_second": 3.125e-05, "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-05, "input_cost_per_character": 3.125e-07, "input_cost_per_character_above_128k_tokens": 6.25e-07, "input_cost_per_image": 0.00032875, "input_cost_per_image_above_128k_tokens": 0.0006575, "input_cost_per_token": 7.8125e-08, "input_cost_per_token_above_128k_tokens": 1.5625e-07, "input_cost_per_video_per_second": 0.00032875, "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 1.25e-06, "output_cost_per_character_above_128k_tokens": 2.5e-06, "output_cost_per_token": 3.125e-07, "output_cost_per_token_above_128k_tokens": 6.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "gemini-1.5-pro-preview-0514": { "input_cost_per_audio_per_second": 3.125e-05, "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-05, "input_cost_per_character": 3.125e-07, "input_cost_per_character_above_128k_tokens": 6.25e-07, "input_cost_per_image": 0.00032875, "input_cost_per_image_above_128k_tokens": 0.0006575, "input_cost_per_token": 7.8125e-08, "input_cost_per_token_above_128k_tokens": 1.5625e-07, "input_cost_per_video_per_second": 0.00032875, "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 1.25e-06, "output_cost_per_character_above_128k_tokens": 2.5e-06, "output_cost_per_token": 3.125e-07, "output_cost_per_token_above_128k_tokens": 6.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true }, "gemini-2.0-flash": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 4e-07, "source": "https://ai.google.dev/pricing#2_0flash", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.0-flash-001": { "cache_read_input_token_cost": 3.75e-08, "deprecation_date": "2026-02-05", "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 6e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.0-flash-exp": { "cache_read_input_token_cost": 3.75e-08, "input_cost_per_audio_per_second": 0, "input_cost_per_audio_per_second_above_128k_tokens": 0, "input_cost_per_character": 0, "input_cost_per_character_above_128k_tokens": 0, "input_cost_per_image": 0, "input_cost_per_image_above_128k_tokens": 0, "input_cost_per_token": 1.5e-07, "input_cost_per_token_above_128k_tokens": 0, "input_cost_per_video_per_second": 0, "input_cost_per_video_per_second_above_128k_tokens": 0, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_character": 0, "output_cost_per_character_above_128k_tokens": 0, "output_cost_per_token": 6e-07, "output_cost_per_token_above_128k_tokens": 0, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.0-flash-lite": { "cache_read_input_token_cost": 1.875e-08, "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 50, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 3e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.0-flash-lite-001": { "cache_read_input_token_cost": 1.875e-08, "deprecation_date": "2026-02-25", "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 50, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 3e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.0-flash-live-preview-04-09": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_audio_token": 3e-06, "input_cost_per_image": 3e-06, "input_cost_per_token": 5e-07, "input_cost_per_video_per_second": 3e-06, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_token": 2e-06, "rpm": 10, "source": "https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini#gemini-2-0-flash-live-preview-04-09", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "audio" ], "supports_audio_output": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 250000 }, "gemini-2.0-flash-preview-image-generation": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 4e-07, "source": "https://ai.google.dev/pricing#2_0flash", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.0-flash-thinking-exp": { "cache_read_input_token_cost": 0.0, "input_cost_per_audio_per_second": 0, "input_cost_per_audio_per_second_above_128k_tokens": 0, "input_cost_per_character": 0, "input_cost_per_character_above_128k_tokens": 0, "input_cost_per_image": 0, "input_cost_per_image_above_128k_tokens": 0, "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "input_cost_per_video_per_second": 0, "input_cost_per_video_per_second_above_128k_tokens": 0, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_character": 0, "output_cost_per_character_above_128k_tokens": 0, "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.0-flash-thinking-exp-01-21": { "cache_read_input_token_cost": 0.0, "input_cost_per_audio_per_second": 0, "input_cost_per_audio_per_second_above_128k_tokens": 0, "input_cost_per_character": 0, "input_cost_per_character_above_128k_tokens": 0, "input_cost_per_image": 0, "input_cost_per_image_above_128k_tokens": 0, "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "input_cost_per_video_per_second": 0, "input_cost_per_video_per_second_above_128k_tokens": 0, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65536, "max_pdf_size_mb": 30, "max_tokens": 65536, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_character": 0, "output_cost_per_character_above_128k_tokens": 0, "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_output": false, "supports_function_calling": false, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": false, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.0-pro-exp-02-05": { "cache_read_input_token_cost": 3.125e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 2097152, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_input": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-flash": { "cache_read_input_token_cost": 3e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "max_pdf_size_mb": 30, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "image_generation", "output_cost_per_image": 0.039, "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "rpm": 100000, "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-flash-image", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": false, "tpm": 8000000 }, "gemini-2.5-flash-image-preview": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "image_generation", "output_cost_per_image": 0.039, "output_cost_per_reasoning_token": 3e-05, "output_cost_per_token": 3e-05, "rpm": 100000, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 8000000 }, "gemini-2.5-flash-lite": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 3e-07, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-flash-preview-09-2025": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-flash-lite-preview-06-17": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-flash-preview-04-17": { "cache_read_input_token_cost": 3.75e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 3.5e-06, "output_cost_per_token": 6e-07, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-flash-preview-05-20": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-pro": { "cache_read_input_token_cost": 1.25e-07, "cache_creation_input_token_cost_above_200k_tokens": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_input": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-pro-exp-03-25": { "cache_read_input_token_cost": 3.125e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_input": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-pro-preview-03-25": { "cache_read_input_token_cost": 3.125e-07, "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-pro-preview-05-06": { "cache_read_input_token_cost": 3.125e-07, "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supported_regions": [ "global" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-pro-preview-06-05": { "cache_read_input_token_cost": 3.125e-07, "input_cost_per_audio_token": 1.25e-06, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 3.125e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-pro-preview", "supported_modalities": [ "text" ], "supported_output_modalities": [ "audio" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gemini-embedding-001": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 2048, "max_tokens": 2048, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 3072, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models" }, "gemini-flash-experimental": { "input_cost_per_character": 0, "input_cost_per_token": 0, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 0, "output_cost_per_token": 0, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/gemini-experimental", "supports_function_calling": false, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "gemini-pro": { "input_cost_per_character": 1.25e-07, "input_cost_per_image": 0.0025, "input_cost_per_token": 5e-07, "input_cost_per_video_per_second": 0.002, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 32760, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 3.75e-07, "output_cost_per_token": 1.5e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "gemini-pro-experimental": { "input_cost_per_character": 0, "input_cost_per_token": 0, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 0, "output_cost_per_token": 0, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/gemini-experimental", "supports_function_calling": false, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "gemini-pro-vision": { "input_cost_per_image": 0.0025, "input_cost_per_token": 5e-07, "litellm_provider": "vertex_ai-vision-models", "max_images_per_prompt": 16, "max_input_tokens": 16384, "max_output_tokens": 2048, "max_tokens": 2048, "max_video_length": 2, "max_videos_per_prompt": 1, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "gemini/gemini-1.5-flash": { "input_cost_per_token": 7.5e-08, "input_cost_per_token_above_128k_tokens": 1.5e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 3e-07, "output_cost_per_token_above_128k_tokens": 6e-07, "rpm": 2000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-flash-001": { "cache_creation_input_token_cost": 1e-06, "cache_read_input_token_cost": 1.875e-08, "deprecation_date": "2025-05-24", "input_cost_per_token": 7.5e-08, "input_cost_per_token_above_128k_tokens": 1.5e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 3e-07, "output_cost_per_token_above_128k_tokens": 6e-07, "rpm": 2000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-flash-002": { "cache_creation_input_token_cost": 1e-06, "cache_read_input_token_cost": 1.875e-08, "deprecation_date": "2025-09-24", "input_cost_per_token": 7.5e-08, "input_cost_per_token_above_128k_tokens": 1.5e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 3e-07, "output_cost_per_token_above_128k_tokens": 6e-07, "rpm": 2000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-flash-8b": { "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "rpm": 4000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-flash-8b-exp-0827": { "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "rpm": 4000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-flash-8b-exp-0924": { "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "rpm": 4000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-flash-exp-0827": { "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "rpm": 2000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-flash-latest": { "input_cost_per_token": 7.5e-08, "input_cost_per_token_above_128k_tokens": 1.5e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 3e-07, "output_cost_per_token_above_128k_tokens": 6e-07, "rpm": 2000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-pro": { "input_cost_per_token": 3.5e-06, "input_cost_per_token_above_128k_tokens": 7e-06, "litellm_provider": "gemini", "max_input_tokens": 2097152, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.05e-05, "output_cost_per_token_above_128k_tokens": 2.1e-05, "rpm": 1000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-pro-001": { "deprecation_date": "2025-05-24", "input_cost_per_token": 3.5e-06, "input_cost_per_token_above_128k_tokens": 7e-06, "litellm_provider": "gemini", "max_input_tokens": 2097152, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.05e-05, "output_cost_per_token_above_128k_tokens": 2.1e-05, "rpm": 1000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-pro-002": { "deprecation_date": "2025-09-24", "input_cost_per_token": 3.5e-06, "input_cost_per_token_above_128k_tokens": 7e-06, "litellm_provider": "gemini", "max_input_tokens": 2097152, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.05e-05, "output_cost_per_token_above_128k_tokens": 2.1e-05, "rpm": 1000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-pro-exp-0801": { "input_cost_per_token": 3.5e-06, "input_cost_per_token_above_128k_tokens": 7e-06, "litellm_provider": "gemini", "max_input_tokens": 2097152, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.05e-05, "output_cost_per_token_above_128k_tokens": 2.1e-05, "rpm": 1000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-pro-exp-0827": { "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "litellm_provider": "gemini", "max_input_tokens": 2097152, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "rpm": 1000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-1.5-pro-latest": { "input_cost_per_token": 3.5e-06, "input_cost_per_token_above_128k_tokens": 7e-06, "litellm_provider": "gemini", "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.05e-06, "output_cost_per_token_above_128k_tokens": 2.1e-05, "rpm": 1000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-2.0-flash": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 4e-07, "rpm": 10000, "source": "https://ai.google.dev/pricing#2_0flash", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 10000000 }, "gemini/gemini-2.0-flash-001": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 4e-07, "rpm": 10000, "source": "https://ai.google.dev/pricing#2_0flash", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_output": false, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tpm": 10000000 }, "gemini/gemini-2.0-flash-exp": { "cache_read_input_token_cost": 0.0, "input_cost_per_audio_per_second": 0, "input_cost_per_audio_per_second_above_128k_tokens": 0, "input_cost_per_character": 0, "input_cost_per_character_above_128k_tokens": 0, "input_cost_per_image": 0, "input_cost_per_image_above_128k_tokens": 0, "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "input_cost_per_video_per_second": 0, "input_cost_per_video_per_second_above_128k_tokens": 0, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_character": 0, "output_cost_per_character_above_128k_tokens": 0, "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "rpm": 10, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_output": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tpm": 4000000 }, "gemini/gemini-2.0-flash-lite": { "cache_read_input_token_cost": 1.875e-08, "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 50, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 3e-07, "rpm": 4000, "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.0-flash-lite", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tpm": 4000000 }, "gemini/gemini-2.0-flash-lite-preview-02-05": { "cache_read_input_token_cost": 1.875e-08, "input_cost_per_audio_token": 7.5e-08, "input_cost_per_token": 7.5e-08, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 3e-07, "rpm": 60000, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash-lite", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tpm": 10000000 }, "gemini/gemini-2.0-flash-live-001": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_audio_token": 2.1e-06, "input_cost_per_image": 2.1e-06, "input_cost_per_token": 3.5e-07, "input_cost_per_video_per_second": 2.1e-06, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_audio_token": 8.5e-06, "output_cost_per_token": 1.5e-06, "rpm": 10, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2-0-flash-live-001", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "audio" ], "supports_audio_output": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 250000 }, "gemini/gemini-2.0-flash-preview-image-generation": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 4e-07, "rpm": 10000, "source": "https://ai.google.dev/pricing#2_0flash", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tpm": 10000000 }, "gemini/gemini-2.0-flash-thinking-exp": { "cache_read_input_token_cost": 0.0, "input_cost_per_audio_per_second": 0, "input_cost_per_audio_per_second_above_128k_tokens": 0, "input_cost_per_character": 0, "input_cost_per_character_above_128k_tokens": 0, "input_cost_per_image": 0, "input_cost_per_image_above_128k_tokens": 0, "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "input_cost_per_video_per_second": 0, "input_cost_per_video_per_second_above_128k_tokens": 0, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65536, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_character": 0, "output_cost_per_character_above_128k_tokens": 0, "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "rpm": 10, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_output": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tpm": 4000000 }, "gemini/gemini-2.0-flash-thinking-exp-01-21": { "cache_read_input_token_cost": 0.0, "input_cost_per_audio_per_second": 0, "input_cost_per_audio_per_second_above_128k_tokens": 0, "input_cost_per_character": 0, "input_cost_per_character_above_128k_tokens": 0, "input_cost_per_image": 0, "input_cost_per_image_above_128k_tokens": 0, "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "input_cost_per_video_per_second": 0, "input_cost_per_video_per_second_above_128k_tokens": 0, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65536, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_character": 0, "output_cost_per_character_above_128k_tokens": 0, "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "rpm": 10, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_output": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tpm": 4000000 }, "gemini/gemini-2.0-pro-exp-02-05": { "cache_read_input_token_cost": 0.0, "input_cost_per_audio_per_second": 0, "input_cost_per_audio_per_second_above_128k_tokens": 0, "input_cost_per_character": 0, "input_cost_per_character_above_128k_tokens": 0, "input_cost_per_image": 0, "input_cost_per_image_above_128k_tokens": 0, "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "input_cost_per_video_per_second": 0, "input_cost_per_video_per_second_above_128k_tokens": 0, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 2097152, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_character": 0, "output_cost_per_character_above_128k_tokens": 0, "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "rpm": 2, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supports_audio_input": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, "supports_web_search": true, "tpm": 1000000 }, "gemini/gemini-2.5-flash": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "rpm": 100000, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 8000000 }, "gemini/gemini-2.5-flash-image": { "cache_read_input_token_cost": 3e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-language-models", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "max_pdf_size_mb": 30, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "image_generation", "output_cost_per_image": 0.039, "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "rpm": 100000, "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-flash-image", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 8000000 }, "gemini/gemini-2.5-flash-image-preview": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "image_generation", "output_cost_per_image": 0.039, "output_cost_per_reasoning_token": 3e-05, "output_cost_per_token": 3e-05, "rpm": 100000, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text", "image" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 8000000 }, "gemini/gemini-2.5-flash-lite": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, "rpm": 15, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-lite", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 250000 }, "gemini/gemini-2.5-flash-lite-preview-09-2025": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 3e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, "rpm": 15, "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 250000 }, "gemini/gemini-2.5-flash-preview-09-2025": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "rpm": 15, "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 250000 }, "gemini/gemini-flash-latest": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "rpm": 15, "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 250000 }, "gemini/gemini-flash-lite-latest": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 3e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, "rpm": 15, "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 250000 }, "gemini/gemini-2.5-flash-lite-preview-06-17": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_audio_token": 5e-07, "input_cost_per_token": 1e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 4e-07, "output_cost_per_token": 4e-07, "rpm": 15, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-lite", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 250000 }, "gemini/gemini-2.5-flash-preview-04-17": { "cache_read_input_token_cost": 3.75e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 1.5e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 3.5e-06, "output_cost_per_token": 6e-07, "rpm": 10, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tpm": 250000 }, "gemini/gemini-2.5-flash-preview-05-20": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 3e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 2.5e-06, "output_cost_per_token": 2.5e-06, "rpm": 10, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 250000 }, "gemini/gemini-2.5-flash-preview-tts": { "cache_read_input_token_cost": 3.75e-08, "input_cost_per_audio_token": 1e-06, "input_cost_per_token": 1.5e-07, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_reasoning_token": 3.5e-06, "output_cost_per_token": 6e-07, "rpm": 10, "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" ], "supported_modalities": [ "text" ], "supported_output_modalities": [ "audio" ], "supports_audio_output": false, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tpm": 250000 }, "gemini/gemini-2.5-pro": { "cache_read_input_token_cost": 3.125e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "rpm": 2000, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_input": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, "supports_web_search": true, "tpm": 800000 }, "gemini/gemini-2.5-pro-exp-03-25": { "cache_read_input_token_cost": 0.0, "input_cost_per_token": 0.0, "input_cost_per_token_above_200k_tokens": 0.0, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 0.0, "output_cost_per_token_above_200k_tokens": 0.0, "rpm": 5, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supported_endpoints": [ "/v1/chat/completions", "/v1/completions" ], "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_input": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_video_input": true, "supports_vision": true, "supports_web_search": true, "tpm": 250000 }, "gemini/gemini-2.5-pro-preview-03-25": { "cache_read_input_token_cost": 3.125e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "rpm": 10000, "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-pro-preview", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tpm": 10000000 }, "gemini/gemini-2.5-pro-preview-05-06": { "cache_read_input_token_cost": 3.125e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "rpm": 10000, "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-pro-preview", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 10000000 }, "gemini/gemini-2.5-pro-preview-06-05": { "cache_read_input_token_cost": 3.125e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "rpm": 10000, "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-pro-preview", "supported_modalities": [ "text", "image", "audio", "video" ], "supported_output_modalities": [ "text" ], "supports_audio_output": false, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_url_context": true, "supports_vision": true, "supports_web_search": true, "tpm": 10000000 }, "gemini/gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 3.125e-07, "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_above_200k_tokens": 2.5e-06, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 65535, "max_pdf_size_mb": 30, "max_tokens": 65535, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_above_200k_tokens": 1.5e-05, "rpm": 10000, "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-pro-preview", "supported_modalities": [ "text" ], "supported_output_modalities": [ "audio" ], "supports_audio_output": false, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true, "tpm": 10000000 }, "gemini/gemini-exp-1114": { "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "metadata": { "notes": "Rate limits not documented for gemini-exp-1114. Assuming same as gemini-1.5-pro.", "supports_tool_choice": true }, "mode": "chat", "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "rpm": 1000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-exp-1206": { "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "litellm_provider": "gemini", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 2097152, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "metadata": { "notes": "Rate limits not documented for gemini-exp-1206. Assuming same as gemini-1.5-pro.", "supports_tool_choice": true }, "mode": "chat", "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "rpm": 1000, "source": "https://ai.google.dev/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 4000000 }, "gemini/gemini-gemma-2-27b-it": { "input_cost_per_token": 3.5e-07, "litellm_provider": "gemini", "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.05e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "gemini/gemini-gemma-2-9b-it": { "input_cost_per_token": 3.5e-07, "litellm_provider": "gemini", "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.05e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "gemini/gemini-pro": { "input_cost_per_token": 3.5e-07, "input_cost_per_token_above_128k_tokens": 7e-07, "litellm_provider": "gemini", "max_input_tokens": 32760, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.05e-06, "output_cost_per_token_above_128k_tokens": 2.1e-06, "rpd": 30000, "rpm": 360, "source": "https://ai.google.dev/gemini-api/docs/models/gemini", "supports_function_calling": true, "supports_tool_choice": true, "tpm": 120000 }, "gemini/gemini-pro-vision": { "input_cost_per_token": 3.5e-07, "input_cost_per_token_above_128k_tokens": 7e-07, "litellm_provider": "gemini", "max_input_tokens": 30720, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 1.05e-06, "output_cost_per_token_above_128k_tokens": 2.1e-06, "rpd": 30000, "rpm": 360, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true, "tpm": 120000 }, "gemini/gemma-3-27b-it": { "input_cost_per_audio_per_second": 0, "input_cost_per_audio_per_second_above_128k_tokens": 0, "input_cost_per_character": 0, "input_cost_per_character_above_128k_tokens": 0, "input_cost_per_image": 0, "input_cost_per_image_above_128k_tokens": 0, "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "input_cost_per_video_per_second": 0, "input_cost_per_video_per_second_above_128k_tokens": 0, "litellm_provider": "gemini", "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 0, "output_cost_per_character_above_128k_tokens": 0, "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "source": "https://aistudio.google.com", "supports_audio_output": false, "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gemini/imagen-3.0-fast-generate-001": { "litellm_provider": "gemini", "mode": "image_generation", "output_cost_per_image": 0.02, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "gemini/imagen-3.0-generate-001": { "litellm_provider": "gemini", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "gemini/imagen-3.0-generate-002": { "litellm_provider": "gemini", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "gemini/imagen-4.0-fast-generate-001": { "litellm_provider": "gemini", "mode": "image_generation", "output_cost_per_image": 0.02, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "gemini/imagen-4.0-generate-001": { "litellm_provider": "gemini", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "gemini/imagen-4.0-ultra-generate-001": { "litellm_provider": "gemini", "mode": "image_generation", "output_cost_per_image": 0.06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "gemini/learnlm-1.5-pro-experimental": { "input_cost_per_audio_per_second": 0, "input_cost_per_audio_per_second_above_128k_tokens": 0, "input_cost_per_character": 0, "input_cost_per_character_above_128k_tokens": 0, "input_cost_per_image": 0, "input_cost_per_image_above_128k_tokens": 0, "input_cost_per_token": 0, "input_cost_per_token_above_128k_tokens": 0, "input_cost_per_video_per_second": 0, "input_cost_per_video_per_second_above_128k_tokens": 0, "litellm_provider": "gemini", "max_input_tokens": 32767, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 0, "output_cost_per_character_above_128k_tokens": 0, "output_cost_per_token": 0, "output_cost_per_token_above_128k_tokens": 0, "source": "https://aistudio.google.com", "supports_audio_output": false, "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gemini/veo-2.0-generate-001": { "litellm_provider": "gemini", "max_input_tokens": 1024, "max_tokens": 1024, "mode": "video_generation", "output_cost_per_second": 0.35, "source": "https://ai.google.dev/gemini-api/docs/video", "supported_modalities": [ "text" ], "supported_output_modalities": [ "video" ] }, "gemini/veo-3.0-fast-generate-preview": { "litellm_provider": "gemini", "max_input_tokens": 1024, "max_tokens": 1024, "mode": "video_generation", "output_cost_per_second": 0.4, "source": "https://ai.google.dev/gemini-api/docs/video", "supported_modalities": [ "text" ], "supported_output_modalities": [ "video" ] }, "gemini/veo-3.0-generate-preview": { "litellm_provider": "gemini", "max_input_tokens": 1024, "max_tokens": 1024, "mode": "video_generation", "output_cost_per_second": 0.75, "source": "https://ai.google.dev/gemini-api/docs/video", "supported_modalities": [ "text" ], "supported_output_modalities": [ "video" ] }, "google_pse/search": { "input_cost_per_query": 0.005, "litellm_provider": "google_pse", "mode": "search" }, "global.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, "global.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "global.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.25e-06, "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, "gpt-3.5-turbo": { "input_cost_per_token": 0.5e-06, "litellm_provider": "openai", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 4097, "mode": "chat", "output_cost_per_token": 1.5e-06, "supports_function_calling": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-3.5-turbo-0125": { "input_cost_per_token": 5e-07, "litellm_provider": "openai", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 16385, "mode": "chat", "output_cost_per_token": 1.5e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-3.5-turbo-0301": { "input_cost_per_token": 1.5e-06, "litellm_provider": "openai", "max_input_tokens": 4097, "max_output_tokens": 4096, "max_tokens": 4097, "mode": "chat", "output_cost_per_token": 2e-06, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-3.5-turbo-0613": { "input_cost_per_token": 1.5e-06, "litellm_provider": "openai", "max_input_tokens": 4097, "max_output_tokens": 4096, "max_tokens": 4097, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-3.5-turbo-1106": { "deprecation_date": "2026-09-28", "input_cost_per_token": 1e-06, "litellm_provider": "openai", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 16385, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-3.5-turbo-16k": { "input_cost_per_token": 3e-06, "litellm_provider": "openai", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 16385, "mode": "chat", "output_cost_per_token": 4e-06, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-3.5-turbo-16k-0613": { "input_cost_per_token": 3e-06, "litellm_provider": "openai", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 16385, "mode": "chat", "output_cost_per_token": 4e-06, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-3.5-turbo-instruct": { "input_cost_per_token": 1.5e-06, "litellm_provider": "text-completion-openai", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 2e-06 }, "gpt-3.5-turbo-instruct-0914": { "input_cost_per_token": 1.5e-06, "litellm_provider": "text-completion-openai", "max_input_tokens": 8192, "max_output_tokens": 4097, "max_tokens": 4097, "mode": "completion", "output_cost_per_token": 2e-06 }, "gpt-4": { "input_cost_per_token": 3e-05, "litellm_provider": "openai", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4-0125-preview": { "deprecation_date": "2026-03-26", "input_cost_per_token": 1e-05, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4-0314": { "input_cost_per_token": 3e-05, "litellm_provider": "openai", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-05, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4-0613": { "deprecation_date": "2025-06-06", "input_cost_per_token": 3e-05, "litellm_provider": "openai", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4-1106-preview": { "deprecation_date": "2026-03-26", "input_cost_per_token": 1e-05, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4-1106-vision-preview": { "deprecation_date": "2024-12-06", "input_cost_per_token": 1e-05, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gpt-4-32k": { "input_cost_per_token": 6e-05, "litellm_provider": "openai", "max_input_tokens": 32768, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.00012, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4-32k-0314": { "input_cost_per_token": 6e-05, "litellm_provider": "openai", "max_input_tokens": 32768, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.00012, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4-32k-0613": { "input_cost_per_token": 6e-05, "litellm_provider": "openai", "max_input_tokens": 32768, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.00012, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4-turbo": { "input_cost_per_token": 1e-05, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gpt-4-turbo-2024-04-09": { "input_cost_per_token": 1e-05, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gpt-4-turbo-preview": { "input_cost_per_token": 1e-05, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4-vision-preview": { "deprecation_date": "2024-12-06", "input_cost_per_token": 1e-05, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 3e-05, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gpt-4.1": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_priority": 8.75e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "openai", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 8e-06, "output_cost_per_token_batches": 4e-06, "output_cost_per_token_priority": 1.4e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-4.1-2025-04-14": { "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, "litellm_provider": "openai", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 8e-06, "output_cost_per_token_batches": 4e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-4.1-mini": { "cache_read_input_token_cost": 1e-07, "cache_read_input_token_cost_priority": 1.75e-07, "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, "input_cost_per_token_priority": 7e-07, "litellm_provider": "openai", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1.6e-06, "output_cost_per_token_batches": 8e-07, "output_cost_per_token_priority": 2.8e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-4.1-mini-2025-04-14": { "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 4e-07, "input_cost_per_token_batches": 2e-07, "litellm_provider": "openai", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1.6e-06, "output_cost_per_token_batches": 8e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-4.1-nano": { "cache_read_input_token_cost": 2.5e-08, "cache_read_input_token_cost_priority": 5e-08, "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, "input_cost_per_token_priority": 2e-07, "litellm_provider": "openai", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, "output_cost_per_token_priority": 8e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-4.1-nano-2025-04-14": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, "litellm_provider": "openai", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-07, "output_cost_per_token_batches": 2e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-4.5-preview": { "cache_read_input_token_cost": 3.75e-05, "input_cost_per_token": 7.5e-05, "input_cost_per_token_batches": 3.75e-05, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 0.00015, "output_cost_per_token_batches": 7.5e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gpt-4.5-preview-2025-02-27": { "cache_read_input_token_cost": 3.75e-05, "deprecation_date": "2025-07-14", "input_cost_per_token": 7.5e-05, "input_cost_per_token_batches": 3.75e-05, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 0.00015, "output_cost_per_token_batches": 7.5e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gpt-4o": { "cache_read_input_token_cost": 1.25e-06, "cache_read_input_token_cost_priority": 2.125e-06, "input_cost_per_token": 2.5e-06, "input_cost_per_token_batches": 1.25e-06, "input_cost_per_token_priority": 4.25e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_batches": 5e-06, "output_cost_per_token_priority": 1.7e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-4o-2024-05-13": { "input_cost_per_token": 5e-06, "input_cost_per_token_batches": 2.5e-06, "input_cost_per_token_priority": 8.75e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "output_cost_per_token_batches": 7.5e-06, "output_cost_per_token_priority": 2.625e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gpt-4o-2024-08-06": { "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_batches": 5e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-4o-2024-11-20": { "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_batches": 5e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-4o-audio-preview": { "input_cost_per_audio_token": 0.0001, "input_cost_per_token": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_audio_token": 0.0002, "output_cost_per_token": 1e-05, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4o-audio-preview-2024-10-01": { "input_cost_per_audio_token": 0.0001, "input_cost_per_token": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_audio_token": 0.0002, "output_cost_per_token": 1e-05, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4o-audio-preview-2024-12-17": { "input_cost_per_audio_token": 4e-05, "input_cost_per_token": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_audio_token": 8e-05, "output_cost_per_token": 1e-05, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4o-audio-preview-2025-06-03": { "input_cost_per_audio_token": 4e-05, "input_cost_per_token": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_audio_token": 8e-05, "output_cost_per_token": 1e-05, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4o-mini": { "cache_read_input_token_cost": 7.5e-08, "cache_read_input_token_cost_priority": 1.25e-07, "input_cost_per_token": 1.5e-07, "input_cost_per_token_batches": 7.5e-08, "input_cost_per_token_priority": 2.5e-07, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6e-07, "output_cost_per_token_batches": 3e-07, "output_cost_per_token_priority": 1e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-4o-mini-2024-07-18": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_token": 1.5e-07, "input_cost_per_token_batches": 7.5e-08, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6e-07, "output_cost_per_token_batches": 3e-07, "search_context_cost_per_query": { "search_context_size_high": 0.03, "search_context_size_low": 0.025, "search_context_size_medium": 0.0275 }, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-4o-mini-audio-preview": { "input_cost_per_audio_token": 1e-05, "input_cost_per_token": 1.5e-07, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 6e-07, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4o-mini-audio-preview-2024-12-17": { "input_cost_per_audio_token": 1e-05, "input_cost_per_token": 1.5e-07, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 6e-07, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4o-mini-realtime-preview": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_token_cost": 3e-07, "input_cost_per_audio_token": 1e-05, "input_cost_per_token": 6e-07, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2.4e-06, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4o-mini-realtime-preview-2024-12-17": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_token_cost": 3e-07, "input_cost_per_audio_token": 1e-05, "input_cost_per_token": 6e-07, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2.4e-06, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4o-mini-search-preview": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_token": 1.5e-07, "input_cost_per_token_batches": 7.5e-08, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6e-07, "output_cost_per_token_batches": 3e-07, "search_context_cost_per_query": { "search_context_size_high": 0.03, "search_context_size_low": 0.025, "search_context_size_medium": 0.0275 }, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gpt-4o-mini-search-preview-2025-03-11": { "cache_read_input_token_cost": 7.5e-08, "input_cost_per_token": 1.5e-07, "input_cost_per_token_batches": 7.5e-08, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 6e-07, "output_cost_per_token_batches": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gpt-4o-mini-transcribe": { "input_cost_per_audio_token": 3e-06, "input_cost_per_token": 1.25e-06, "litellm_provider": "openai", "max_input_tokens": 16000, "max_output_tokens": 2000, "mode": "audio_transcription", "output_cost_per_token": 5e-06, "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "gpt-4o-mini-tts": { "input_cost_per_token": 2.5e-06, "litellm_provider": "openai", "mode": "audio_speech", "output_cost_per_audio_token": 1.2e-05, "output_cost_per_second": 0.00025, "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/audio/speech" ], "supported_modalities": [ "text", "audio" ], "supported_output_modalities": [ "audio" ] }, "gpt-4o-realtime-preview": { "cache_read_input_token_cost": 2.5e-06, "input_cost_per_audio_token": 4e-05, "input_cost_per_token": 5e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 8e-05, "output_cost_per_token": 2e-05, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4o-realtime-preview-2024-10-01": { "cache_creation_input_audio_token_cost": 2e-05, "cache_read_input_token_cost": 2.5e-06, "input_cost_per_audio_token": 0.0001, "input_cost_per_token": 5e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 0.0002, "output_cost_per_token": 2e-05, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4o-realtime-preview-2024-12-17": { "cache_read_input_token_cost": 2.5e-06, "input_cost_per_audio_token": 4e-05, "input_cost_per_token": 5e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 8e-05, "output_cost_per_token": 2e-05, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4o-realtime-preview-2025-06-03": { "cache_read_input_token_cost": 2.5e-06, "input_cost_per_audio_token": 4e-05, "input_cost_per_token": 5e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 8e-05, "output_cost_per_token": 2e-05, "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-4o-search-preview": { "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_batches": 5e-06, "search_context_cost_per_query": { "search_context_size_high": 0.05, "search_context_size_low": 0.03, "search_context_size_medium": 0.035 }, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gpt-4o-search-preview-2025-03-11": { "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, "input_cost_per_token_batches": 1.25e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_batches": 5e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gpt-4o-transcribe": { "input_cost_per_audio_token": 6e-06, "input_cost_per_token": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 16000, "max_output_tokens": 2000, "mode": "audio_transcription", "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "gpt-5": { "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_flex": 6.25e-08, "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_flex": 6.25e-07, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_flex": 5e-06, "output_cost_per_token_priority": 2e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-5-pro": { "input_cost_per_token": 1.5e-05, "input_cost_per_token_batches": 7.5e-06, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 272000, "max_tokens": 272000, "mode": "responses", "output_cost_per_token": 1.2e-04, "output_cost_per_token_batches": 6e-05, "supported_endpoints": [ "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gpt-5-pro-2025-10-06": { "input_cost_per_token": 1.5e-05, "input_cost_per_token_batches": 7.5e-06, "litellm_provider": "openai", "max_input_tokens": 400000, "max_output_tokens": 272000, "max_tokens": 272000, "mode": "responses", "output_cost_per_token": 1.2e-04, "output_cost_per_token_batches": 6e-05, "supported_endpoints": [ "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "gpt-5-2025-08-07": { "cache_read_input_token_cost": 1.25e-07, "cache_read_input_token_cost_flex": 6.25e-08, "cache_read_input_token_cost_priority": 2.5e-07, "input_cost_per_token": 1.25e-06, "input_cost_per_token_flex": 6.25e-07, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, "output_cost_per_token_flex": 5e-06, "output_cost_per_token_priority": 2e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-5-chat": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openai", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": false, "supports_native_streaming": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": false, "supports_vision": true }, "gpt-5-chat-latest": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": false, "supports_native_streaming": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": false, "supports_vision": true }, "gpt-5-codex": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openai", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "responses", "output_cost_per_token": 1e-05, "supported_endpoints": [ "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": false, "supports_tool_choice": true, "supports_vision": true }, "gpt-5-mini": { "cache_read_input_token_cost": 2.5e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_token": 2.5e-07, "input_cost_per_token_flex": 1.25e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, "output_cost_per_token_flex": 1e-06, "output_cost_per_token_priority": 3.6e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-5-mini-2025-08-07": { "cache_read_input_token_cost": 2.5e-08, "cache_read_input_token_cost_flex": 1.25e-08, "cache_read_input_token_cost_priority": 4.5e-08, "input_cost_per_token": 2.5e-07, "input_cost_per_token_flex": 1.25e-07, "input_cost_per_token_priority": 4.5e-07, "litellm_provider": "openai", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, "output_cost_per_token_flex": 1e-06, "output_cost_per_token_priority": 3.6e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "gpt-5-nano": { "cache_read_input_token_cost": 5e-09, "cache_read_input_token_cost_flex": 2.5e-09, "input_cost_per_token": 5e-08, "input_cost_per_token_flex": 2.5e-08, "input_cost_per_token_priority": 2.5e-06, "litellm_provider": "openai", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, "output_cost_per_token_flex": 2e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gpt-5-nano-2025-08-07": { "cache_read_input_token_cost": 5e-09, "cache_read_input_token_cost_flex": 2.5e-09, "input_cost_per_token": 5e-08, "input_cost_per_token_flex": 2.5e-08, "litellm_provider": "openai", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, "output_cost_per_token_flex": 2e-07, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "gpt-image-1": { "input_cost_per_pixel": 4.0054321e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "gpt-image-1-mini": { "cache_read_input_image_token_cost": 2.5e-07, "cache_read_input_token_cost": 2e-07, "input_cost_per_image_token": 2.5e-06, "input_cost_per_token": 2e-06, "litellm_provider": "openai", "mode": "chat", "output_cost_per_image_token": 8e-06, "supported_endpoints": [ "/v1/images/generations", "/v1/images/edits" ] }, "gpt-realtime": { "cache_creation_input_audio_token_cost": 4e-07, "cache_read_input_token_cost": 4e-07, "input_cost_per_audio_token": 3.2e-05, "input_cost_per_image": 5e-06, "input_cost_per_token": 4e-06, "litellm_provider": "openai", "max_input_tokens": 32000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1.6e-05, "supported_endpoints": [ "/v1/realtime" ], "supported_modalities": [ "text", "image", "audio" ], "supported_output_modalities": [ "text", "audio" ], "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-realtime-mini": { "cache_creation_input_audio_token_cost": 3e-07, "cache_read_input_audio_token_cost": 3e-07, "input_cost_per_audio_token": 1e-05, "input_cost_per_token": 6e-07, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 2e-05, "output_cost_per_token": 2.4e-06, "supported_endpoints": [ "/v1/realtime" ], "supported_modalities": [ "text", "image", "audio" ], "supported_output_modalities": [ "text", "audio" ], "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gpt-realtime-2025-08-28": { "cache_creation_input_audio_token_cost": 4e-07, "cache_read_input_token_cost": 4e-07, "input_cost_per_audio_token": 3.2e-05, "input_cost_per_image": 5e-06, "input_cost_per_token": 4e-06, "litellm_provider": "openai", "max_input_tokens": 32000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_audio_token": 6.4e-05, "output_cost_per_token": 1.6e-05, "supported_endpoints": [ "/v1/realtime" ], "supported_modalities": [ "text", "image", "audio" ], "supported_output_modalities": [ "text", "audio" ], "supports_audio_input": true, "supports_audio_output": true, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "gradient_ai/alibaba-qwen3-32b": { "litellm_provider": "gradient_ai", "max_tokens": 2048, "mode": "chat", "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "gradient_ai/anthropic-claude-3-opus": { "input_cost_per_token": 1.5e-05, "litellm_provider": "gradient_ai", "max_tokens": 1024, "mode": "chat", "output_cost_per_token": 7.5e-05, "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "gradient_ai/anthropic-claude-3.5-haiku": { "input_cost_per_token": 8e-07, "litellm_provider": "gradient_ai", "max_tokens": 1024, "mode": "chat", "output_cost_per_token": 4e-06, "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "gradient_ai/anthropic-claude-3.5-sonnet": { "input_cost_per_token": 3e-06, "litellm_provider": "gradient_ai", "max_tokens": 1024, "mode": "chat", "output_cost_per_token": 1.5e-05, "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "gradient_ai/anthropic-claude-3.7-sonnet": { "input_cost_per_token": 3e-06, "litellm_provider": "gradient_ai", "max_tokens": 1024, "mode": "chat", "output_cost_per_token": 1.5e-05, "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "gradient_ai/deepseek-r1-distill-llama-70b": { "input_cost_per_token": 9.9e-07, "litellm_provider": "gradient_ai", "max_tokens": 8000, "mode": "chat", "output_cost_per_token": 9.9e-07, "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "gradient_ai/llama3-8b-instruct": { "input_cost_per_token": 2e-07, "litellm_provider": "gradient_ai", "max_tokens": 512, "mode": "chat", "output_cost_per_token": 2e-07, "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "gradient_ai/llama3.3-70b-instruct": { "input_cost_per_token": 6.5e-07, "litellm_provider": "gradient_ai", "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 6.5e-07, "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "gradient_ai/mistral-nemo-instruct-2407": { "input_cost_per_token": 3e-07, "litellm_provider": "gradient_ai", "max_tokens": 512, "mode": "chat", "output_cost_per_token": 3e-07, "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "gradient_ai/openai-gpt-4o": { "litellm_provider": "gradient_ai", "max_tokens": 16384, "mode": "chat", "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "gradient_ai/openai-gpt-4o-mini": { "litellm_provider": "gradient_ai", "max_tokens": 16384, "mode": "chat", "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "gradient_ai/openai-o3": { "input_cost_per_token": 2e-06, "litellm_provider": "gradient_ai", "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8e-06, "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "gradient_ai/openai-o3-mini": { "input_cost_per_token": 1.1e-06, "litellm_provider": "gradient_ai", "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, "supported_endpoints": [ "/v1/chat/completions" ], "supported_modalities": [ "text" ], "supports_tool_choice": false }, "lemonade/Qwen3-Coder-30B-A3B-Instruct-GGUF": { "input_cost_per_token": 0, "litellm_provider": "lemonade", "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 32768, "mode": "chat", "output_cost_per_token": 0, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "lemonade/gpt-oss-20b-mxfp4-GGUF": { "input_cost_per_token": 0, "litellm_provider": "lemonade", "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 32768, "mode": "chat", "output_cost_per_token": 0, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "lemonade/gpt-oss-120b-mxfp-GGUF": { "input_cost_per_token": 0, "litellm_provider": "lemonade", "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 32768, "mode": "chat", "output_cost_per_token": 0, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "lemonade/Gemma-3-4b-it-GGUF": { "input_cost_per_token": 0, "litellm_provider": "lemonade", "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 8192, "mode": "chat", "output_cost_per_token": 0, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "lemonade/Qwen3-4B-Instruct-2507-GGUF": { "input_cost_per_token": 0, "litellm_provider": "lemonade", "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 32768, "mode": "chat", "output_cost_per_token": 0, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/deepseek-r1-distill-llama-70b": { "input_cost_per_token": 7.5e-07, "litellm_provider": "groq", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 9.9e-07, "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/distil-whisper-large-v3-en": { "input_cost_per_second": 5.56e-06, "litellm_provider": "groq", "mode": "audio_transcription", "output_cost_per_second": 0.0 }, "groq/gemma-7b-it": { "deprecation_date": "2024-12-18", "input_cost_per_token": 7e-08, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 7e-08, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/gemma2-9b-it": { "input_cost_per_token": 2e-07, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2e-07, "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false }, "groq/llama-3.1-405b-reasoning": { "input_cost_per_token": 5.9e-07, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 7.9e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/llama-3.1-70b-versatile": { "deprecation_date": "2025-01-24", "input_cost_per_token": 5.9e-07, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 7.9e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/llama-3.1-8b-instant": { "input_cost_per_token": 5e-08, "litellm_provider": "groq", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 8e-08, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/llama-3.2-11b-text-preview": { "deprecation_date": "2024-10-28", "input_cost_per_token": 1.8e-07, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.8e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/llama-3.2-11b-vision-preview": { "deprecation_date": "2025-04-14", "input_cost_per_token": 1.8e-07, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.8e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "groq/llama-3.2-1b-preview": { "deprecation_date": "2025-04-14", "input_cost_per_token": 4e-08, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4e-08, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/llama-3.2-3b-preview": { "deprecation_date": "2025-04-14", "input_cost_per_token": 6e-08, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 6e-08, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/llama-3.2-90b-text-preview": { "deprecation_date": "2024-11-25", "input_cost_per_token": 9e-07, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 9e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/llama-3.2-90b-vision-preview": { "deprecation_date": "2025-04-14", "input_cost_per_token": 9e-07, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 9e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "groq/llama-3.3-70b-specdec": { "deprecation_date": "2025-04-14", "input_cost_per_token": 5.9e-07, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 9.9e-07, "supports_tool_choice": true }, "groq/llama-3.3-70b-versatile": { "input_cost_per_token": 5.9e-07, "litellm_provider": "groq", "max_input_tokens": 128000, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 7.9e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/llama-guard-3-8b": { "input_cost_per_token": 2e-07, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2e-07 }, "groq/llama2-70b-4096": { "input_cost_per_token": 7e-07, "litellm_provider": "groq", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 8e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/llama3-groq-70b-8192-tool-use-preview": { "deprecation_date": "2025-01-06", "input_cost_per_token": 8.9e-07, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 8.9e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/llama3-groq-8b-8192-tool-use-preview": { "deprecation_date": "2025-01-06", "input_cost_per_token": 1.9e-07, "litellm_provider": "groq", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.9e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/meta-llama/llama-4-maverick-17b-128e-instruct": { "input_cost_per_token": 2e-07, "litellm_provider": "groq", "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/meta-llama/llama-4-scout-17b-16e-instruct": { "input_cost_per_token": 1.1e-07, "litellm_provider": "groq", "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 3.4e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/mistral-saba-24b": { "input_cost_per_token": 7.9e-07, "litellm_provider": "groq", "max_input_tokens": 32000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.9e-07 }, "groq/mixtral-8x7b-32768": { "deprecation_date": "2025-03-20", "input_cost_per_token": 2.4e-07, "litellm_provider": "groq", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 2.4e-07, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/moonshotai/kimi-k2-instruct": { "input_cost_per_token": 1e-06, "litellm_provider": "groq", "max_input_tokens": 131072, "max_output_tokens": 16384, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3e-06, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/moonshotai/kimi-k2-instruct-0905": { "input_cost_per_token": 1e-06, "output_cost_per_token": 3e-06, "cache_read_input_token_cost": 0.5e-06, "litellm_provider": "groq", "max_input_tokens": 262144, "max_output_tokens": 16384, "max_tokens": 278528, "mode": "chat", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/openai/gpt-oss-120b": { "input_cost_per_token": 1.5e-07, "litellm_provider": "groq", "max_input_tokens": 131072, "max_output_tokens": 32766, "max_tokens": 32766, "mode": "chat", "output_cost_per_token": 7.5e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_web_search": true }, "groq/openai/gpt-oss-20b": { "input_cost_per_token": 1e-07, "litellm_provider": "groq", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 5e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_web_search": true }, "groq/playai-tts": { "input_cost_per_character": 5e-05, "litellm_provider": "groq", "max_input_tokens": 10000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "audio_speech" }, "groq/qwen/qwen3-32b": { "input_cost_per_token": 2.9e-07, "litellm_provider": "groq", "max_input_tokens": 131000, "max_output_tokens": 131000, "max_tokens": 131000, "mode": "chat", "output_cost_per_token": 5.9e-07, "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "groq/whisper-large-v3": { "input_cost_per_second": 3.083e-05, "litellm_provider": "groq", "mode": "audio_transcription", "output_cost_per_second": 0.0 }, "groq/whisper-large-v3-turbo": { "input_cost_per_second": 1.111e-05, "litellm_provider": "groq", "mode": "audio_transcription", "output_cost_per_second": 0.0 }, "hd/1024-x-1024/dall-e-3": { "input_cost_per_pixel": 7.629e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0 }, "hd/1024-x-1792/dall-e-3": { "input_cost_per_pixel": 6.539e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0 }, "hd/1792-x-1024/dall-e-3": { "input_cost_per_pixel": 6.539e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0 }, "heroku/claude-3-5-haiku": { "litellm_provider": "heroku", "max_tokens": 4096, "mode": "chat", "supports_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "heroku/claude-3-5-sonnet-latest": { "litellm_provider": "heroku", "max_tokens": 8192, "mode": "chat", "supports_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "heroku/claude-3-7-sonnet": { "litellm_provider": "heroku", "max_tokens": 8192, "mode": "chat", "supports_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "heroku/claude-4-sonnet": { "litellm_provider": "heroku", "max_tokens": 8192, "mode": "chat", "supports_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "high/1024-x-1024/gpt-image-1": { "input_cost_per_pixel": 1.59263611e-07, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "high/1024-x-1536/gpt-image-1": { "input_cost_per_pixel": 1.58945719e-07, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "high/1536-x-1024/gpt-image-1": { "input_cost_per_pixel": 1.58945719e-07, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "hyperbolic/NousResearch/Hermes-3-Llama-3.1-70B": { "input_cost_per_token": 1.2e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/Qwen/QwQ-32B": { "input_cost_per_token": 2e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/Qwen/Qwen2.5-72B-Instruct": { "input_cost_per_token": 1.2e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/Qwen/Qwen2.5-Coder-32B-Instruct": { "input_cost_per_token": 1.2e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/Qwen/Qwen3-235B-A22B": { "input_cost_per_token": 2e-06, "litellm_provider": "hyperbolic", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/deepseek-ai/DeepSeek-R1": { "input_cost_per_token": 4e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/deepseek-ai/DeepSeek-R1-0528": { "input_cost_per_token": 2.5e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.5e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/deepseek-ai/DeepSeek-V3": { "input_cost_per_token": 2e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 2e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/deepseek-ai/DeepSeek-V3-0324": { "input_cost_per_token": 4e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/meta-llama/Llama-3.2-3B-Instruct": { "input_cost_per_token": 1.2e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/meta-llama/Llama-3.3-70B-Instruct": { "input_cost_per_token": 1.2e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/meta-llama/Meta-Llama-3-70B-Instruct": { "input_cost_per_token": 1.2e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/meta-llama/Meta-Llama-3.1-405B-Instruct": { "input_cost_per_token": 1.2e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/meta-llama/Meta-Llama-3.1-70B-Instruct": { "input_cost_per_token": 1.2e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/meta-llama/Meta-Llama-3.1-8B-Instruct": { "input_cost_per_token": 1.2e-07, "litellm_provider": "hyperbolic", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "hyperbolic/moonshotai/Kimi-K2-Instruct": { "input_cost_per_token": 2e-06, "litellm_provider": "hyperbolic", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "j2-light": { "input_cost_per_token": 3e-06, "litellm_provider": "ai21", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "completion", "output_cost_per_token": 3e-06 }, "j2-mid": { "input_cost_per_token": 1e-05, "litellm_provider": "ai21", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "completion", "output_cost_per_token": 1e-05 }, "j2-ultra": { "input_cost_per_token": 1.5e-05, "litellm_provider": "ai21", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "completion", "output_cost_per_token": 1.5e-05 }, "jamba-1.5": { "input_cost_per_token": 2e-07, "litellm_provider": "ai21", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 4e-07, "supports_tool_choice": true }, "jamba-1.5-large": { "input_cost_per_token": 2e-06, "litellm_provider": "ai21", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 8e-06, "supports_tool_choice": true }, "jamba-1.5-large@001": { "input_cost_per_token": 2e-06, "litellm_provider": "ai21", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 8e-06, "supports_tool_choice": true }, "jamba-1.5-mini": { "input_cost_per_token": 2e-07, "litellm_provider": "ai21", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 4e-07, "supports_tool_choice": true }, "jamba-1.5-mini@001": { "input_cost_per_token": 2e-07, "litellm_provider": "ai21", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 4e-07, "supports_tool_choice": true }, "jamba-large-1.6": { "input_cost_per_token": 2e-06, "litellm_provider": "ai21", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 8e-06, "supports_tool_choice": true }, "jamba-large-1.7": { "input_cost_per_token": 2e-06, "litellm_provider": "ai21", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 8e-06, "supports_tool_choice": true }, "jamba-mini-1.6": { "input_cost_per_token": 2e-07, "litellm_provider": "ai21", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 4e-07, "supports_tool_choice": true }, "jamba-mini-1.7": { "input_cost_per_token": 2e-07, "litellm_provider": "ai21", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 4e-07, "supports_tool_choice": true }, "jina-reranker-v2-base-multilingual": { "input_cost_per_token": 1.8e-08, "litellm_provider": "jina_ai", "max_document_chunks_per_query": 2048, "max_input_tokens": 1024, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "rerank", "output_cost_per_token": 1.8e-08 }, "jp.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.125e-06, "cache_read_input_token_cost": 3.3e-07, "input_cost_per_token": 3.3e-06, "input_cost_per_token_above_200k_tokens": 6.6e-06, "output_cost_per_token_above_200k_tokens": 2.475e-05, "cache_creation_input_token_cost_above_200k_tokens": 8.25e-06, "cache_read_input_token_cost_above_200k_tokens": 6.6e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.65e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, "jp.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, "cache_read_input_token_cost": 1.1e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.5e-06, "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "lambda_ai/deepseek-llama3.3-70b": { "input_cost_per_token": 2e-07, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/deepseek-r1-0528": { "input_cost_per_token": 2e-07, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/deepseek-r1-671b": { "input_cost_per_token": 8e-07, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 8e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/deepseek-v3-0324": { "input_cost_per_token": 2e-07, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/hermes3-405b": { "input_cost_per_token": 8e-07, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 8e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/hermes3-70b": { "input_cost_per_token": 1.2e-07, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/hermes3-8b": { "input_cost_per_token": 2.5e-08, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4e-08, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/lfm-40b": { "input_cost_per_token": 1e-07, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/lfm-7b": { "input_cost_per_token": 2.5e-08, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4e-08, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/llama-4-maverick-17b-128e-instruct-fp8": { "input_cost_per_token": 5e-08, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/llama-4-scout-17b-16e-instruct": { "input_cost_per_token": 5e-08, "litellm_provider": "lambda_ai", "max_input_tokens": 16384, "max_output_tokens": 8192, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/llama3.1-405b-instruct-fp8": { "input_cost_per_token": 8e-07, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 8e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/llama3.1-70b-instruct-fp8": { "input_cost_per_token": 1.2e-07, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/llama3.1-8b-instruct": { "input_cost_per_token": 2.5e-08, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4e-08, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/llama3.1-nemotron-70b-instruct-fp8": { "input_cost_per_token": 1.2e-07, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/llama3.2-11b-vision-instruct": { "input_cost_per_token": 1.5e-08, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.5e-08, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "lambda_ai/llama3.2-3b-instruct": { "input_cost_per_token": 1.5e-08, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.5e-08, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/llama3.3-70b-instruct-fp8": { "input_cost_per_token": 1.2e-07, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/qwen25-coder-32b-instruct": { "input_cost_per_token": 5e-08, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true }, "lambda_ai/qwen3-32b-fp8": { "input_cost_per_token": 5e-08, "litellm_provider": "lambda_ai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_system_messages": true, "supports_tool_choice": true }, "low/1024-x-1024/gpt-image-1": { "input_cost_per_pixel": 1.0490417e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "low/1024-x-1536/gpt-image-1": { "input_cost_per_pixel": 1.0172526e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "low/1536-x-1024/gpt-image-1": { "input_cost_per_pixel": 1.0172526e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "luminous-base": { "input_cost_per_token": 3e-05, "litellm_provider": "aleph_alpha", "max_tokens": 2048, "mode": "completion", "output_cost_per_token": 3.3e-05 }, "luminous-base-control": { "input_cost_per_token": 3.75e-05, "litellm_provider": "aleph_alpha", "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 4.125e-05 }, "luminous-extended": { "input_cost_per_token": 4.5e-05, "litellm_provider": "aleph_alpha", "max_tokens": 2048, "mode": "completion", "output_cost_per_token": 4.95e-05 }, "luminous-extended-control": { "input_cost_per_token": 5.625e-05, "litellm_provider": "aleph_alpha", "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 6.1875e-05 }, "luminous-supreme": { "input_cost_per_token": 0.000175, "litellm_provider": "aleph_alpha", "max_tokens": 2048, "mode": "completion", "output_cost_per_token": 0.0001925 }, "luminous-supreme-control": { "input_cost_per_token": 0.00021875, "litellm_provider": "aleph_alpha", "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 0.000240625 }, "max-x-max/50-steps/stability.stable-diffusion-xl-v0": { "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "image_generation", "output_cost_per_image": 0.036 }, "max-x-max/max-steps/stability.stable-diffusion-xl-v0": { "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "image_generation", "output_cost_per_image": 0.072 }, "medium/1024-x-1024/gpt-image-1": { "input_cost_per_pixel": 4.0054321e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "medium/1024-x-1536/gpt-image-1": { "input_cost_per_pixel": 4.0054321e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "medium/1536-x-1024/gpt-image-1": { "input_cost_per_pixel": 4.0054321e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0, "supported_endpoints": [ "/v1/images/generations" ] }, "low/1024-x-1024/gpt-image-1-mini": { "input_cost_per_image": 0.005, "litellm_provider": "openai", "mode": "image_generation", "supported_endpoints": [ "/v1/images/generations" ] }, "low/1024-x-1536/gpt-image-1-mini": { "input_cost_per_image": 0.006, "litellm_provider": "openai", "mode": "image_generation", "supported_endpoints": [ "/v1/images/generations" ] }, "low/1536-x-1024/gpt-image-1-mini": { "input_cost_per_image": 0.006, "litellm_provider": "openai", "mode": "image_generation", "supported_endpoints": [ "/v1/images/generations" ] }, "medium/1024-x-1024/gpt-image-1-mini": { "input_cost_per_image": 0.011, "litellm_provider": "openai", "mode": "image_generation", "supported_endpoints": [ "/v1/images/generations" ] }, "medium/1024-x-1536/gpt-image-1-mini": { "input_cost_per_image": 0.015, "litellm_provider": "openai", "mode": "image_generation", "supported_endpoints": [ "/v1/images/generations" ] }, "medium/1536-x-1024/gpt-image-1-mini": { "input_cost_per_image": 0.015, "litellm_provider": "openai", "mode": "image_generation", "supported_endpoints": [ "/v1/images/generations" ] }, "medlm-large": { "input_cost_per_character": 5e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "chat", "output_cost_per_character": 1.5e-05, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "medlm-medium": { "input_cost_per_character": 5e-07, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 32768, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_character": 1e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", "supports_tool_choice": true }, "meta.llama2-13b-chat-v1": { "input_cost_per_token": 7.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1e-06 }, "meta.llama2-70b-chat-v1": { "input_cost_per_token": 1.95e-06, "litellm_provider": "bedrock", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.56e-06 }, "meta.llama3-1-405b-instruct-v1:0": { "input_cost_per_token": 5.32e-06, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.6e-05, "supports_function_calling": true, "supports_tool_choice": false }, "meta.llama3-1-70b-instruct-v1:0": { "input_cost_per_token": 9.9e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 9.9e-07, "supports_function_calling": true, "supports_tool_choice": false }, "meta.llama3-1-8b-instruct-v1:0": { "input_cost_per_token": 2.2e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.2e-07, "supports_function_calling": true, "supports_tool_choice": false }, "meta.llama3-2-11b-instruct-v1:0": { "input_cost_per_token": 3.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3.5e-07, "supports_function_calling": true, "supports_tool_choice": false, "supports_vision": true }, "meta.llama3-2-1b-instruct-v1:0": { "input_cost_per_token": 1e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-07, "supports_function_calling": true, "supports_tool_choice": false }, "meta.llama3-2-3b-instruct-v1:0": { "input_cost_per_token": 1.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-07, "supports_function_calling": true, "supports_tool_choice": false }, "meta.llama3-2-90b-instruct-v1:0": { "input_cost_per_token": 2e-06, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_tool_choice": false, "supports_vision": true }, "meta.llama3-3-70b-instruct-v1:0": { "input_cost_per_token": 7.2e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 7.2e-07, "supports_function_calling": true, "supports_tool_choice": false }, "meta.llama3-70b-instruct-v1:0": { "input_cost_per_token": 2.65e-06, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 3.5e-06 }, "meta.llama3-8b-instruct-v1:0": { "input_cost_per_token": 3e-07, "litellm_provider": "bedrock", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 6e-07 }, "meta.llama4-maverick-17b-instruct-v1:0": { "input_cost_per_token": 2.4e-07, "input_cost_per_token_batches": 1.2e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 9.7e-07, "output_cost_per_token_batches": 4.85e-07, "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text", "code" ], "supports_function_calling": true, "supports_tool_choice": false }, "meta.llama4-scout-17b-instruct-v1:0": { "input_cost_per_token": 1.7e-07, "input_cost_per_token_batches": 8.5e-08, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6.6e-07, "output_cost_per_token_batches": 3.3e-07, "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text", "code" ], "supports_function_calling": true, "supports_tool_choice": false }, "meta_llama/Llama-3.3-70B-Instruct": { "litellm_provider": "meta_llama", "max_input_tokens": 128000, "max_output_tokens": 4028, "max_tokens": 128000, "mode": "chat", "source": "https://llama.developer.meta.com/docs/models", "supported_modalities": [ "text" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_tool_choice": true }, "meta_llama/Llama-3.3-8B-Instruct": { "litellm_provider": "meta_llama", "max_input_tokens": 128000, "max_output_tokens": 4028, "max_tokens": 128000, "mode": "chat", "source": "https://llama.developer.meta.com/docs/models", "supported_modalities": [ "text" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_tool_choice": true }, "meta_llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { "litellm_provider": "meta_llama", "max_input_tokens": 1000000, "max_output_tokens": 4028, "max_tokens": 128000, "mode": "chat", "source": "https://llama.developer.meta.com/docs/models", "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_tool_choice": true }, "meta_llama/Llama-4-Scout-17B-16E-Instruct-FP8": { "litellm_provider": "meta_llama", "max_input_tokens": 10000000, "max_output_tokens": 4028, "max_tokens": 128000, "mode": "chat", "source": "https://llama.developer.meta.com/docs/models", "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_tool_choice": true }, "mistral.mistral-7b-instruct-v0:2": { "input_cost_per_token": 1.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2e-07, "supports_tool_choice": true }, "mistral.mistral-large-2402-v1:0": { "input_cost_per_token": 8e-06, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_function_calling": true }, "mistral.mistral-large-2407-v1:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 9e-06, "supports_function_calling": true, "supports_tool_choice": true }, "mistral.mistral-small-2402-v1:0": { "input_cost_per_token": 1e-06, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 3e-06, "supports_function_calling": true }, "mistral.mixtral-8x7b-instruct-v0:1": { "input_cost_per_token": 4.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 7e-07, "supports_tool_choice": true }, "mistral/codestral-2405": { "input_cost_per_token": 1e-06, "litellm_provider": "mistral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 3e-06, "supports_assistant_prefill": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/codestral-latest": { "input_cost_per_token": 1e-06, "litellm_provider": "mistral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 3e-06, "supports_assistant_prefill": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/codestral-mamba-latest": { "input_cost_per_token": 2.5e-07, "litellm_provider": "mistral", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 2.5e-07, "source": "https://mistral.ai/technology/", "supports_assistant_prefill": true, "supports_tool_choice": true }, "mistral/devstral-medium-2507": { "input_cost_per_token": 4e-07, "litellm_provider": "mistral", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, "source": "https://mistral.ai/news/devstral", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/devstral-small-2505": { "input_cost_per_token": 1e-07, "litellm_provider": "mistral", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3e-07, "source": "https://mistral.ai/news/devstral", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/devstral-small-2507": { "input_cost_per_token": 1e-07, "litellm_provider": "mistral", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3e-07, "source": "https://mistral.ai/news/devstral", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/magistral-medium-2506": { "input_cost_per_token": 2e-06, "litellm_provider": "mistral", "max_input_tokens": 40000, "max_output_tokens": 40000, "max_tokens": 40000, "mode": "chat", "output_cost_per_token": 5e-06, "source": "https://mistral.ai/news/magistral", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/mistral-ocr-latest": { "litellm_provider": "mistral", "ocr_cost_per_page": 1e-3, "annotation_cost_per_page": 3e-3, "mode": "ocr", "supported_endpoints": [ "/v1/ocr" ], "source": "https://mistral.ai/pricing#api-pricing" }, "mistral/mistral-ocr-2505-completion": { "litellm_provider": "mistral", "ocr_cost_per_page": 1e-3, "annotation_cost_per_page": 3e-3, "mode": "ocr", "supported_endpoints": [ "/v1/ocr" ], "source": "https://mistral.ai/pricing#api-pricing" }, "mistral/magistral-medium-latest": { "input_cost_per_token": 2e-06, "litellm_provider": "mistral", "max_input_tokens": 40000, "max_output_tokens": 40000, "max_tokens": 40000, "mode": "chat", "output_cost_per_token": 5e-06, "source": "https://mistral.ai/news/magistral", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/magistral-small-2506": { "input_cost_per_token": 5e-07, "litellm_provider": "mistral", "max_input_tokens": 40000, "max_output_tokens": 40000, "max_tokens": 40000, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://mistral.ai/pricing#api-pricing", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/magistral-small-latest": { "input_cost_per_token": 5e-07, "litellm_provider": "mistral", "max_input_tokens": 40000, "max_output_tokens": 40000, "max_tokens": 40000, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://mistral.ai/pricing#api-pricing", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/mistral-embed": { "input_cost_per_token": 1e-07, "litellm_provider": "mistral", "max_input_tokens": 8192, "max_tokens": 8192, "mode": "embedding" }, "mistral/mistral-large-2402": { "input_cost_per_token": 4e-06, "litellm_provider": "mistral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 1.2e-05, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/mistral-large-2407": { "input_cost_per_token": 3e-06, "litellm_provider": "mistral", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 9e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/mistral-large-2411": { "input_cost_per_token": 2e-06, "litellm_provider": "mistral", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/mistral-large-latest": { "input_cost_per_token": 2e-06, "litellm_provider": "mistral", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/mistral-medium": { "input_cost_per_token": 2.7e-06, "litellm_provider": "mistral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 8.1e-06, "supports_assistant_prefill": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/mistral-medium-2312": { "input_cost_per_token": 2.7e-06, "litellm_provider": "mistral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 8.1e-06, "supports_assistant_prefill": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/mistral-medium-2505": { "input_cost_per_token": 4e-07, "litellm_provider": "mistral", "max_input_tokens": 131072, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/mistral-medium-latest": { "input_cost_per_token": 4e-07, "litellm_provider": "mistral", "max_input_tokens": 131072, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/mistral-small": { "input_cost_per_token": 1e-07, "litellm_provider": "mistral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 3e-07, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/mistral-small-latest": { "input_cost_per_token": 1e-07, "litellm_provider": "mistral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 3e-07, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/mistral-tiny": { "input_cost_per_token": 2.5e-07, "litellm_provider": "mistral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.5e-07, "supports_assistant_prefill": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/open-codestral-mamba": { "input_cost_per_token": 2.5e-07, "litellm_provider": "mistral", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 2.5e-07, "source": "https://mistral.ai/technology/", "supports_assistant_prefill": true, "supports_tool_choice": true }, "mistral/open-mistral-7b": { "input_cost_per_token": 2.5e-07, "litellm_provider": "mistral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2.5e-07, "supports_assistant_prefill": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/open-mistral-nemo": { "input_cost_per_token": 3e-07, "litellm_provider": "mistral", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3e-07, "source": "https://mistral.ai/technology/", "supports_assistant_prefill": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/open-mistral-nemo-2407": { "input_cost_per_token": 3e-07, "litellm_provider": "mistral", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3e-07, "source": "https://mistral.ai/technology/", "supports_assistant_prefill": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/open-mixtral-8x22b": { "input_cost_per_token": 2e-06, "litellm_provider": "mistral", "max_input_tokens": 65336, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 6e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/open-mixtral-8x7b": { "input_cost_per_token": 7e-07, "litellm_provider": "mistral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 7e-07, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "mistral/pixtral-12b-2409": { "input_cost_per_token": 1.5e-07, "litellm_provider": "mistral", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-07, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "mistral/pixtral-large-2411": { "input_cost_per_token": 2e-06, "litellm_provider": "mistral", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "mistral/pixtral-large-latest": { "input_cost_per_token": 2e-06, "litellm_provider": "mistral", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "moonshot/kimi-k2-0711-preview": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 6e-07, "litellm_provider": "moonshot", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.5e-06, "source": "https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2", "supports_function_calling": true, "supports_tool_choice": true, "supports_web_search": true }, "moonshot/kimi-latest": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "moonshot", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 5e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "moonshot/kimi-latest-128k": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "moonshot", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 5e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "moonshot/kimi-latest-32k": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 1e-06, "litellm_provider": "moonshot", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "moonshot/kimi-latest-8k": { "cache_read_input_token_cost": 1.5e-07, "input_cost_per_token": 2e-07, "litellm_provider": "moonshot", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "moonshot/kimi-thinking-preview": { "input_cost_per_token": 3e-05, "litellm_provider": "moonshot", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3e-05, "source": "https://platform.moonshot.ai/docs/pricing", "supports_vision": true }, "moonshot/moonshot-v1-128k": { "input_cost_per_token": 2e-06, "litellm_provider": "moonshot", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 5e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "moonshot/moonshot-v1-128k-0430": { "input_cost_per_token": 2e-06, "litellm_provider": "moonshot", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 5e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "moonshot/moonshot-v1-128k-vision-preview": { "input_cost_per_token": 2e-06, "litellm_provider": "moonshot", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 5e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "moonshot/moonshot-v1-32k": { "input_cost_per_token": 1e-06, "litellm_provider": "moonshot", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "moonshot/moonshot-v1-32k-0430": { "input_cost_per_token": 1e-06, "litellm_provider": "moonshot", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "moonshot/moonshot-v1-32k-vision-preview": { "input_cost_per_token": 1e-06, "litellm_provider": "moonshot", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "moonshot/moonshot-v1-8k": { "input_cost_per_token": 2e-07, "litellm_provider": "moonshot", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "moonshot/moonshot-v1-8k-0430": { "input_cost_per_token": 2e-07, "litellm_provider": "moonshot", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "moonshot/moonshot-v1-8k-vision-preview": { "input_cost_per_token": 2e-07, "litellm_provider": "moonshot", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "moonshot/moonshot-v1-auto": { "input_cost_per_token": 2e-06, "litellm_provider": "moonshot", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 5e-06, "source": "https://platform.moonshot.ai/docs/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "morph/morph-v3-fast": { "input_cost_per_token": 8e-07, "litellm_provider": "morph", "max_input_tokens": 16000, "max_output_tokens": 16000, "max_tokens": 16000, "mode": "chat", "output_cost_per_token": 1.2e-06, "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_system_messages": true, "supports_tool_choice": false, "supports_vision": false }, "morph/morph-v3-large": { "input_cost_per_token": 9e-07, "litellm_provider": "morph", "max_input_tokens": 16000, "max_output_tokens": 16000, "max_tokens": 16000, "mode": "chat", "output_cost_per_token": 1.9e-06, "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_system_messages": true, "supports_tool_choice": false, "supports_vision": false }, "multimodalembedding": { "input_cost_per_character": 2e-07, "input_cost_per_image": 0.0001, "input_cost_per_token": 8e-07, "input_cost_per_video_per_second": 0.0005, "input_cost_per_video_per_second_above_15s_interval": 0.002, "input_cost_per_video_per_second_above_8s_interval": 0.001, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 2048, "max_tokens": 2048, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models", "supported_endpoints": [ "/v1/embeddings" ], "supported_modalities": [ "text", "image", "video" ] }, "multimodalembedding@001": { "input_cost_per_character": 2e-07, "input_cost_per_image": 0.0001, "input_cost_per_token": 8e-07, "input_cost_per_video_per_second": 0.0005, "input_cost_per_video_per_second_above_15s_interval": 0.002, "input_cost_per_video_per_second_above_8s_interval": 0.001, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 2048, "max_tokens": 2048, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models", "supported_endpoints": [ "/v1/embeddings" ], "supported_modalities": [ "text", "image", "video" ] }, "nscale/Qwen/QwQ-32B": { "input_cost_per_token": 1.8e-07, "litellm_provider": "nscale", "mode": "chat", "output_cost_per_token": 2e-07, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/Qwen/Qwen2.5-Coder-32B-Instruct": { "input_cost_per_token": 6e-08, "litellm_provider": "nscale", "mode": "chat", "output_cost_per_token": 2e-07, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/Qwen/Qwen2.5-Coder-3B-Instruct": { "input_cost_per_token": 1e-08, "litellm_provider": "nscale", "mode": "chat", "output_cost_per_token": 3e-08, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/Qwen/Qwen2.5-Coder-7B-Instruct": { "input_cost_per_token": 1e-08, "litellm_provider": "nscale", "mode": "chat", "output_cost_per_token": 3e-08, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/black-forest-labs/FLUX.1-schnell": { "input_cost_per_pixel": 1.3e-09, "litellm_provider": "nscale", "mode": "image_generation", "output_cost_per_pixel": 0.0, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#image-models", "supported_endpoints": [ "/v1/images/generations" ] }, "nscale/deepseek-ai/DeepSeek-R1-Distill-Llama-70B": { "input_cost_per_token": 3.75e-07, "litellm_provider": "nscale", "metadata": { "notes": "Pricing listed as $0.75/1M tokens total. Assumed 50/50 split for input/output." }, "mode": "chat", "output_cost_per_token": 3.75e-07, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/deepseek-ai/DeepSeek-R1-Distill-Llama-8B": { "input_cost_per_token": 2.5e-08, "litellm_provider": "nscale", "metadata": { "notes": "Pricing listed as $0.05/1M tokens total. Assumed 50/50 split for input/output." }, "mode": "chat", "output_cost_per_token": 2.5e-08, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B": { "input_cost_per_token": 9e-08, "litellm_provider": "nscale", "metadata": { "notes": "Pricing listed as $0.18/1M tokens total. Assumed 50/50 split for input/output." }, "mode": "chat", "output_cost_per_token": 9e-08, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-14B": { "input_cost_per_token": 7e-08, "litellm_provider": "nscale", "metadata": { "notes": "Pricing listed as $0.14/1M tokens total. Assumed 50/50 split for input/output." }, "mode": "chat", "output_cost_per_token": 7e-08, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-32B": { "input_cost_per_token": 1.5e-07, "litellm_provider": "nscale", "metadata": { "notes": "Pricing listed as $0.30/1M tokens total. Assumed 50/50 split for input/output." }, "mode": "chat", "output_cost_per_token": 1.5e-07, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B": { "input_cost_per_token": 2e-07, "litellm_provider": "nscale", "metadata": { "notes": "Pricing listed as $0.40/1M tokens total. Assumed 50/50 split for input/output." }, "mode": "chat", "output_cost_per_token": 2e-07, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/meta-llama/Llama-3.1-8B-Instruct": { "input_cost_per_token": 3e-08, "litellm_provider": "nscale", "metadata": { "notes": "Pricing listed as $0.06/1M tokens total. Assumed 50/50 split for input/output." }, "mode": "chat", "output_cost_per_token": 3e-08, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/meta-llama/Llama-3.3-70B-Instruct": { "input_cost_per_token": 2e-07, "litellm_provider": "nscale", "metadata": { "notes": "Pricing listed as $0.40/1M tokens total. Assumed 50/50 split for input/output." }, "mode": "chat", "output_cost_per_token": 2e-07, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/meta-llama/Llama-4-Scout-17B-16E-Instruct": { "input_cost_per_token": 9e-08, "litellm_provider": "nscale", "mode": "chat", "output_cost_per_token": 2.9e-07, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/mistralai/mixtral-8x22b-instruct-v0.1": { "input_cost_per_token": 6e-07, "litellm_provider": "nscale", "metadata": { "notes": "Pricing listed as $1.20/1M tokens total. Assumed 50/50 split for input/output." }, "mode": "chat", "output_cost_per_token": 6e-07, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#chat-models" }, "nscale/stabilityai/stable-diffusion-xl-base-1.0": { "input_cost_per_pixel": 3e-09, "litellm_provider": "nscale", "mode": "image_generation", "output_cost_per_pixel": 0.0, "source": "https://docs.nscale.com/docs/inference/serverless-models/current#image-models", "supported_endpoints": [ "/v1/images/generations" ] }, "o1": { "cache_read_input_token_cost": 7.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "o1-2024-12-17": { "cache_read_input_token_cost": 7.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "o1-mini": { "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 4.4e-06, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_vision": true }, "o1-mini-2024-09-12": { "deprecation_date": "2025-10-27", "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 3e-06, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.2e-05, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_vision": true }, "o1-preview": { "cache_read_input_token_cost": 7.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-05, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_vision": true }, "o1-preview-2024-09-12": { "cache_read_input_token_cost": 7.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "openai", "max_input_tokens": 128000, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-05, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_vision": true }, "o1-pro": { "input_cost_per_token": 0.00015, "input_cost_per_token_batches": 7.5e-05, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 0.0006, "output_cost_per_token_batches": 0.0003, "supported_endpoints": [ "/v1/responses", "/v1/batch" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "o1-pro-2025-03-19": { "input_cost_per_token": 0.00015, "input_cost_per_token_batches": 7.5e-05, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 0.0006, "output_cost_per_token_batches": 0.0003, "supported_endpoints": [ "/v1/responses", "/v1/batch" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": false, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "o3": { "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_flex": 2.5e-07, "cache_read_input_token_cost_priority": 8.75e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_flex": 1e-06, "input_cost_per_token_priority": 3.5e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8e-06, "output_cost_per_token_flex": 4e-06, "output_cost_per_token_priority": 1.4e-05, "supported_endpoints": [ "/v1/responses", "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "o3-2025-04-16": { "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 8e-06, "supported_endpoints": [ "/v1/responses", "/v1/chat/completions", "/v1/completions", "/v1/batch" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "o3-deep-research": { "cache_read_input_token_cost": 2.5e-06, "input_cost_per_token": 1e-05, "input_cost_per_token_batches": 5e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 4e-05, "output_cost_per_token_batches": 2e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "o3-deep-research-2025-06-26": { "cache_read_input_token_cost": 2.5e-06, "input_cost_per_token": 1e-05, "input_cost_per_token_batches": 5e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 4e-05, "output_cost_per_token_batches": 2e-05, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "o3-mini": { "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": false }, "o3-mini-2025-01-31": { "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": false }, "o3-pro": { "input_cost_per_token": 2e-05, "input_cost_per_token_batches": 1e-05, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 8e-05, "output_cost_per_token_batches": 4e-05, "supported_endpoints": [ "/v1/responses", "/v1/batch" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "o3-pro-2025-06-10": { "input_cost_per_token": 2e-05, "input_cost_per_token_batches": 1e-05, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 8e-05, "output_cost_per_token_batches": 4e-05, "supported_endpoints": [ "/v1/responses", "/v1/batch" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "o4-mini": { "cache_read_input_token_cost": 2.75e-07, "cache_read_input_token_cost_flex": 1.375e-07, "cache_read_input_token_cost_priority": 5e-07, "input_cost_per_token": 1.1e-06, "input_cost_per_token_flex": 5.5e-07, "input_cost_per_token_priority": 2e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, "output_cost_per_token_flex": 2.2e-06, "output_cost_per_token_priority": 8e-06, "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "o4-mini-2025-04-16": { "cache_read_input_token_cost": 2.75e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 4.4e-06, "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_service_tier": true, "supports_vision": true }, "o4-mini-deep-research": { "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 8e-06, "output_cost_per_token_batches": 4e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "o4-mini-deep-research-2025-06-26": { "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "input_cost_per_token_batches": 1e-06, "litellm_provider": "openai", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "responses", "output_cost_per_token": 8e-06, "output_cost_per_token_batches": 4e-06, "supported_endpoints": [ "/v1/chat/completions", "/v1/batch", "/v1/responses" ], "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_function_calling": true, "supports_native_streaming": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "oci/meta.llama-3.1-405b-instruct": { "input_cost_per_token": 1.068e-05, "litellm_provider": "oci", "max_input_tokens": 128000, "max_output_tokens": 4000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.068e-05, "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", "supports_function_calling": true, "supports_response_schema": false }, "oci/meta.llama-3.2-90b-vision-instruct": { "input_cost_per_token": 2e-06, "litellm_provider": "oci", "max_input_tokens": 128000, "max_output_tokens": 4000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", "supports_function_calling": true, "supports_response_schema": false }, "oci/meta.llama-3.3-70b-instruct": { "input_cost_per_token": 7.2e-07, "litellm_provider": "oci", "max_input_tokens": 128000, "max_output_tokens": 4000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 7.2e-07, "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", "supports_function_calling": true, "supports_response_schema": false }, "oci/meta.llama-4-maverick-17b-128e-instruct-fp8": { "input_cost_per_token": 7.2e-07, "litellm_provider": "oci", "max_input_tokens": 512000, "max_output_tokens": 4000, "max_tokens": 512000, "mode": "chat", "output_cost_per_token": 7.2e-07, "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", "supports_function_calling": true, "supports_response_schema": false }, "oci/meta.llama-4-scout-17b-16e-instruct": { "input_cost_per_token": 7.2e-07, "litellm_provider": "oci", "max_input_tokens": 192000, "max_output_tokens": 4000, "max_tokens": 192000, "mode": "chat", "output_cost_per_token": 7.2e-07, "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", "supports_function_calling": true, "supports_response_schema": false }, "oci/xai.grok-3": { "input_cost_per_token": 3e-06, "litellm_provider": "oci", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-07, "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", "supports_function_calling": true, "supports_response_schema": false }, "oci/xai.grok-3-fast": { "input_cost_per_token": 5e-06, "litellm_provider": "oci", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.5e-05, "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", "supports_function_calling": true, "supports_response_schema": false }, "oci/xai.grok-3-mini": { "input_cost_per_token": 3e-07, "litellm_provider": "oci", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 5e-07, "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", "supports_function_calling": true, "supports_response_schema": false }, "oci/xai.grok-3-mini-fast": { "input_cost_per_token": 6e-07, "litellm_provider": "oci", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4e-06, "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", "supports_function_calling": true, "supports_response_schema": false }, "oci/xai.grok-4": { "input_cost_per_token": 3e-06, "litellm_provider": "oci", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-07, "source": "https://www.oracle.com/artificial-intelligence/generative-ai/generative-ai-service/pricing", "supports_function_calling": true, "supports_response_schema": false }, "oci/cohere.command-latest": { "input_cost_per_token": 1.56e-06, "litellm_provider": "oci", "max_input_tokens": 128000, "max_output_tokens": 4000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.56e-06, "source": "https://www.oracle.com/cloud/ai/generative-ai/pricing/", "supports_function_calling": true, "supports_response_schema": false }, "oci/cohere.command-a-03-2025": { "input_cost_per_token": 1.56e-06, "litellm_provider": "oci", "max_input_tokens": 256000, "max_output_tokens": 4000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 1.56e-06, "source": "https://www.oracle.com/cloud/ai/generative-ai/pricing/", "supports_function_calling": true, "supports_response_schema": false }, "oci/cohere.command-plus-latest": { "input_cost_per_token": 1.56e-06, "litellm_provider": "oci", "max_input_tokens": 128000, "max_output_tokens": 4000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.56e-06, "source": "https://www.oracle.com/cloud/ai/generative-ai/pricing/", "supports_function_calling": true, "supports_response_schema": false }, "ollama/codegeex4": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 32768, "max_output_tokens": 8192, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": false }, "ollama/codegemma": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "completion", "output_cost_per_token": 0.0 }, "ollama/codellama": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 0.0 }, "ollama/deepseek-coder-v2-base": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "completion", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/deepseek-coder-v2-instruct": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 32768, "max_output_tokens": 8192, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/deepseek-coder-v2-lite-base": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "completion", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/deepseek-coder-v2-lite-instruct": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 32768, "max_output_tokens": 8192, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/deepseek-v3.1:671b-cloud" : { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 163840, "max_output_tokens": 163840, "max_tokens": 163840, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/gpt-oss:120b-cloud" : { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/gpt-oss:20b-cloud" : { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/internlm2_5-20b-chat": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 32768, "max_output_tokens": 8192, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/llama2": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.0 }, "ollama/llama2-uncensored": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 0.0 }, "ollama/llama2:13b": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.0 }, "ollama/llama2:70b": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.0 }, "ollama/llama2:7b": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.0 }, "ollama/llama3": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 0.0 }, "ollama/llama3.1": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/llama3:70b": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 0.0 }, "ollama/llama3:8b": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 0.0 }, "ollama/mistral": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "completion", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/mistral-7B-Instruct-v0.1": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/mistral-7B-Instruct-v0.2": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/mistral-large-instruct-2407": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 65536, "max_output_tokens": 8192, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/mixtral-8x22B-Instruct-v0.1": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 65536, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/mixtral-8x7B-Instruct-v0.1": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/orca-mini": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 0.0 }, "ollama/qwen3-coder:480b-cloud": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 262144, "max_output_tokens": 262144, "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 0.0, "supports_function_calling": true }, "ollama/vicuna": { "input_cost_per_token": 0.0, "litellm_provider": "ollama", "max_input_tokens": 2048, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "completion", "output_cost_per_token": 0.0 }, "omni-moderation-2024-09-26": { "input_cost_per_token": 0.0, "litellm_provider": "openai", "max_input_tokens": 32768, "max_output_tokens": 0, "max_tokens": 32768, "mode": "moderation", "output_cost_per_token": 0.0 }, "omni-moderation-latest": { "input_cost_per_token": 0.0, "litellm_provider": "openai", "max_input_tokens": 32768, "max_output_tokens": 0, "max_tokens": 32768, "mode": "moderation", "output_cost_per_token": 0.0 }, "omni-moderation-latest-intents": { "input_cost_per_token": 0.0, "litellm_provider": "openai", "max_input_tokens": 32768, "max_output_tokens": 0, "max_tokens": 32768, "mode": "moderation", "output_cost_per_token": 0.0 }, "openai.gpt-oss-120b-1:0": { "input_cost_per_token": 1.5e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "openai.gpt-oss-20b-1:0": { "input_cost_per_token": 7e-08, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3e-07, "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "openrouter/anthropic/claude-2": { "input_cost_per_token": 1.102e-05, "litellm_provider": "openrouter", "max_output_tokens": 8191, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 3.268e-05, "supports_tool_choice": true }, "openrouter/anthropic/claude-3-5-haiku": { "input_cost_per_token": 1e-06, "litellm_provider": "openrouter", "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 5e-06, "supports_function_calling": true, "supports_tool_choice": true }, "openrouter/anthropic/claude-3-5-haiku-20241022": { "input_cost_per_token": 1e-06, "litellm_provider": "openrouter", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5e-06, "supports_function_calling": true, "supports_tool_choice": true, "tool_use_system_prompt_tokens": 264 }, "openrouter/anthropic/claude-3-haiku": { "input_cost_per_image": 0.0004, "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 1.25e-06, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/anthropic/claude-3-haiku-20240307": { "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.25e-06, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 264 }, "openrouter/anthropic/claude-3-opus": { "input_cost_per_token": 1.5e-05, "litellm_provider": "openrouter", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 7.5e-05, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 395 }, "openrouter/anthropic/claude-3-sonnet": { "input_cost_per_image": 0.0048, "input_cost_per_token": 3e-06, "litellm_provider": "openrouter", "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/anthropic/claude-3.5-sonnet": { "input_cost_per_token": 3e-06, "litellm_provider": "openrouter", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "openrouter/anthropic/claude-3.5-sonnet:beta": { "input_cost_per_token": 3e-06, "litellm_provider": "openrouter", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_computer_use": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "openrouter/anthropic/claude-3.7-sonnet": { "input_cost_per_image": 0.0048, "input_cost_per_token": 3e-06, "litellm_provider": "openrouter", "max_input_tokens": 200000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "openrouter/anthropic/claude-3.7-sonnet:beta": { "input_cost_per_image": 0.0048, "input_cost_per_token": 3e-06, "litellm_provider": "openrouter", "max_input_tokens": 200000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_computer_use": true, "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "openrouter/anthropic/claude-instant-v1": { "input_cost_per_token": 1.63e-06, "litellm_provider": "openrouter", "max_output_tokens": 8191, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 5.51e-06, "supports_tool_choice": true }, "openrouter/anthropic/claude-opus-4": { "input_cost_per_image": 0.0048, "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "openrouter", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "openrouter/anthropic/claude-opus-4.1": { "input_cost_per_image": 0.0048, "cache_creation_input_token_cost": 1.875e-05, "cache_creation_input_token_cost_above_1hr": 3e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "openrouter", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "openrouter/anthropic/claude-sonnet-4": { "input_cost_per_image": 0.0048, "cache_creation_input_token_cost": 3.75e-06, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost": 3e-07, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "litellm_provider": "openrouter", "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "openrouter", "max_input_tokens": 1000000, "max_output_tokens": 1000000, "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "openrouter/anthropic/claude-haiku-4.5": { "cache_creation_input_token_cost": 1.25e-06, "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "litellm_provider": "openrouter", "max_input_tokens": 200000, "max_output_tokens": 200000, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 5e-06, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, "openrouter/bytedance/ui-tars-1.5-7b": { "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", "max_input_tokens": 131072, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "chat", "output_cost_per_token": 2e-07, "source": "https://openrouter.ai/api/v1/models/bytedance/ui-tars-1.5-7b", "supports_tool_choice": true }, "openrouter/cognitivecomputations/dolphin-mixtral-8x7b": { "input_cost_per_token": 5e-07, "litellm_provider": "openrouter", "max_tokens": 32769, "mode": "chat", "output_cost_per_token": 5e-07, "supports_tool_choice": true }, "openrouter/cohere/command-r-plus": { "input_cost_per_token": 3e-06, "litellm_provider": "openrouter", "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_tool_choice": true }, "openrouter/databricks/dbrx-instruct": { "input_cost_per_token": 6e-07, "litellm_provider": "openrouter", "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-07, "supports_tool_choice": true }, "openrouter/deepseek/deepseek-chat": { "input_cost_per_token": 1.4e-07, "litellm_provider": "openrouter", "max_input_tokens": 65536, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.8e-07, "supports_prompt_caching": true, "supports_tool_choice": true }, "openrouter/deepseek/deepseek-chat-v3-0324": { "input_cost_per_token": 1.4e-07, "litellm_provider": "openrouter", "max_input_tokens": 65536, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.8e-07, "supports_prompt_caching": true, "supports_tool_choice": true }, "openrouter/deepseek/deepseek-chat-v3.1": { "input_cost_per_token": 2e-07, "input_cost_per_token_cache_hit": 2e-08, "litellm_provider": "openrouter", "max_input_tokens": 163840, "max_output_tokens": 163840, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 8e-07, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, "openrouter/deepseek/deepseek-coder": { "input_cost_per_token": 1.4e-07, "litellm_provider": "openrouter", "max_input_tokens": 66000, "max_output_tokens": 4096, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.8e-07, "supports_prompt_caching": true, "supports_tool_choice": true }, "openrouter/deepseek/deepseek-r1": { "input_cost_per_token": 5.5e-07, "input_cost_per_token_cache_hit": 1.4e-07, "litellm_provider": "openrouter", "max_input_tokens": 65336, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.19e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, "openrouter/deepseek/deepseek-r1-0528": { "input_cost_per_token": 5e-07, "input_cost_per_token_cache_hit": 1.4e-07, "litellm_provider": "openrouter", "max_input_tokens": 65336, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.15e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, "openrouter/fireworks/firellava-13b": { "input_cost_per_token": 2e-07, "litellm_provider": "openrouter", "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2e-07, "supports_tool_choice": true }, "openrouter/google/gemini-2.0-flash-001": { "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 4e-07, "supports_audio_output": true, "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/google/gemini-2.5-flash": { "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 3e-07, "litellm_provider": "openrouter", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 2.5e-06, "supports_audio_output": true, "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/google/gemini-2.5-pro": { "input_cost_per_audio_token": 7e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", "max_audio_length_hours": 8.4, "max_audio_per_prompt": 1, "max_images_per_prompt": 3000, "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_pdf_size_mb": 30, "max_tokens": 8192, "max_video_length": 1, "max_videos_per_prompt": 10, "mode": "chat", "output_cost_per_token": 1e-05, "supports_audio_output": true, "supports_function_calling": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/google/gemini-pro-1.5": { "input_cost_per_image": 0.00265, "input_cost_per_token": 2.5e-06, "litellm_provider": "openrouter", "max_input_tokens": 1000000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 7.5e-06, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/google/gemini-pro-vision": { "input_cost_per_image": 0.0025, "input_cost_per_token": 1.25e-07, "litellm_provider": "openrouter", "max_tokens": 45875, "mode": "chat", "output_cost_per_token": 3.75e-07, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/google/palm-2-chat-bison": { "input_cost_per_token": 5e-07, "litellm_provider": "openrouter", "max_tokens": 25804, "mode": "chat", "output_cost_per_token": 5e-07, "supports_tool_choice": true }, "openrouter/google/palm-2-codechat-bison": { "input_cost_per_token": 5e-07, "litellm_provider": "openrouter", "max_tokens": 20070, "mode": "chat", "output_cost_per_token": 5e-07, "supports_tool_choice": true }, "openrouter/gryphe/mythomax-l2-13b": { "input_cost_per_token": 1.875e-06, "litellm_provider": "openrouter", "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.875e-06, "supports_tool_choice": true }, "openrouter/jondurbin/airoboros-l2-70b-2.1": { "input_cost_per_token": 1.3875e-05, "litellm_provider": "openrouter", "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.3875e-05, "supports_tool_choice": true }, "openrouter/mancer/weaver": { "input_cost_per_token": 5.625e-06, "litellm_provider": "openrouter", "max_tokens": 8000, "mode": "chat", "output_cost_per_token": 5.625e-06, "supports_tool_choice": true }, "openrouter/meta-llama/codellama-34b-instruct": { "input_cost_per_token": 5e-07, "litellm_provider": "openrouter", "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5e-07, "supports_tool_choice": true }, "openrouter/meta-llama/llama-2-13b-chat": { "input_cost_per_token": 2e-07, "litellm_provider": "openrouter", "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2e-07, "supports_tool_choice": true }, "openrouter/meta-llama/llama-2-70b-chat": { "input_cost_per_token": 1.5e-06, "litellm_provider": "openrouter", "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-06, "supports_tool_choice": true }, "openrouter/meta-llama/llama-3-70b-instruct": { "input_cost_per_token": 5.9e-07, "litellm_provider": "openrouter", "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 7.9e-07, "supports_tool_choice": true }, "openrouter/meta-llama/llama-3-70b-instruct:nitro": { "input_cost_per_token": 9e-07, "litellm_provider": "openrouter", "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 9e-07, "supports_tool_choice": true }, "openrouter/meta-llama/llama-3-8b-instruct:extended": { "input_cost_per_token": 2.25e-07, "litellm_provider": "openrouter", "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 2.25e-06, "supports_tool_choice": true }, "openrouter/meta-llama/llama-3-8b-instruct:free": { "input_cost_per_token": 0.0, "litellm_provider": "openrouter", "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 0.0, "supports_tool_choice": true }, "openrouter/microsoft/wizardlm-2-8x22b:nitro": { "input_cost_per_token": 1e-06, "litellm_provider": "openrouter", "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1e-06, "supports_tool_choice": true }, "openrouter/mistralai/mistral-7b-instruct": { "input_cost_per_token": 1.3e-07, "litellm_provider": "openrouter", "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.3e-07, "supports_tool_choice": true }, "openrouter/mistralai/mistral-7b-instruct:free": { "input_cost_per_token": 0.0, "litellm_provider": "openrouter", "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 0.0, "supports_tool_choice": true }, "openrouter/mistralai/mistral-large": { "input_cost_per_token": 8e-06, "litellm_provider": "openrouter", "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 2.4e-05, "supports_tool_choice": true }, "openrouter/mistralai/mistral-small-3.1-24b-instruct": { "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 3e-07, "supports_tool_choice": true }, "openrouter/mistralai/mistral-small-3.2-24b-instruct": { "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 3e-07, "supports_tool_choice": true }, "openrouter/mistralai/mixtral-8x22b-instruct": { "input_cost_per_token": 6.5e-07, "litellm_provider": "openrouter", "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 6.5e-07, "supports_tool_choice": true }, "openrouter/nousresearch/nous-hermes-llama2-13b": { "input_cost_per_token": 2e-07, "litellm_provider": "openrouter", "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2e-07, "supports_tool_choice": true }, "openrouter/openai/gpt-3.5-turbo": { "input_cost_per_token": 1.5e-06, "litellm_provider": "openrouter", "max_tokens": 4095, "mode": "chat", "output_cost_per_token": 2e-06, "supports_tool_choice": true }, "openrouter/openai/gpt-3.5-turbo-16k": { "input_cost_per_token": 3e-06, "litellm_provider": "openrouter", "max_tokens": 16383, "mode": "chat", "output_cost_per_token": 4e-06, "supports_tool_choice": true }, "openrouter/openai/gpt-4": { "input_cost_per_token": 3e-05, "litellm_provider": "openrouter", "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 6e-05, "supports_tool_choice": true }, "openrouter/openai/gpt-4-vision-preview": { "input_cost_per_image": 0.01445, "input_cost_per_token": 1e-05, "litellm_provider": "openrouter", "max_tokens": 130000, "mode": "chat", "output_cost_per_token": 3e-05, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/openai/gpt-4.1": { "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "openrouter", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 8e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/openai/gpt-4.1-2025-04-14": { "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "openrouter", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 8e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/openai/gpt-4.1-mini": { "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 4e-07, "litellm_provider": "openrouter", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1.6e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/openai/gpt-4.1-mini-2025-04-14": { "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 4e-07, "litellm_provider": "openrouter", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1.6e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/openai/gpt-4.1-nano": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/openai/gpt-4.1-nano-2025-04-14": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/openai/gpt-4o": { "input_cost_per_token": 2.5e-06, "litellm_provider": "openrouter", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/openai/gpt-4o-2024-05-13": { "input_cost_per_token": 5e-06, "litellm_provider": "openrouter", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/openai/gpt-5-chat": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_reasoning": true, "supports_tool_choice": true }, "openrouter/openai/gpt-5-codex": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_reasoning": true, "supports_tool_choice": true }, "openrouter/openai/gpt-5": { "cache_read_input_token_cost": 1.25e-07, "input_cost_per_token": 1.25e-06, "litellm_provider": "openrouter", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05, "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_reasoning": true, "supports_tool_choice": true }, "openrouter/openai/gpt-5-mini": { "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_reasoning": true, "supports_tool_choice": true }, "openrouter/openai/gpt-5-nano": { "cache_read_input_token_cost": 5e-09, "input_cost_per_token": 5e-08, "litellm_provider": "openrouter", "max_input_tokens": 272000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-07, "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text" ], "supports_reasoning": true, "supports_tool_choice": true }, "openrouter/openai/gpt-oss-120b": { "input_cost_per_token": 1.8e-07, "litellm_provider": "openrouter", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 8e-07, "source": "https://openrouter.ai/openai/gpt-oss-120b", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "openrouter/openai/gpt-oss-20b": { "input_cost_per_token": 1.8e-07, "litellm_provider": "openrouter", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 8e-07, "source": "https://openrouter.ai/openai/gpt-oss-20b", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "openrouter/openai/o1": { "cache_read_input_token_cost": 7.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "openrouter", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 100000, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "openrouter/openai/o1-mini": { "input_cost_per_token": 3e-06, "litellm_provider": "openrouter", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.2e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_vision": false }, "openrouter/openai/o1-mini-2024-09-12": { "input_cost_per_token": 3e-06, "litellm_provider": "openrouter", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.2e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_vision": false }, "openrouter/openai/o1-preview": { "input_cost_per_token": 1.5e-05, "litellm_provider": "openrouter", "max_input_tokens": 128000, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_vision": false }, "openrouter/openai/o1-preview-2024-09-12": { "input_cost_per_token": 1.5e-05, "litellm_provider": "openrouter", "max_input_tokens": 128000, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true, "supports_vision": false }, "openrouter/openai/o3-mini": { "input_cost_per_token": 1.1e-06, "litellm_provider": "openrouter", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 4.4e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": false }, "openrouter/openai/o3-mini-high": { "input_cost_per_token": 1.1e-06, "litellm_provider": "openrouter", "max_input_tokens": 128000, "max_output_tokens": 65536, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 4.4e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": false }, "openrouter/pygmalionai/mythalion-13b": { "input_cost_per_token": 1.875e-06, "litellm_provider": "openrouter", "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.875e-06, "supports_tool_choice": true }, "openrouter/qwen/qwen-2.5-coder-32b-instruct": { "input_cost_per_token": 1.8e-07, "litellm_provider": "openrouter", "max_input_tokens": 33792, "max_output_tokens": 33792, "max_tokens": 33792, "mode": "chat", "output_cost_per_token": 1.8e-07, "supports_tool_choice": true }, "openrouter/qwen/qwen-vl-plus": { "input_cost_per_token": 2.1e-07, "litellm_provider": "openrouter", "max_input_tokens": 8192, "max_output_tokens": 2048, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 6.3e-07, "supports_tool_choice": true }, "openrouter/qwen/qwen3-coder": { "input_cost_per_token": 1e-06, "litellm_provider": "openrouter", "max_input_tokens": 1000000, "max_output_tokens": 1000000, "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 5e-06, "source": "https://openrouter.ai/qwen/qwen3-coder", "supports_tool_choice": true }, "openrouter/switchpoint/router": { "input_cost_per_token": 8.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3.4e-06, "source": "https://openrouter.ai/switchpoint/router", "supports_tool_choice": true }, "openrouter/undi95/remm-slerp-l2-13b": { "input_cost_per_token": 1.875e-06, "litellm_provider": "openrouter", "max_tokens": 6144, "mode": "chat", "output_cost_per_token": 1.875e-06, "supports_tool_choice": true }, "openrouter/x-ai/grok-4": { "input_cost_per_token": 3e-06, "litellm_provider": "openrouter", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 1.5e-05, "source": "https://openrouter.ai/x-ai/grok-4", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_web_search": true }, "openrouter/x-ai/grok-4-fast:free": { "input_cost_per_token": 0, "litellm_provider": "openrouter", "max_input_tokens": 2000000, "max_output_tokens": 30000, "max_tokens": 2000000, "mode": "chat", "output_cost_per_token": 0, "source": "https://openrouter.ai/x-ai/grok-4-fast:free", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_web_search": false }, "ovhcloud/DeepSeek-R1-Distill-Llama-70B": { "input_cost_per_token": 6.7e-07, "litellm_provider": "ovhcloud", "max_input_tokens": 131000, "max_output_tokens": 131000, "max_tokens": 131000, "mode": "chat", "output_cost_per_token": 6.7e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/deepseek-r1-distill-llama-70b", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "ovhcloud/Llama-3.1-8B-Instruct": { "input_cost_per_token": 1e-07, "litellm_provider": "ovhcloud", "max_input_tokens": 131000, "max_output_tokens": 131000, "max_tokens": 131000, "mode": "chat", "output_cost_per_token": 1e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/llama-3-1-8b-instruct", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "ovhcloud/Meta-Llama-3_1-70B-Instruct": { "input_cost_per_token": 6.7e-07, "litellm_provider": "ovhcloud", "max_input_tokens": 131000, "max_output_tokens": 131000, "max_tokens": 131000, "mode": "chat", "output_cost_per_token": 6.7e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/meta-llama-3-1-70b-instruct", "supports_function_calling": false, "supports_response_schema": false, "supports_tool_choice": false }, "ovhcloud/Meta-Llama-3_3-70B-Instruct": { "input_cost_per_token": 6.7e-07, "litellm_provider": "ovhcloud", "max_input_tokens": 131000, "max_output_tokens": 131000, "max_tokens": 131000, "mode": "chat", "output_cost_per_token": 6.7e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/meta-llama-3-3-70b-instruct", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "ovhcloud/Mistral-7B-Instruct-v0.3": { "input_cost_per_token": 1e-07, "litellm_provider": "ovhcloud", "max_input_tokens": 127000, "max_output_tokens": 127000, "max_tokens": 127000, "mode": "chat", "output_cost_per_token": 1e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/mistral-7b-instruct-v0-3", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "ovhcloud/Mistral-Nemo-Instruct-2407": { "input_cost_per_token": 1.3e-07, "litellm_provider": "ovhcloud", "max_input_tokens": 118000, "max_output_tokens": 118000, "max_tokens": 118000, "mode": "chat", "output_cost_per_token": 1.3e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/mistral-nemo-instruct-2407", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "ovhcloud/Mistral-Small-3.2-24B-Instruct-2506": { "input_cost_per_token": 9e-08, "litellm_provider": "ovhcloud", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.8e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/mistral-small-3-2-24b-instruct-2506", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "ovhcloud/Mixtral-8x7B-Instruct-v0.1": { "input_cost_per_token": 6.3e-07, "litellm_provider": "ovhcloud", "max_input_tokens": 32000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 6.3e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/mixtral-8x7b-instruct-v0-1", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false }, "ovhcloud/Qwen2.5-Coder-32B-Instruct": { "input_cost_per_token": 8.7e-07, "litellm_provider": "ovhcloud", "max_input_tokens": 32000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 8.7e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/qwen2-5-coder-32b-instruct", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false }, "ovhcloud/Qwen2.5-VL-72B-Instruct": { "input_cost_per_token": 9.1e-07, "litellm_provider": "ovhcloud", "max_input_tokens": 32000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 9.1e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/qwen2-5-vl-72b-instruct", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false, "supports_vision": true }, "ovhcloud/Qwen3-32B": { "input_cost_per_token": 8e-08, "litellm_provider": "ovhcloud", "max_input_tokens": 32000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 2.3e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/qwen3-32b", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "ovhcloud/gpt-oss-120b": { "input_cost_per_token": 8e-08, "litellm_provider": "ovhcloud", "max_input_tokens": 131000, "max_output_tokens": 131000, "max_tokens": 131000, "mode": "chat", "output_cost_per_token": 4e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/gpt-oss-120b", "supports_function_calling": false, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": false }, "ovhcloud/gpt-oss-20b": { "input_cost_per_token": 4e-08, "litellm_provider": "ovhcloud", "max_input_tokens": 131000, "max_output_tokens": 131000, "max_tokens": 131000, "mode": "chat", "output_cost_per_token": 1.5e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/gpt-oss-20b", "supports_function_calling": false, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": false }, "ovhcloud/llava-v1.6-mistral-7b-hf": { "input_cost_per_token": 2.9e-07, "litellm_provider": "ovhcloud", "max_input_tokens": 32000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 2.9e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/llava-next-mistral-7b", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false, "supports_vision": true }, "ovhcloud/mamba-codestral-7B-v0.1": { "input_cost_per_token": 1.9e-07, "litellm_provider": "ovhcloud", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 1.9e-07, "source": "https://endpoints.ai.cloud.ovh.net/models/mamba-codestral-7b-v0-1", "supports_function_calling": false, "supports_response_schema": true, "supports_tool_choice": false }, "palm/chat-bison": { "input_cost_per_token": 1.25e-07, "litellm_provider": "palm", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "palm/chat-bison-001": { "input_cost_per_token": 1.25e-07, "litellm_provider": "palm", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "palm/text-bison": { "input_cost_per_token": 1.25e-07, "litellm_provider": "palm", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "palm/text-bison-001": { "input_cost_per_token": 1.25e-07, "litellm_provider": "palm", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "palm/text-bison-safety-off": { "input_cost_per_token": 1.25e-07, "litellm_provider": "palm", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "palm/text-bison-safety-recitation-off": { "input_cost_per_token": 1.25e-07, "litellm_provider": "palm", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "parallel_ai/search": { "input_cost_per_query": 0.004, "litellm_provider": "parallel_ai", "mode": "search" }, "parallel_ai/search-pro": { "input_cost_per_query": 0.009, "litellm_provider": "parallel_ai", "mode": "search" }, "perplexity/codellama-34b-instruct": { "input_cost_per_token": 3.5e-07, "litellm_provider": "perplexity", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.4e-06 }, "perplexity/codellama-70b-instruct": { "input_cost_per_token": 7e-07, "litellm_provider": "perplexity", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 2.8e-06 }, "perplexity/llama-2-70b-chat": { "input_cost_per_token": 7e-07, "litellm_provider": "perplexity", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.8e-06 }, "perplexity/llama-3.1-70b-instruct": { "input_cost_per_token": 1e-06, "litellm_provider": "perplexity", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1e-06 }, "perplexity/llama-3.1-8b-instruct": { "input_cost_per_token": 2e-07, "litellm_provider": "perplexity", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-07 }, "perplexity/llama-3.1-sonar-huge-128k-online": { "deprecation_date": "2025-02-22", "input_cost_per_token": 5e-06, "litellm_provider": "perplexity", "max_input_tokens": 127072, "max_output_tokens": 127072, "max_tokens": 127072, "mode": "chat", "output_cost_per_token": 5e-06 }, "perplexity/llama-3.1-sonar-large-128k-chat": { "deprecation_date": "2025-02-22", "input_cost_per_token": 1e-06, "litellm_provider": "perplexity", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1e-06 }, "perplexity/llama-3.1-sonar-large-128k-online": { "deprecation_date": "2025-02-22", "input_cost_per_token": 1e-06, "litellm_provider": "perplexity", "max_input_tokens": 127072, "max_output_tokens": 127072, "max_tokens": 127072, "mode": "chat", "output_cost_per_token": 1e-06 }, "perplexity/llama-3.1-sonar-small-128k-chat": { "deprecation_date": "2025-02-22", "input_cost_per_token": 2e-07, "litellm_provider": "perplexity", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2e-07 }, "perplexity/llama-3.1-sonar-small-128k-online": { "deprecation_date": "2025-02-22", "input_cost_per_token": 2e-07, "litellm_provider": "perplexity", "max_input_tokens": 127072, "max_output_tokens": 127072, "max_tokens": 127072, "mode": "chat", "output_cost_per_token": 2e-07 }, "perplexity/mistral-7b-instruct": { "input_cost_per_token": 7e-08, "litellm_provider": "perplexity", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.8e-07 }, "perplexity/mixtral-8x7b-instruct": { "input_cost_per_token": 7e-08, "litellm_provider": "perplexity", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.8e-07 }, "perplexity/pplx-70b-chat": { "input_cost_per_token": 7e-07, "litellm_provider": "perplexity", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.8e-06 }, "perplexity/pplx-70b-online": { "input_cost_per_request": 0.005, "input_cost_per_token": 0.0, "litellm_provider": "perplexity", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.8e-06 }, "perplexity/pplx-7b-chat": { "input_cost_per_token": 7e-08, "litellm_provider": "perplexity", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.8e-07 }, "perplexity/pplx-7b-online": { "input_cost_per_request": 0.005, "input_cost_per_token": 0.0, "litellm_provider": "perplexity", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.8e-07 }, "perplexity/sonar": { "input_cost_per_token": 1e-06, "litellm_provider": "perplexity", "max_input_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-06, "search_context_cost_per_query": { "search_context_size_high": 0.012, "search_context_size_low": 0.005, "search_context_size_medium": 0.008 }, "supports_web_search": true }, "perplexity/sonar-deep-research": { "citation_cost_per_token": 2e-06, "input_cost_per_token": 2e-06, "litellm_provider": "perplexity", "max_input_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_reasoning_token": 3e-06, "output_cost_per_token": 8e-06, "search_context_cost_per_query": { "search_context_size_high": 0.005, "search_context_size_low": 0.005, "search_context_size_medium": 0.005 }, "supports_reasoning": true, "supports_web_search": true }, "perplexity/sonar-medium-chat": { "input_cost_per_token": 6e-07, "litellm_provider": "perplexity", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1.8e-06 }, "perplexity/sonar-medium-online": { "input_cost_per_request": 0.005, "input_cost_per_token": 0, "litellm_provider": "perplexity", "max_input_tokens": 12000, "max_output_tokens": 12000, "max_tokens": 12000, "mode": "chat", "output_cost_per_token": 1.8e-06 }, "perplexity/sonar-pro": { "input_cost_per_token": 3e-06, "litellm_provider": "perplexity", "max_input_tokens": 200000, "max_output_tokens": 8000, "max_tokens": 8000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.014, "search_context_size_low": 0.006, "search_context_size_medium": 0.01 }, "supports_web_search": true }, "perplexity/sonar-reasoning": { "input_cost_per_token": 1e-06, "litellm_provider": "perplexity", "max_input_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 5e-06, "search_context_cost_per_query": { "search_context_size_high": 0.014, "search_context_size_low": 0.005, "search_context_size_medium": 0.008 }, "supports_reasoning": true, "supports_web_search": true }, "perplexity/sonar-reasoning-pro": { "input_cost_per_token": 2e-06, "litellm_provider": "perplexity", "max_input_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 8e-06, "search_context_cost_per_query": { "search_context_size_high": 0.014, "search_context_size_low": 0.006, "search_context_size_medium": 0.01 }, "supports_reasoning": true, "supports_web_search": true }, "perplexity/sonar-small-chat": { "input_cost_per_token": 7e-08, "litellm_provider": "perplexity", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 2.8e-07 }, "perplexity/sonar-small-online": { "input_cost_per_request": 0.005, "input_cost_per_token": 0, "litellm_provider": "perplexity", "max_input_tokens": 12000, "max_output_tokens": 12000, "max_tokens": 12000, "mode": "chat", "output_cost_per_token": 2.8e-07 }, "qwen.qwen3-coder-480b-a35b-v1:0": { "input_cost_per_token": 2.2e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 262000, "max_output_tokens": 65536, "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 1.8e-06, "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "qwen.qwen3-235b-a22b-2507-v1:0": { "input_cost_per_token": 2.2e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 262144, "max_output_tokens": 131072, "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 8.8e-07, "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "qwen.qwen3-coder-30b-a3b-v1:0": { "input_cost_per_token": 1.5e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 262144, "max_output_tokens": 131072, "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 6.0e-07, "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "qwen.qwen3-32b-v1:0": { "input_cost_per_token": 1.5e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 131072, "max_output_tokens": 16384, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6.0e-07, "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "recraft/recraftv2": { "litellm_provider": "recraft", "mode": "image_generation", "output_cost_per_image": 0.022, "source": "https://www.recraft.ai/docs#pricing", "supported_endpoints": [ "/v1/images/generations" ] }, "recraft/recraftv3": { "litellm_provider": "recraft", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://www.recraft.ai/docs#pricing", "supported_endpoints": [ "/v1/images/generations" ] }, "replicate/meta/llama-2-13b": { "input_cost_per_token": 1e-07, "litellm_provider": "replicate", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5e-07, "supports_tool_choice": true }, "replicate/meta/llama-2-13b-chat": { "input_cost_per_token": 1e-07, "litellm_provider": "replicate", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5e-07, "supports_tool_choice": true }, "replicate/meta/llama-2-70b": { "input_cost_per_token": 6.5e-07, "litellm_provider": "replicate", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.75e-06, "supports_tool_choice": true }, "replicate/meta/llama-2-70b-chat": { "input_cost_per_token": 6.5e-07, "litellm_provider": "replicate", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.75e-06, "supports_tool_choice": true }, "replicate/meta/llama-2-7b": { "input_cost_per_token": 5e-08, "litellm_provider": "replicate", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.5e-07, "supports_tool_choice": true }, "replicate/meta/llama-2-7b-chat": { "input_cost_per_token": 5e-08, "litellm_provider": "replicate", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.5e-07, "supports_tool_choice": true }, "replicate/meta/llama-3-70b": { "input_cost_per_token": 6.5e-07, "litellm_provider": "replicate", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.75e-06, "supports_tool_choice": true }, "replicate/meta/llama-3-70b-instruct": { "input_cost_per_token": 6.5e-07, "litellm_provider": "replicate", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2.75e-06, "supports_tool_choice": true }, "replicate/meta/llama-3-8b": { "input_cost_per_token": 5e-08, "litellm_provider": "replicate", "max_input_tokens": 8086, "max_output_tokens": 8086, "max_tokens": 8086, "mode": "chat", "output_cost_per_token": 2.5e-07, "supports_tool_choice": true }, "replicate/meta/llama-3-8b-instruct": { "input_cost_per_token": 5e-08, "litellm_provider": "replicate", "max_input_tokens": 8086, "max_output_tokens": 8086, "max_tokens": 8086, "mode": "chat", "output_cost_per_token": 2.5e-07, "supports_tool_choice": true }, "replicate/mistralai/mistral-7b-instruct-v0.2": { "input_cost_per_token": 5e-08, "litellm_provider": "replicate", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.5e-07, "supports_tool_choice": true }, "replicate/mistralai/mistral-7b-v0.1": { "input_cost_per_token": 5e-08, "litellm_provider": "replicate", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 2.5e-07, "supports_tool_choice": true }, "replicate/mistralai/mixtral-8x7b-instruct-v0.1": { "input_cost_per_token": 3e-07, "litellm_provider": "replicate", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1e-06, "supports_tool_choice": true }, "rerank-english-v2.0": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, "litellm_provider": "cohere", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_query_tokens": 2048, "max_tokens": 4096, "mode": "rerank", "output_cost_per_token": 0.0 }, "rerank-english-v3.0": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, "litellm_provider": "cohere", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_query_tokens": 2048, "max_tokens": 4096, "mode": "rerank", "output_cost_per_token": 0.0 }, "rerank-multilingual-v2.0": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, "litellm_provider": "cohere", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_query_tokens": 2048, "max_tokens": 4096, "mode": "rerank", "output_cost_per_token": 0.0 }, "rerank-multilingual-v3.0": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, "litellm_provider": "cohere", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_query_tokens": 2048, "max_tokens": 4096, "mode": "rerank", "output_cost_per_token": 0.0 }, "rerank-v3.5": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, "litellm_provider": "cohere", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_query_tokens": 2048, "max_tokens": 4096, "mode": "rerank", "output_cost_per_token": 0.0 }, "nvidia_nim/nvidia/nv-rerankqa-mistral-4b-v3": { "input_cost_per_query": 0.0, "input_cost_per_token": 0.0, "litellm_provider": "nvidia_nim", "mode": "rerank", "output_cost_per_token": 0.0 }, "nvidia_nim/nvidia/llama-3_2-nv-rerankqa-1b-v2": { "input_cost_per_query": 0.0, "input_cost_per_token": 0.0, "litellm_provider": "nvidia_nim", "mode": "rerank", "output_cost_per_token": 0.0 }, "sagemaker/meta-textgeneration-llama-2-13b": { "input_cost_per_token": 0.0, "litellm_provider": "sagemaker", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 0.0 }, "sagemaker/meta-textgeneration-llama-2-13b-f": { "input_cost_per_token": 0.0, "litellm_provider": "sagemaker", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.0 }, "sagemaker/meta-textgeneration-llama-2-70b": { "input_cost_per_token": 0.0, "litellm_provider": "sagemaker", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 0.0 }, "sagemaker/meta-textgeneration-llama-2-70b-b-f": { "input_cost_per_token": 0.0, "litellm_provider": "sagemaker", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.0 }, "sagemaker/meta-textgeneration-llama-2-7b": { "input_cost_per_token": 0.0, "litellm_provider": "sagemaker", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "completion", "output_cost_per_token": 0.0 }, "sagemaker/meta-textgeneration-llama-2-7b-f": { "input_cost_per_token": 0.0, "litellm_provider": "sagemaker", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.0 }, "sambanova/DeepSeek-R1": { "input_cost_per_token": 5e-06, "litellm_provider": "sambanova", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 7e-06, "source": "https://cloud.sambanova.ai/plans/pricing" }, "sambanova/DeepSeek-R1-Distill-Llama-70B": { "input_cost_per_token": 7e-07, "litellm_provider": "sambanova", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.4e-06, "source": "https://cloud.sambanova.ai/plans/pricing" }, "sambanova/DeepSeek-V3-0324": { "input_cost_per_token": 3e-06, "litellm_provider": "sambanova", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4.5e-06, "source": "https://cloud.sambanova.ai/plans/pricing", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "sambanova/Llama-4-Maverick-17B-128E-Instruct": { "input_cost_per_token": 6.3e-07, "litellm_provider": "sambanova", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "metadata": { "notes": "For vision models, images are converted to 6432 input tokens and are billed at that amount" }, "mode": "chat", "output_cost_per_token": 1.8e-06, "source": "https://cloud.sambanova.ai/plans/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "sambanova/Llama-4-Scout-17B-16E-Instruct": { "input_cost_per_token": 4e-07, "litellm_provider": "sambanova", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "metadata": { "notes": "For vision models, images are converted to 6432 input tokens and are billed at that amount" }, "mode": "chat", "output_cost_per_token": 7e-07, "source": "https://cloud.sambanova.ai/plans/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "sambanova/Meta-Llama-3.1-405B-Instruct": { "input_cost_per_token": 5e-06, "litellm_provider": "sambanova", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-05, "source": "https://cloud.sambanova.ai/plans/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "sambanova/Meta-Llama-3.1-8B-Instruct": { "input_cost_per_token": 1e-07, "litellm_provider": "sambanova", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 2e-07, "source": "https://cloud.sambanova.ai/plans/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "sambanova/Meta-Llama-3.2-1B-Instruct": { "input_cost_per_token": 4e-08, "litellm_provider": "sambanova", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 8e-08, "source": "https://cloud.sambanova.ai/plans/pricing" }, "sambanova/Meta-Llama-3.2-3B-Instruct": { "input_cost_per_token": 8e-08, "litellm_provider": "sambanova", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.6e-07, "source": "https://cloud.sambanova.ai/plans/pricing" }, "sambanova/Meta-Llama-3.3-70B-Instruct": { "input_cost_per_token": 6e-07, "litellm_provider": "sambanova", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.2e-06, "source": "https://cloud.sambanova.ai/plans/pricing", "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "sambanova/Meta-Llama-Guard-3-8B": { "input_cost_per_token": 3e-07, "litellm_provider": "sambanova", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 3e-07, "source": "https://cloud.sambanova.ai/plans/pricing" }, "sambanova/QwQ-32B": { "input_cost_per_token": 5e-07, "litellm_provider": "sambanova", "max_input_tokens": 16384, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-06, "source": "https://cloud.sambanova.ai/plans/pricing" }, "sambanova/Qwen2-Audio-7B-Instruct": { "input_cost_per_token": 5e-07, "litellm_provider": "sambanova", "max_input_tokens": 4096, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 0.0001, "source": "https://cloud.sambanova.ai/plans/pricing", "supports_audio_input": true }, "sambanova/Qwen3-32B": { "input_cost_per_token": 4e-07, "litellm_provider": "sambanova", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 8e-07, "source": "https://cloud.sambanova.ai/plans/pricing", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "sambanova/DeepSeek-V3.1": { "max_tokens": 32768, "max_input_tokens": 32768, "max_output_tokens": 32768, "input_cost_per_token": 3e-06, "output_cost_per_token": 4.5e-06, "litellm_provider": "sambanova", "mode": "chat", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "source": "https://cloud.sambanova.ai/plans/pricing" }, "sambanova/gpt-oss-120b": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 3e-06, "output_cost_per_token": 4.5e-06, "litellm_provider": "sambanova", "mode": "chat", "supports_function_calling": true, "supports_tool_choice": true, "supports_reasoning": true, "source": "https://cloud.sambanova.ai/plans/pricing" }, "snowflake/claude-3-5-sonnet": { "litellm_provider": "snowflake", "max_input_tokens": 18000, "max_output_tokens": 8192, "max_tokens": 18000, "mode": "chat", "supports_computer_use": true }, "snowflake/deepseek-r1": { "litellm_provider": "snowflake", "max_input_tokens": 32768, "max_output_tokens": 8192, "max_tokens": 32768, "mode": "chat", "supports_reasoning": true }, "snowflake/gemma-7b": { "litellm_provider": "snowflake", "max_input_tokens": 8000, "max_output_tokens": 8192, "max_tokens": 8000, "mode": "chat" }, "snowflake/jamba-1.5-large": { "litellm_provider": "snowflake", "max_input_tokens": 256000, "max_output_tokens": 8192, "max_tokens": 256000, "mode": "chat" }, "snowflake/jamba-1.5-mini": { "litellm_provider": "snowflake", "max_input_tokens": 256000, "max_output_tokens": 8192, "max_tokens": 256000, "mode": "chat" }, "snowflake/jamba-instruct": { "litellm_provider": "snowflake", "max_input_tokens": 256000, "max_output_tokens": 8192, "max_tokens": 256000, "mode": "chat" }, "snowflake/llama2-70b-chat": { "litellm_provider": "snowflake", "max_input_tokens": 4096, "max_output_tokens": 8192, "max_tokens": 4096, "mode": "chat" }, "snowflake/llama3-70b": { "litellm_provider": "snowflake", "max_input_tokens": 8000, "max_output_tokens": 8192, "max_tokens": 8000, "mode": "chat" }, "snowflake/llama3-8b": { "litellm_provider": "snowflake", "max_input_tokens": 8000, "max_output_tokens": 8192, "max_tokens": 8000, "mode": "chat" }, "snowflake/llama3.1-405b": { "litellm_provider": "snowflake", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat" }, "snowflake/llama3.1-70b": { "litellm_provider": "snowflake", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat" }, "snowflake/llama3.1-8b": { "litellm_provider": "snowflake", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat" }, "snowflake/llama3.2-1b": { "litellm_provider": "snowflake", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat" }, "snowflake/llama3.2-3b": { "litellm_provider": "snowflake", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat" }, "snowflake/llama3.3-70b": { "litellm_provider": "snowflake", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat" }, "snowflake/mistral-7b": { "litellm_provider": "snowflake", "max_input_tokens": 32000, "max_output_tokens": 8192, "max_tokens": 32000, "mode": "chat" }, "snowflake/mistral-large": { "litellm_provider": "snowflake", "max_input_tokens": 32000, "max_output_tokens": 8192, "max_tokens": 32000, "mode": "chat" }, "snowflake/mistral-large2": { "litellm_provider": "snowflake", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat" }, "snowflake/mixtral-8x7b": { "litellm_provider": "snowflake", "max_input_tokens": 32000, "max_output_tokens": 8192, "max_tokens": 32000, "mode": "chat" }, "snowflake/reka-core": { "litellm_provider": "snowflake", "max_input_tokens": 32000, "max_output_tokens": 8192, "max_tokens": 32000, "mode": "chat" }, "snowflake/reka-flash": { "litellm_provider": "snowflake", "max_input_tokens": 100000, "max_output_tokens": 8192, "max_tokens": 100000, "mode": "chat" }, "snowflake/snowflake-arctic": { "litellm_provider": "snowflake", "max_input_tokens": 4096, "max_output_tokens": 8192, "max_tokens": 4096, "mode": "chat" }, "snowflake/snowflake-llama-3.1-405b": { "litellm_provider": "snowflake", "max_input_tokens": 8000, "max_output_tokens": 8192, "max_tokens": 8000, "mode": "chat" }, "snowflake/snowflake-llama-3.3-70b": { "litellm_provider": "snowflake", "max_input_tokens": 8000, "max_output_tokens": 8192, "max_tokens": 8000, "mode": "chat" }, "stability.sd3-5-large-v1:0": { "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "image_generation", "output_cost_per_image": 0.08 }, "stability.sd3-large-v1:0": { "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "image_generation", "output_cost_per_image": 0.08 }, "stability.stable-image-core-v1:0": { "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "image_generation", "output_cost_per_image": 0.04 }, "stability.stable-image-core-v1:1": { "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "image_generation", "output_cost_per_image": 0.04 }, "stability.stable-image-ultra-v1:0": { "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "image_generation", "output_cost_per_image": 0.14 }, "stability.stable-image-ultra-v1:1": { "litellm_provider": "bedrock", "max_input_tokens": 77, "max_tokens": 77, "mode": "image_generation", "output_cost_per_image": 0.14 }, "standard/1024-x-1024/dall-e-3": { "input_cost_per_pixel": 3.81469e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0 }, "standard/1024-x-1792/dall-e-3": { "input_cost_per_pixel": 4.359e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0 }, "standard/1792-x-1024/dall-e-3": { "input_cost_per_pixel": 4.359e-08, "litellm_provider": "openai", "mode": "image_generation", "output_cost_per_pixel": 0.0 }, "tavily/search": { "input_cost_per_query": 0.008, "litellm_provider": "tavily", "mode": "search" }, "tavily/search-advanced": { "input_cost_per_query": 0.016, "litellm_provider": "tavily", "mode": "search" }, "text-bison": { "input_cost_per_character": 2.5e-07, "litellm_provider": "vertex_ai-text-models", "max_input_tokens": 8192, "max_output_tokens": 2048, "max_tokens": 2048, "mode": "completion", "output_cost_per_character": 5e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "text-bison32k": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-text-models", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "text-bison32k@002": { "input_cost_per_character": 2.5e-07, "input_cost_per_token": 1.25e-07, "litellm_provider": "vertex_ai-text-models", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_character": 5e-07, "output_cost_per_token": 1.25e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "text-bison@001": { "input_cost_per_character": 2.5e-07, "litellm_provider": "vertex_ai-text-models", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_character": 5e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "text-bison@002": { "input_cost_per_character": 2.5e-07, "litellm_provider": "vertex_ai-text-models", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_character": 5e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "text-completion-codestral/codestral-2405": { "input_cost_per_token": 0.0, "litellm_provider": "text-completion-codestral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "completion", "output_cost_per_token": 0.0, "source": "https://docs.mistral.ai/capabilities/code_generation/" }, "text-completion-codestral/codestral-latest": { "input_cost_per_token": 0.0, "litellm_provider": "text-completion-codestral", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "completion", "output_cost_per_token": 0.0, "source": "https://docs.mistral.ai/capabilities/code_generation/" }, "text-embedding-004": { "input_cost_per_character": 2.5e-08, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 2048, "max_tokens": 2048, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models" }, "text-embedding-005": { "input_cost_per_character": 2.5e-08, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 2048, "max_tokens": 2048, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models" }, "text-embedding-3-large": { "input_cost_per_token": 1.3e-07, "input_cost_per_token_batches": 6.5e-08, "litellm_provider": "openai", "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", "output_cost_per_token": 0.0, "output_cost_per_token_batches": 0.0, "output_vector_size": 3072 }, "text-embedding-3-small": { "input_cost_per_token": 2e-08, "input_cost_per_token_batches": 1e-08, "litellm_provider": "openai", "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", "output_cost_per_token": 0.0, "output_cost_per_token_batches": 0.0, "output_vector_size": 1536 }, "text-embedding-ada-002": { "input_cost_per_token": 1e-07, "litellm_provider": "openai", "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 1536 }, "text-embedding-ada-002-v2": { "input_cost_per_token": 1e-07, "input_cost_per_token_batches": 5e-08, "litellm_provider": "openai", "max_input_tokens": 8191, "max_tokens": 8191, "mode": "embedding", "output_cost_per_token": 0.0, "output_cost_per_token_batches": 0.0 }, "text-embedding-large-exp-03-07": { "input_cost_per_character": 2.5e-08, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 8192, "max_tokens": 8192, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 3072, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models" }, "text-embedding-preview-0409": { "input_cost_per_token": 6.25e-09, "input_cost_per_token_batch_requests": 5e-09, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 3072, "max_tokens": 3072, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "text-moderation-007": { "input_cost_per_token": 0.0, "litellm_provider": "openai", "max_input_tokens": 32768, "max_output_tokens": 0, "max_tokens": 32768, "mode": "moderation", "output_cost_per_token": 0.0 }, "text-moderation-latest": { "input_cost_per_token": 0.0, "litellm_provider": "openai", "max_input_tokens": 32768, "max_output_tokens": 0, "max_tokens": 32768, "mode": "moderation", "output_cost_per_token": 0.0 }, "text-moderation-stable": { "input_cost_per_token": 0.0, "litellm_provider": "openai", "max_input_tokens": 32768, "max_output_tokens": 0, "max_tokens": 32768, "mode": "moderation", "output_cost_per_token": 0.0 }, "text-multilingual-embedding-002": { "input_cost_per_character": 2.5e-08, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 2048, "max_tokens": 2048, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models" }, "text-multilingual-embedding-preview-0409": { "input_cost_per_token": 6.25e-09, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 3072, "max_tokens": 3072, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "text-unicorn": { "input_cost_per_token": 1e-05, "litellm_provider": "vertex_ai-text-models", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_token": 2.8e-05, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "text-unicorn@001": { "input_cost_per_token": 1e-05, "litellm_provider": "vertex_ai-text-models", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 1024, "mode": "completion", "output_cost_per_token": 2.8e-05, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "textembedding-gecko": { "input_cost_per_character": 2.5e-08, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 3072, "max_tokens": 3072, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "textembedding-gecko-multilingual": { "input_cost_per_character": 2.5e-08, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 3072, "max_tokens": 3072, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "textembedding-gecko-multilingual@001": { "input_cost_per_character": 2.5e-08, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 3072, "max_tokens": 3072, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "textembedding-gecko@001": { "input_cost_per_character": 2.5e-08, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 3072, "max_tokens": 3072, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "textembedding-gecko@003": { "input_cost_per_character": 2.5e-08, "input_cost_per_token": 1e-07, "litellm_provider": "vertex_ai-embedding-models", "max_input_tokens": 3072, "max_tokens": 3072, "mode": "embedding", "output_cost_per_token": 0, "output_vector_size": 768, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models" }, "together-ai-21.1b-41b": { "input_cost_per_token": 8e-07, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 8e-07 }, "together-ai-4.1b-8b": { "input_cost_per_token": 2e-07, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 2e-07 }, "together-ai-41.1b-80b": { "input_cost_per_token": 9e-07, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 9e-07 }, "together-ai-8.1b-21b": { "input_cost_per_token": 3e-07, "litellm_provider": "together_ai", "max_tokens": 1000, "mode": "chat", "output_cost_per_token": 3e-07 }, "together-ai-81.1b-110b": { "input_cost_per_token": 1.8e-06, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 1.8e-06 }, "together-ai-embedding-151m-to-350m": { "input_cost_per_token": 1.6e-08, "litellm_provider": "together_ai", "mode": "embedding", "output_cost_per_token": 0.0 }, "together-ai-embedding-up-to-150m": { "input_cost_per_token": 8e-09, "litellm_provider": "together_ai", "mode": "embedding", "output_cost_per_token": 0.0 }, "together_ai/baai/bge-base-en-v1.5": { "input_cost_per_token": 8e-09, "litellm_provider": "together_ai", "max_input_tokens": 512, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 768 }, "together_ai/BAAI/bge-base-en-v1.5": { "input_cost_per_token": 8e-09, "litellm_provider": "together_ai", "max_input_tokens": 512, "mode": "embedding", "output_cost_per_token": 0.0, "output_vector_size": 768 }, "together-ai-up-to-4b": { "input_cost_per_token": 1e-07, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 1e-07 }, "together_ai/Qwen/Qwen2.5-72B-Instruct-Turbo": { "litellm_provider": "together_ai", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen2.5-7B-Instruct-Turbo": { "litellm_provider": "together_ai", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-235B-A22B-Instruct-2507-tput": { "input_cost_per_token": 2e-07, "litellm_provider": "together_ai", "max_input_tokens": 262000, "mode": "chat", "output_cost_per_token": 6e-06, "source": "https://www.together.ai/models/qwen3-235b-a22b-instruct-2507-fp8", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-235B-A22B-Thinking-2507": { "input_cost_per_token": 6.5e-07, "litellm_provider": "together_ai", "max_input_tokens": 256000, "mode": "chat", "output_cost_per_token": 3e-06, "source": "https://www.together.ai/models/qwen3-235b-a22b-thinking-2507", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-235B-A22B-fp8-tput": { "input_cost_per_token": 2e-07, "litellm_provider": "together_ai", "max_input_tokens": 40000, "mode": "chat", "output_cost_per_token": 6e-07, "source": "https://www.together.ai/models/qwen3-235b-a22b-fp8-tput", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_tool_choice": false }, "together_ai/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": { "input_cost_per_token": 2e-06, "litellm_provider": "together_ai", "max_input_tokens": 256000, "mode": "chat", "output_cost_per_token": 2e-06, "source": "https://www.together.ai/models/qwen3-coder-480b-a35b-instruct", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/deepseek-ai/DeepSeek-R1": { "input_cost_per_token": 3e-06, "litellm_provider": "together_ai", "max_input_tokens": 128000, "max_output_tokens": 20480, "max_tokens": 20480, "mode": "chat", "output_cost_per_token": 7e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/deepseek-ai/DeepSeek-R1-0528-tput": { "input_cost_per_token": 5.5e-07, "litellm_provider": "together_ai", "max_input_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.19e-06, "source": "https://www.together.ai/models/deepseek-r1-0528-throughput", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/deepseek-ai/DeepSeek-V3": { "input_cost_per_token": 1.25e-06, "litellm_provider": "together_ai", "max_input_tokens": 65536, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.25e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/deepseek-ai/DeepSeek-V3.1": { "input_cost_per_token": 6e-07, "litellm_provider": "together_ai", "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.7e-06, "source": "https://www.together.ai/models/deepseek-v3-1", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "together_ai/meta-llama/Llama-3.2-3B-Instruct-Turbo": { "litellm_provider": "together_ai", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo": { "input_cost_per_token": 8.8e-07, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 8.8e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo-Free": { "input_cost_per_token": 0, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 0, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { "input_cost_per_token": 2.7e-07, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 8.5e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/meta-llama/Llama-4-Scout-17B-16E-Instruct": { "input_cost_per_token": 1.8e-07, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 5.9e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo": { "input_cost_per_token": 3.5e-06, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 3.5e-06, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo": { "input_cost_per_token": 8.8e-07, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 8.8e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo": { "input_cost_per_token": 1.8e-07, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 1.8e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/mistralai/Mistral-7B-Instruct-v0.1": { "litellm_provider": "together_ai", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/mistralai/Mistral-Small-24B-Instruct-2501": { "litellm_provider": "together_ai", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/mistralai/Mixtral-8x7B-Instruct-v0.1": { "input_cost_per_token": 6e-07, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true }, "together_ai/moonshotai/Kimi-K2-Instruct": { "input_cost_per_token": 1e-06, "litellm_provider": "together_ai", "mode": "chat", "output_cost_per_token": 3e-06, "source": "https://www.together.ai/models/kimi-k2-instruct", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/openai/gpt-oss-120b": { "input_cost_per_token": 1.5e-07, "litellm_provider": "together_ai", "max_input_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-07, "source": "https://www.together.ai/models/gpt-oss-120b", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/openai/gpt-oss-20b": { "input_cost_per_token": 5e-08, "litellm_provider": "together_ai", "max_input_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-07, "source": "https://www.together.ai/models/gpt-oss-20b", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/togethercomputer/CodeLlama-34b-Instruct": { "litellm_provider": "together_ai", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/zai-org/GLM-4.5-Air-FP8": { "input_cost_per_token": 2e-07, "litellm_provider": "together_ai", "max_input_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.1e-06, "source": "https://www.together.ai/models/glm-4-5-air", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/moonshotai/Kimi-K2-Instruct-0905": { "input_cost_per_token": 1e-06, "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 3e-06, "source": "https://www.together.ai/models/kimi-k2-0905", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-Next-80B-A3B-Instruct": { "input_cost_per_token": 1.5e-07, "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://www.together.ai/models/qwen3-next-80b-a3b-instruct", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "together_ai/Qwen/Qwen3-Next-80B-A3B-Thinking": { "input_cost_per_token": 1.5e-07, "litellm_provider": "together_ai", "max_input_tokens": 262144, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://www.together.ai/models/qwen3-next-80b-a3b-thinking", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_tool_choice": true }, "tts-1": { "input_cost_per_character": 1.5e-05, "litellm_provider": "openai", "mode": "audio_speech", "supported_endpoints": [ "/v1/audio/speech" ] }, "tts-1-hd": { "input_cost_per_character": 3e-05, "litellm_provider": "openai", "mode": "audio_speech", "supported_endpoints": [ "/v1/audio/speech" ] }, "us.amazon.nova-lite-v1:0": { "input_cost_per_token": 6e-08, "litellm_provider": "bedrock_converse", "max_input_tokens": 300000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 2.4e-07, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true }, "us.amazon.nova-micro-v1:0": { "input_cost_per_token": 3.5e-08, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 1.4e-07, "supports_function_calling": true, "supports_prompt_caching": true, "supports_response_schema": true }, "us.amazon.nova-premier-v1:0": { "input_cost_per_token": 2.5e-06, "litellm_provider": "bedrock_converse", "max_input_tokens": 1000000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 1.25e-05, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": false, "supports_response_schema": true, "supports_vision": true }, "us.amazon.nova-pro-v1:0": { "input_cost_per_token": 8e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 300000, "max_output_tokens": 10000, "max_tokens": 10000, "mode": "chat", "output_cost_per_token": 3.2e-06, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_vision": true }, "us.anthropic.claude-3-5-haiku-20241022-v1:0": { "cache_creation_input_token_cost": 1e-06, "cache_read_input_token_cost": 8e-08, "input_cost_per_token": 8e-07, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 4e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true }, "us.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, "cache_read_input_token_cost": 1.1e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.5e-06, "source": "https://aws.amazon.com/about-aws/whats-new/2025/10/claude-4-5-haiku-anthropic-amazon-bedrock", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "us.anthropic.claude-3-5-sonnet-20240620-v1:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "us.anthropic.claude-3-5-sonnet-20241022-v2:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "us.anthropic.claude-3-7-sonnet-20250219-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "us.anthropic.claude-3-haiku-20240307-v1:0": { "input_cost_per_token": 2.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.25e-06, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "us.anthropic.claude-3-opus-20240229-v1:0": { "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 7.5e-05, "supports_function_calling": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "us.anthropic.claude-3-sonnet-20240229-v1:0": { "input_cost_per_token": 3e-06, "litellm_provider": "bedrock", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_pdf_input": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "us.anthropic.claude-opus-4-1-20250805-v1:0": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "us.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.125e-06, "cache_read_input_token_cost": 3.3e-07, "input_cost_per_token": 3.3e-06, "input_cost_per_token_above_200k_tokens": 6.6e-06, "output_cost_per_token_above_200k_tokens": 2.475e-05, "cache_creation_input_token_cost_above_200k_tokens": 8.25e-06, "cache_read_input_token_cost_above_200k_tokens": 6.6e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.65e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, "au.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, "cache_read_input_token_cost": 1.1e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.5e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 346 }, "us.anthropic.claude-opus-4-20250514-v1:0": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "us.anthropic.claude-sonnet-4-20250514-v1:0": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "us.deepseek.r1-v1:0": { "input_cost_per_token": 1.35e-06, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 5.4e-06, "supports_function_calling": false, "supports_reasoning": true, "supports_tool_choice": false }, "us.meta.llama3-1-405b-instruct-v1:0": { "input_cost_per_token": 5.32e-06, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.6e-05, "supports_function_calling": true, "supports_tool_choice": false }, "us.meta.llama3-1-70b-instruct-v1:0": { "input_cost_per_token": 9.9e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 9.9e-07, "supports_function_calling": true, "supports_tool_choice": false }, "us.meta.llama3-1-8b-instruct-v1:0": { "input_cost_per_token": 2.2e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.2e-07, "supports_function_calling": true, "supports_tool_choice": false }, "us.meta.llama3-2-11b-instruct-v1:0": { "input_cost_per_token": 3.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3.5e-07, "supports_function_calling": true, "supports_tool_choice": false, "supports_vision": true }, "us.meta.llama3-2-1b-instruct-v1:0": { "input_cost_per_token": 1e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-07, "supports_function_calling": true, "supports_tool_choice": false }, "us.meta.llama3-2-3b-instruct-v1:0": { "input_cost_per_token": 1.5e-07, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-07, "supports_function_calling": true, "supports_tool_choice": false }, "us.meta.llama3-2-90b-instruct-v1:0": { "input_cost_per_token": 2e-06, "litellm_provider": "bedrock", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_tool_choice": false, "supports_vision": true }, "us.meta.llama3-3-70b-instruct-v1:0": { "input_cost_per_token": 7.2e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 7.2e-07, "supports_function_calling": true, "supports_tool_choice": false }, "us.meta.llama4-maverick-17b-instruct-v1:0": { "input_cost_per_token": 2.4e-07, "input_cost_per_token_batches": 1.2e-07, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 9.7e-07, "output_cost_per_token_batches": 4.85e-07, "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text", "code" ], "supports_function_calling": true, "supports_tool_choice": false }, "us.meta.llama4-scout-17b-instruct-v1:0": { "input_cost_per_token": 1.7e-07, "input_cost_per_token_batches": 8.5e-08, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 6.6e-07, "output_cost_per_token_batches": 3.3e-07, "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text", "code" ], "supports_function_calling": true, "supports_tool_choice": false }, "us.mistral.pixtral-large-2502-v1:0": { "input_cost_per_token": 2e-06, "litellm_provider": "bedrock_converse", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-06, "supports_function_calling": true, "supports_tool_choice": false }, "v0/v0-1.0-md": { "input_cost_per_token": 3e-06, "litellm_provider": "v0", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "v0/v0-1.5-lg": { "input_cost_per_token": 1.5e-05, "litellm_provider": "v0", "max_input_tokens": 512000, "max_output_tokens": 512000, "max_tokens": 512000, "mode": "chat", "output_cost_per_token": 7.5e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "v0/v0-1.5-md": { "input_cost_per_token": 3e-06, "litellm_provider": "v0", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "vercel_ai_gateway/alibaba/qwen-3-14b": { "input_cost_per_token": 8e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 40960, "max_output_tokens": 16384, "max_tokens": 40960, "mode": "chat", "output_cost_per_token": 2.4e-07 }, "vercel_ai_gateway/glm-4.6": { "litellm_provider": "vercel_ai_gateway", "cache_read_input_token_cost": 1.1e-07, "input_cost_per_token": 6e-07, "max_input_tokens": 200000, "max_output_tokens": 200000, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 2.2e-06 }, "vercel_ai_gateway/alibaba/qwen-3-235b": { "input_cost_per_token": 2e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 40960, "max_output_tokens": 16384, "max_tokens": 40960, "mode": "chat", "output_cost_per_token": 6e-07 }, "vercel_ai_gateway/alibaba/qwen-3-30b": { "input_cost_per_token": 1e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 40960, "max_output_tokens": 16384, "max_tokens": 40960, "mode": "chat", "output_cost_per_token": 3e-07 }, "vercel_ai_gateway/alibaba/qwen-3-32b": { "input_cost_per_token": 1e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 40960, "max_output_tokens": 16384, "max_tokens": 40960, "mode": "chat", "output_cost_per_token": 3e-07 }, "vercel_ai_gateway/alibaba/qwen3-coder": { "input_cost_per_token": 4e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 262144, "max_output_tokens": 66536, "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 1.6e-06 }, "vercel_ai_gateway/amazon/nova-lite": { "input_cost_per_token": 6e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 300000, "max_output_tokens": 8192, "max_tokens": 300000, "mode": "chat", "output_cost_per_token": 2.4e-07 }, "vercel_ai_gateway/amazon/nova-micro": { "input_cost_per_token": 3.5e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.4e-07 }, "vercel_ai_gateway/amazon/nova-pro": { "input_cost_per_token": 8e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 300000, "max_output_tokens": 8192, "max_tokens": 300000, "mode": "chat", "output_cost_per_token": 3.2e-06 }, "vercel_ai_gateway/amazon/titan-embed-text-v2": { "input_cost_per_token": 2e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 0, "max_output_tokens": 0, "max_tokens": 0, "mode": "chat", "output_cost_per_token": 0.0 }, "vercel_ai_gateway/anthropic/claude-3-haiku": { "cache_creation_input_token_cost": 3e-07, "cache_read_input_token_cost": 3e-08, "input_cost_per_token": 2.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 1.25e-06 }, "vercel_ai_gateway/anthropic/claude-3-opus": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 7.5e-05 }, "vercel_ai_gateway/anthropic/claude-3.5-haiku": { "cache_creation_input_token_cost": 1e-06, "cache_read_input_token_cost": 8e-08, "input_cost_per_token": 8e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 4e-06 }, "vercel_ai_gateway/anthropic/claude-3.5-sonnet": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 1.5e-05 }, "vercel_ai_gateway/anthropic/claude-3.7-sonnet": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 1.5e-05 }, "vercel_ai_gateway/anthropic/claude-4-opus": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 7.5e-05 }, "vercel_ai_gateway/anthropic/claude-4-sonnet": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 1.5e-05 }, "vercel_ai_gateway/cohere/command-a": { "input_cost_per_token": 2.5e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 256000, "max_output_tokens": 8000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 1e-05 }, "vercel_ai_gateway/cohere/command-r": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-07 }, "vercel_ai_gateway/cohere/command-r-plus": { "input_cost_per_token": 2.5e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05 }, "vercel_ai_gateway/cohere/embed-v4.0": { "input_cost_per_token": 1.2e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 0, "max_output_tokens": 0, "max_tokens": 0, "mode": "chat", "output_cost_per_token": 0.0 }, "vercel_ai_gateway/deepseek/deepseek-r1": { "input_cost_per_token": 5.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.19e-06 }, "vercel_ai_gateway/deepseek/deepseek-r1-distill-llama-70b": { "input_cost_per_token": 7.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 9.9e-07 }, "vercel_ai_gateway/deepseek/deepseek-v3": { "input_cost_per_token": 9e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 9e-07 }, "vercel_ai_gateway/google/gemini-2.0-flash": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 6e-07 }, "vercel_ai_gateway/google/gemini-2.0-flash-lite": { "input_cost_per_token": 7.5e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 1048576, "max_output_tokens": 8192, "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 3e-07 }, "vercel_ai_gateway/google/gemini-2.5-flash": { "input_cost_per_token": 3e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 1000000, "max_output_tokens": 65536, "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 2.5e-06 }, "vercel_ai_gateway/google/gemini-2.5-pro": { "input_cost_per_token": 2.5e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 1048576, "max_output_tokens": 65536, "max_tokens": 1048576, "mode": "chat", "output_cost_per_token": 1e-05 }, "vercel_ai_gateway/google/gemini-embedding-001": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 0, "max_output_tokens": 0, "max_tokens": 0, "mode": "embedding", "output_cost_per_token": 0.0 }, "vercel_ai_gateway/google/gemma-2-9b": { "input_cost_per_token": 2e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2e-07 }, "vercel_ai_gateway/google/text-embedding-005": { "input_cost_per_token": 2.5e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 0, "max_output_tokens": 0, "max_tokens": 0, "mode": "embedding", "output_cost_per_token": 0.0 }, "vercel_ai_gateway/google/text-multilingual-embedding-002": { "input_cost_per_token": 2.5e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 0, "max_output_tokens": 0, "max_tokens": 0, "mode": "embedding", "output_cost_per_token": 0.0 }, "vercel_ai_gateway/inception/mercury-coder-small": { "input_cost_per_token": 2.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 32000, "max_output_tokens": 16384, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 1e-06 }, "vercel_ai_gateway/meta/llama-3-70b": { "input_cost_per_token": 5.9e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 7.9e-07 }, "vercel_ai_gateway/meta/llama-3-8b": { "input_cost_per_token": 5e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 8e-08 }, "vercel_ai_gateway/meta/llama-3.1-70b": { "input_cost_per_token": 7.2e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 7.2e-07 }, "vercel_ai_gateway/meta/llama-3.1-8b": { "input_cost_per_token": 5e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 131000, "max_output_tokens": 131072, "max_tokens": 131000, "mode": "chat", "output_cost_per_token": 8e-08 }, "vercel_ai_gateway/meta/llama-3.2-11b": { "input_cost_per_token": 1.6e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.6e-07 }, "vercel_ai_gateway/meta/llama-3.2-1b": { "input_cost_per_token": 1e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-07 }, "vercel_ai_gateway/meta/llama-3.2-3b": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-07 }, "vercel_ai_gateway/meta/llama-3.2-90b": { "input_cost_per_token": 7.2e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 7.2e-07 }, "vercel_ai_gateway/meta/llama-3.3-70b": { "input_cost_per_token": 7.2e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 8192, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 7.2e-07 }, "vercel_ai_gateway/meta/llama-4-maverick": { "input_cost_per_token": 2e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 6e-07 }, "vercel_ai_gateway/meta/llama-4-scout": { "input_cost_per_token": 1e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 3e-07 }, "vercel_ai_gateway/mistral/codestral": { "input_cost_per_token": 3e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 256000, "max_output_tokens": 4000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 9e-07 }, "vercel_ai_gateway/mistral/codestral-embed": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 0, "max_output_tokens": 0, "max_tokens": 0, "mode": "chat", "output_cost_per_token": 0.0 }, "vercel_ai_gateway/mistral/devstral-small": { "input_cost_per_token": 7e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 2.8e-07 }, "vercel_ai_gateway/mistral/magistral-medium": { "input_cost_per_token": 2e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 64000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 5e-06 }, "vercel_ai_gateway/mistral/magistral-small": { "input_cost_per_token": 5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 64000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-06 }, "vercel_ai_gateway/mistral/ministral-3b": { "input_cost_per_token": 4e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 4000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 4e-08 }, "vercel_ai_gateway/mistral/ministral-8b": { "input_cost_per_token": 1e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 4000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-07 }, "vercel_ai_gateway/mistral/mistral-embed": { "input_cost_per_token": 1e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 0, "max_output_tokens": 0, "max_tokens": 0, "mode": "chat", "output_cost_per_token": 0.0 }, "vercel_ai_gateway/mistral/mistral-large": { "input_cost_per_token": 2e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 32000, "max_output_tokens": 4000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 6e-06 }, "vercel_ai_gateway/mistral/mistral-saba-24b": { "input_cost_per_token": 7.9e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 7.9e-07 }, "vercel_ai_gateway/mistral/mistral-small": { "input_cost_per_token": 1e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 32000, "max_output_tokens": 4000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 3e-07 }, "vercel_ai_gateway/mistral/mixtral-8x22b-instruct": { "input_cost_per_token": 1.2e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 65536, "max_output_tokens": 2048, "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 1.2e-06 }, "vercel_ai_gateway/mistral/pixtral-12b": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 4000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-07 }, "vercel_ai_gateway/mistral/pixtral-large": { "input_cost_per_token": 2e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 4000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-06 }, "vercel_ai_gateway/moonshotai/kimi-k2": { "input_cost_per_token": 5.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 131072, "max_output_tokens": 16384, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.2e-06 }, "vercel_ai_gateway/morph/morph-v3-fast": { "input_cost_per_token": 8e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 32768, "max_output_tokens": 16384, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1.2e-06 }, "vercel_ai_gateway/morph/morph-v3-large": { "input_cost_per_token": 9e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 32768, "max_output_tokens": 16384, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1.9e-06 }, "vercel_ai_gateway/openai/gpt-3.5-turbo": { "input_cost_per_token": 5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 16385, "max_output_tokens": 4096, "max_tokens": 16385, "mode": "chat", "output_cost_per_token": 1.5e-06 }, "vercel_ai_gateway/openai/gpt-3.5-turbo-instruct": { "input_cost_per_token": 1.5e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 8192, "max_output_tokens": 4096, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 2e-06 }, "vercel_ai_gateway/openai/gpt-4-turbo": { "input_cost_per_token": 1e-05, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 4096, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3e-05 }, "vercel_ai_gateway/openai/gpt-4.1": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 1047576, "mode": "chat", "output_cost_per_token": 8e-06 }, "vercel_ai_gateway/openai/gpt-4.1-mini": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 4e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 1047576, "mode": "chat", "output_cost_per_token": 1.6e-06 }, "vercel_ai_gateway/openai/gpt-4.1-nano": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 2.5e-08, "input_cost_per_token": 1e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 1047576, "max_output_tokens": 32768, "max_tokens": 1047576, "mode": "chat", "output_cost_per_token": 4e-07 }, "vercel_ai_gateway/openai/gpt-4o": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 1.25e-06, "input_cost_per_token": 2.5e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1e-05 }, "vercel_ai_gateway/openai/gpt-4o-mini": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 7.5e-08, "input_cost_per_token": 1.5e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 16384, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-07 }, "vercel_ai_gateway/openai/o1": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 7.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 6e-05 }, "vercel_ai_gateway/openai/o3": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 5e-07, "input_cost_per_token": 2e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 8e-06 }, "vercel_ai_gateway/openai/o3-mini": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 5.5e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 4.4e-06 }, "vercel_ai_gateway/openai/o4-mini": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 2.75e-07, "input_cost_per_token": 1.1e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 200000, "max_output_tokens": 100000, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 4.4e-06 }, "vercel_ai_gateway/openai/text-embedding-3-large": { "input_cost_per_token": 1.3e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 0, "max_output_tokens": 0, "max_tokens": 0, "mode": "embedding", "output_cost_per_token": 0.0 }, "vercel_ai_gateway/openai/text-embedding-3-small": { "input_cost_per_token": 2e-08, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 0, "max_output_tokens": 0, "max_tokens": 0, "mode": "embedding", "output_cost_per_token": 0.0 }, "vercel_ai_gateway/openai/text-embedding-ada-002": { "input_cost_per_token": 1e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 0, "max_output_tokens": 0, "max_tokens": 0, "mode": "embedding", "output_cost_per_token": 0.0 }, "vercel_ai_gateway/perplexity/sonar": { "input_cost_per_token": 1e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 127000, "max_output_tokens": 8000, "max_tokens": 127000, "mode": "chat", "output_cost_per_token": 1e-06 }, "vercel_ai_gateway/perplexity/sonar-pro": { "input_cost_per_token": 3e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 200000, "max_output_tokens": 8000, "max_tokens": 200000, "mode": "chat", "output_cost_per_token": 1.5e-05 }, "vercel_ai_gateway/perplexity/sonar-reasoning": { "input_cost_per_token": 1e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 127000, "max_output_tokens": 8000, "max_tokens": 127000, "mode": "chat", "output_cost_per_token": 5e-06 }, "vercel_ai_gateway/perplexity/sonar-reasoning-pro": { "input_cost_per_token": 2e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 127000, "max_output_tokens": 8000, "max_tokens": 127000, "mode": "chat", "output_cost_per_token": 8e-06 }, "vercel_ai_gateway/vercel/v0-1.0-md": { "input_cost_per_token": 3e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 32000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-05 }, "vercel_ai_gateway/vercel/v0-1.5-md": { "input_cost_per_token": 3e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 32768, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-05 }, "vercel_ai_gateway/xai/grok-2": { "input_cost_per_token": 2e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 131072, "max_output_tokens": 4000, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1e-05 }, "vercel_ai_gateway/xai/grok-2-vision": { "input_cost_per_token": 2e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1e-05 }, "vercel_ai_gateway/xai/grok-3": { "input_cost_per_token": 3e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05 }, "vercel_ai_gateway/xai/grok-3-fast": { "input_cost_per_token": 5e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.5e-05 }, "vercel_ai_gateway/xai/grok-3-mini": { "input_cost_per_token": 3e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 5e-07 }, "vercel_ai_gateway/xai/grok-3-mini-fast": { "input_cost_per_token": 6e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4e-06 }, "vercel_ai_gateway/xai/grok-4": { "input_cost_per_token": 3e-06, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 1.5e-05 }, "vercel_ai_gateway/zai/glm-4.5": { "input_cost_per_token": 6e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.2e-06 }, "vercel_ai_gateway/zai/glm-4.5-air": { "input_cost_per_token": 2e-07, "litellm_provider": "vercel_ai_gateway", "max_input_tokens": 128000, "max_output_tokens": 96000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.1e-06 }, "vertex_ai/claude-3-5-haiku": { "input_cost_per_token": 1e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_tool_choice": true }, "vertex_ai/claude-3-5-haiku@20241022": { "input_cost_per_token": 1e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_tool_choice": true }, "vertex_ai/claude-haiku-4-5@20251001": { "cache_creation_input_token_cost": 1.25e-06, "cache_read_input_token_cost": 1e-07, "input_cost_per_token": 1e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/claude/haiku-4-5", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true }, "vertex_ai/claude-3-5-sonnet": { "input_cost_per_token": 3e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-3-5-sonnet-v2": { "input_cost_per_token": 3e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-3-5-sonnet-v2@20241022": { "input_cost_per_token": 3e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-3-5-sonnet@20240620": { "input_cost_per_token": 3e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-3-7-sonnet@20250219": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "deprecation_date": "2025-06-01", "input_cost_per_token": 3e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "vertex_ai/claude-3-haiku": { "input_cost_per_token": 2.5e-07, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.25e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-3-haiku@20240307": { "input_cost_per_token": 2.5e-07, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.25e-06, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-3-opus": { "input_cost_per_token": 1.5e-05, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 7.5e-05, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-3-opus@20240229": { "input_cost_per_token": 1.5e-05, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 7.5e-05, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-3-sonnet": { "input_cost_per_token": 3e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-3-sonnet@20240229": { "input_cost_per_token": 3e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 4096, "max_tokens": 4096, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-opus-4": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "vertex_ai/claude-opus-4-1": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "input_cost_per_token_batches": 7.5e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "output_cost_per_token_batches": 3.75e-05, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-opus-4-1@20250805": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "input_cost_per_token_batches": 7.5e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "output_cost_per_token_batches": 3.75e-05, "supports_assistant_prefill": true, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-sonnet-4-5": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "input_cost_per_token_batches": 1.5e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "output_cost_per_token_batches": 7.5e-06, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-sonnet-4-5@20250929": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "input_cost_per_token_batches": 1.5e-06, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "output_cost_per_token_batches": 7.5e-06, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/claude-opus-4@20250514": { "cache_creation_input_token_cost": 1.875e-05, "cache_read_input_token_cost": 1.5e-06, "input_cost_per_token": 1.5e-05, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 200000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 7.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "vertex_ai/claude-sonnet-4": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "vertex_ai/claude-sonnet-4@20250514": { "cache_creation_input_token_cost": 3.75e-06, "cache_read_input_token_cost": 3e-07, "input_cost_per_token": 3e-06, "input_cost_per_token_above_200k_tokens": 6e-06, "output_cost_per_token_above_200k_tokens": 2.25e-05, "cache_creation_input_token_cost_above_200k_tokens": 7.5e-06, "cache_read_input_token_cost_above_200k_tokens": 6e-07, "litellm_provider": "vertex_ai-anthropic_models", "max_input_tokens": 1000000, "max_output_tokens": 64000, "max_tokens": 64000, "mode": "chat", "output_cost_per_token": 1.5e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, "search_context_size_medium": 0.01 }, "supports_assistant_prefill": true, "supports_computer_use": true, "supports_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, "vertex_ai/mistralai/codestral-2@001": { "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 9e-07, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/codestral-2": { "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 9e-07, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/codestral-2@001": { "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 9e-07, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/mistralai/codestral-2": { "input_cost_per_token": 3e-07, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 9e-07, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/codestral-2501": { "input_cost_per_token": 2e-07, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/codestral@2405": { "input_cost_per_token": 2e-07, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/codestral@latest": { "input_cost_per_token": 2e-07, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 6e-07, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/deepseek-ai/deepseek-v3.1-maas": { "input_cost_per_token": 1.35e-06, "litellm_provider": "vertex_ai-deepseek_models", "max_input_tokens": 163840, "max_output_tokens": 32768, "max_tokens": 163840, "mode": "chat", "output_cost_per_token": 5.4e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", "supported_regions": [ "us-west2" ], "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, "vertex_ai/deepseek-ai/deepseek-r1-0528-maas": { "input_cost_per_token": 1.35e-06, "litellm_provider": "vertex_ai-deepseek_models", "max_input_tokens": 65336, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 5.4e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", "supports_assistant_prefill": true, "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true }, "vertex_ai/imagegeneration@006": { "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.02, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-3.0-fast-generate-001": { "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.02, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-3.0-generate-001": { "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-3.0-generate-002": { "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-4.0-fast-generate-001": { "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.02, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-4.0-generate-001": { "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.04, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/imagen-4.0-ultra-generate-001": { "litellm_provider": "vertex_ai-image-models", "mode": "image_generation", "output_cost_per_image": 0.06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing" }, "vertex_ai/jamba-1.5": { "input_cost_per_token": 2e-07, "litellm_provider": "vertex_ai-ai21_models", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 4e-07, "supports_tool_choice": true }, "vertex_ai/jamba-1.5-large": { "input_cost_per_token": 2e-06, "litellm_provider": "vertex_ai-ai21_models", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 8e-06, "supports_tool_choice": true }, "vertex_ai/jamba-1.5-large@001": { "input_cost_per_token": 2e-06, "litellm_provider": "vertex_ai-ai21_models", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 8e-06, "supports_tool_choice": true }, "vertex_ai/jamba-1.5-mini": { "input_cost_per_token": 2e-07, "litellm_provider": "vertex_ai-ai21_models", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 4e-07, "supports_tool_choice": true }, "vertex_ai/jamba-1.5-mini@001": { "input_cost_per_token": 2e-07, "litellm_provider": "vertex_ai-ai21_models", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 4e-07, "supports_tool_choice": true }, "vertex_ai/meta/llama-3.1-405b-instruct-maas": { "input_cost_per_token": 5e-06, "litellm_provider": "vertex_ai-llama_models", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.6e-05, "source": "https://console.cloud.google.com/vertex-ai/publishers/meta/model-garden/llama-3.2-90b-vision-instruct-maas", "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/meta/llama-3.1-70b-instruct-maas": { "input_cost_per_token": 0.0, "litellm_provider": "vertex_ai-llama_models", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 0.0, "source": "https://console.cloud.google.com/vertex-ai/publishers/meta/model-garden/llama-3.2-90b-vision-instruct-maas", "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/meta/llama-3.1-8b-instruct-maas": { "input_cost_per_token": 0.0, "litellm_provider": "vertex_ai-llama_models", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 128000, "metadata": { "notes": "VertexAI states that The Llama 3.1 API service for llama-3.1-70b-instruct-maas and llama-3.1-8b-instruct-maas are in public preview and at no cost." }, "mode": "chat", "output_cost_per_token": 0.0, "source": "https://console.cloud.google.com/vertex-ai/publishers/meta/model-garden/llama-3.2-90b-vision-instruct-maas", "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/meta/llama-3.2-90b-vision-instruct-maas": { "input_cost_per_token": 0.0, "litellm_provider": "vertex_ai-llama_models", "max_input_tokens": 128000, "max_output_tokens": 2048, "max_tokens": 128000, "metadata": { "notes": "VertexAI states that The Llama 3.2 API service is at no cost during public preview, and will be priced as per dollar-per-1M-tokens at GA." }, "mode": "chat", "output_cost_per_token": 0.0, "source": "https://console.cloud.google.com/vertex-ai/publishers/meta/model-garden/llama-3.2-90b-vision-instruct-maas", "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/meta/llama-4-maverick-17b-128e-instruct-maas": { "input_cost_per_token": 3.5e-07, "litellm_provider": "vertex_ai-llama_models", "max_input_tokens": 1000000, "max_output_tokens": 1000000, "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 1.15e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text", "code" ], "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/meta/llama-4-maverick-17b-16e-instruct-maas": { "input_cost_per_token": 3.5e-07, "litellm_provider": "vertex_ai-llama_models", "max_input_tokens": 1000000, "max_output_tokens": 1000000, "max_tokens": 1000000, "mode": "chat", "output_cost_per_token": 1.15e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text", "code" ], "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/meta/llama-4-scout-17b-128e-instruct-maas": { "input_cost_per_token": 2.5e-07, "litellm_provider": "vertex_ai-llama_models", "max_input_tokens": 10000000, "max_output_tokens": 10000000, "max_tokens": 10000000, "mode": "chat", "output_cost_per_token": 7e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text", "code" ], "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/meta/llama-4-scout-17b-16e-instruct-maas": { "input_cost_per_token": 2.5e-07, "litellm_provider": "vertex_ai-llama_models", "max_input_tokens": 10000000, "max_output_tokens": 10000000, "max_tokens": 10000000, "mode": "chat", "output_cost_per_token": 7e-07, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "text", "code" ], "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/meta/llama3-405b-instruct-maas": { "input_cost_per_token": 0.0, "litellm_provider": "vertex_ai-llama_models", "max_input_tokens": 32000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 0.0, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", "supports_tool_choice": true }, "vertex_ai/meta/llama3-70b-instruct-maas": { "input_cost_per_token": 0.0, "litellm_provider": "vertex_ai-llama_models", "max_input_tokens": 32000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 0.0, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", "supports_tool_choice": true }, "vertex_ai/meta/llama3-8b-instruct-maas": { "input_cost_per_token": 0.0, "litellm_provider": "vertex_ai-llama_models", "max_input_tokens": 32000, "max_output_tokens": 32000, "max_tokens": 32000, "mode": "chat", "output_cost_per_token": 0.0, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#partner-models", "supports_tool_choice": true }, "vertex_ai/mistral-medium-3": { "input_cost_per_token": 4e-07, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/mistral-medium-3@001": { "input_cost_per_token": 4e-07, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/mistralai/mistral-medium-3": { "input_cost_per_token": 4e-07, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/mistralai/mistral-medium-3@001": { "input_cost_per_token": 4e-07, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 2e-06, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/mistral-large-2411": { "input_cost_per_token": 2e-06, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 6e-06, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/mistral-large@2407": { "input_cost_per_token": 2e-06, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 6e-06, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/mistral-large@2411-001": { "input_cost_per_token": 2e-06, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 6e-06, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/mistral-large@latest": { "input_cost_per_token": 2e-06, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 6e-06, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/mistral-nemo@2407": { "input_cost_per_token": 3e-06, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3e-06, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/mistral-nemo@latest": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 1.5e-07, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/mistral-small-2503": { "input_cost_per_token": 1e-06, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 128000, "max_output_tokens": 128000, "max_tokens": 128000, "mode": "chat", "output_cost_per_token": 3e-06, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true }, "vertex_ai/mistral-small-2503@001": { "input_cost_per_token": 1e-06, "litellm_provider": "vertex_ai-mistral_models", "max_input_tokens": 32000, "max_output_tokens": 8191, "max_tokens": 8191, "mode": "chat", "output_cost_per_token": 3e-06, "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/openai/gpt-oss-120b-maas": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-openai_models", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 6e-07, "source": "https://console.cloud.google.com/vertex-ai/publishers/openai/model-garden/gpt-oss-120b-maas", "supports_reasoning": true }, "vertex_ai/openai/gpt-oss-20b-maas": { "input_cost_per_token": 7.5e-08, "litellm_provider": "vertex_ai-openai_models", "max_input_tokens": 131072, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 3e-07, "source": "https://console.cloud.google.com/vertex-ai/publishers/openai/model-garden/gpt-oss-120b-maas", "supports_reasoning": true }, "vertex_ai/qwen/qwen3-235b-a22b-instruct-2507-maas": { "input_cost_per_token": 2.5e-07, "litellm_provider": "vertex_ai-qwen_models", "max_input_tokens": 262144, "max_output_tokens": 16384, "max_tokens": 16384, "mode": "chat", "output_cost_per_token": 1e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/qwen/qwen3-coder-480b-a35b-instruct-maas": { "input_cost_per_token": 1e-06, "litellm_provider": "vertex_ai-qwen_models", "max_input_tokens": 262144, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 4e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-qwen_models", "max_input_tokens": 262144, "max_output_tokens": 262144, "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 1.2e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-qwen_models", "max_input_tokens": 262144, "max_output_tokens": 262144, "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 1.2e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/veo-2.0-generate-001": { "litellm_provider": "vertex_ai-video-models", "max_input_tokens": 1024, "max_tokens": 1024, "mode": "video_generation", "output_cost_per_second": 0.35, "source": "https://ai.google.dev/gemini-api/docs/video", "supported_modalities": [ "text" ], "supported_output_modalities": [ "video" ] }, "vertex_ai/veo-3.0-fast-generate-preview": { "litellm_provider": "vertex_ai-video-models", "max_input_tokens": 1024, "max_tokens": 1024, "mode": "video_generation", "output_cost_per_second": 0.4, "source": "https://ai.google.dev/gemini-api/docs/video", "supported_modalities": [ "text" ], "supported_output_modalities": [ "video" ] }, "vertex_ai/veo-3.0-generate-preview": { "litellm_provider": "vertex_ai-video-models", "max_input_tokens": 1024, "max_tokens": 1024, "mode": "video_generation", "output_cost_per_second": 0.75, "source": "https://ai.google.dev/gemini-api/docs/video", "supported_modalities": [ "text" ], "supported_output_modalities": [ "video" ] }, "voyage/rerank-2": { "input_cost_per_query": 5e-08, "input_cost_per_token": 5e-08, "litellm_provider": "voyage", "max_input_tokens": 16000, "max_output_tokens": 16000, "max_query_tokens": 16000, "max_tokens": 16000, "mode": "rerank", "output_cost_per_token": 0.0 }, "voyage/rerank-2-lite": { "input_cost_per_query": 2e-08, "input_cost_per_token": 2e-08, "litellm_provider": "voyage", "max_input_tokens": 8000, "max_output_tokens": 8000, "max_query_tokens": 8000, "max_tokens": 8000, "mode": "rerank", "output_cost_per_token": 0.0 }, "voyage/voyage-2": { "input_cost_per_token": 1e-07, "litellm_provider": "voyage", "max_input_tokens": 4000, "max_tokens": 4000, "mode": "embedding", "output_cost_per_token": 0.0 }, "voyage/voyage-3": { "input_cost_per_token": 6e-08, "litellm_provider": "voyage", "max_input_tokens": 32000, "max_tokens": 32000, "mode": "embedding", "output_cost_per_token": 0.0 }, "voyage/voyage-3-large": { "input_cost_per_token": 1.8e-07, "litellm_provider": "voyage", "max_input_tokens": 32000, "max_tokens": 32000, "mode": "embedding", "output_cost_per_token": 0.0 }, "voyage/voyage-3-lite": { "input_cost_per_token": 2e-08, "litellm_provider": "voyage", "max_input_tokens": 32000, "max_tokens": 32000, "mode": "embedding", "output_cost_per_token": 0.0 }, "voyage/voyage-code-2": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", "max_input_tokens": 16000, "max_tokens": 16000, "mode": "embedding", "output_cost_per_token": 0.0 }, "voyage/voyage-code-3": { "input_cost_per_token": 1.8e-07, "litellm_provider": "voyage", "max_input_tokens": 32000, "max_tokens": 32000, "mode": "embedding", "output_cost_per_token": 0.0 }, "voyage/voyage-context-3": { "input_cost_per_token": 1.8e-07, "litellm_provider": "voyage", "max_input_tokens": 120000, "max_tokens": 120000, "mode": "embedding", "output_cost_per_token": 0.0 }, "voyage/voyage-finance-2": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", "max_input_tokens": 32000, "max_tokens": 32000, "mode": "embedding", "output_cost_per_token": 0.0 }, "voyage/voyage-large-2": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", "max_input_tokens": 16000, "max_tokens": 16000, "mode": "embedding", "output_cost_per_token": 0.0 }, "voyage/voyage-law-2": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", "max_input_tokens": 16000, "max_tokens": 16000, "mode": "embedding", "output_cost_per_token": 0.0 }, "voyage/voyage-lite-01": { "input_cost_per_token": 1e-07, "litellm_provider": "voyage", "max_input_tokens": 4096, "max_tokens": 4096, "mode": "embedding", "output_cost_per_token": 0.0 }, "voyage/voyage-lite-02-instruct": { "input_cost_per_token": 1e-07, "litellm_provider": "voyage", "max_input_tokens": 4000, "max_tokens": 4000, "mode": "embedding", "output_cost_per_token": 0.0 }, "voyage/voyage-multimodal-3": { "input_cost_per_token": 1.2e-07, "litellm_provider": "voyage", "max_input_tokens": 32000, "max_tokens": 32000, "mode": "embedding", "output_cost_per_token": 0.0 }, "wandb/openai/gpt-oss-120b": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 0.015, "output_cost_per_token": 0.06, "litellm_provider": "wandb", "mode": "chat" }, "wandb/openai/gpt-oss-20b": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 0.005, "output_cost_per_token": 0.02, "litellm_provider": "wandb", "mode": "chat" }, "wandb/zai-org/GLM-4.5": { "max_tokens": 131072, "max_input_tokens": 131072, "max_output_tokens": 131072, "input_cost_per_token": 0.055, "output_cost_per_token": 0.2, "litellm_provider": "wandb", "mode": "chat" }, "wandb/Qwen/Qwen3-235B-A22B-Instruct-2507": { "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 262144, "input_cost_per_token": 0.01, "output_cost_per_token": 0.01, "litellm_provider": "wandb", "mode": "chat" }, "wandb/Qwen/Qwen3-Coder-480B-A35B-Instruct": { "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 262144, "input_cost_per_token": 0.1, "output_cost_per_token": 0.15, "litellm_provider": "wandb", "mode": "chat" }, "wandb/Qwen/Qwen3-235B-A22B-Thinking-2507": { "max_tokens": 262144, "max_input_tokens": 262144, "max_output_tokens": 262144, "input_cost_per_token": 0.01, "output_cost_per_token": 0.01, "litellm_provider": "wandb", "mode": "chat" }, "wandb/moonshotai/Kimi-K2-Instruct": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 0.135, "output_cost_per_token": 0.4, "litellm_provider": "wandb", "mode": "chat" }, "wandb/meta-llama/Llama-3.1-8B-Instruct": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 0.022, "output_cost_per_token": 0.022, "litellm_provider": "wandb", "mode": "chat" }, "wandb/deepseek-ai/DeepSeek-V3.1": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 0.055, "output_cost_per_token": 0.165, "litellm_provider": "wandb", "mode": "chat" }, "wandb/deepseek-ai/DeepSeek-R1-0528": { "max_tokens": 161000, "max_input_tokens": 161000, "max_output_tokens": 161000, "input_cost_per_token": 0.135, "output_cost_per_token": 0.54, "litellm_provider": "wandb", "mode": "chat" }, "wandb/deepseek-ai/DeepSeek-V3-0324": { "max_tokens": 161000, "max_input_tokens": 161000, "max_output_tokens": 161000, "input_cost_per_token": 0.114, "output_cost_per_token": 0.275, "litellm_provider": "wandb", "mode": "chat" }, "wandb/meta-llama/Llama-3.3-70B-Instruct": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 0.071, "output_cost_per_token": 0.071, "litellm_provider": "wandb", "mode": "chat" }, "wandb/meta-llama/Llama-4-Scout-17B-16E-Instruct": { "max_tokens": 64000, "max_input_tokens": 64000, "max_output_tokens": 64000, "input_cost_per_token": 0.017, "output_cost_per_token": 0.066, "litellm_provider": "wandb", "mode": "chat" }, "wandb/microsoft/Phi-4-mini-instruct": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 0.008, "output_cost_per_token": 0.035, "litellm_provider": "wandb", "mode": "chat" }, "watsonx/ibm/granite-3-8b-instruct": { "input_cost_per_token": 0.2e-06, "litellm_provider": "watsonx", "max_input_tokens": 8192, "max_output_tokens": 1024, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 0.2e-06, "supports_audio_input": false, "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": false }, "watsonx/mistralai/mistral-large": { "input_cost_per_token": 3e-06, "litellm_provider": "watsonx", "max_input_tokens": 131072, "max_output_tokens": 16384, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 10e-06, "supports_audio_input": false, "supports_audio_output": false, "supports_function_calling": true, "supports_parallel_function_calling": false, "supports_prompt_caching": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, "supports_vision": false }, "watsonx/bigscience/mt0-xxl-13b": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 0.0005, "output_cost_per_token": 0.002, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": false }, "watsonx/core42/jais-13b-chat": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 0.0005, "output_cost_per_token": 0.002, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": false }, "watsonx/google/flan-t5-xl-3b": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 0.6e-06, "output_cost_per_token": 0.6e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": false }, "watsonx/ibm/granite-13b-chat-v2": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 0.6e-06, "output_cost_per_token": 0.6e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": false }, "watsonx/ibm/granite-13b-instruct-v2": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 0.6e-06, "output_cost_per_token": 0.6e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": false }, "watsonx/ibm/granite-3-3-8b-instruct": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 0.2e-06, "output_cost_per_token": 0.2e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": false }, "watsonx/ibm/granite-4-h-small": { "max_tokens": 20480, "max_input_tokens": 20480, "max_output_tokens": 20480, "input_cost_per_token": 0.06e-06, "output_cost_per_token": 0.25e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": false }, "watsonx/ibm/granite-guardian-3-2-2b": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 0.1e-06, "output_cost_per_token": 0.1e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": false }, "watsonx/ibm/granite-guardian-3-3-8b": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 0.2e-06, "output_cost_per_token": 0.2e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": false }, "watsonx/ibm/granite-ttm-1024-96-r2": { "max_tokens": 512, "max_input_tokens": 512, "max_output_tokens": 512, "input_cost_per_token": 0.38e-06, "output_cost_per_token": 0.38e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": false }, "watsonx/ibm/granite-ttm-1536-96-r2": { "max_tokens": 512, "max_input_tokens": 512, "max_output_tokens": 512, "input_cost_per_token": 0.38e-06, "output_cost_per_token": 0.38e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": false }, "watsonx/ibm/granite-ttm-512-96-r2": { "max_tokens": 512, "max_input_tokens": 512, "max_output_tokens": 512, "input_cost_per_token": 0.38e-06, "output_cost_per_token": 0.38e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": false }, "watsonx/ibm/granite-vision-3-2-2b": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 0.1e-06, "output_cost_per_token": 0.1e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": true }, "watsonx/meta-llama/llama-3-2-11b-vision-instruct": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 0.35e-06, "output_cost_per_token": 0.35e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true }, "watsonx/meta-llama/llama-3-2-1b-instruct": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 0.1e-06, "output_cost_per_token": 0.1e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": false }, "watsonx/meta-llama/llama-3-2-3b-instruct": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 0.15e-06, "output_cost_per_token": 0.15e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": false }, "watsonx/meta-llama/llama-3-2-90b-vision-instruct": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 2e-06, "output_cost_per_token": 2e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": true }, "watsonx/meta-llama/llama-3-3-70b-instruct": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 0.71e-06, "output_cost_per_token": 0.71e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": false }, "watsonx/meta-llama/llama-4-maverick-17b": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 0.35e-06, "output_cost_per_token": 1.4e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": false }, "watsonx/meta-llama/llama-guard-3-11b-vision": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 0.35e-06, "output_cost_per_token": 0.35e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": true }, "watsonx/mistralai/mistral-medium-2505": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 3e-06, "output_cost_per_token": 10e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": false }, "watsonx/mistralai/mistral-small-2503": { "max_tokens": 32000, "max_input_tokens": 32000, "max_output_tokens": 32000, "input_cost_per_token": 0.1e-06, "output_cost_per_token": 0.3e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": false }, "watsonx/mistralai/mistral-small-3-1-24b-instruct-2503": { "max_tokens": 32000, "max_input_tokens": 32000, "max_output_tokens": 32000, "input_cost_per_token": 0.1e-06, "output_cost_per_token": 0.3e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": true, "supports_parallel_function_calling": true, "supports_vision": false }, "watsonx/mistralai/pixtral-12b-2409": { "max_tokens": 128000, "max_input_tokens": 128000, "max_output_tokens": 128000, "input_cost_per_token": 0.35e-06, "output_cost_per_token": 0.35e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": true }, "watsonx/openai/gpt-oss-120b": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 0.15e-06, "output_cost_per_token": 0.6e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": false }, "watsonx/sdaia/allam-1-13b-instruct": { "max_tokens": 8192, "max_input_tokens": 8192, "max_output_tokens": 8192, "input_cost_per_token": 1.8e-06, "output_cost_per_token": 1.8e-06, "litellm_provider": "watsonx", "mode": "chat", "supports_function_calling": false, "supports_parallel_function_calling": false, "supports_vision": false }, "whisper-1": { "input_cost_per_second": 0.0001, "litellm_provider": "openai", "mode": "audio_transcription", "output_cost_per_second": 0.0001, "supported_endpoints": [ "/v1/audio/transcriptions" ] }, "vertex_ai/qwen/qwen3-next-80b-a3b-instruct-maas": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-qwen_models", "max_input_tokens": 262144, "max_output_tokens": 262144, "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 1.2e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "vertex_ai/qwen/qwen3-next-80b-a3b-thinking-maas": { "input_cost_per_token": 1.5e-07, "litellm_provider": "vertex_ai-qwen_models", "max_input_tokens": 262144, "max_output_tokens": 262144, "max_tokens": 262144, "mode": "chat", "output_cost_per_token": 1.2e-06, "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", "supports_function_calling": true, "supports_tool_choice": true }, "xai/grok-2": { "input_cost_per_token": 2e-06, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-2-1212": { "input_cost_per_token": 2e-06, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-2-latest": { "input_cost_per_token": 2e-06, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-2-vision": { "input_cost_per_image": 2e-06, "input_cost_per_token": 2e-06, "litellm_provider": "xai", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "xai/grok-2-vision-1212": { "input_cost_per_image": 2e-06, "input_cost_per_token": 2e-06, "litellm_provider": "xai", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "xai/grok-2-vision-latest": { "input_cost_per_image": 2e-06, "input_cost_per_token": 2e-06, "litellm_provider": "xai", "max_input_tokens": 32768, "max_output_tokens": 32768, "max_tokens": 32768, "mode": "chat", "output_cost_per_token": 1e-05, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "xai/grok-3": { "input_cost_per_token": 3e-06, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, "source": "https://x.ai/api#pricing", "supports_function_calling": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-3-beta": { "input_cost_per_token": 3e-06, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, "source": "https://x.ai/api#pricing", "supports_function_calling": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-3-fast-beta": { "input_cost_per_token": 5e-06, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.5e-05, "source": "https://x.ai/api#pricing", "supports_function_calling": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-3-fast-latest": { "input_cost_per_token": 5e-06, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 2.5e-05, "source": "https://x.ai/api#pricing", "supports_function_calling": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-3-latest": { "input_cost_per_token": 3e-06, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, "source": "https://x.ai/api#pricing", "supports_function_calling": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-3-mini": { "input_cost_per_token": 3e-07, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 5e-07, "source": "https://x.ai/api#pricing", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-3-mini-beta": { "input_cost_per_token": 3e-07, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 5e-07, "source": "https://x.ai/api#pricing", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-3-mini-fast": { "input_cost_per_token": 6e-07, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4e-06, "source": "https://x.ai/api#pricing", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-3-mini-fast-beta": { "input_cost_per_token": 6e-07, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4e-06, "source": "https://x.ai/api#pricing", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-3-mini-fast-latest": { "input_cost_per_token": 6e-07, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 4e-06, "source": "https://x.ai/api#pricing", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-3-mini-latest": { "input_cost_per_token": 3e-07, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 5e-07, "source": "https://x.ai/api#pricing", "supports_function_calling": true, "supports_reasoning": true, "supports_response_schema": false, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-4": { "input_cost_per_token": 3e-06, "litellm_provider": "xai", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 1.5e-05, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-4-fast-reasoning": { "litellm_provider": "xai", "max_input_tokens": 2e6, "max_output_tokens": 2e6, "max_tokens": 2e6, "mode": "chat", "input_cost_per_token": 0.2e-06, "input_cost_per_token_above_128k_tokens": 0.4e-06, "output_cost_per_token": 0.5e-06, "output_cost_per_token_above_128k_tokens": 1e-06, "cache_read_input_token_cost": 0.05e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-4-fast-non-reasoning": { "litellm_provider": "xai", "max_input_tokens": 2e6, "max_output_tokens": 2e6, "cache_read_input_token_cost": 0.05e-06, "max_tokens": 2e6, "mode": "chat", "input_cost_per_token": 0.2e-06, "input_cost_per_token_above_128k_tokens": 0.4e-06, "output_cost_per_token": 0.5e-06, "output_cost_per_token_above_128k_tokens": 1e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-4-0709": { "input_cost_per_token": 3e-06, "input_cost_per_token_above_128k_tokens": 6e-06, "litellm_provider": "xai", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 1.5e-05, "output_cost_per_token_above_128k_tokens": 30e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-4-latest": { "input_cost_per_token": 3e-06, "input_cost_per_token_above_128k_tokens": 6e-06, "litellm_provider": "xai", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 1.5e-05, "output_cost_per_token_above_128k_tokens": 30e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true, "supports_web_search": true }, "xai/grok-beta": { "input_cost_per_token": 5e-06, "litellm_provider": "xai", "max_input_tokens": 131072, "max_output_tokens": 131072, "max_tokens": 131072, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "xai/grok-code-fast": { "cache_read_input_token_cost": 2e-08, "input_cost_per_token": 2e-07, "litellm_provider": "xai", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "xai/grok-code-fast-1": { "cache_read_input_token_cost": 2e-08, "input_cost_per_token": 2e-07, "litellm_provider": "xai", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "xai/grok-code-fast-1-0825": { "cache_read_input_token_cost": 2e-08, "input_cost_per_token": 2e-07, "litellm_provider": "xai", "max_input_tokens": 256000, "max_output_tokens": 256000, "max_tokens": 256000, "mode": "chat", "output_cost_per_token": 1.5e-06, "source": "https://docs.x.ai/docs/models", "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true }, "xai/grok-vision-beta": { "input_cost_per_image": 5e-06, "input_cost_per_token": 5e-06, "litellm_provider": "xai", "max_input_tokens": 8192, "max_output_tokens": 8192, "max_tokens": 8192, "mode": "chat", "output_cost_per_token": 1.5e-05, "supports_function_calling": true, "supports_tool_choice": true, "supports_vision": true, "supports_web_search": true }, "vertex_ai/search_api": { "input_cost_per_query": 1.5e-03, "litellm_provider": "vertex_ai", "mode": "vector_store" }, "openai/sora-2": { "litellm_provider": "openai", "mode": "video_generation", "output_cost_per_video_per_second": 0.10, "source": "https://platform.openai.com/docs/api-reference/videos", "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "video" ], "supported_resolutions": [ "720x1280", "1280x720" ] }, "openai/sora-2-pro": { "litellm_provider": "openai", "mode": "video_generation", "output_cost_per_video_per_second": 0.30, "source": "https://platform.openai.com/docs/api-reference/videos", "supported_modalities": [ "text", "image" ], "supported_output_modalities": [ "video" ], "supported_resolutions": [ "720x1280", "1280x720" ] }, "azure/sora-2": { "litellm_provider": "azure", "mode": "video_generation", "output_cost_per_video_per_second": 0.10, "source": "https://azure.microsoft.com/en-us/products/ai-services/video-generation", "supported_modalities": [ "text" ], "supported_output_modalities": [ "video" ], "supported_resolutions": [ "720x1280", "1280x720" ] }, "azure/sora-2-pro": { "litellm_provider": "azure", "mode": "video_generation", "output_cost_per_video_per_second": 0.30, "source": "https://azure.microsoft.com/en-us/products/ai-services/video-generation", "supported_modalities": [ "text" ], "supported_output_modalities": [ "video" ], "supported_resolutions": [ "720x1280", "1280x720" ] }, "azure/sora-2-pro-high-res": { "litellm_provider": "azure", "mode": "video_generation", "output_cost_per_video_per_second": 0.50, "source": "https://azure.microsoft.com/en-us/products/ai-services/video-generation", "supported_modalities": [ "text" ], "supported_output_modalities": [ "video" ], "supported_resolutions": [ "1024x1792", "1792x1024" ] } } ================================================ FILE: cli/src/utils/costCalculator.ts ================================================ import costData from './cost.json' with { type: 'json' }; interface ModelCost { input_cost_per_token?: number; output_cost_per_token?: number; cache_creation_input_token_cost?: number; cache_read_input_token_cost?: number; } /** * Calculate the total cost for a given model and token usage * @param modelId The model identifier (e.g., "us.anthropic.claude-haiku-4-5-20251001-v1:0") * @param inputTokens Number of input tokens used * @param outputTokens Number of output tokens used * @returns Total cost in dollars, or undefined if model cost data not found */ export function calculateCost( modelId: string, inputTokens: number, outputTokens: number ): number | undefined { const modelCostData = (costData as Record)[modelId]; if (!modelCostData) { return undefined; } const inputCostPerToken = modelCostData.input_cost_per_token ?? 0; const outputCostPerToken = modelCostData.output_cost_per_token ?? 0; const inputCost = inputTokens * inputCostPerToken; const outputCost = outputTokens * outputCostPerToken; const totalCost = inputCost + outputCost; return totalCost; } /** * Format cost as a readable string with appropriate precision * @param cost Cost in dollars * @returns Formatted string (e.g., "$0.0023" or "$0.00") */ export function formatCost(cost: number): string { if (cost >= 0.01) { return `$${cost.toFixed(2)}`; } else if (cost >= 0.001) { return `$${cost.toFixed(4)}`; } else if (cost > 0) { return `$${cost.toFixed(6)}`; } else { return "$0.00"; } } ================================================ FILE: cli/src/utils/docsReader.ts ================================================ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const DOCS_DIR = path.resolve(__dirname, '../../..', 'docs'); export interface DocFile { path: string; name: string; content: string; } function _walkDirectory(dir: string, baseDir: string, files: string[] = []): string[] { const entries = fs.readdirSync(dir, { withFileTypes: true }); const resolvedBase = path.resolve(baseDir); for (const entry of entries) { // Validate entry name doesn't contain path traversal sequences if (entry.name.includes('..') || entry.name.includes('/') || entry.name.includes('\\')) { continue; } const fullPath = path.join(dir, entry.name); // Ensure resolved path is still within baseDir const resolvedPath = path.resolve(fullPath); if (!resolvedPath.startsWith(resolvedBase)) { continue; } if (entry.isDirectory()) { _walkDirectory(fullPath, baseDir, files); } else if (entry.isFile() && entry.name.endsWith('.md')) { const relativePath = path.relative(baseDir, fullPath); files.push(relativePath); } } return files; } function _scoreDocument(content: string, fileName: string, keywords: string[]): number { let score = 0; const lowerContent = content.toLowerCase(); const lowerFileName = fileName.toLowerCase(); for (const keyword of keywords) { const lowerKeyword = keyword.toLowerCase(); // Count occurrences in content const contentMatches = (lowerContent.match(new RegExp(lowerKeyword, 'g')) || []).length; score += contentMatches; // Boost score if keyword appears in filename or path if (lowerFileName.includes(lowerKeyword)) { score += 10; } } return score; } export function getAllDocFiles(): string[] { if (!fs.existsSync(DOCS_DIR)) { return []; } return _walkDirectory(DOCS_DIR, DOCS_DIR); } export function readDocFile(filePath: string): DocFile | null { // Reject path traversal sequences and absolute paths if (filePath.includes('..') || path.isAbsolute(filePath)) { throw new Error(`Invalid file path: ${filePath}`); } const fullPath = path.join(DOCS_DIR, filePath); // Resolve paths and ensure result is within DOCS_DIR const resolvedPath = path.resolve(fullPath); const resolvedBase = path.resolve(DOCS_DIR); if (!resolvedPath.startsWith(resolvedBase)) { throw new Error(`Path traversal detected: ${filePath}`); } if (!fs.existsSync(resolvedPath)) { return null; } try { const content = fs.readFileSync(resolvedPath, 'utf-8'); const name = path.basename(filePath); return { path: filePath, name, content }; } catch (error) { return null; } } export function searchDocs(query: string): DocFile[] { const keywords = query.trim().split(/\s+/).filter(k => k.length > 0); if (keywords.length === 0) { return []; } const allFiles = getAllDocFiles(); const scoredDocs: Array<{ doc: DocFile; score: number }> = []; for (const filePath of allFiles) { const doc = readDocFile(filePath); if (!doc) continue; const score = _scoreDocument(doc.content, doc.path, keywords); if (score > 0) { scoredDocs.push({ doc, score }); } } // Sort by score descending and return top 3 scoredDocs.sort((a, b) => b.score - a.score); return scoredDocs.slice(0, 3).map(item => item.doc); } ================================================ FILE: cli/src/utils/markdown.ts ================================================ /** * Render markdown to terminal-friendly format * Simple cleanup for terminal display - doesn't do heavy rendering */ export function renderMarkdown(markdown: string): string { try { let text = markdown; // Render tables before other processing text = renderMarkdownTables(text); // Remove markdown headers but keep the text text = text.replace(/^#{1,6}\s+/gm, ''); // Remove bold/italic markers text = text.replace(/\*\*(.+?)\*\*/g, '$1'); text = text.replace(/\*(.+?)\*/g, '$1'); text = text.replace(/_(.+?)_/g, '$1'); // Keep code blocks simple - just remove the markers text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, (_match, _lang, code) => { return `\n${code.trim()}\n`; }); // Keep inline code with special markers for highlighting // Using ANSI-style markers that Ink will preserve text = text.replace(/`([^`]+)`/g, '`$1`'); // Links - show just the text text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); return text; } catch (error) { // Fallback to plain text if parsing fails return markdown; } } /** * Render markdown tables to a cleaner terminal format */ function renderMarkdownTables(text: string): string { // Match markdown tables (header row, separator row, and data rows) // Allow leading whitespace before the table const tableRegex = /^[ \t]*(\|.+\|)[ \t]*\n[ \t]*(\|(?:[\s:-]+\|)+)[ \t]*\n((?:[ \t]*\|.+\|[ \t]*\n?)*)/gm; return text.replace(tableRegex, (_match, headerRow, _separatorRow, dataRows) => { const parseRow = (row: string): string[] => { return row .split('|') .map(cell => cell.trim()) .filter(cell => cell.length > 0); }; const headers = parseRow(headerRow); const rows = dataRows .trim() .split('\n') .filter((row: string) => row.trim().length > 0) .map(parseRow); // Calculate column widths const columnWidths = headers.map((header, i) => { const maxDataWidth = Math.max(...rows.map((row: string[]) => (row[i] || '').length)); return Math.max(header.length, maxDataWidth); }); // Format a row with proper padding const formatRow = (cells: string[]): string => { return ' ' + cells.map((cell, i) => { const width = columnWidths[i] || 0; return cell.padEnd(width, ' '); }).join(' | '); }; // Build the formatted table const lines: string[] = []; lines.push(''); // Empty line before table lines.push(formatRow(headers)); lines.push(' ' + columnWidths.map((w: number) => '─'.repeat(w)).join('──┼──')); rows.forEach((row: string[]) => lines.push(formatRow(row))); lines.push(''); // Empty line after table return lines.join('\n'); }); } /** * Check if text contains markdown formatting */ export function hasMarkdown(text: string): boolean { const markdownPatterns = [ /^#{1,6}\s/m, // Headers /\*\*.*?\*\*/, // Bold /_.*?_/, // Italic /`.*?`/, // Inline code /```[\s\S]*?```/, // Code blocks /^\s*[-*+]\s/m, // Lists /^\s*\d+\.\s/m, // Numbered lists /\[.*?\]\(.*?\)/, // Links ]; return markdownPatterns.some(pattern => pattern.test(text)); } /** * Format tool output with syntax highlighting hints */ export function formatToolOutput(toolName: string, output: string, isError: boolean = false): string { const status = isError ? "✗" : "✓"; const header = `\n${status} **${toolName}**\n`; // Try to parse as JSON for better formatting try { const parsed = JSON.parse(output); return `${header}\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``; } catch { // Not JSON, return as code block return `${header}\`\`\`\n${output}\n\`\`\``; } } ================================================ FILE: cli/src/utils/tokenRefresh.ts ================================================ import {exec} from "node:child_process"; import {promisify} from "node:util"; import path from "node:path"; const execAsync = promisify(exec); export interface TokenRefreshResult { success: boolean; message: string; } /** * Automatically refresh OAuth tokens by calling generate_creds.sh * @param projectRoot - Path to the project root directory * @returns Result of the token refresh operation */ export async function refreshTokens(projectRoot?: string): Promise { try { // Default to parent of cli directory const root = projectRoot || path.join(process.cwd(), ".."); const scriptPath = path.join(root, "credentials-provider", "generate_creds.sh"); // Check if script exists try { await execAsync(`test -f "${scriptPath}"`); } catch { return { success: false, message: `Token refresh script not found at ${scriptPath}` }; } // Run the script with --ingress-only and --force flags const {stdout, stderr} = await execAsync( `cd "${root}" && ./credentials-provider/generate_creds.sh --ingress-only --force`, { timeout: 30000, // 30 second timeout maxBuffer: 1024 * 1024 // 1MB buffer } ); // Check if successful by looking for success indicators in output const output = stdout + stderr; if (output.includes("Successfully") || output.includes("Token generated") || output.includes("Tokens saved")) { return { success: true, message: "OAuth tokens refreshed successfully" }; } return { success: false, message: `Token refresh completed but status unclear: ${output.substring(0, 200)}` }; } catch (error: any) { return { success: false, message: `Failed to refresh tokens: ${error.message}` }; } } /** * Check if we should attempt automatic token refresh * @param secondsRemaining - Seconds until token expires * @returns true if we should refresh */ export function shouldRefreshToken(secondsRemaining: number | undefined): boolean { // Refresh if token expires in less than 10 seconds or already expired return secondsRemaining !== undefined && secondsRemaining <= 10; } ================================================ FILE: cli/sync_okta_m2m.py ================================================ """CLI script to sync Okta M2M clients to MongoDB. This script connects to MongoDB and syncs all Okta M2M applications, storing their client IDs and group mappings for authorization decisions. """ import asyncio import logging import os import sys from motor.motor_asyncio import AsyncIOMotorClient # Add parent directory to path so we can import registry modules sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from registry.services.okta_m2m_sync import OktaM2MSync logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) async def main(): """Main function to sync Okta M2M clients.""" # Get configuration from environment mongo_uri = os.getenv("DOCUMENTDB_URI", "mongodb://localhost:27017") mongo_db_name = os.getenv("DOCUMENTDB_DB_NAME", "mcp_registry") okta_domain = os.getenv("OKTA_DOMAIN") okta_api_token = os.getenv("OKTA_API_TOKEN") if not okta_domain or not okta_api_token: logger.error("ERROR: OKTA_DOMAIN and OKTA_API_TOKEN environment variables must be set") logger.error("Example:") logger.error(" export OKTA_DOMAIN=integrator-9917255.okta.com") logger.error(" export OKTA_API_TOKEN=your_api_token_here") sys.exit(1) logger.info("=" * 60) logger.info("Okta M2M Client Sync") logger.info("=" * 60) logger.info(f"MongoDB URI: {mongo_uri}") logger.info(f"Database: {mongo_db_name}") logger.info(f"Okta Domain: {okta_domain}") logger.info("=" * 60) # Connect to MongoDB try: mongo_client = AsyncIOMotorClient(mongo_uri) db = mongo_client[mongo_db_name] # Test connection await db.command("ping") logger.info("✓ Connected to MongoDB") except Exception as e: logger.error(f"Failed to connect to MongoDB: {e}") sys.exit(1) # Initialize Okta sync service try: okta_sync = OktaM2MSync( db=db, okta_domain=okta_domain, okta_api_token=okta_api_token, ) # Perform sync logger.info("\nStarting sync from Okta...") result = await okta_sync.sync_from_okta(force_full_sync=True) logger.info("\n" + "=" * 60) logger.info("SYNC COMPLETE") logger.info("=" * 60) logger.info(f"Added: {result['added_count']} clients") logger.info(f"Updated: {result['updated_count']} clients") logger.info(f"Total synced: {result['synced_count']} clients") if result["errors"]: logger.warning(f"\nErrors encountered: {len(result['errors'])}") for error in result["errors"]: logger.warning(f" - {error}") # Display synced clients logger.info("\nSynced clients:") clients = await okta_sync.get_all_clients() for client in clients: logger.info(f" - {client.name} (ID: {client.client_id}, Groups: {client.groups})") logger.info("\n✓ Sync successful!") except Exception as e: logger.exception(f"Sync failed: {e}") sys.exit(1) finally: mongo_client.close() if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: cli/test_a2a_agents.py ================================================ #!/usr/bin/env python3 """ Test A2A Agents Public API Endpoints. This script tests the A2A Agents API endpoints using JWT tokens generated from the MCP Registry UI or credentials provider. Usage: uv run python cli/test_a2a_agents.py --token-file .oauth-tokens/ingress.json uv run python cli/test_a2a_agents.py --token-file .oauth-tokens/ingress.json --test list-agents uv run python cli/test_a2a_agents.py --token-file .oauth-tokens/ingress.json --test get-agent --agent-name test-agent uv run python cli/test_a2a_agents.py --token-file .oauth-tokens/ingress.json --test pagination-flow uv run python cli/test_a2a_agents.py --token-file .oauth-tokens/ingress.json --test all --verbose uv run python cli/test_a2a_agents.py --token-file .oauth-tokens/ingress.json --base-url http://localhost --debug Note: Tokens have a short lifetime for security. If your token expires, generate a new one from the UI or ask your administrator to increase the access token timeout in Keycloak. """ import argparse import base64 import json import logging import sys import time from datetime import UTC, datetime from pathlib import Path from typing import Any from urllib.parse import quote import requests # Add project root to path to import constants SCRIPT_DIR = Path(__file__).parent PROJECT_ROOT = SCRIPT_DIR.parent sys.path.insert(0, str(PROJECT_ROOT)) from registry.constants import REGISTRY_CONSTANTS logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) DEFAULT_BASE_URL: str = "http://localhost" AGENTS_API_VERSION: str = REGISTRY_CONSTANTS.ANTHROPIC_API_VERSION class TestResult: """Container for test results.""" def __init__(self, test_name: str) -> None: """Initialize test result.""" self.test_name = test_name self.passed = False self.duration_ms = 0 self.response = None self.error = None self.message = "" def _check_token_expiration(access_token: str) -> None: """ Check if JWT token is expired and warn if expiring soon. Args: access_token: JWT access token to check """ try: parts = access_token.split(".") if len(parts) != 3: logger.warning("Invalid JWT format, cannot check expiration") return payload = parts[1] padding = len(payload) % 4 if padding: payload += "=" * (4 - padding) decoded = base64.urlsafe_b64decode(payload) token_data = json.loads(decoded) exp = token_data.get("exp") if not exp: logger.warning("Token does not have expiration field") return exp_dt = datetime.fromtimestamp(exp, tz=UTC) now = datetime.now(UTC) time_until_expiry = exp_dt - now if time_until_expiry.total_seconds() < 0: logger.error("=" * 80) logger.error("TOKEN EXPIRED") logger.error("=" * 80) logger.error(f"Token expired at: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}") logger.error(f"Current time is: {now.strftime('%Y-%m-%d %H:%M:%S UTC')}") logger.error(f"Token expired {abs(time_until_expiry.total_seconds())} seconds ago") logger.error("") logger.error("Please regenerate your token:") logger.error(" ./credentials-provider/generate_creds.sh") logger.error("=" * 80) sys.exit(1) elif time_until_expiry.total_seconds() < 120: seconds = int(time_until_expiry.total_seconds()) logger.warning( f"WARNING: Token will expire in {seconds} seconds at {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}" ) else: remaining_seconds = int(time_until_expiry.total_seconds()) logger.info( f"Token is valid until {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} ({remaining_seconds} seconds remaining)" ) except Exception as e: logger.warning(f"Could not check token expiration: {e}") def _load_token_file(token_file_path: Path) -> dict[str, Any]: """ Load token data from JSON file. Args: token_file_path: Path to token JSON file Returns: Token data dictionary """ try: with open(token_file_path) as f: token_data = json.load(f) logger.info(f"Loaded token file: {token_file_path}") return token_data except (OSError, json.JSONDecodeError) as e: logger.error(f"Failed to load token file: {e}") sys.exit(1) def _make_api_request( endpoint: str, access_token: str, base_url: str, method: str = "GET", params: dict[str, Any] | None = None, ) -> dict[str, Any] | None: """ Make an API request to the A2A Agents API. Args: endpoint: API endpoint access_token: JWT access token base_url: Base URL for the API method: HTTP method params: Query parameters Returns: Response JSON or None if request fails """ url = f"{base_url}{endpoint}" headers = {"X-Authorization": f"Bearer {access_token}", "Content-Type": "application/json"} try: logger.debug(f"Making {method} request to: {url}") response = requests.request( method=method, url=url, headers=headers, params=params, timeout=10 ) if response.status_code == 401: logger.warning("Received 401 Unauthorized") return None response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: logger.debug(f"API request failed: {e}") if hasattr(e, "response") and e.response is not None: logger.debug(f"Response status: {e.response.status_code}") logger.debug(f"Response body: {e.response.text}") return None def _format_json_output(data: Any, verbose: bool = False) -> str: """ Format JSON output for display. Args: data: Data to format verbose: Whether to show full output Returns: Formatted JSON string """ if verbose: return json.dumps(data, indent=2) return json.dumps(data, indent=2)[:200] + ("..." if len(json.dumps(data)) > 200 else "") def _print_test_result(result: TestResult, verbose: bool = False) -> None: """ Print formatted test result. Args: result: Test result object verbose: Whether to show full output """ status = "PASS" if result.passed else "FAIL" print(f"[TEST] {result.test_name}: {status} ({result.duration_ms}ms)") if result.message: print(f" {result.message}") if verbose and result.response: print(f" Response: {_format_json_output(result.response, verbose=True)}") if result.error: print(f" Error: {result.error}") print() def _test_list_agents(access_token: str, base_url: str, limit: int = 10) -> TestResult: """ Test listing agents endpoint. Args: access_token: JWT access token base_url: Base URL for the API limit: Number of agents to list Returns: Test result object """ result = TestResult("list-agents") start_time = time.time() endpoint = f"/{AGENTS_API_VERSION}/agents" response = _make_api_request( endpoint=endpoint, access_token=access_token, base_url=base_url, params={"limit": limit} ) result.duration_ms = int((time.time() - start_time) * 1000) if response: result.response = response result.passed = True agents = response.get("agents", []) next_cursor = response.get("metadata", {}).get("nextCursor") result.message = f"{len(agents)} agents returned" if next_cursor: result.message += f", nextCursor={next_cursor}" else: result.error = "Failed to list agents" return result def _test_list_agents_paginated(access_token: str, base_url: str, limit: int = 3) -> TestResult: """ Test pagination endpoint. Args: access_token: JWT access token base_url: Base URL for the API limit: Number of agents per page Returns: Test result object """ result = TestResult("list-agents-paginated") start_time = time.time() endpoint = f"/{AGENTS_API_VERSION}/agents" response = _make_api_request( endpoint=endpoint, access_token=access_token, base_url=base_url, params={"limit": limit} ) result.duration_ms = int((time.time() - start_time) * 1000) if response: result.response = response result.passed = True agents = response.get("agents", []) next_cursor = response.get("metadata", {}).get("nextCursor") result.message = f"Page 1: {len(agents)} agents" if next_cursor: result.message += ", nextCursor available" else: result.error = "Failed to list agents" return result def _test_get_agent(access_token: str, base_url: str, agent_name: str) -> TestResult: """ Test getting specific agent endpoint. Args: access_token: JWT access token base_url: Base URL for the API agent_name: Agent name (URL-encoded or plain) Returns: Test result object """ result = TestResult(f"get-agent ({agent_name})") start_time = time.time() encoded_name = quote(agent_name, safe="") endpoint = f"/{AGENTS_API_VERSION}/agents/{encoded_name}" response = _make_api_request(endpoint=endpoint, access_token=access_token, base_url=base_url) result.duration_ms = int((time.time() - start_time) * 1000) if response: result.response = response result.passed = True agent_data = response.get("agent", {}) name = agent_data.get("name", agent_name) description = agent_data.get("description", "")[:50] result.message = f"Agent name={name}" if description: result.message += f", desc={description}..." else: result.error = "Failed to get agent" return result def _test_get_agent_versions(access_token: str, base_url: str, agent_name: str) -> TestResult: """ Test getting agent versions endpoint. Args: access_token: JWT access token base_url: Base URL for the API agent_name: Agent name (URL-encoded or plain) Returns: Test result object """ result = TestResult(f"get-agent-versions ({agent_name})") start_time = time.time() encoded_name = quote(agent_name, safe="") endpoint = f"/{AGENTS_API_VERSION}/agents/{encoded_name}/versions" response = _make_api_request(endpoint=endpoint, access_token=access_token, base_url=base_url) result.duration_ms = int((time.time() - start_time) * 1000) if response: result.response = response result.passed = True versions = response.get("versions", []) result.message = f"{len(versions)} versions found" else: result.error = "Failed to get agent versions" return result def _test_pagination_flow(access_token: str, base_url: str) -> TestResult: """ Test full pagination flow through pages. Args: access_token: JWT access token base_url: Base URL for the API Returns: Test result object """ result = TestResult("pagination-flow") start_time = time.time() endpoint = f"/{AGENTS_API_VERSION}/agents" all_agents = [] cursor = None page_count = 0 max_pages = 5 try: while page_count < max_pages: params = {"limit": 3} if cursor: params["cursor"] = cursor response = _make_api_request( endpoint=endpoint, access_token=access_token, base_url=base_url, params=params ) if not response: result.error = "Failed to fetch page" break agents = response.get("agents", []) all_agents.extend(agents) page_count += 1 cursor = response.get("metadata", {}).get("nextCursor") if not cursor: break result.duration_ms = int((time.time() - start_time) * 1000) if all_agents: result.response = {"agents": all_agents[:3], "total_collected": len(all_agents)} result.passed = True result.message = f"Collected {len(all_agents)} agents across {page_count} pages" else: result.error = "No agents found" except Exception as e: result.error = str(e) return result def _test_error_invalid_token(base_url: str) -> TestResult: """ Test error handling with invalid token. Args: base_url: Base URL for the API Returns: Test result object """ result = TestResult("error-invalid-token") start_time = time.time() endpoint = f"/{AGENTS_API_VERSION}/agents" url = f"{base_url}{endpoint}" headers = {"X-Authorization": "Bearer invalid_token_here", "Content-Type": "application/json"} try: response = requests.get(url, headers=headers, timeout=10) result.duration_ms = int((time.time() - start_time) * 1000) if response.status_code == 401: result.passed = True result.message = "Correctly returned 401 Unauthorized" result.response = response.json() if response.text else {} else: result.error = f"Expected 401, got {response.status_code}" except requests.exceptions.RequestException as e: result.error = str(e) return result def _test_error_missing_agent(access_token: str, base_url: str) -> TestResult: """ Test error handling with non-existent agent. Args: access_token: JWT access token base_url: Base URL for the API Returns: Test result object """ result = TestResult("error-missing-agent") start_time = time.time() endpoint = f"/{AGENTS_API_VERSION}/agents/non-existent-agent-xyz-123" url = f"{base_url}{endpoint}" headers = {"X-Authorization": f"Bearer {access_token}", "Content-Type": "application/json"} try: response = requests.get(url, headers=headers, timeout=10) result.duration_ms = int((time.time() - start_time) * 1000) if response.status_code == 404: result.passed = True result.message = "Correctly returned 404 Not Found" result.response = response.json() if response.text else {} else: result.error = f"Expected 404, got {response.status_code}" except requests.exceptions.RequestException as e: result.error = str(e) return result def _run_all_tests( access_token: str, base_url: str, agent_name: str | None = None, verbose: bool = False ) -> list[TestResult]: """ Run all API tests. Args: access_token: JWT access token base_url: Base URL for the API agent_name: Optional agent name for specific tests verbose: Show verbose output Returns: List of test results """ logger.info("Running all API tests...") results = [] results.append(_test_list_agents(access_token, base_url, limit=10)) _print_test_result(results[-1], verbose) time.sleep(0.5) results.append(_test_list_agents_paginated(access_token, base_url, limit=3)) _print_test_result(results[-1], verbose) time.sleep(0.5) results.append(_test_pagination_flow(access_token, base_url)) _print_test_result(results[-1], verbose) time.sleep(0.5) if agent_name: results.append(_test_get_agent(access_token, base_url, agent_name)) _print_test_result(results[-1], verbose) time.sleep(0.5) results.append(_test_get_agent_versions(access_token, base_url, agent_name)) _print_test_result(results[-1], verbose) time.sleep(0.5) results.append(_test_error_invalid_token(base_url)) _print_test_result(results[-1], verbose) time.sleep(0.5) results.append(_test_error_missing_agent(access_token, base_url)) _print_test_result(results[-1], verbose) return results def _print_summary(results: list[TestResult]) -> None: """ Print test summary report. Args: results: List of test results """ passed = sum(1 for r in results if r.passed) total = len(results) status = "ALL PASSED" if passed == total else f"{passed}/{total} PASSED" print("=" * 80) print(f"[SUMMARY] {status}") print("=" * 80) for result in results: status_str = "PASS" if result.passed else "FAIL" print(f" {result.test_name:<40} {status_str:<8} {result.duration_ms}ms") def _parse_arguments() -> argparse.Namespace: """ Parse command-line arguments. Returns: Parsed arguments """ parser = argparse.ArgumentParser( description=f"Test A2A Agents API {AGENTS_API_VERSION}", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: uv run python cli/test_a2a_agents.py --token-file .oauth-tokens/ingress.json uv run python cli/test_a2a_agents.py --token-file .oauth-tokens/ingress.json --test list-agents uv run python cli/test_a2a_agents.py --token-file .oauth-tokens/ingress.json --test get-agent --agent-name test-agent uv run python cli/test_a2a_agents.py --token-file .oauth-tokens/ingress.json --test pagination-flow --verbose uv run python cli/test_a2a_agents.py --token-file .oauth-tokens/ingress.json --base-url https://api.example.com --debug Note: If your token expires, generate a new one from the UI. Administrators can increase token lifetime in Keycloak: Realm Settings → Tokens → Access Token Lifespan """, ) parser.add_argument( "--token-file", type=str, required=True, help="Path to token JSON file (e.g., .oauth-tokens/ingress.json)", ) parser.add_argument( "--base-url", type=str, default=DEFAULT_BASE_URL, help=f"Base URL for API (default: {DEFAULT_BASE_URL})", ) parser.add_argument( "--test", type=str, choices=[ "all", "list-agents", "list-agents-paginated", "get-agent", "get-agent-versions", "pagination-flow", "error-invalid-token", "error-missing-agent", ], default="all", help="Which test to run (default: all)", ) parser.add_argument( "--agent-name", type=str, help="Agent name for get-agent or get-agent-versions tests" ) parser.add_argument( "--verbose", action="store_true", help="Show detailed output including full responses" ) parser.add_argument("--debug", action="store_true", help="Enable debug logging") return parser.parse_args() def _execute_test( test_name: str, access_token: str, base_url: str, agent_name: str | None, verbose: bool ) -> list[TestResult]: """ Execute a single test based on test name. Args: test_name: Name of test to execute access_token: JWT access token base_url: Base URL for API agent_name: Optional agent name verbose: Verbose output flag Returns: List of test results """ results = [] if test_name == "all": results = _run_all_tests(access_token, base_url, agent_name, verbose) elif test_name == "list-agents": result = _test_list_agents(access_token, base_url) results.append(result) _print_test_result(result, verbose) elif test_name == "list-agents-paginated": result = _test_list_agents_paginated(access_token, base_url) results.append(result) _print_test_result(result, verbose) elif test_name == "get-agent": if not agent_name: logger.error("--agent-name required for get-agent test") sys.exit(1) result = _test_get_agent(access_token, base_url, agent_name) results.append(result) _print_test_result(result, verbose) elif test_name == "get-agent-versions": if not agent_name: logger.error("--agent-name required for get-agent-versions test") sys.exit(1) result = _test_get_agent_versions(access_token, base_url, agent_name) results.append(result) _print_test_result(result, verbose) elif test_name == "pagination-flow": result = _test_pagination_flow(access_token, base_url) results.append(result) _print_test_result(result, verbose) elif test_name == "error-invalid-token": result = _test_error_invalid_token(base_url) results.append(result) _print_test_result(result, verbose) elif test_name == "error-missing-agent": result = _test_error_missing_agent(access_token, base_url) results.append(result) _print_test_result(result, verbose) return results def main(): """Main entry point.""" args = _parse_arguments() if args.debug: logging.getLogger().setLevel(logging.DEBUG) logger.info("=" * 80) logger.info(f"A2A Agents API {AGENTS_API_VERSION} Test Tool") logger.info("=" * 80) token_file_path = Path(args.token_file) if not token_file_path.exists(): logger.error(f"Token file not found: {token_file_path}") sys.exit(1) token_data = _load_token_file(token_file_path) access_token = None if "tokens" in token_data: access_token = token_data["tokens"].get("access_token") else: access_token = token_data.get("access_token") if not access_token: logger.error("No access_token found in token file") sys.exit(1) logger.info("Access token loaded successfully") logger.info(f"Base URL: {args.base_url}") _check_token_expiration(access_token) results = _execute_test(args.test, access_token, args.base_url, args.agent_name, args.verbose) if results: _print_summary(results) if __name__ == "__main__": main() ================================================ FILE: cli/test_anthropic_api.py ================================================ #!/usr/bin/env python3 """ DEPRECATED: This script is deprecated in favor of the Registry Management API. Use instead: uv run python api/registry_management.py anthropic-list --help uv run python api/registry_management.py anthropic-get --help See api/README.md for full documentation. Test Anthropic MCP Registry API. This script tests the Anthropic MCP Registry API endpoints using JWT tokens generated from the MCP Registry UI. Usage: uv run python cli/test_anthropic_api.py --token-file .oauth-tokens/mcp-registry-api-tokens-2025-10-12.json uv run python cli/test_anthropic_api.py --token-file .oauth-tokens/ingress.json --base-url http://localhost uv run python cli/test_anthropic_api.py --token-file .oauth-tokens/ingress.json --test list-servers uv run python cli/test_anthropic_api.py --token-file .oauth-tokens/ingress.json --test get-server --server-name io.mcpgateway/atlassian Note: Tokens have a short lifetime for security. If your token expires, generate a new one from the UI or ask your administrator to increase the access token timeout in Keycloak. """ print("=" * 80) print("WARNING: This script is DEPRECATED.") print("Please use the Registry Management API instead:") print(" uv run python api/registry_management.py anthropic-list --help") print(" uv run python api/registry_management.py anthropic-get --help") print("See api/README.md for full documentation.") print("=" * 80) print() import argparse import base64 import json import logging import sys import time from datetime import UTC, datetime from pathlib import Path from typing import Any import requests # Add project root to path to import constants SCRIPT_DIR = Path(__file__).parent PROJECT_ROOT = SCRIPT_DIR.parent sys.path.insert(0, str(PROJECT_ROOT)) from registry.constants import REGISTRY_CONSTANTS logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) DEFAULT_BASE_URL: str = "http://localhost" def _check_token_expiration(access_token: str) -> None: """ Check if JWT token is expired and exit with informative message if so. Args: access_token: JWT access token to check Exits: If token is expired or will expire soon """ try: # Decode JWT payload (without verification, just to check expiry) parts = access_token.split(".") if len(parts) != 3: logger.warning("Invalid JWT format, cannot check expiration") return # Decode payload payload = parts[1] # Add padding if needed padding = len(payload) % 4 if padding: payload += "=" * (4 - padding) decoded = base64.urlsafe_b64decode(payload) token_data = json.loads(decoded) # Check expiration exp = token_data.get("exp") if not exp: logger.warning("Token does not have expiration field") return exp_dt = datetime.fromtimestamp(exp, tz=UTC) now = datetime.now(UTC) time_until_expiry = exp_dt - now if time_until_expiry.total_seconds() < 0: # Token is expired logger.error("=" * 80) logger.error("TOKEN EXPIRED") logger.error("=" * 80) logger.error(f"Token expired at: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}") logger.error(f"Current time is: {now.strftime('%Y-%m-%d %H:%M:%S UTC')}") logger.error(f"Token expired {abs(time_until_expiry.total_seconds())} seconds ago") logger.error("") logger.error("Please regenerate your token:") logger.error(" ./credentials-provider/generate_creds.sh") logger.error("=" * 80) sys.exit(1) elif time_until_expiry.total_seconds() < 60: # Token expires soon logger.warning( f"Token will expire in {int(time_until_expiry.total_seconds())} seconds at {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}" ) else: logger.info( f"Token is valid until {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')} ({int(time_until_expiry.total_seconds())} seconds remaining)" ) except Exception as e: logger.warning(f"Could not check token expiration: {e}") def _load_token_file(token_file_path: Path) -> dict[str, Any]: """ Load token data from JSON file. Args: token_file_path: Path to token JSON file Returns: Token data dictionary """ try: with open(token_file_path) as f: token_data = json.load(f) logger.info(f"Loaded token file: {token_file_path}") return token_data except (OSError, json.JSONDecodeError) as e: logger.error(f"Failed to load token file: {e}") sys.exit(1) def _save_token_file(token_file_path: Path, token_data: dict[str, Any]) -> None: """ Save updated token data to JSON file. Args: token_file_path: Path to token JSON file token_data: Token data dictionary """ try: with open(token_file_path, "w") as f: json.dump(token_data, f, indent=2) logger.info(f"Saved updated tokens to: {token_file_path}") except OSError as e: logger.error(f"Failed to save token file: {e}") def _make_api_request( endpoint: str, access_token: str, base_url: str, method: str = "GET", params: dict[str, Any] | None = None, ) -> dict[str, Any] | None: """ Make an API request to the Anthropic MCP Registry API. Args: endpoint: API endpoint (e.g., /{ANTHROPIC_API_VERSION}/servers) access_token: JWT access token base_url: Base URL for the API method: HTTP method params: Query parameters Returns: Response JSON or None if request fails """ url = f"{base_url}{endpoint}" headers = {"X-Authorization": f"Bearer {access_token}", "Content-Type": "application/json"} try: logger.info(f"Making {method} request to: {url}") response = requests.request( method=method, url=url, headers=headers, params=params, timeout=10 ) if response.status_code == 401: logger.warning("Received 401 Unauthorized - token may be expired") return None response.raise_for_status() return response.json() except requests.exceptions.RequestException as e: logger.error(f"API request failed: {e}") if hasattr(e, "response") and e.response is not None: logger.error(f"Response status: {e.response.status_code}") logger.error(f"Response body: {e.response.text}") return None def _test_list_servers(access_token: str, base_url: str, limit: int = 5) -> None: """ Test listing servers endpoint. Args: access_token: JWT access token base_url: Base URL for the API limit: Number of servers to list """ logger.info(f"Testing: List servers (limit={limit})") result = _make_api_request( endpoint=f"/{REGISTRY_CONSTANTS.ANTHROPIC_API_VERSION}/servers", access_token=access_token, base_url=base_url, params={"limit": limit}, ) if result: print("\n" + "=" * 80) print("LIST SERVERS RESPONSE:") print("=" * 80) print(json.dumps(result, indent=2)) print("=" * 80 + "\n") servers = result.get("servers", []) logger.info(f"Found {len(servers)} servers") else: logger.error("Failed to list servers") def _test_get_server_versions(access_token: str, base_url: str, server_name: str) -> None: """ Test getting server versions endpoint. Args: access_token: JWT access token base_url: Base URL for the API server_name: Server name (e.g., io.mcpgateway/atlassian) """ logger.info(f"Testing: Get server versions for {server_name}") encoded_name = server_name.replace("/", "%2F") endpoint = f"/{REGISTRY_CONSTANTS.ANTHROPIC_API_VERSION}/servers/{encoded_name}/versions" result = _make_api_request(endpoint=endpoint, access_token=access_token, base_url=base_url) if result: print("\n" + "=" * 80) print(f"SERVER VERSIONS RESPONSE: {server_name}") print("=" * 80) print(json.dumps(result, indent=2)) print("=" * 80 + "\n") else: logger.error(f"Failed to get versions for {server_name}") def _test_get_server_version_details( access_token: str, base_url: str, server_name: str, version: str = "latest" ) -> None: """ Test getting server version details endpoint. Args: access_token: JWT access token base_url: Base URL for the API server_name: Server name (e.g., io.mcpgateway/atlassian) version: Version (default: latest) """ logger.info(f"Testing: Get server version details for {server_name} v{version}") encoded_name = server_name.replace("/", "%2F") endpoint = ( f"/{REGISTRY_CONSTANTS.ANTHROPIC_API_VERSION}/servers/{encoded_name}/versions/{version}" ) result = _make_api_request(endpoint=endpoint, access_token=access_token, base_url=base_url) if result: print("\n" + "=" * 80) print(f"SERVER VERSION DETAILS: {server_name} v{version}") print("=" * 80) print(json.dumps(result, indent=2)) print("=" * 80 + "\n") else: logger.error(f"Failed to get version details for {server_name}") def _run_all_tests(access_token: str, base_url: str) -> None: """ Run all API tests. Args: access_token: JWT access token base_url: Base URL for the API """ logger.info("Running all API tests...") _test_list_servers(access_token, base_url, limit=10) time.sleep(1) _test_get_server_versions(access_token, base_url, "io.mcpgateway/atlassian") time.sleep(1) _test_get_server_version_details(access_token, base_url, "io.mcpgateway/atlassian", "latest") logger.info("All tests completed") def main(): """Main entry point.""" parser = argparse.ArgumentParser( description=f"Test Anthropic MCP Registry API {REGISTRY_CONSTANTS.ANTHROPIC_API_VERSION}", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Run all tests with default settings uv run python cli/test_anthropic_api.py --token-file .oauth-tokens/mcp-registry-api-tokens-2025-10-12.json # Test specific endpoint uv run python cli/test_anthropic_api.py --token-file .oauth-tokens/ingress.json --test list-servers # Get server details uv run python cli/test_anthropic_api.py --token-file .oauth-tokens/ingress.json --test get-server --server-name io.mcpgateway/atlassian # Custom base URL uv run python cli/test_anthropic_api.py --token-file .oauth-tokens/ingress.json --base-url https://mcpgateway.ddns.net Note: If your token expires, generate a new one from the UI. Administrators can increase token lifetime in Keycloak: Realm Settings → Tokens → Access Token Lifespan """, ) parser.add_argument( "--token-file", type=str, required=True, help="Path to token JSON file (e.g., .oauth-tokens/mcp-registry-api-tokens-2025-10-12.json)", ) parser.add_argument( "--base-url", type=str, default=DEFAULT_BASE_URL, help=f"Base URL for API (default: {DEFAULT_BASE_URL})", ) parser.add_argument( "--test", type=str, choices=["all", "list-servers", "get-versions", "get-server"], default="all", help="Which test to run (default: all)", ) parser.add_argument( "--server-name", type=str, help="Server name for get-versions or get-server tests (e.g., io.mcpgateway/atlassian)", ) parser.add_argument( "--limit", type=int, default=5, help="Number of servers to list (default: 5)" ) parser.add_argument("--debug", action="store_true", help="Enable debug logging") args = parser.parse_args() if args.debug: logging.getLogger().setLevel(logging.DEBUG) logger.info("=" * 80) logger.info(f"Anthropic MCP Registry API {REGISTRY_CONSTANTS.ANTHROPIC_API_VERSION} Test Tool") logger.info("=" * 80) token_file_path = Path(args.token_file) if not token_file_path.exists(): logger.error(f"Token file not found: {token_file_path}") sys.exit(1) token_data = _load_token_file(token_file_path) access_token = None if "tokens" in token_data: access_token = token_data["tokens"].get("access_token") else: access_token = token_data.get("access_token") if not access_token: logger.error("No access_token found in token file") sys.exit(1) logger.info("Access token loaded successfully") logger.info(f"Base URL: {args.base_url}") # Check token expiration before making any API calls _check_token_expiration(access_token) if args.test == "all": _run_all_tests(access_token, args.base_url) elif args.test == "list-servers": _test_list_servers(access_token, args.base_url, args.limit) elif args.test == "get-versions": if not args.server_name: logger.error("--server-name required for get-versions test") sys.exit(1) _test_get_server_versions(access_token, args.base_url, args.server_name) elif args.test == "get-server": if not args.server_name: logger.error("--server-name required for get-server test") sys.exit(1) _test_get_server_version_details(access_token, args.base_url, args.server_name, "latest") # Note: Tokens have a short lifetime for security. If your token expires, # generate a new one from the UI or ask your administrator to increase # the access token timeout in Keycloak (Realm Settings → Tokens → Access Token Lifespan) if __name__ == "__main__": main() ================================================ FILE: cli/test_asor_complete.py ================================================ #!/usr/bin/env python3 """ Complete ASOR API test with token exchange """ import json import os import urllib.parse import requests # Configuration CLIENT_ID = os.getenv("ASOR_CLIENT_ID") CLIENT_SECRET = os.getenv("ASOR_CLIENT_SECRET") TENANT_NAME = os.getenv("ASOR_TENANT_NAME") HOSTNAME = os.getenv("ASOR_HOSTNAME") BASE_URL = f"https://{HOSTNAME}/ccx/api/asor/v1/{TENANT_NAME}" def get_token(): """Get access token via OAuth flow""" print("🔑 OAuth Token Exchange") print("=" * 30) # Generate auth URL auth_url = f"https://wcpdev.wd103.myworkday.com/{TENANT_NAME}/authorize" params = { "response_type": "code", "client_id": CLIENT_ID, "redirect_uri": "https://localhost:7860/callback", "scope": "Agent System of Record", } print(f"1. Visit: {auth_url}?{urllib.parse.urlencode(params)}") auth_code = input("2. Enter authorization code: ").strip() if not auth_code: return None # Exchange code for token token_url = f"https://{HOSTNAME}/ccx/oauth2/{TENANT_NAME}/token" data = { "grant_type": "authorization_code", "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "code": auth_code, "redirect_uri": "https://localhost:7860/callback", } try: response = requests.post(token_url, data=data, timeout=30) if response.status_code == 200: tokens = response.json() access_token = tokens.get("access_token") masked_token = ( f"{access_token[:8]}..." if access_token and len(access_token) > 8 else "***" ) print(f"✅ Token obtained: {masked_token}") return access_token else: print(f"❌ Token exchange failed: {response.status_code} - {response.text}") return None except Exception as e: print(f"❌ Error: {e}") return None def api_call(token, method, endpoint, data=None): """Make ASOR API call""" headers = { "Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json", } url = f"{BASE_URL}{endpoint}" print(url) try: if method == "GET": response = requests.get(url, headers=headers, timeout=15) elif method == "POST": response = requests.post(url, headers=headers, json=data, timeout=15) elif method == "PUT": response = requests.put(url, headers=headers, json=data, timeout=15) return response.status_code, response.text except Exception as e: return None, str(e) def test_agent_definition_crud(token): """Test Agent Definition CRUD operations""" print("\n🤖 Testing Agent Definition API") print("=" * 40) # GET /agentDefinition (list agents) print("1. GET /agentDefinition (list existing agents)") status, response = api_call(token, "GET", "/agentDefinition") if status == 200: print("✅ SUCCESS") try: data = json.loads(response) print(f" Found {data.get('total', 0)} agents") print("dddddddddd") print(json.dumps(data, indent=2)) if data.get("data"): agent = data["data"][0] # Get first agent print("\n 📋 Agent JSON (Pretty Printed):") print(" " + "=" * 50) print(json.dumps(agent, indent=2)) print(" " + "=" * 50) except Exception as e: print(f"⚠️ Failed to parse agent list response: {e}") else: print(f"❌ Failed: {status} - {response[:200]}") if status in [200, 201]: print("✅ Agent created successfully!") print(f" Response: {response[:200]}...") elif status == 400: print(f"⚠️ Bad Request: {response[:300]}") elif status == 403: print("🚫 Forbidden - may need different permissions") else: print(f"❌ Failed: {status} - {response[:200]}") def main(): print("🔍 Complete ASOR API Test Suite with OAuth") print("=" * 50) print(f"Base URL: {BASE_URL}") print() # Get access token token = get_token() if not token: print("❌ Failed to get access token") return # Test main Agent Definition API test_agent_definition_crud(token) print("\n📋 SUMMARY") print("=" * 50) print("✅ OAuth Flow: SUCCESS") print("✅ ASOR API Base URL confirmed working") print("✅ Agent Definition endpoint accessible") print("✅ Ready for MCP Gateway integration") print("\n🔧 Final MCP Gateway Configuration:") print("{") print(' "name": "workday-asor",') print(f' "url": "{BASE_URL}",') print(' "auth_type": "oauth_3lo",') print(' "oauth_config": {') print(f' "client_id": "{CLIENT_ID}",') print(' "client_secret": "***REDACTED***",') print(f' "auth_url": "https://wcpdev.wd103.myworkday.com/{TENANT_NAME}/authorize",') print(f' "token_url": "https://{HOSTNAME}/ccx/oauth2/{TENANT_NAME}/token",') print(' "scope": "Agent System of Record"') print(" }") print("}") if __name__ == "__main__": main() ================================================ FILE: cli/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "jsx": "react-jsx", "lib": [ "ES2022", "DOM" ], "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "outDir": "dist", "rootDir": "src", "types": [ "node" ] }, "include": [ "src/**/*" ] } ================================================ FILE: cli/user_mgmt.sh ================================================ #!/bin/bash # DEPRECATED: This script is deprecated in favor of the Registry Management API # Use: uv run python api/registry_management.py OR cli/registry_cli_wrapper.py # See: api/README.md for documentation # # User Management Script for MCP Gateway Registry # This script manages both M2M (machine-to-machine) service accounts and human users echo "WARNING: This script is DEPRECATED. Please use the Registry Management API instead:" echo " uv run python api/registry_management.py --help" echo " OR cli/registry_cli_wrapper.py --help" echo "See api/README.md for full documentation." echo "" set -e # Configuration ADMIN_URL="http://localhost:8080" REALM="mcp-gateway" ADMIN_USER="admin" ADMIN_PASS="${KEYCLOAK_ADMIN_PASSWORD}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" OAUTH_TOKENS_DIR="$SCRIPT_DIR/../.oauth-tokens" CLIENT_SECRETS_FILE="$OAUTH_TOKENS_DIR/keycloak-client-secrets.txt" # Colors for output GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # Usage function usage() { echo "Usage: $0 {create-m2m|create-human|delete-user|list-users|list-groups} [OPTIONS]" echo "" echo "Commands:" echo " create-m2m - Create M2M service account for machine-to-machine authentication" echo " create-human - Create human user with Keycloak login capabilities" echo " delete-user - Delete a user (M2M or human)" echo " list-users - List all users in the realm" echo " list-groups - List all available groups" echo "" echo "M2M Service Account Options:" echo " -n, --name NAME - Service account name (required)" echo " -g, --groups GROUPS - Comma-separated list of groups (required)" echo " -d, --description DESC - Description of the service account" echo "" echo "Human User Options:" echo " -u, --username USERNAME - Username (required)" echo " -e, --email EMAIL - Email address (required)" echo " -f, --firstname NAME - First name (required)" echo " -l, --lastname NAME - Last name (required)" echo " -g, --groups GROUPS - Comma-separated list of groups (required)" echo " -p, --password PASS - Initial password (optional, will prompt if not provided)" echo "" echo "Delete User Options:" echo " -u, --username USERNAME - Username to delete (required)" echo "" echo "Examples:" echo " # Create M2M service account" echo " $0 create-m2m --name agent-finance-bot --groups 'mcp-servers-finance/read,mcp-servers-finance/execute'" echo "" echo " # Create human user" echo " $0 create-human --username jdoe --email jdoe@example.com --firstname John --lastname Doe --groups 'mcp-servers-restricted/read'" echo "" echo " # Delete user" echo " $0 delete-user --username agent-finance-bot" echo "" echo " # List all users" echo " $0 list-users" echo "" echo " # List all groups" echo " $0 list-groups" } # Function to get admin token get_admin_token() { if [ -z "$ADMIN_PASS" ]; then echo -e "${RED}Error: KEYCLOAK_ADMIN_PASSWORD environment variable is required${NC}" echo "Please set it before running this script:" echo "export KEYCLOAK_ADMIN_PASSWORD=\"your-secure-password\"" exit 1 fi TOKEN=$(curl -s -X POST "$ADMIN_URL/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=$ADMIN_USER" \ -d "password=$ADMIN_PASS" \ -d "grant_type=password" \ -d "client_id=admin-cli" | jq -r '.access_token // empty') if [ -z "$TOKEN" ]; then echo -e "${RED}Failed to get admin token${NC}" exit 1 fi } # Function to list all groups list_groups() { echo -e "${BLUE}Listing all groups in realm '$REALM'${NC}" echo "==============================================" get_admin_token GROUPS=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/groups") echo "$GROUPS" | jq -r '.[] | "\(.name) (ID: \(.id))"' echo "" echo -e "${GREEN}Total groups: $(echo "$GROUPS" | jq '. | length')${NC}" } # Function to list all users list_users() { echo -e "${BLUE}Listing all users in realm '$REALM'${NC}" echo "==============================================" get_admin_token USERS=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/users") echo "$USERS" | jq -r '.[] | "Username: \(.username), Email: \(.email // "N/A"), Enabled: \(.enabled), ID: \(.id)"' echo "" echo -e "${GREEN}Total users: $(echo "$USERS" | jq '. | length')${NC}" } # Function to check if group exists check_group_exists() { local group_name="$1" GROUP_ID=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/groups" | \ jq -r ".[] | select(.name==\"$group_name\") | .id") if [ -z "$GROUP_ID" ] || [ "$GROUP_ID" = "null" ]; then return 1 fi return 0 } # Function to validate groups validate_groups() { local groups_input="$1" IFS=',' read -ra GROUPS_ARRAY <<< "$groups_input" local invalid_groups=() for group in "${GROUPS_ARRAY[@]}"; do group=$(echo "$group" | xargs) # trim whitespace if ! check_group_exists "$group"; then invalid_groups+=("$group") fi done if [ ${#invalid_groups[@]} -gt 0 ]; then echo -e "${RED}Error: The following groups do not exist:${NC}" for group in "${invalid_groups[@]}"; do echo " - $group" done echo "" echo -e "${YELLOW}Available groups:${NC}" curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/groups" | \ jq -r '.[].name' | sed 's/^/ - /' return 1 fi return 0 } # Function to create M2M client create_m2m_client() { local client_id="$1" local description="$2" echo "Creating M2M client: $client_id" # Check if client already exists EXISTING_CLIENT=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/clients?clientId=$client_id" | \ jq -r '.[0].id // empty') if [ -n "$EXISTING_CLIENT" ]; then echo -e "${YELLOW}Client '$client_id' already exists, using existing client${NC}" CLIENT_UUID="$EXISTING_CLIENT" return 0 fi # Create the client CLIENT_JSON="{ \"clientId\": \"$client_id\", \"name\": \"$client_id\", \"description\": \"$description\", \"enabled\": true, \"clientAuthenticatorType\": \"client-secret\", \"serviceAccountsEnabled\": true, \"standardFlowEnabled\": false, \"directAccessGrantsEnabled\": false, \"publicClient\": false, \"protocol\": \"openid-connect\" }" RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$ADMIN_URL/admin/realms/$REALM/clients" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "$CLIENT_JSON") if [ "$RESPONSE" = "201" ]; then echo -e "${GREEN}✓ M2M client created successfully${NC}" # Get the client UUID CLIENT_UUID=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/clients?clientId=$client_id" | \ jq -r '.[0].id') echo "Client UUID: $CLIENT_UUID" else echo -e "${RED}Failed to create M2M client. HTTP: $RESPONSE${NC}" exit 1 fi } # Function to get client secret get_client_secret() { local client_uuid="$1" CLIENT_SECRET=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/clients/$client_uuid/client-secret" | \ jq -r '.value') if [ -z "$CLIENT_SECRET" ] || [ "$CLIENT_SECRET" = "null" ]; then echo -e "${RED}Failed to retrieve client secret${NC}" exit 1 fi } # Function to add groups mapper to client add_groups_mapper() { local client_uuid="$1" echo "Adding groups mapper to client..." # Check if groups mapper already exists EXISTING_MAPPER=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/clients/$client_uuid/protocol-mappers/models" | \ jq -r '.[] | select(.name=="groups") | .id') if [ -n "$EXISTING_MAPPER" ] && [ "$EXISTING_MAPPER" != "null" ]; then echo -e "${GREEN}✓ Groups mapper already exists${NC}" return 0 fi GROUPS_MAPPER='{ "name": "groups", "protocol": "openid-connect", "protocolMapper": "oidc-group-membership-mapper", "consentRequired": false, "config": { "full.path": "false", "id.token.claim": "true", "access.token.claim": "true", "claim.name": "groups", "userinfo.token.claim": "true" } }' RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$ADMIN_URL/admin/realms/$REALM/clients/$client_uuid/protocol-mappers/models" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "$GROUPS_MAPPER") if [ "$RESPONSE" = "201" ] || [ "$RESPONSE" = "409" ]; then echo -e "${GREEN}✓ Groups mapper configured${NC}" else echo -e "${RED}Failed to add groups mapper. HTTP: $RESPONSE${NC}" exit 1 fi } # Function to get service account user ID get_service_account_user() { local client_uuid="$1" SERVICE_ACCOUNT_USER=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/clients/$client_uuid/service-account-user" | \ jq -r '.id') if [ -z "$SERVICE_ACCOUNT_USER" ] || [ "$SERVICE_ACCOUNT_USER" = "null" ]; then echo -e "${RED}Failed to retrieve service account user${NC}" exit 1 fi } # Function to assign user to groups assign_user_to_groups() { local user_id="$1" local groups_input="$2" IFS=',' read -ra GROUPS_ARRAY <<< "$groups_input" for group in "${GROUPS_ARRAY[@]}"; do group=$(echo "$group" | xargs) # trim whitespace # Get group ID GROUP_ID=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/groups" | \ jq -r ".[] | select(.name==\"$group\") | .id") if [ -z "$GROUP_ID" ] || [ "$GROUP_ID" = "null" ]; then echo -e "${RED}Group '$group' not found${NC}" continue fi # Assign to group RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "$ADMIN_URL/admin/realms/$REALM/users/$user_id/groups/$GROUP_ID" \ -H "Authorization: Bearer $TOKEN") if [ "$RESPONSE" = "204" ]; then echo -e "${GREEN}✓ Assigned to group: $group${NC}" else echo -e "${RED}Failed to assign to group '$group'. HTTP: $RESPONSE${NC}" fi done } # Function to refresh all credentials using get-all-client-credentials.sh refresh_all_credentials() { echo "Refreshing all client credentials..." # Call the existing script to regenerate all credential files # Run from project root so it saves to .oauth-tokens/ at the root PROJECT_ROOT="$SCRIPT_DIR/.." KEYCLOAK_SETUP_SCRIPT="$PROJECT_ROOT/keycloak/setup/get-all-client-credentials.sh" if [ -f "$KEYCLOAK_SETUP_SCRIPT" ]; then (cd "$PROJECT_ROOT" && ./keycloak/setup/get-all-client-credentials.sh) echo -e "${GREEN}✓ All credentials refreshed${NC}" else echo -e "${RED}Error: get-all-client-credentials.sh not found at $KEYCLOAK_SETUP_SCRIPT${NC}" exit 1 fi } # Function to generate access token for M2M client generate_access_token() { local client_id="$1" echo "Generating access token for: $client_id" # Call the existing script to generate token and .env files PROJECT_ROOT="$SCRIPT_DIR/.." GENERATE_TOKEN_SCRIPT="$PROJECT_ROOT/keycloak/setup/generate-agent-token.sh" if [ -f "$GENERATE_TOKEN_SCRIPT" ]; then (cd "$PROJECT_ROOT/keycloak/setup" && ./generate-agent-token.sh "$client_id") echo -e "${GREEN}✓ Access token generated${NC}" else echo -e "${RED}Error: generate-agent-token.sh not found at $GENERATE_TOKEN_SCRIPT${NC}" exit 1 fi } # Function to create M2M service account create_m2m_account() { local name="" local groups="" local description="" # Parse arguments while [[ $# -gt 0 ]]; do case $1 in -n|--name) name="$2" shift 2 ;; -g|--groups) groups="$2" shift 2 ;; -d|--description) description="$2" shift 2 ;; *) echo -e "${RED}Unknown option: $1${NC}" usage exit 1 ;; esac done # Validate required parameters if [ -z "$name" ]; then echo -e "${RED}Error: Service account name is required${NC}" usage exit 1 fi if [ -z "$groups" ]; then echo -e "${RED}Error: Groups are required${NC}" usage exit 1 fi if [ -z "$description" ]; then description="M2M service account for $name" fi CLIENT_ID="$name" echo -e "${BLUE}Creating M2M Service Account${NC}" echo "==============================================" echo "Name: $name" echo "Groups: $groups" echo "Description: $description" echo "" # Get admin token get_admin_token # Validate groups if ! validate_groups "$groups"; then exit 1 fi # Create M2M client create_m2m_client "$CLIENT_ID" "$description" # Add groups mapper add_groups_mapper "$CLIENT_UUID" # Get service account user get_service_account_user "$CLIENT_UUID" # Assign to groups assign_user_to_groups "$SERVICE_ACCOUNT_USER" "$groups" # Get client secret get_client_secret "$CLIENT_UUID" # Refresh all credentials using the existing script echo "" refresh_all_credentials # Generate access token and .env file echo "" generate_access_token "$CLIENT_ID" echo "" echo -e "${GREEN}SUCCESS! M2M service account created${NC}" echo "==============================================" echo "Client ID: $CLIENT_ID" echo "Client Secret: $CLIENT_SECRET" echo "Groups: $groups" echo "" echo -e "${YELLOW}Credentials saved to:${NC}" echo " $OAUTH_TOKENS_DIR/${CLIENT_ID}.json (client credentials)" echo " $OAUTH_TOKENS_DIR/${CLIENT_ID}-token.json (access token)" echo " $OAUTH_TOKENS_DIR/${CLIENT_ID}.env (environment variables)" echo " $OAUTH_TOKENS_DIR/keycloak-client-secrets.txt (all client secrets)" echo "" echo -e "${YELLOW}Test the account:${NC}" echo "curl -X POST '$ADMIN_URL/realms/$REALM/protocol/openid-connect/token' \\" echo " -H 'Content-Type: application/x-www-form-urlencoded' \\" echo " -d 'grant_type=client_credentials' \\" echo " -d 'client_id=$CLIENT_ID' \\" echo " -d 'client_secret=$CLIENT_SECRET'" } # Function to create human user create_human_user() { local username="" local email="" local firstname="" local lastname="" local groups="" local password="" # Parse arguments while [[ $# -gt 0 ]]; do case $1 in -u|--username) username="$2" shift 2 ;; -e|--email) email="$2" shift 2 ;; -f|--firstname) firstname="$2" shift 2 ;; -l|--lastname) lastname="$2" shift 2 ;; -g|--groups) groups="$2" shift 2 ;; -p|--password) password="$2" shift 2 ;; *) echo -e "${RED}Unknown option: $1${NC}" usage exit 1 ;; esac done # Validate required parameters if [ -z "$username" ]; then echo -e "${RED}Error: Username is required${NC}" usage exit 1 fi if [ -z "$email" ]; then echo -e "${RED}Error: Email is required${NC}" usage exit 1 fi if [ -z "$firstname" ]; then echo -e "${RED}Error: First name is required${NC}" usage exit 1 fi if [ -z "$lastname" ]; then echo -e "${RED}Error: Last name is required${NC}" usage exit 1 fi if [ -z "$groups" ]; then echo -e "${RED}Error: Groups are required${NC}" usage exit 1 fi # Prompt for password if not provided if [ -z "$password" ]; then echo -n "Enter password for user: " read -s password echo "" echo -n "Confirm password: " read -s password_confirm echo "" if [ "$password" != "$password_confirm" ]; then echo -e "${RED}Error: Passwords do not match${NC}" exit 1 fi fi echo -e "${BLUE}Creating Human User${NC}" echo "==============================================" echo "Username: $username" echo "Email: $email" echo "Name: $firstname $lastname" echo "Groups: $groups" echo "" # Get admin token get_admin_token # Validate groups if ! validate_groups "$groups"; then exit 1 fi # Check if user already exists EXISTING_USER=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/users?username=$username" | \ jq -r '.[0].id // empty') if [ -n "$EXISTING_USER" ]; then echo -e "${RED}Error: User '$username' already exists${NC}" exit 1 fi # Create user USER_JSON="{ \"username\": \"$username\", \"email\": \"$email\", \"firstName\": \"$firstname\", \"lastName\": \"$lastname\", \"enabled\": true, \"emailVerified\": true, \"credentials\": [{ \"type\": \"password\", \"value\": \"$password\", \"temporary\": false }] }" RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$ADMIN_URL/admin/realms/$REALM/users" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "$USER_JSON") if [ "$RESPONSE" = "201" ]; then echo -e "${GREEN}✓ User created successfully${NC}" # Get the user ID USER_ID=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/users?username=$username" | \ jq -r '.[0].id') echo "User ID: $USER_ID" # Assign to groups assign_user_to_groups "$USER_ID" "$groups" echo "" echo -e "${GREEN}SUCCESS! Human user created${NC}" echo "==============================================" echo "Username: $username" echo "Email: $email" echo "Groups: $groups" echo "" echo -e "${YELLOW}User can login to Keycloak at:${NC}" echo "$ADMIN_URL/realms/$REALM/account" echo "" echo -e "${YELLOW}Or authenticate via API:${NC}" echo "curl -X POST '$ADMIN_URL/realms/$REALM/protocol/openid-connect/token' \\" echo " -H 'Content-Type: application/x-www-form-urlencoded' \\" echo " -d 'grant_type=password' \\" echo " -d 'client_id=mcp-gateway-m2m' \\" echo " -d 'username=$username' \\" echo " -d 'password=YOUR_PASSWORD'" else echo -e "${RED}Failed to create user. HTTP: $RESPONSE${NC}" exit 1 fi } # Function to delete user delete_user() { local username="" # Parse arguments while [[ $# -gt 0 ]]; do case $1 in -u|--username) username="$2" shift 2 ;; *) echo -e "${RED}Unknown option: $1${NC}" usage exit 1 ;; esac done # Validate required parameters if [ -z "$username" ]; then echo -e "${RED}Error: Username is required${NC}" usage exit 1 fi echo -e "${BLUE}Deleting User${NC}" echo "==============================================" echo "Username: $username" echo "" # Get admin token get_admin_token # Find user USER_ID=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/users?username=$username" | \ jq -r '.[0].id // empty') if [ -z "$USER_ID" ]; then echo -e "${RED}Error: User '$username' not found${NC}" exit 1 fi # Delete user RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ -X DELETE "$ADMIN_URL/admin/realms/$REALM/users/$USER_ID" \ -H "Authorization: Bearer $TOKEN") if [ "$RESPONSE" = "204" ]; then echo -e "${GREEN}✓ User deleted successfully${NC}" # Refresh all credentials to update files echo "" refresh_all_credentials echo "" echo -e "${GREEN}✓ Credential files updated${NC}" else echo -e "${RED}Failed to delete user. HTTP: $RESPONSE${NC}" exit 1 fi } # Main execution main() { if [ $# -eq 0 ]; then usage exit 1 fi COMMAND=$1 shift case $COMMAND in create-m2m) create_m2m_account "$@" ;; create-human) create_human_user "$@" ;; delete-user) delete_user "$@" ;; list-users) list_users ;; list-groups) list_groups ;; -h|--help|help) usage exit 0 ;; *) echo -e "${RED}Unknown command: $COMMAND${NC}" usage exit 1 ;; esac } # Run main function main "$@" ================================================ FILE: config/grafana/dashboards/dashboard.yml ================================================ apiVersion: 1 providers: - name: 'MCP Dashboards' orgId: 1 folder: '' type: file disableDeletion: false updateIntervalSeconds: 10 allowUiUpdates: true options: path: /etc/grafana/provisioning/dashboards ================================================ FILE: config/grafana/dashboards/mcp-analytics-comprehensive.json ================================================ { "id": null, "title": "MCP Gateway - Analytics Dashboard", "tags": [ "mcp", "analytics", "auth", "tools" ], "timezone": "browser", "refresh": "30s", "time": { "from": "now-1h", "to": "now" }, "panels": [ { "id": 1, "title": "Real-time Protocol Activity", "type": "timeseries", "targets": [ { "legendFormat": "Initialize Rate", "expr": "sum(increase(mcp_tool_executions_total{method=\"initialize\", success=\"true\"}[1m]))" }, { "legendFormat": "Tools List Rate", "expr": "sum(increase(mcp_tool_executions_total{method=\"tools/list\", success=\"true\"}[1m]))" }, { "legendFormat": "Tool Call Rate", "expr": "sum(increase(mcp_tool_executions_total{method=\"tools/call\", success=\"true\"}[1m]))" }, { "legendFormat": "Auth Success Rate", "expr": "sum(increase(mcp_auth_requests_total{success=\"true\"}[1m]))" } ], "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, "options": { "legend": { "displayMode": "table", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Requests per Second", "axisPlacement": "left" }, "unit": "reqps" } } }, { "id": 2, "title": "Authentication Flow Analysis", "type": "timeseries", "targets": [ { "expr": "sum(rate(mcp_auth_requests_total{success=\"true\"}[5m]))", "legendFormat": "Successful Auth" }, { "expr": "sum(rate(mcp_auth_requests_total{success=\"false\"}[5m]))", "legendFormat": "Failed Auth" } ], "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, "options": { "legend": { "displayMode": "table", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Auth Requests per Second", "axisPlacement": "left" }, "unit": "reqps" } } }, { "id": 3, "title": "Authentication Success Rate", "type": "stat", "targets": [ { "expr": "sum(mcp_auth_requests_total{success=\"true\"}) / sum(mcp_auth_requests_total) * 100", "legendFormat": "Success Rate %" } ], "gridPos": { "h": 4, "w": 6, "x": 0, "y": 8 }, "options": { "colorMode": "background", "graphMode": "area" }, "fieldConfig": { "defaults": { "unit": "percent", "thresholds": { "steps": [ { "color": "red", "value": 0 }, { "color": "orange", "value": 85 }, { "color": "green", "value": 95 } ] } } } }, { "id": 4, "title": "Active MCP Servers", "type": "stat", "targets": [ { "expr": "count(count by (server_name)(mcp_tool_executions_total))", "legendFormat": "Active Servers" } ], "gridPos": { "h": 4, "w": 6, "x": 6, "y": 8 }, "options": { "colorMode": "background", "graphMode": "area" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "unit": "short", "thresholds": { "steps": [ { "color": "blue", "value": 0 }, { "color": "green", "value": 3 }, { "color": "green", "value": 10 } ] } } } }, { "id": 5, "title": "Tool Executions per Hour", "type": "stat", "targets": [ { "expr": "sum(increase(mcp_tool_executions_total[1h]))", "legendFormat": "Tools/Hour" } ], "gridPos": { "h": 4, "w": 6, "x": 12, "y": 8 }, "options": { "colorMode": "background", "graphMode": "area" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "unit": "short", "thresholds": { "steps": [ { "color": "blue", "value": 0 }, { "color": "blue", "value": 50 }, { "color": "blue", "value": 100 } ] } } } }, { "id": 6, "title": "Most Popular Tool", "type": "stat", "targets": [ { "expr": "topk(1, sum(mcp_tool_executions_total{method=\"tools/call\"}) by (tool_name))", "legendFormat": "{{tool_name}}" } ], "gridPos": { "h": 4, "w": 6, "x": 18, "y": 8 }, "options": { "colorMode": "background", "textMode": "name" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "steps": [ { "color": "purple", "value": 0 } ] } } } }, { "id": 7, "title": "MCP Latency P95 (by Server & Method)", "type": "timeseries", "targets": [ { "expr": "histogram_quantile(0.95, sum by (le, server_name)(rate(mcp_tool_execution_duration_seconds_bucket[5m])))", "legendFormat": "{{server_name}} P95" }, { "expr": "histogram_quantile(0.95, sum by (le, method)(rate(mcp_tool_execution_duration_seconds_bucket[5m])))", "legendFormat": "{{method}} P95" } ], "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, "options": { "legend": { "displayMode": "table", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Latency (seconds)", "axisPlacement": "left" }, "unit": "s" } } }, { "id": 8, "title": "Request Volume Over Time", "type": "timeseries", "targets": [ { "expr": "sum(rate(mcp_tool_executions_total[5m])) by (method)", "legendFormat": "{{method}}" } ], "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, "options": { "legend": { "displayMode": "table", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Requests per Second", "axisPlacement": "left" }, "unit": "reqps" } } }, { "id": 9, "title": "Error Rate Analysis", "type": "timeseries", "targets": [ { "legendFormat": "Auth Error Rate", "expr": "sum(increase(mcp_auth_requests_total{success=\"false\"}[5m])) / sum(increase(mcp_auth_requests_total[5m])) * 100" }, { "legendFormat": "Tool Execution Error Rate", "expr": "sum(increase(mcp_tool_executions_total{success=\"false\"}[5m])) / sum(increase(mcp_tool_executions_total[5m])) * 100" } ], "gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 }, "options": { "legend": { "displayMode": "table", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Error Rate (%)", "axisPlacement": "left" }, "unit": "percent", "thresholds": { "steps": [ { "color": "green", "value": 0 }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 5 } ] } } } }, { "id": 10, "title": "Average Response Times", "type": "timeseries", "targets": [ { "expr": "avg(rate(mcp_auth_request_duration_seconds_sum[5m])) / avg(rate(mcp_auth_request_duration_seconds_count[5m]))", "legendFormat": "Auth Avg Response Time" }, { "expr": "avg(rate(mcp_tool_execution_duration_seconds_sum[5m])) / avg(rate(mcp_tool_execution_duration_seconds_count[5m]))", "legendFormat": "Tool Exec Avg Response Time" } ], "gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 }, "options": { "legend": { "displayMode": "table", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Response Time (seconds)", "axisPlacement": "left" }, "unit": "s" } } }, { "id": 11, "title": "Server Performance Dashboard", "type": "table", "targets": [ { "expr": "sum(mcp_tool_executions_total) by (server_name)", "legendFormat": "{{server_name}}_total_calls", "format": "table", "instant": true }, { "expr": "sum(increase(mcp_tool_executions_total[1h])) by (server_name)", "legendFormat": "{{server_name}}_calls_per_hour", "format": "table", "instant": true }, { "expr": "count(count by (server_name, tool_name)(mcp_tool_executions_total)) by (server_name)", "legendFormat": "{{server_name}}_unique_tools", "format": "table", "instant": true } ], "gridPos": { "h": 8, "w": 12, "x": 0, "y": 28 }, "transformations": [ { "id": "merge", "options": {} }, { "id": "organize", "options": { "excludeByName": { "Time": true }, "renameByName": { "server_name": "Server", "Value #A": "Total Calls", "Value #B": "Calls/Hour", "Value #C": "Unique Tools" } } } ] }, { "id": 12, "title": "Tool Usage Rankings", "type": "table", "targets": [ { "expr": "sum(mcp_tool_executions_total{method=\"tools/call\"}) by (tool_name)", "legendFormat": "{{tool_name}}", "format": "table", "instant": true }, { "expr": "sum(increase(mcp_tool_executions_total{method=\"tools/call\"}[1h])) by (tool_name)", "legendFormat": "{{tool_name}}_rate", "format": "table", "instant": true } ], "gridPos": { "h": 8, "w": 12, "x": 12, "y": 28 }, "transformations": [ { "id": "merge", "options": {} }, { "id": "organize", "options": { "excludeByName": { "Time": true }, "renameByName": { "tool_name": "Tool Name", "Value #A": "Total Calls", "Value #B": "Calls/Hour" } } } ], "options": { "sortBy": [ { "desc": true, "displayName": "Total Calls" } ] } }, { "id": 13, "title": "MCP Protocol Methods Distribution", "type": "bargauge", "targets": [ { "expr": "topk(10, sum(mcp_tool_executions_total{method!=\"tools/call\"}) by (method))", "legendFormat": "{{method}}" } ], "gridPos": { "h": 8, "w": 8, "x": 0, "y": 36 }, "options": { "orientation": "horizontal", "displayMode": "gradient" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisLabel": "Request Count" }, "unit": "short" } } }, { "id": 14, "title": "Tool Usage by Call Count", "type": "barchart", "targets": [ { "expr": "topk(10, sum(mcp_tool_executions_total{method=\"tools/call\"}) by (tool_name))", "legendFormat": "{{tool_name}}", "instant": true, "format": "table" } ], "gridPos": { "h": 8, "w": 8, "x": 8, "y": 36 }, "options": { "orientation": "vertical", "xTickLabelRotation": -45, "legend": { "displayMode": "hidden" } }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisLabel": "Tool Call Count", "axisPlacement": "left" }, "unit": "short" } } }, { "id": 15, "title": "Client Applications Distribution", "type": "bargauge", "targets": [ { "expr": "topk(10, sum(mcp_tool_executions_total) by (client_name))", "legendFormat": "{{client_name}}" } ], "gridPos": { "h": 8, "w": 8, "x": 16, "y": 36 }, "options": { "orientation": "horizontal", "displayMode": "gradient" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisLabel": "Total Executions" }, "unit": "short" } } }, { "id": 16, "title": "MCP Protocol Flow Analysis", "type": "table", "targets": [ { "expr": "sum(mcp_tool_executions_total{method=\"initialize\"}) by (client_name)", "legendFormat": "{{client_name}}_init", "format": "table", "instant": true }, { "expr": "sum(mcp_tool_executions_total{method=\"tools/list\"}) by (client_name)", "legendFormat": "{{client_name}}_list", "format": "table", "instant": true }, { "expr": "sum(mcp_tool_executions_total{method=\"tools/call\"}) by (client_name)", "legendFormat": "{{client_name}}_call", "format": "table", "instant": true } ], "gridPos": { "h": 8, "w": 12, "x": 0, "y": 44 }, "transformations": [ { "id": "merge", "options": {} }, { "id": "organize", "options": { "excludeByName": { "Time": true }, "renameByName": { "client_name": "Client", "Value #A": "Initialize", "Value #B": "Tools List", "Value #C": "Tool Calls" } } } ] }, { "id": 17, "title": "Authentication Methods Distribution", "type": "bargauge", "targets": [ { "expr": "sum(mcp_auth_requests_total) by (method)", "legendFormat": "{{method}}" } ], "gridPos": { "h": 8, "w": 12, "x": 12, "y": 44 }, "options": { "orientation": "horizontal", "displayMode": "gradient" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisLabel": "Request Count" }, "unit": "short" } } }, { "id": 18, "title": "Tool Execution Success Rate", "type": "timeseries", "targets": [ { "legendFormat": "Success Rate", "expr": "sum(increase(mcp_tool_executions_total{success=\"true\"}[5m])) / sum(increase(mcp_tool_executions_total[5m])) * 100" } ], "gridPos": { "h": 8, "w": 12, "x": 0, "y": 52 }, "options": { "legend": { "displayMode": "list", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Success Rate (%)", "axisPlacement": "left" }, "unit": "percent", "min": 0, "max": 100, "thresholds": { "mode": "absolute", "steps": [ { "value": 0, "color": "red" }, { "value": 90, "color": "yellow" }, { "value": 95, "color": "green" } ] } } } }, { "id": 19, "title": "Session Activity by Client", "type": "bargauge", "targets": [ { "expr": "topk(15, sum(rate(mcp_tool_executions_total[5m])) by (client_name))", "legendFormat": "{{client_name}}" } ], "gridPos": { "h": 8, "w": 12, "x": 12, "y": 52 }, "options": { "orientation": "horizontal", "displayMode": "gradient" }, "fieldConfig": { "defaults": { "color": { "mode": "continuous-GrYlRd" }, "custom": { "axisLabel": "Activity Rate (req/s)" }, "unit": "reqps" } } } ], "templating": { "list": [ { "name": "server", "type": "query", "query": "label_values(mcp_auth_requests_total, server)", "refresh": 1, "includeAll": true, "allValue": ".*" }, { "name": "client", "type": "query", "query": "label_values(mcp_tool_executions_total, client_name)", "refresh": 1, "includeAll": true, "allValue": ".*" }, { "name": "method", "type": "query", "query": "label_values(mcp_tool_executions_total, method)", "refresh": 1, "includeAll": true, "allValue": ".*" } ] }, "schemaVersion": 16, "version": 1 } ================================================ FILE: config/grafana/datasources/prometheus.yml ================================================ apiVersion: 1 datasources: - name: Prometheus type: prometheus access: proxy url: http://prometheus:9090 isDefault: true editable: true ================================================ FILE: config/prometheus.yml ================================================ global: scrape_interval: 15s evaluation_interval: 15s scrape_configs: - job_name: 'prometheus' static_configs: - targets: ['localhost:9090'] - job_name: 'mcp-metrics-service' static_configs: - targets: ['metrics-service:9465'] scrape_interval: 10s metrics_path: /metrics ================================================ FILE: credentials-provider/add_noauth_services.py ================================================ #!/usr/bin/env python3 """ Add No-Auth Services to MCP Configuration This script scans the registry/servers JSON files and adds services with auth_scheme: "none" to the MCP configuration files (vscode_mcp.json and mcp.json). These services only require ingress authentication headers for access. """ import argparse import json import logging import os import sys from pathlib import Path from typing import Any # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def _load_env_file() -> None: """Load environment variables from .env file in project root.""" # Get the project root directory (parent of credentials-provider) script_dir = Path(__file__).parent project_root = script_dir.parent env_file = project_root / ".env" if env_file.exists(): try: with open(env_file) as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: key, value = line.split("=", 1) # Remove quotes if present value = value.strip('"').strip("'") os.environ[key] = value logger.debug(f"Loaded environment variables from {env_file}") except Exception as e: logger.warning(f"Failed to load .env file: {e}") else: logger.debug(f"No .env file found at {env_file}") def _load_json_file(file_path: Path) -> dict[str, Any] | None: """Load and parse a JSON file safely.""" try: with open(file_path, encoding="utf-8") as f: return json.load(f) except (FileNotFoundError, json.JSONDecodeError) as e: logger.error(f"Failed to load {file_path}: {type(e).__name__}") return None def _save_json_file(file_path: Path, data: dict[str, Any], description: str) -> None: """Save data to JSON file safely.""" try: with open(file_path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) os.chmod(file_path, 0o600) logger.info(f"Updated {description}: {file_path}") except Exception as e: logger.error(f"Failed to save {description} to {file_path}: {type(e).__name__}") def _get_registry_servers_dir() -> Path: """Get the path to the registry servers directory.""" script_dir = Path(__file__).parent registry_dir = script_dir.parent / "registry" / "servers" if not registry_dir.exists(): raise FileNotFoundError(f"Registry servers directory not found: {registry_dir}") return registry_dir def _get_oauth_tokens_dir() -> Path: """Get the path to the oauth tokens directory.""" script_dir = Path(__file__).parent tokens_dir = script_dir.parent / ".oauth-tokens" if not tokens_dir.exists(): tokens_dir.mkdir(mode=0o700, parents=True) logger.info(f"Created oauth tokens directory: {tokens_dir}") return tokens_dir def _scan_noauth_services() -> list[dict[str, Any]]: """Scan registry servers and find services with auth_scheme: none.""" registry_dir = _get_registry_servers_dir() noauth_services = [] logger.info(f"Scanning registry servers directory: {registry_dir}") for json_file in registry_dir.glob("*.json"): # Skip server_state.json as requested if json_file.name == "server_state.json": continue server_config = _load_json_file(json_file) if not server_config: continue # Backward-compatible read: prefer auth_scheme, fall back to auth_type auth_scheme = server_config.get("auth_scheme", server_config.get("auth_type", "none")) if auth_scheme == "none": # Extract relevant service information service = { "server_name": server_config.get("server_name", "Unknown"), "path": server_config.get("path", ""), "proxy_pass_url": server_config.get("proxy_pass_url", ""), "supported_transports": server_config.get( "supported_transports", ["streamable-http"] ), "description": server_config.get("description", ""), "file_name": json_file.name, } noauth_services.append(service) logger.info(f"Found no-auth service: {service['server_name']} ({service['path']})") return noauth_services def _get_ingress_headers() -> dict[str, str] | None: """Get ingress authentication headers from tokens file.""" tokens_dir = _get_oauth_tokens_dir() ingress_file = tokens_dir / "ingress.json" # Check AUTH_PROVIDER from environment auth_provider = os.environ.get("AUTH_PROVIDER", "") if auth_provider == "keycloak": # When using Keycloak, get token from agent token file agent_token_file = tokens_dir / "agent-ai-coding-assistant-m2m-token.json" if agent_token_file.exists(): agent_data = _load_json_file(agent_token_file) if agent_data and agent_data.get("access_token"): logger.debug("Using Keycloak agent token for ingress authentication") headers = {"X-Authorization": f"Bearer {agent_data.get('access_token', '')}"} return headers # If no Keycloak token found, fall through to check ingress.json logger.warning("No Keycloak agent token found, trying ingress.json") if not ingress_file.exists(): if auth_provider == "keycloak": logger.warning( "No ingress.json or Keycloak agent token found - no-auth services will have no headers" ) else: logger.warning("No ingress.json file found - no-auth services will have no headers") return None ingress_data = _load_json_file(ingress_file) if not ingress_data: return None headers = { "X-Authorization": f"Bearer {ingress_data.get('access_token', '')}", "X-User-Pool-Id": ingress_data.get("user_pool_id", ""), "X-Client-Id": ingress_data.get("client_id", ""), "X-Region": ingress_data.get("region", "us-east-1"), } return headers def _update_vscode_config( noauth_services: list[dict[str, Any]], ingress_headers: dict[str, str] | None ) -> None: """Update VS Code MCP configuration with no-auth services.""" tokens_dir = _get_oauth_tokens_dir() vscode_file = tokens_dir / "vscode_mcp.json" # Load existing config or create new one config = _load_json_file(vscode_file) or {"mcp": {"servers": {}}} # Ensure structure exists if "mcp" not in config: config["mcp"] = {} if "servers" not in config["mcp"]: config["mcp"]["servers"] = {} registry_url = os.environ.get("REGISTRY_URL", "https://mcpgateway.ddns.net") # Add no-auth services for service in noauth_services: # Use path as server key (remove leading and trailing slashes) server_key = service["path"].strip("/") if not server_key: continue # Construct service URL (handle trailing slashes properly) path = service["path"].rstrip("/") # Check if this server should skip the /mcp suffix (e.g., atlassian) servers_no_mcp_suffix = ["/atlassian"] if path in servers_no_mcp_suffix: service_url = f"{registry_url}{path}" else: service_url = f"{registry_url}{path}/mcp" # Create server configuration server_config = {"url": service_url} # Add headers if ingress auth is available if ingress_headers: server_config["headers"] = ingress_headers.copy() config["mcp"]["servers"][server_key] = server_config logger.info(f"Added {server_key} to VS Code config") _save_json_file(vscode_file, config, "VS Code MCP configuration") def _update_roocode_config( noauth_services: list[dict[str, Any]], ingress_headers: dict[str, str] | None ) -> None: """Update Roocode MCP configuration with no-auth services.""" tokens_dir = _get_oauth_tokens_dir() roocode_file = tokens_dir / "mcp.json" # Load existing config or create new one config = _load_json_file(roocode_file) or {"mcpServers": {}} # Ensure structure exists if "mcpServers" not in config: config["mcpServers"] = {} registry_url = os.environ.get("REGISTRY_URL", "https://mcpgateway.ddns.net") # Add no-auth services for service in noauth_services: # Use path as server key (remove leading and trailing slashes) server_key = service["path"].strip("/") if not server_key: continue # Construct service URL (handle trailing slashes properly) path = service["path"].rstrip("/") # Check if this server should skip the /mcp suffix (e.g., atlassian) servers_no_mcp_suffix = ["/atlassian"] if path in servers_no_mcp_suffix: service_url = f"{registry_url}{path}" else: service_url = f"{registry_url}{path}/mcp" # Determine transport type supported_transports = service.get("supported_transports", ["streamable-http"]) transport_type = supported_transports[0] if supported_transports else "streamable-http" # Create server configuration server_config = { "type": transport_type, "url": service_url, "disabled": False, "alwaysAllow": [], } # Add headers if ingress auth is available if ingress_headers: server_config["headers"] = ingress_headers.copy() config["mcpServers"][server_key] = server_config logger.info(f"Added {server_key} to Roocode config ({transport_type})") _save_json_file(roocode_file, config, "Roocode MCP configuration") def _parse_arguments() -> argparse.Namespace: """Parse command line arguments.""" parser = argparse.ArgumentParser( description="Add no-auth services to MCP configurations", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose debug logging") return parser.parse_args() def main() -> None: """Main function to add no-auth services to MCP configurations.""" try: # Load environment variables from .env file _load_env_file() # Parse command line arguments args = _parse_arguments() # Set logging level based on verbose flag if args.verbose: logging.getLogger().setLevel(logging.DEBUG) logger.debug("Verbose logging enabled") logger.info("🔍 Starting no-auth services discovery and configuration update") # Scan for no-auth services noauth_services = _scan_noauth_services() if not noauth_services: logger.info("No services with auth_scheme: 'none' found") return logger.info(f"Found {len(noauth_services)} no-auth services") # Get ingress authentication headers ingress_headers = _get_ingress_headers() if ingress_headers: logger.info("Using ingress authentication headers for no-auth services") else: logger.warning("No ingress authentication available - services will have no headers") # Update both MCP configuration files _update_vscode_config(noauth_services, ingress_headers) _update_roocode_config(noauth_services, ingress_headers) logger.info("✅ Successfully updated MCP configurations with no-auth services") except Exception as e: logger.error(f"Failed to update MCP configurations: {e}") sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: credentials-provider/agentcore-auth/.env.example ================================================ # AgentCore Gateway Access Token Configuration # Copy this file to .env and update with your values # ============================================================================= # SINGLETON COGNITO CONFIGURATION # ============================================================================= # Amazon Cognito Configuration - shared across all gateways COGNITO_DOMAIN=https://your-cognito-domain.auth.region.amazoncognito.com COGNITO_USER_POOL_ID=region_your_pool_id # Alternative: Auth0 or Other OAuth Provider Configuration # OAUTH_DOMAIN=https://your-domain.auth0.com # ============================================================================= # GATEWAY-SPECIFIC CONFIGURATIONS # ============================================================================= # Support for multiple gateways with _1, _2, _3, etc. suffixes (up to _100) # Each configuration set requires all four parameters # Configuration Set 1 AGENTCORE_CLIENT_ID_1=your_client_id_here AGENTCORE_CLIENT_SECRET_1=your_client_secret_here AGENTCORE_GATEWAY_ARN_1=arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/my-gateway-1 AGENTCORE_SERVER_NAME_1=my-gateway-1 # Configuration Set 2 (uncomment and configure as needed) # AGENTCORE_CLIENT_ID_2=your_client_id_here # AGENTCORE_CLIENT_SECRET_2=your_client_secret_here # AGENTCORE_GATEWAY_ARN_2=arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/my-gateway-2 # AGENTCORE_SERVER_NAME_2=my-gateway-2 # Configuration Set 3 (uncomment and configure as needed) # AGENTCORE_CLIENT_ID_3=your_client_id_here # AGENTCORE_CLIENT_SECRET_3=your_client_secret_here # AGENTCORE_GATEWAY_ARN_3=arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/my-gateway-3 # AGENTCORE_SERVER_NAME_3=my-gateway-3 ================================================ FILE: credentials-provider/agentcore-auth/README.md ================================================ # AgentCore Gateway Access Token Utility A standalone utility for generating OAuth2 access tokens for existing Amazon Bedrock AgentCore Gateways. ## Overview This utility extracts the essential token generation functionality from the main SRE Agent gateway scripts, allowing you to easily generate access tokens for existing gateways without needing the full gateway creation infrastructure. ## Features - Generate OAuth2 access tokens for existing AgentCore Gateways - Support for Amazon Cognito and Auth0 OAuth providers - Flexible configuration via YAML files and environment variables - Minimal dependencies for easy deployment - Command-line interface with comprehensive options ## Prerequisites - Python 3.14+ - AWS credentials configured (if using Cognito) - Access to the OAuth provider (Cognito User Pool or Auth0) - Client ID and Client Secret for your OAuth application ## Installation 1. Copy the `agentcore` folder to your desired location 2. Install dependencies: ```bash cd agentcore uv install # or with pip: pip install -r requirements.txt ``` ## Configuration ### Option 1: Configuration File Create or edit `config.yaml`: ```yaml # Gateway Configuration (optional, for reference) gateway_arn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/your-gateway-id" # Cognito Configuration user_pool_id: "us-west-2_abcdef123" client_id: "your_cognito_client_id" # OAuth Configuration (alternative to Cognito) # oauth_domain: "https://your-domain.auth0.com" # oauth_client_id: "your_oauth_client_id" # oauth_audience: "MCPGateway" ``` ### Option 2: Environment Variables Create a `.env` file: ```env # For Cognito COGNITO_DOMAIN=https://cognito-idp.us-west-2.amazonaws.com/us-west-2_abcdef123 COGNITO_CLIENT_ID=your_cognito_client_id COGNITO_CLIENT_SECRET=your_cognito_client_secret # For Auth0 or other OAuth providers # OAUTH_DOMAIN=https://your-domain.auth0.com # OAUTH_CLIENT_ID=your_oauth_client_id # OAUTH_CLIENT_SECRET=your_oauth_client_secret ``` ## Usage ### Basic Usage Generate a token using configuration file and environment variables: ```bash python get_m2m_token.py ``` ### Advanced Usage ```bash # Specify gateway ARN for reference python get_m2m_token.py --gateway-arn arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/my-gateway # Use custom config file python get_m2m_token.py --config-file my-config.yaml # Save token to custom file python get_m2m_token.py --output-file my-token.txt # Use custom audience for Auth0 python get_m2m_token.py --audience "https://api.mycompany.com" # Enable debug logging python get_m2m_token.py --debug ``` ### Using as a Module ```python from generate_access_token import generate_access_token # Generate token programmatically generate_access_token( gateway_arn="arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/my-gateway", output_file="my_token.txt" ) ``` ## Configuration Priority The utility uses the following priority order for configuration: 1. Environment variables (highest priority) 2. Configuration file values 3. Command-line arguments 4. Default values (lowest priority) ## Environment Variables | Variable | Description | Required | |----------|-------------|----------| | `COGNITO_DOMAIN` | Full Cognito domain URL | Yes* | | `COGNITO_CLIENT_ID` | Cognito App Client ID | Yes | | `COGNITO_CLIENT_SECRET` | Cognito App Client Secret | Yes | | `OAUTH_DOMAIN` | OAuth provider domain (Auth0, etc.) | Yes* | | `OAUTH_CLIENT_ID` | OAuth client ID | Yes* | | `OAUTH_CLIENT_SECRET` | OAuth client secret | Yes* | *Either Cognito or OAuth variables are required, not both. ## Output The utility generates: - `.access_token` file containing the access token (default) - Console output with token expiration information - Logs showing the generation process ## Example Output ``` 2024-07-31 10:30:15,p12345,{get_m2m_token.py:89},INFO,Loaded configuration from config.yaml 2024-07-31 10:30:15,p12345,{get_m2m_token.py:156},INFO,Generating OAuth2 access token... 2024-07-31 10:30:16,p12345,{get_m2m_token.py:76},INFO,Successfully obtained Cognito access token 2024-07-31 10:30:16,p12345,{get_m2m_token.py:98},INFO,Access token saved to .access_token 2024-07-31 10:30:16,p12345,{get_m2m_token.py:100},INFO,Token expires in 3600 seconds 2024-07-31 10:30:16,p12345,{get_m2m_token.py:178},INFO,Token generation completed successfully! Token saved to .access_token ``` ## Troubleshooting ### Common Issues 1. **Missing environment variables** ``` ERROR: Missing required parameters: COGNITO_CLIENT_SECRET ``` Solution: Ensure all required environment variables are set in your `.env` file. 2. **Invalid User Pool ID** ``` ERROR: Invalid User Pool ID format: invalid_pool_id ``` Solution: Ensure the User Pool ID follows the format `region_poolId` (e.g., `us-west-2_abcdef123`). 3. **Authentication failed** ``` ERROR: Error getting token: 401 Client Error: Unauthorized ``` Solution: Verify your client ID and client secret are correct and that the client has the necessary permissions. ### Debug Mode Enable debug logging to see detailed information: ```bash python get_m2m_token.py --debug ``` ## Dependencies Minimal dependencies for easy deployment: - `requests` - HTTP client for OAuth requests - `python-dotenv` - Environment variable loading - `pyyaml` - YAML configuration file parsing ## Security Notes - Never commit `.env` files or access tokens to version control - Access tokens are temporary and should be regenerated as needed - Store client secrets securely using environment variables or secret management systems - The generated access token file (`.access_token`) should be protected with appropriate file permissions ## Integration with AgentCore Once you have generated an access token, you can use it with AgentCore Gateway APIs: ```bash # Use the generated token in API requests TOKEN=$(cat .access_token) curl -H "Authorization: Bearer $TOKEN" https://your-gateway-url/api/endpoint ``` ## Support For issues related to: - Gateway creation: See the main SRE Agent documentation - OAuth configuration: Consult your OAuth provider documentation - This utility: Check the troubleshooting section above ================================================ FILE: credentials-provider/agentcore-auth/get_m2m_token.py ================================================ #!/usr/bin/env python3 """ AgentCore Gateway Access Token Generator This standalone utility generates OAuth2 access tokens for existing AgentCore Gateways using Cognito or other OAuth providers. It can be used independently from the main gateway creation scripts. Usage: # Using Cognito (default) python generate_access_token.py # Using custom gateway ARN python generate_access_token.py --gateway-arn arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/my-gateway # Using environment variables export COGNITO_DOMAIN=https://your-cognito-domain.auth.us-west-2.amazoncognito.com export COGNITO_CLIENT_ID=your_client_id export COGNITO_CLIENT_SECRET=your_client_secret python generate_access_token.py """ import argparse import json import logging import os import time from datetime import UTC, datetime from pathlib import Path from typing import Any from urllib.parse import urlparse import requests from dotenv import load_dotenv # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def _load_gateway_configs() -> list[dict[str, Any]]: """ Load gateway configurations from environment variables. Supports multiple configurations with _1, _2, _3 suffixes. Returns: List of gateway configuration dictionaries """ configs = [] # Check for numbered configurations (up to 100) for i in range(1, 101): client_id = os.environ.get(f"AGENTCORE_CLIENT_ID_{i}") client_secret = os.environ.get(f"AGENTCORE_CLIENT_SECRET_{i}") gateway_arn = os.environ.get(f"AGENTCORE_GATEWAY_ARN_{i}") server_name = os.environ.get(f"AGENTCORE_SERVER_NAME_{i}") # If we find a configuration set, add it if client_id and client_secret: config = { "client_id": client_id, "client_secret": client_secret, "gateway_arn": gateway_arn, "server_name": server_name, "index": i, } configs.append(config) logger.debug(f"Found gateway configuration #{i}: {server_name or 'unnamed'}") elif any([client_id, client_secret, gateway_arn, server_name]): # Partial configuration found - warn user logger.warning(f"Incomplete configuration set #{i} - skipping") return configs def _extract_cognito_region_from_pool_id(user_pool_id: str) -> str: """ Extract Cognito region from User Pool ID. Args: user_pool_id: Cognito User Pool ID (format: region_poolId) Returns: AWS region extracted from pool ID """ try: return user_pool_id.split("_")[0] except (IndexError, AttributeError): logger.error(f"Invalid User Pool ID format: {user_pool_id}") raise ValueError(f"Invalid User Pool ID format: {user_pool_id}") def _get_cognito_token( cognito_domain_url: str, client_id: str, client_secret: str, audience: str = "MCPGateway", ) -> dict[str, Any]: """ Get OAuth2 token from Amazon Cognito or Auth0 using client credentials grant type. Args: cognito_domain_url: The full Cognito/Auth0 domain URL client_id: The App Client ID client_secret: The App Client Secret audience: The audience for the token (default: MCPGateway) Returns: Token response containing access_token, expires_in, token_type """ # Construct the token endpoint URL parsed_domain = urlparse(cognito_domain_url) domain_hostname = parsed_domain.hostname or "" is_auth0 = domain_hostname == "auth0.com" or domain_hostname.endswith(".auth0.com") if is_auth0: url = f"{cognito_domain_url.rstrip('/')}/oauth/token" # Use JSON format for Auth0 headers = {"Content-Type": "application/json"} data = { "client_id": client_id, "client_secret": client_secret, "audience": audience, "grant_type": "client_credentials", "scope": "invoke:gateway", } # Send as JSON for Auth0 response_method = lambda: requests.post(url, headers=headers, json=data, timeout=30) else: # Cognito format url = f"{cognito_domain_url.rstrip('/')}/oauth2/token" headers = {"Content-Type": "application/x-www-form-urlencoded"} data = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, } # Send as form data for Cognito response_method = lambda: requests.post(url, headers=headers, data=data, timeout=30) try: # Make the request response = response_method() response.raise_for_status() # Raise exception for bad status codes provider_type = "Auth0" if is_auth0 else "Cognito" logger.info(f"Successfully obtained {provider_type} access token") return response.json() except requests.exceptions.RequestException as e: logger.error(f"Error getting token: {type(e).__name__}") if hasattr(response, "text") and response.text: logger.debug("Token endpoint returned an error response") raise def _save_egress_token( token_response: dict[str, Any], provider: str = "bedrock-agentcore", server_name: str | None = None, oauth_tokens_dir: str = ".oauth-tokens", ) -> str: """ Save the access token as an egress token file following the same structure as Atlassian tokens. Args: token_response: Token response from OAuth provider provider: Auth provider name (default: bedrock-agentcore) server_name: Server name from config (for filename) oauth_tokens_dir: Path to .oauth-tokens directory Returns: Path to the saved token file """ # Create oauth-tokens directory if it doesn't exist tokens_dir = Path(oauth_tokens_dir) tokens_dir.mkdir(exist_ok=True, mode=0o700) # Calculate expiration timestamp and human-readable format expires_in = token_response.get("expires_in", 10800) # Default 3 hours current_time = time.time() expires_at = current_time + expires_in expires_at_human = datetime.fromtimestamp(expires_at, tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC") saved_at = datetime.fromtimestamp(current_time, tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC") # Build egress token data structure egress_data = { "provider": provider, "access_token": token_response["access_token"], "expires_at": expires_at, "expires_at_human": expires_at_human, "token_type": token_response.get("token_type", "Bearer"), # nosec B105 - OAuth2 standard token type per RFC 6750 "scope": token_response.get("scope", "invoke:gateway"), "saved_at": saved_at, "usage_notes": f"This token is for EGRESS authentication to {provider} external services", } # Add refresh token if present (though Cognito client credentials doesn't have refresh tokens) if "refresh_token" in token_response: egress_data["refresh_token"] = token_response["refresh_token"] # Determine filename: {provider}-{server_name}-egress.json or {provider}-egress.json if server_name: filename = f"{provider}-{server_name.lower()}-egress.json" else: filename = f"{provider}-egress.json" # Save to file egress_path = tokens_dir / filename with open(egress_path, "w") as f: json.dump(egress_data, f, indent=2) # Set secure file permissions egress_path.chmod(0o600) logger.info(f"Egress token saved to {egress_path}") logger.info(f"Token expires at: {expires_at_human} (in {expires_in} seconds)") return str(egress_path) def _get_cognito_domain_from_env() -> tuple[str, str | None]: """ Get Cognito domain and user pool ID from environment variables. Returns: Tuple of (cognito_domain, user_pool_id) """ cognito_domain = os.environ.get("COGNITO_DOMAIN") or os.environ.get("OAUTH_DOMAIN") user_pool_id = os.environ.get("COGNITO_USER_POOL_ID") # If no domain provided, try to construct from user_pool_id if not cognito_domain and user_pool_id: cognito_region = _extract_cognito_region_from_pool_id(user_pool_id) cognito_domain = f"https://cognito-idp.{cognito_region}.amazonaws.com/{user_pool_id}" logger.info("Constructed Cognito domain from pool ID") return cognito_domain, user_pool_id def generate_access_token( gateway_index: int | None = None, gateway_name: str | None = None, oauth_tokens_dir: str = ".oauth-tokens", audience: str = "MCPGateway", generate_all: bool = False, ) -> None: """ Generate access token for AgentCore Gateway using environment variables. Args: gateway_index: Index of gateway configuration to use (1-100) gateway_name: Name of gateway to generate token for oauth_tokens_dir: Path to .oauth-tokens directory audience: Token audience for OAuth providers generate_all: Generate tokens for all configured gateways """ # Load environment variables load_dotenv() # Get singleton Cognito configuration cognito_domain, user_pool_id = _get_cognito_domain_from_env() if not cognito_domain: raise ValueError("COGNITO_DOMAIN or COGNITO_USER_POOL_ID must be set in .env file") # Load gateway configurations gateway_configs = _load_gateway_configs() if not gateway_configs: raise ValueError( "No gateway configurations found. Please set AGENTCORE_CLIENT_ID_1, AGENTCORE_CLIENT_SECRET_1, etc. in .env file" ) # Determine which configurations to process configs_to_process = [] if generate_all: configs_to_process = gateway_configs logger.info(f"Generating tokens for all {len(gateway_configs)} configured gateways") elif gateway_index: config = next((c for c in gateway_configs if c["index"] == gateway_index), None) if not config: raise ValueError(f"No configuration found for index {gateway_index}") configs_to_process = [config] elif gateway_name: config = next((c for c in gateway_configs if c.get("server_name") == gateway_name), None) if not config: available_names = [ c.get("server_name", f"config_{c['index']}") for c in gateway_configs ] raise ValueError( f"No configuration found for gateway '{gateway_name}'. Available: {', '.join(available_names)}" ) configs_to_process = [config] else: # Default to first configuration configs_to_process = [gateway_configs[0]] logger.info("Using first gateway configuration (index 1)") # Resolve oauth_tokens_dir path relative to current working directory if not Path(oauth_tokens_dir).is_absolute(): oauth_tokens_path = Path.cwd() / oauth_tokens_dir else: oauth_tokens_path = Path(oauth_tokens_dir) # Process each configuration for config in configs_to_process: client_id = config["client_id"] client_secret = config["client_secret"] gateway_arn = config.get("gateway_arn") server_name = config.get("server_name") logger.info( f"\nProcessing gateway configuration #{config['index']}: {server_name or 'unnamed'}" ) if gateway_arn: logger.debug(f"Gateway ARN: {gateway_arn}") logger.info("Generating OAuth2 access token...") try: # Generate token token_response = _get_cognito_token( cognito_domain_url=cognito_domain, client_id=client_id, client_secret=client_secret, audience=audience, ) # Save token as egress token file saved_path = _save_egress_token( token_response=token_response, provider="bedrock-agentcore", server_name=server_name, oauth_tokens_dir=str(oauth_tokens_path), ) logger.info("Token generation completed successfully!") except Exception as e: config_label = server_name or f"config_{config['index']}" logger.error(f"Failed to generate token for {config_label}: {type(e).__name__}") if not generate_all: raise def _parse_arguments() -> argparse.Namespace: """ Parse command line arguments. Returns: Parsed command line arguments """ parser = argparse.ArgumentParser( description="Generate OAuth2 access tokens for AgentCore Gateways", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Generate token for first configured gateway python generate_access_token.py # Generate token for specific gateway by index python generate_access_token.py --gateway-index 2 # Generate token for specific gateway by name python generate_access_token.py --gateway-name sre-gateway # Generate tokens for ALL configured gateways python generate_access_token.py --all # Custom oauth-tokens directory python generate_access_token.py --oauth-tokens-dir /path/to/.oauth-tokens # Custom audience for Auth0 python generate_access_token.py --audience "https://api.mycompany.com" Environment Variables: # Singleton configuration (shared across all gateways): COGNITO_DOMAIN - Cognito/OAuth domain URL COGNITO_USER_POOL_ID - Cognito User Pool ID # Per-gateway configuration (use _1, _2, etc. suffixes): AGENTCORE_CLIENT_ID_1 - OAuth client ID for gateway 1 AGENTCORE_CLIENT_SECRET_1 - OAuth client secret for gateway 1 AGENTCORE_GATEWAY_ARN_1 - Gateway ARN for gateway 1 AGENTCORE_SERVER_NAME_1 - Server name for gateway 1 """, ) parser.add_argument( "--gateway-index", type=int, help="Index of gateway configuration to use (1-100)", ) parser.add_argument( "--gateway-name", help="Name of gateway to generate token for", ) parser.add_argument( "--all", action="store_true", help="Generate tokens for all configured gateways", ) parser.add_argument( "--oauth-tokens-dir", default=".oauth-tokens", help="Path to .oauth-tokens directory (default: .oauth-tokens)", ) parser.add_argument( "--audience", default="MCPGateway", help="Token audience (default: MCPGateway)", ) parser.add_argument( "--debug", action="store_true", help="Enable debug logging", ) return parser.parse_args() def main() -> None: """Main entry point.""" args = _parse_arguments() if args.debug: logging.getLogger().setLevel(logging.DEBUG) try: generate_access_token( gateway_index=args.gateway_index, gateway_name=args.gateway_name, oauth_tokens_dir=args.oauth_tokens_dir, audience=args.audience, generate_all=args.all, ) except Exception as e: logger.error(f"Token generation failed: {e}") exit(1) if __name__ == "__main__": main() ================================================ FILE: credentials-provider/auth0/README.md ================================================ # Auth0 Credentials Provider Get M2M (Machine-to-Machine) JWT tokens from Auth0 using OAuth2 client credentials flow. ## Prerequisites 1. **Auth0 M2M Application**: Create a Machine-to-Machine application in Auth0 2. **Management API Authorization**: Authorize the M2M app for Auth0 Management API 3. **Required Scopes**: Grant appropriate scopes (e.g., `read:users`, `create:users`, `read:roles`, etc.) ## Installation No additional dependencies required beyond the main project dependencies (`requests`, `PyJWT`). ## Usage ### Method 1: Environment Variables (Recommended) ```bash export AUTH0_DOMAIN=dev-abc123.us.auth0.com export AUTH0_M2M_CLIENT_ID=your_m2m_client_id export AUTH0_M2M_CLIENT_SECRET=your_m2m_client_secret uv run python -m credentials-provider.auth0.get_m2m_token ``` ### Method 2: Command-Line Arguments ```bash uv run python -m credentials-provider.auth0.get_m2m_token \ --auth0-domain dev-abc123.us.auth0.com \ --client-id your_m2m_client_id \ --client-secret your_m2m_client_secret ``` ### Custom API Audience By default, the script requests tokens for the Auth0 Management API (`https://{domain}/api/v2/`). To request tokens for a custom API: ```bash uv run python -m credentials-provider.auth0.get_m2m_token \ --audience https://my-custom-api.example.com ``` ### Options - `--auth0-domain`: Auth0 domain (e.g., `dev-abc123.us.auth0.com`) - `--client-id`: OAuth2 M2M client ID - `--client-secret`: OAuth2 M2M client secret - `--audience`: API audience (default: Management API) - `--show-token`: Display decoded token claims (default: true) - `--no-show-token`: Skip displaying token claims - `--debug`: Enable debug logging ## Output The script: 1. Requests an M2M token from Auth0 2. Displays decoded token claims (unless `--no-show-token` is specified) 3. Saves the token to a temporary file in `/tmp/` 4. Prints the file path to stdout Example output: ``` Token saved to: /tmp/auth0_m2m_token_abc123.json ``` The token file contains: ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIs...", "token_type": "Bearer", "expires_in": 86400 } ``` ## Token Lifetime Auth0 M2M tokens typically have a 24-hour lifetime (86400 seconds). The token expiration is displayed when `--show-token` is enabled. ## Security - Token files are created with restrictive permissions (0600) - Tokens are stored in `/tmp/` which is cleared on system restart - Never commit tokens or credentials to version control - Use environment variables or secure secret management for credentials ## Troubleshooting ### Error: "Auth0 domain must be provided" Set the `AUTH0_DOMAIN` environment variable or use the `--auth0-domain` flag. ### Error: "Client ID must be provided" Set the `AUTH0_M2M_CLIENT_ID` environment variable or use the `--client-id` flag. ### Error: "Client secret must be provided" Set the `AUTH0_M2M_CLIENT_SECRET` environment variable or use the `--client-secret` flag. ### Error: "M2M token request failed" Check that: 1. Your M2M application is authorized for the target API 2. The client ID and secret are correct 3. The audience matches your API identifier 4. Network connectivity to Auth0 is available ## Integration with MCP Gateway The MCP Gateway Registry uses these credentials to manage users and roles via the Auth0 Management API. The credentials are configured in: - `.env` file: `AUTH0_DOMAIN`, `AUTH0_M2M_CLIENT_ID`, `AUTH0_M2M_CLIENT_SECRET` - Terraform: `terraform/aws-ecs/variables.tf` and `terraform.tfvars` - Docker Compose: `docker-compose.yml` - Helm: `charts/*/values.yaml` ## Related Files - `registry/utils/auth0_manager.py`: Auth0 Management API integration - `registry/utils/iam_manager.py`: IAM manager factory including Auth0 ================================================ FILE: credentials-provider/auth0/__init__.py ================================================ """Auth0 credentials provider module.""" ================================================ FILE: credentials-provider/auth0/get_m2m_token.py ================================================ """Get Auth0 M2M token using client credentials flow. This script obtains a JWT token from Auth0 using OAuth2 client credentials grant. The token is saved to a temporary file and the file path is printed. """ import argparse import json import logging import os import sys import tempfile import jwt import requests # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def _get_auth0_domain() -> str: """Get Auth0 domain from CLI arg or environment variable. Returns: Auth0 domain (e.g., dev-abc123.us.auth0.com) Raises: ValueError: If domain not provided """ domain = os.getenv("AUTH0_DOMAIN") if domain: return domain.replace("https://", "").rstrip("/") raise ValueError("Auth0 domain must be provided via --auth0-domain or AUTH0_DOMAIN env var") def _get_client_id() -> str: """Get client ID from CLI arg or environment variable. Returns: Auth0 M2M client ID Raises: ValueError: If client ID not provided """ client_id = os.getenv("AUTH0_M2M_CLIENT_ID") if client_id: return client_id raise ValueError("Client ID must be provided via --client-id or AUTH0_M2M_CLIENT_ID env var") def _get_client_secret() -> str: """Get client secret from CLI arg or environment variable. Returns: Auth0 M2M client secret Raises: ValueError: If client secret not provided """ client_secret = os.getenv("AUTH0_M2M_CLIENT_SECRET") if client_secret: return client_secret raise ValueError( "Client secret must be provided via --client-secret or AUTH0_M2M_CLIENT_SECRET env var" ) def _request_m2m_token( auth0_domain: str, client_id: str, client_secret: str, audience: str | None = None, ) -> dict[str, str]: """Request M2M token from Auth0 using client credentials. Args: auth0_domain: Auth0 domain (e.g., dev-abc123.us.auth0.com) client_id: OAuth2 client ID client_secret: OAuth2 client secret audience: API audience (defaults to Management API: https://{domain}/api/v2/) Returns: Token response dictionary with access_token, token_type, expires_in Raises: ValueError: If token request fails """ # Default to Management API audience if not provided if not audience: audience = f"https://{auth0_domain}/api/v2/" token_url = f"https://{auth0_domain}/oauth/token" logger.info(f"Requesting M2M token from {token_url}") logger.info(f"Audience: {audience}") data = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "audience": audience, } headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", } try: response = requests.post( token_url, data=data, headers=headers, timeout=30, ) # Log response details for debugging if response.status_code != 200: try: error_data = response.json() logger.error(f"Auth0 error response: {json.dumps(error_data, indent=2)}") except Exception: logger.error(f"Auth0 error response (non-JSON): {response.text}") response.raise_for_status() token_data = response.json() logger.info( f"Successfully obtained M2M token, expires in {token_data.get('expires_in', 'unknown')} seconds" ) return token_data except requests.RequestException as e: logger.error(f"Failed to get M2M token: {e}") raise ValueError(f"M2M token request failed: {e}") def _decode_token(access_token: str) -> dict[str, str]: """Decode JWT token without verification to display claims. Args: access_token: JWT access token string Returns: Dictionary of decoded token claims """ try: claims = jwt.decode(access_token, options={"verify_signature": False}) return claims except Exception as e: logger.warning(f"Failed to decode token: {e}") return {} def _display_decoded_token(claims: dict[str, str]) -> None: """Display decoded token claims in a readable format. Args: claims: Dictionary of decoded JWT claims """ if not claims: return print("\n" + "=" * 60) print("DECODED JWT TOKEN CLAIMS") print("=" * 60) print(json.dumps(claims, indent=2)) print("\n" + "=" * 60) print("KEY INFORMATION") print("=" * 60) print(f"Grant ID (gty): {claims.get('gty', 'N/A')}") print(f"Azure AD (azp): {claims.get('azp', 'N/A')}") print(f"Subject (sub): {claims.get('sub', 'N/A')}") print(f"Issuer (iss): {claims.get('iss', 'N/A')}") print(f"Audience (aud): {claims.get('aud', 'N/A')}") print(f"Scopes (scope): {claims.get('scope', 'N/A')}") print(f"Permissions: {claims.get('permissions', [])}") # Display expiration info if "exp" in claims and "iat" in claims: from datetime import datetime exp_time = datetime.fromtimestamp(claims["exp"]) iat_time = datetime.fromtimestamp(claims["iat"]) lifetime_hours = (claims["exp"] - claims["iat"]) / 3600 print(f"\nIssued at: {iat_time} UTC") print(f"Expires at: {exp_time} UTC") print(f"Lifetime: {lifetime_hours:.1f} hours") print("=" * 60 + "\n") def _save_token_to_file(token_data: dict[str, str]) -> str: """Save token data to temporary file. Args: token_data: Token response dictionary Returns: Path to temporary file containing token """ # Create temporary file with secure permissions (0600) fd, temp_path = tempfile.mkstemp( prefix="auth0_m2m_token_", suffix=".json", dir="/tmp", ) try: # Write token data as JSON with os.fdopen(fd, "w") as f: json.dump(token_data, f, indent=2) # Ensure file has restrictive permissions os.chmod(temp_path, 0o600) logger.info(f"Token saved to {temp_path}") return temp_path except Exception as e: # Clean up on error try: os.unlink(temp_path) except Exception: pass raise ValueError(f"Failed to save token to file: {e}") def main() -> None: """Main function to get Auth0 M2M token and save to file.""" parser = argparse.ArgumentParser( description="Get Auth0 M2M token using client credentials flow", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Example usage: # Using environment variables export AUTH0_DOMAIN=dev-abc123.us.auth0.com export AUTH0_M2M_CLIENT_ID=KhZMijfKUcl2TEJqZzrzVJb8rmwk6Qcd export AUTH0_M2M_CLIENT_SECRET=lbjH6Z81GkovgAHwXRV-qiKV9f6sUVzsnheJoX7KJcu2ojGXMTjJ4i0Zn49kKfVm uv run python -m credentials-provider.auth0.get_m2m_token # Using CLI arguments (Management API) uv run python -m credentials-provider.auth0.get_m2m_token \\ --auth0-domain dev-abc123.us.auth0.com \\ --client-id KhZMijfKUcl2TEJqZzrzVJb8rmwk6Qcd \\ --client-secret lbjH6Z81GkovgAHwXRV-qiKV9f6sUVzsnheJoX7KJcu2ojGXMTjJ4i0Zn49kKfVm # Custom API audience uv run python -m credentials-provider.auth0.get_m2m_token \\ --auth0-domain dev-abc123.us.auth0.com \\ --client-id KhZMijfKUcl2TEJqZzrzVJb8rmwk6Qcd \\ --client-secret lbjH6Z81GkovgAHwXRV-qiKV9f6sUVzsnheJoX7KJcu2ojGXMTjJ4i0Zn49kKfVm \\ --audience https://my-api.example.com """, ) parser.add_argument( "--auth0-domain", type=str, help="Auth0 domain (e.g., dev-abc123.us.auth0.com). Can also use AUTH0_DOMAIN env var.", ) parser.add_argument( "--client-id", type=str, help="OAuth2 M2M client ID. Can also use AUTH0_M2M_CLIENT_ID env var.", ) parser.add_argument( "--client-secret", type=str, help="OAuth2 M2M client secret. Can also use AUTH0_M2M_CLIENT_SECRET env var.", ) parser.add_argument( "--audience", type=str, help="API audience (default: https://{domain}/api/v2/ for Management API)", ) parser.add_argument( "--show-token", action="store_true", help="Display decoded token claims (default: True)", default=True, ) parser.add_argument( "--no-show-token", action="store_true", help="Do not display decoded token claims", ) parser.add_argument( "--debug", action="store_true", help="Enable debug logging", ) args = parser.parse_args() # Set debug logging if requested if args.debug: logging.getLogger().setLevel(logging.DEBUG) try: # Get configuration from CLI args or environment variables auth0_domain = args.auth0_domain or _get_auth0_domain() client_id = args.client_id or _get_client_id() client_secret = args.client_secret or _get_client_secret() # Request M2M token from Auth0 token_data = _request_m2m_token( auth0_domain=auth0_domain, client_id=client_id, client_secret=client_secret, audience=args.audience, ) # Decode and display token if requested show_token = args.show_token and not args.no_show_token if show_token and "access_token" in token_data: claims = _decode_token(token_data["access_token"]) _display_decoded_token(claims) # Save token to temporary file token_file_path = _save_token_to_file(token_data) # Print the file path print(f"Token saved to: {token_file_path}") except ValueError as e: logger.error(f"Error: {e}") sys.exit(1) except Exception as e: logger.exception(f"Unexpected error: {e}") sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: credentials-provider/check_and_refresh_creds.sh ================================================ #!/bin/bash # Script to check JWT token validity and refresh credentials only if needed # Usage: ./scripts/check_and_refresh_creds.sh set -e # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" # Check if token is valid using Python echo "Checking token validity..." TOKEN_VALID=$(cd "$PROJECT_ROOT" && python3 -c " import sys import os sys.path.append('cli') from mcp_utils import _load_oauth_token_from_file import time try: token_data = _load_oauth_token_from_file() if token_data and 'expires_at' in token_data: current_time = time.time() expires_at = token_data['expires_at'] # Add 60 second buffer to avoid edge cases if current_time < (expires_at - 60): print('valid') else: print('expired') else: print('missing') except Exception: print('missing') ") if [ "$TOKEN_VALID" = "valid" ]; then echo "Token is still valid, skipping credential generation" exit 0 elif [ "$TOKEN_VALID" = "expired" ]; then echo "Token has expired, generating fresh credentials..." else echo "No valid token found, generating fresh credentials..." fi # Generate fresh credentials echo "Running credential generation..." cd "$PROJECT_ROOT" ./credentials-provider/generate_creds.sh echo "Credentials refreshed successfully" ================================================ FILE: credentials-provider/entra/__init__.py ================================================ ================================================ FILE: credentials-provider/entra/get_m2m_token.py ================================================ #!/usr/bin/env python3 """ Generate OAuth2 access tokens for identities using Microsoft Entra ID. Reads identity credentials from an input JSON file and generates tokens for each identity using the OAuth2 client credentials flow. """ import argparse import json import logging import os import sys from datetime import ( UTC, datetime, ) from typing import ( Any, ) import requests # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Default Entra ID login base URL DEFAULT_ENTRA_LOGIN_BASE_URL = "https://login.microsoftonline.com" DEFAULT_IDENTITIES_FILE = ".oauth-tokens/entra-identities.json" class Colors: """ANSI color codes for console output.""" RED = "\033[0;31m" GREEN = "\033[0;32m" YELLOW = "\033[1;33m" BLUE = "\033[0;34m" NC = "\033[0m" def _redact_sensitive_value( value: str, show_chars: int = 8, ) -> str: """Redact sensitive value for logging.""" if not value or len(value) <= show_chars: return "*" * len(value) if value else "" return value[:show_chars] + "*" * (len(value) - show_chars) def _get_token_from_entra( client_id: str, client_secret: str, tenant_id: str, scope: str | None = None, verbose: bool = False, ) -> dict[str, Any] | None: """Request access token from Microsoft Entra ID using client credentials.""" login_base_url = os.environ.get( "ENTRA_LOGIN_BASE_URL", DEFAULT_ENTRA_LOGIN_BASE_URL, ) token_url = f"{login_base_url}/{tenant_id}/oauth2/v2.0/token" # Default scope for Entra ID M2M tokens if not scope: scope = f"api://{client_id}/.default" if verbose: print(f"{Colors.BLUE}[DEBUG]{Colors.NC} Token URL: {token_url}") print(f"{Colors.BLUE}[DEBUG]{Colors.NC} Client ID: {client_id}") print(f"{Colors.BLUE}[DEBUG]{Colors.NC} Tenant ID: {tenant_id}") print(f"{Colors.BLUE}[DEBUG]{Colors.NC} Scope: {scope}") data = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": scope, } headers = {"Content-Type": "application/x-www-form-urlencoded"} try: response = requests.post(token_url, data=data, headers=headers, timeout=30) # Check for error response before raise_for_status if response.status_code >= 400: try: error_data = response.json() error_msg = error_data.get( "error_description", error_data.get("error", "Unknown error") ) print(f"{Colors.RED}[ERROR]{Colors.NC} Entra ID error: {error_msg}") if verbose: print( f"{Colors.BLUE}[DEBUG]{Colors.NC} Full error response: {json.dumps(error_data, indent=2)}" ) except json.JSONDecodeError: print( f"{Colors.RED}[ERROR]{Colors.NC} HTTP {response.status_code}: {response.text}" ) return None token_data = response.json() if "error_description" in token_data: print( f"{Colors.RED}[ERROR]{Colors.NC} Token request failed: {token_data['error_description']}" ) return None if "access_token" not in token_data: print(f"{Colors.RED}[ERROR]{Colors.NC} No access token in response") return None return token_data except requests.exceptions.RequestException as e: print(f"{Colors.RED}[ERROR]{Colors.NC} Failed to make token request to Entra ID: {e}") return None except json.JSONDecodeError as e: print(f"{Colors.RED}[ERROR]{Colors.NC} Invalid JSON response: {e}") return None def _save_token_file( identity_name: str, token_data: dict[str, Any], client_id: str, tenant_id: str, scope: str, output_dir: str, ) -> bool: """Save token to JSON file.""" access_token = token_data["access_token"] expires_in = token_data.get("expires_in") os.makedirs(output_dir, exist_ok=True) generated_at = datetime.now(UTC).isoformat() expires_at = None if expires_in: expiry_timestamp = datetime.now(UTC).timestamp() + expires_in expires_at = datetime.fromtimestamp( expiry_timestamp, UTC, ).isoformat() token_json = { "identity_name": identity_name, "access_token": access_token, "token_type": "Bearer", # nosec B105 - OAuth2 standard token type per RFC 6750 "expires_in": expires_in, "generated_at": generated_at, "expires_at": expires_at, "provider": "entra", "tenant_id": tenant_id, "client_id": client_id, "scope": scope, } json_file = os.path.join(output_dir, f"{identity_name}.json") try: with open(json_file, "w") as f: json.dump(token_json, f, indent=2) os.chmod(json_file, 0o600) except Exception as e: print(f"{Colors.RED}[ERROR]{Colors.NC} Failed to save token file: {e}") return False print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} Token saved to: {json_file}") redacted_token = _redact_sensitive_value(access_token, 8) print(f"\nAccess Token: {redacted_token}") if expires_in: print(f"Expires in: {expires_in} seconds") if expires_at: expiry_time = datetime.fromisoformat(expires_at.replace("Z", "+00:00")) print(f"Expires at: {expiry_time.strftime('%Y-%m-%d %H:%M:%S UTC')}") print() return True def _load_identities_file( file_path: str, ) -> list[dict[str, Any]] | None: """Load identities from JSON file.""" if not os.path.exists(file_path): print(f"{Colors.RED}[ERROR]{Colors.NC} Identities file not found: {file_path}") return None try: with open(file_path) as f: identities = json.load(f) if not isinstance(identities, list): print(f"{Colors.RED}[ERROR]{Colors.NC} Identities file must contain a JSON array") return None return identities except json.JSONDecodeError as e: print(f"{Colors.RED}[ERROR]{Colors.NC} Failed to parse identities file: {e}") return None except Exception as e: print(f"{Colors.RED}[ERROR]{Colors.NC} Failed to load identities file: {e}") return None def generate_tokens( identities_file: str, output_dir: str, verbose: bool = False, ) -> bool: """Generate tokens for all identities in the input file.""" identities = _load_identities_file(identities_file) if identities is None: return False if not identities: print(f"{Colors.YELLOW}[WARNING]{Colors.NC} No identities found in file") return True print( f"{Colors.GREEN}[SUCCESS]{Colors.NC} Found {len(identities)} identity(ies) in {identities_file}" ) success_count = 0 total_count = len(identities) for identity in identities: identity_name = identity.get("identity_name") if not identity_name: print(f"{Colors.RED}[ERROR]{Colors.NC} Identity missing 'identity_name' field") continue print(f"\n{'=' * 60}") print(f"Processing identity: {identity_name}") print("=" * 60) client_id = identity.get("client_id") client_secret = identity.get("client_secret") tenant_id = identity.get("tenant_id") scope = identity.get("scope") if not client_id: print(f"{Colors.RED}[ERROR]{Colors.NC} Identity '{identity_name}' missing 'client_id'") continue if not client_secret: print( f"{Colors.RED}[ERROR]{Colors.NC} Identity '{identity_name}' missing 'client_secret'" ) continue if not tenant_id: print(f"{Colors.RED}[ERROR]{Colors.NC} Identity '{identity_name}' missing 'tenant_id'") continue print(f"Requesting access token for identity: {identity_name}") token_data = _get_token_from_entra( client_id, client_secret, tenant_id, scope, verbose, ) if not token_data: print( f"{Colors.RED}[ERROR]{Colors.NC} Failed to generate token for identity: {identity_name}" ) continue print(f"{Colors.GREEN}[SUCCESS]{Colors.NC} Access token generated!") if not scope: scope = f"api://{client_id}/.default" if _save_token_file( identity_name, token_data, client_id, tenant_id, scope, output_dir, ): success_count += 1 print(f"\n{'=' * 60}") print(f"Token generation complete: {success_count}/{total_count} successful") print("=" * 60) return success_count == total_count def main() -> None: """Main function.""" parser = argparse.ArgumentParser( description="Generate OAuth2 access tokens using Microsoft Entra ID", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Generate tokens using default identities file python generate_tokens.py # Generate tokens using custom identities file python generate_tokens.py --identities-file /path/to/identities.json # Generate tokens with verbose output python generate_tokens.py --verbose Identities File Format (JSON array): [ { "identity_name": "admin", "tenant_id": "your-tenant-id", "client_id": "your-client-id", "client_secret": "your-client-secret", "scope": "api://your-app-id/.default" // optional } ] Environment Variables: ENTRA_LOGIN_BASE_URL - Login base URL (default: https://login.microsoftonline.com) """, ) parser.add_argument( "--identities-file", type=str, default=DEFAULT_IDENTITIES_FILE, help=f"Path to JSON file with identity credentials (default: {DEFAULT_IDENTITIES_FILE})", ) parser.add_argument( "--output-dir", type=str, default=".oauth-tokens", help="Output directory for token files (default: .oauth-tokens)", ) parser.add_argument( "--verbose", "-v", action="store_true", help="Verbose output", ) # Keep --all-agents for backwards compatibility but ignore it parser.add_argument( "--all-agents", action="store_true", help=argparse.SUPPRESS, ) args = parser.parse_args() try: success = generate_tokens( identities_file=args.identities_file, output_dir=args.output_dir, verbose=args.verbose, ) sys.exit(0 if success else 1) except KeyboardInterrupt: print(f"\n{Colors.YELLOW}[WARNING]{Colors.NC} Operation interrupted by user") sys.exit(1) except Exception as e: print(f"{Colors.RED}[ERROR]{Colors.NC} Unexpected error: {e}") sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: credentials-provider/generate_creds.sh ================================================ #!/bin/bash # # Ingress Token Generator Script # # This script generates ingress authentication tokens using the configured # identity provider (Keycloak or Entra ID based on AUTH_PROVIDER). # # Usage: # ./generate_creds.sh # Generate ingress token # ./generate_creds.sh --verbose # Enable verbose logging # ./generate_creds.sh --force # Force new token generation # ./generate_creds.sh --help # Show this help set -e # Exit on error # Script directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Load .env file if it exists if [ -f "$SCRIPT_DIR/.env" ]; then source "$SCRIPT_DIR/.env" fi # Also load main project .env file to get AUTH_PROVIDER if [ -f "$(dirname "$SCRIPT_DIR")/.env" ]; then source "$(dirname "$SCRIPT_DIR")/.env" fi # Default values (empty - require explicit configuration) VERBOSE=false FORCE=false IDENTITIES_FILE="" AUTH_PROVIDER_ARG="" KEYCLOAK_URL_ARG="" KEYCLOAK_REALM_ARG="" ENTRA_TENANT_ID_ARG="" ENTRA_CLIENT_ID_ARG="" ENTRA_CLIENT_SECRET_ARG="" ENTRA_LOGIN_BASE_URL_ARG="" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Logging functions log_info() { echo -e "${GREEN}[INFO]${NC} $1" } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" } log_error() { echo -e "${RED}[ERROR]${NC} $1" } log_debug() { if [ "$VERBOSE" = true ]; then echo -e "${BLUE}[DEBUG]${NC} $1" fi } show_help() { cat << EOF Ingress Token Generator Script This script generates ingress authentication tokens for the MCP Gateway. It automatically uses the configured AUTH_PROVIDER (keycloak or entra). USAGE: ./generate_creds.sh [OPTIONS] OPTIONS: --auth-provider, -a PROVIDER Auth provider: 'keycloak' or 'entra' (required if AUTH_PROVIDER env not set) --keycloak-url, -k URL Keycloak server URL (required for keycloak if KEYCLOAK_EXTERNAL_URL env not set) --keycloak-realm, -r REALM Keycloak realm name (default: mcp-gateway, or KEYCLOAK_REALM env) --entra-tenant-id TENANT_ID Entra tenant ID (required for entra if ENTRA_TENANT_ID env not set) --entra-client-id CLIENT_ID Entra client ID (required for entra if ENTRA_CLIENT_ID env not set) --entra-client-secret SECRET Entra client secret (required for entra if ENTRA_CLIENT_SECRET env not set) --entra-login-url URL Entra login base URL (default: https://login.microsoftonline.com) --identities-file, -i FILE Custom path to identities JSON file (for entra) --force, -f Force new token generation, ignore existing tokens --verbose, -v Enable verbose debug logging --help, -h Show this help message EXAMPLES: # Keycloak with explicit URL ./generate_creds.sh -a keycloak -k https://kc.example.com # Keycloak using environment variables export KEYCLOAK_EXTERNAL_URL=https://kc.example.com ./generate_creds.sh -a keycloak # Entra ID with explicit parameters ./generate_creds.sh -a entra --entra-tenant-id "tenant-id" --entra-client-id "client-id" --entra-client-secret "secret" # Entra ID using identities file ./generate_creds.sh -a entra -i /path/to/identities.json ENVIRONMENT VARIABLES: General: AUTH_PROVIDER # IdP selection: 'keycloak' or 'entra' For Keycloak (AUTH_PROVIDER=keycloak): KEYCLOAK_EXTERNAL_URL # Keycloak server URL (external/public URL) KEYCLOAK_REALM # Keycloak realm name (default: mcp-gateway) For Entra ID (AUTH_PROVIDER=entra): ENTRA_TENANT_ID # Azure AD tenant ID ENTRA_CLIENT_ID # App registration client ID ENTRA_CLIENT_SECRET # App registration client secret ENTRA_LOGIN_BASE_URL # Login base URL (default: https://login.microsoftonline.com) EOF } # Parse command line arguments while [[ $# -gt 0 ]]; do case $1 in --auth-provider|-a) AUTH_PROVIDER_ARG="$2" shift 2 ;; --keycloak-url|-k) KEYCLOAK_URL_ARG="$2" shift 2 ;; --keycloak-realm|-r) KEYCLOAK_REALM_ARG="$2" shift 2 ;; --entra-tenant-id) ENTRA_TENANT_ID_ARG="$2" shift 2 ;; --entra-client-id) ENTRA_CLIENT_ID_ARG="$2" shift 2 ;; --entra-client-secret) ENTRA_CLIENT_SECRET_ARG="$2" shift 2 ;; --entra-login-url) ENTRA_LOGIN_BASE_URL_ARG="$2" shift 2 ;; --force|-f) FORCE=true shift ;; --verbose|-v) VERBOSE=true shift ;; --identities-file|-i) IDENTITIES_FILE="$2" shift 2 ;; --help|-h) show_help exit 0 ;; *) log_error "Unknown option: $1" show_help exit 1 ;; esac done # Function to run Keycloak token generation run_keycloak_auth() { log_info "Running Keycloak M2M token generation..." # Determine Keycloak URL (CLI arg > env var) local keycloak_url="" if [ -n "$KEYCLOAK_URL_ARG" ]; then keycloak_url="$KEYCLOAK_URL_ARG" elif [ -n "$KEYCLOAK_EXTERNAL_URL" ]; then keycloak_url="$KEYCLOAK_EXTERNAL_URL" fi # Determine Keycloak realm (CLI arg > env var > default) local keycloak_realm="" if [ -n "$KEYCLOAK_REALM_ARG" ]; then keycloak_realm="$KEYCLOAK_REALM_ARG" elif [ -n "$KEYCLOAK_REALM" ]; then keycloak_realm="$KEYCLOAK_REALM" else keycloak_realm="mcp-gateway" fi # Validate required parameters if [ -z "$keycloak_url" ]; then log_error "Keycloak URL is required. Provide via --keycloak-url or KEYCLOAK_EXTERNAL_URL environment variable." return 1 fi log_info "Keycloak URL: $keycloak_url" log_info "Keycloak Realm: $keycloak_realm" local cmd="uv run '$SCRIPT_DIR/keycloak/get_m2m_token.py' --all-agents" cmd="$cmd --keycloak-url '$keycloak_url'" cmd="$cmd --realm '$keycloak_realm'" if [ "$VERBOSE" = true ]; then cmd="$cmd --verbose" fi log_debug "Executing: $cmd" if eval "$cmd"; then log_info "Keycloak token generation completed successfully" return 0 else log_error "Keycloak token generation failed" return 1 fi } # Function to run Entra ID token generation run_entra_auth() { log_info "Running Entra ID token generation..." # Export Entra environment variables (CLI args override env vars) if [ -n "$ENTRA_TENANT_ID_ARG" ]; then export ENTRA_TENANT_ID="$ENTRA_TENANT_ID_ARG" fi if [ -n "$ENTRA_CLIENT_ID_ARG" ]; then export ENTRA_CLIENT_ID="$ENTRA_CLIENT_ID_ARG" fi if [ -n "$ENTRA_CLIENT_SECRET_ARG" ]; then export ENTRA_CLIENT_SECRET="$ENTRA_CLIENT_SECRET_ARG" fi if [ -n "$ENTRA_LOGIN_BASE_URL_ARG" ]; then export ENTRA_LOGIN_BASE_URL="$ENTRA_LOGIN_BASE_URL_ARG" fi local cmd="uv run '$SCRIPT_DIR/entra/get_m2m_token.py' --all-agents" if [ -n "$IDENTITIES_FILE" ]; then cmd="$cmd --identities-file '$IDENTITIES_FILE'" fi if [ "$VERBOSE" = true ]; then cmd="$cmd --verbose" fi log_debug "Executing: $cmd" if eval "$cmd"; then log_info "Entra ID token generation completed successfully" return 0 else log_error "Entra ID token generation failed" return 1 fi } # Main execution main() { # CLI argument takes precedence over environment variable local auth_provider if [ -n "$AUTH_PROVIDER_ARG" ]; then auth_provider="$AUTH_PROVIDER_ARG" elif [ -n "$AUTH_PROVIDER" ]; then auth_provider="$AUTH_PROVIDER" else log_error "Auth provider is required. Provide via --auth-provider or AUTH_PROVIDER environment variable." log_error "Valid values: 'keycloak' or 'entra'" exit 1 fi # Validate auth provider value if [ "$auth_provider" != "keycloak" ] && [ "$auth_provider" != "entra" ]; then log_error "Invalid auth provider: $auth_provider (must be 'keycloak' or 'entra')" exit 1 fi log_info "Starting Ingress Token Generator" log_info "AUTH_PROVIDER: $auth_provider" local success=false if [ "$auth_provider" = "entra" ]; then if run_entra_auth; then success=true fi else if run_keycloak_auth; then success=true fi fi # Summary echo "" log_info "Summary:" if [ "$success" = true ]; then log_info " Token generation: SUCCESS" else log_info " Token generation: FAILED" fi log_info "Check ./.oauth-tokens/ for generated token files" if [ "$success" = false ]; then exit 1 fi } # Run main function main "$@" ================================================ FILE: credentials-provider/keycloak/get_m2m_token.py ================================================ #!/usr/bin/env python3 """ Generate OAuth2 access tokens for MCP agents using Keycloak Python version of generate-agent-token.sh with batch processing capabilities """ import argparse import glob import json import logging import os import sys from datetime import UTC, datetime from typing import Any import requests class Colors: """ANSI color codes for console output""" RED = "\033[0;31m" GREEN = "\033[0;32m" YELLOW = "\033[1;33m" BLUE = "\033[0;34m" NC = "\033[0m" # No Color class TokenGenerator: """Generate tokens for MCP agents using Keycloak OAuth2""" def __init__(self, verbose: bool = False): self.verbose = verbose self.setup_logging() def setup_logging(self): """Setup logging configuration""" level = logging.DEBUG if self.verbose else logging.INFO logging.basicConfig(level=level, format="%(asctime)s - %(levelname)s - %(message)s") self.logger = logging.getLogger(__name__) def log(self, message: str): """Log info message if verbose mode is enabled""" if self.verbose: self.logger.debug(message) def error(self, message: str): """Print error message""" self.logger.error(message) def success(self, message: str): """Print success message""" self.logger.info(message) def warning(self, message: str): """Print warning message""" self.logger.warning(message) def load_agent_config(self, agent_name: str, oauth_tokens_dir: str) -> dict[str, Any] | None: """Load agent configuration from JSON file""" config_file = os.path.join(oauth_tokens_dir, f"{agent_name}.json") if not os.path.exists(config_file): self.error(f"Config file not found: {config_file}") return None self.log(f"Loading config from: {config_file}") try: with open(config_file) as f: config = json.load(f) return config except json.JSONDecodeError as e: self.error(f"Failed to parse JSON config file: {e}") return None except Exception as e: self.error(f"Failed to load config file: {e}") return None def get_token_from_keycloak( self, client_id: str, client_secret: str, keycloak_url: str, realm: str ) -> dict[str, Any] | None: """Request access token from Keycloak""" token_url = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token" self.log(f"Token URL: {token_url}") self.log(f"Client ID: {client_id}") self.log(f"Realm: {realm}") data = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": "openid email profile", } headers = {"Content-Type": "application/x-www-form-urlencoded"} try: response = requests.post(token_url, data=data, headers=headers, timeout=30) response.raise_for_status() token_data = response.json() # Check for error in response if "error_description" in token_data: self.error(f"Token request failed: {token_data['error_description']}") return None # Validate access token exists if "access_token" not in token_data: self.error("No access token in response") self.log(f"Response keys: {list(token_data.keys())}") return None return token_data except requests.exceptions.RequestException as e: self.error(f"Failed to make token request to Keycloak: {e}") return None except json.JSONDecodeError as e: self.error(f"Invalid JSON response: {e}") return None def save_token_files( self, agent_name: str, token_data: dict[str, Any], client_id: str, client_secret: str, keycloak_url: str, realm: str, oauth_tokens_dir: str, ) -> bool: """Save token to both .env and .json files""" access_token = token_data["access_token"] expires_in = token_data.get("expires_in") # Create output directory os.makedirs(oauth_tokens_dir, exist_ok=True) # Generate timestamps generated_at = datetime.now(UTC).isoformat() expires_at = None if expires_in: expiry_timestamp = datetime.now(UTC).timestamp() + expires_in expires_at = datetime.fromtimestamp(expiry_timestamp, UTC).isoformat() # Save .env file with restricted permissions (contains secrets) env_file = os.path.join(oauth_tokens_dir, f"{agent_name}.env") try: with open(env_file, "w") as f: # nosec - intentional credential storage for CLI token cache f.write(f"# Generated access token for {agent_name}\n") f.write(f"# Generated at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") f.write(f'export ACCESS_TOKEN="{access_token}"\n') # nosec - intentional token storage in secured file f.write(f'export CLIENT_ID="{client_id}"\n') f.write(f'export CLIENT_SECRET="{client_secret}"\n') # nosec - intentional credential storage in secured file f.write(f'export KEYCLOAK_URL="{keycloak_url}"\n') f.write(f'export KEYCLOAK_REALM="{realm}"\n') f.write('export AUTH_PROVIDER="keycloak"\n') os.chmod(env_file, 0o600) # Restrict file permissions to owner only except Exception as e: self.error(f"Failed to save .env file: {e}") return False # Save .json file with metadata json_file = os.path.join(oauth_tokens_dir, f"{agent_name}-token.json") token_json = { "agent_name": agent_name, "access_token": access_token, "token_type": "Bearer", # nosec B105 - OAuth2 standard token type per RFC 6750 "expires_in": expires_in, "generated_at": generated_at, "expires_at": expires_at, "provider": "keycloak", "keycloak_url": keycloak_url, "keycloak_realm": realm, "client_id": client_id, "scope": "openid email profile", "metadata": { "generated_by": "generate_tokens.py", "script_version": "1.0", "token_format": "JWT", # nosec B105 - Standard token format identifier, not a password "auth_method": "client_credentials", }, } try: with open(json_file, "w") as f: json.dump(token_json, f, indent=2) os.chmod(json_file, 0o600) # Restrict file permissions to owner only except Exception as e: self.error(f"Failed to save JSON file: {e}") return False self.success(f"Token saved to: {env_file}") self.success(f"Token metadata saved to: {json_file}") # Display token info (redacted for security) try: from ..utils import redact_sensitive_value except ImportError: # Fallback for when running as standalone script import sys from pathlib import Path utils_path = Path(__file__).parent.parent / "utils.py" if utils_path.exists(): sys.path.insert(0, str(utils_path.parent)) from utils import redact_sensitive_value else: # Simple fallback redaction function def redact_sensitive_value(value: str, show_chars: int = 8) -> str: if not value or len(value) <= show_chars: return "*" * len(value) if value else "" return value[:show_chars] + "*" * (len(value) - show_chars) redacted_token = redact_sensitive_value(access_token, 8) self.logger.info(f"Access Token: {redacted_token}") if expires_in: self.logger.info(f"Expires in: {expires_in} seconds") if expires_at: expiry_time = datetime.fromisoformat(expires_at.replace("Z", "+00:00")) self.logger.info(f"Expires at: {expiry_time.strftime('%Y-%m-%d %H:%M:%S UTC')}") return True def generate_token_for_agent( self, agent_name: str, client_id: str = None, client_secret: str = None, keycloak_url: str = None, realm: str = "mcp-gateway", oauth_tokens_dir: str = None, ) -> bool: """Generate token for a single agent""" if oauth_tokens_dir is None: oauth_tokens_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".oauth-tokens" ) # Load config from JSON if parameters not provided config = None if not all([client_id, client_secret, keycloak_url]): config = self.load_agent_config(agent_name, oauth_tokens_dir) if not config: return False # Use provided parameters or fall back to config if not client_id: client_id = config.get("client_id") if not client_secret: client_secret = config.get("client_secret") if not keycloak_url: keycloak_url = ( config.get("keycloak_url") or config.get("gateway_url", "").split("/realms/")[0] ) # Also try to get realm from config if config and realm == "mcp-gateway": config_realm = config.get("keycloak_realm") or config.get("realm") if config_realm: realm = config_realm # Validate required parameters if not client_id: self.error("CLIENT_ID is required. Provide via --client-id or in config file.") return False if not client_secret: self.error("CLIENT_SECRET is required. Provide via --client-secret or in config file.") return False if not keycloak_url: self.error("KEYCLOAK_URL is required. Provide via --keycloak-url or in config file.") return False self.logger.info(f"Requesting access token for agent: {agent_name}") # Get token from Keycloak token_data = self.get_token_from_keycloak(client_id, client_secret, keycloak_url, realm) if not token_data: return False self.success("Access token generated successfully!") # Save token files return self.save_token_files( agent_name, token_data, client_id, client_secret, keycloak_url, realm, oauth_tokens_dir ) def find_agent_configs(self, oauth_tokens_dir: str) -> list[str]: """Find all agent-{}.json files, excluding agent-{}-token.json files""" if not os.path.exists(oauth_tokens_dir): self.warning(f"OAuth tokens directory not found: {oauth_tokens_dir}") return [] # Find all agent-*.json files pattern = os.path.join(oauth_tokens_dir, "agent-*.json") all_files = glob.glob(pattern) # Filter out token files (agent-*-token.json) agent_configs = [] for file_path in all_files: filename = os.path.basename(file_path) if not filename.endswith("-token.json"): # Use the full filename without extension as agent name agent_name = filename[:-5] # Remove '.json' (5 chars) agent_configs.append(agent_name) return sorted(agent_configs) def generate_tokens_for_all_agents( self, oauth_tokens_dir: str = None, keycloak_url: str = None, realm: str = "mcp-gateway" ) -> bool: """Generate tokens for all agents found in .oauth-tokens directory""" if oauth_tokens_dir is None: oauth_tokens_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".oauth-tokens" ) self.log(f"Searching for agent configs in: {oauth_tokens_dir}") agent_configs = self.find_agent_configs(oauth_tokens_dir) if not agent_configs: self.warning("No agent configuration files found") return True self.success( f"Found {len(agent_configs)} agent configuration(s): {', '.join(agent_configs)}" ) success_count = 0 total_count = len(agent_configs) for agent_name in agent_configs: self.logger.info("=" * 60) self.logger.info(f"Processing agent: {agent_name}") self.logger.info("=" * 60) try: if self.generate_token_for_agent( agent_name, keycloak_url=keycloak_url, realm=realm, oauth_tokens_dir=oauth_tokens_dir, ): success_count += 1 else: self.error(f"Failed to generate token for agent: {agent_name}") except Exception as e: self.error(f"Exception while processing agent {agent_name}: {e}") self.logger.info("=" * 60) self.logger.info(f"Token generation complete: {success_count}/{total_count} successful") self.logger.info("=" * 60) return success_count == total_count def main(): """Main function""" parser = argparse.ArgumentParser( description="Generate OAuth2 access tokens for MCP agents using Keycloak", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Generate tokens for all agents in .oauth-tokens directory python generate_tokens.py --all-agents # Generate token for specific agent python generate_tokens.py --agent-name my-agent # Generate token with custom parameters python generate_tokens.py --agent-name my-agent --client-id custom-client --keycloak-url http://localhost:8080 # Generate tokens for all agents with custom Keycloak URL python generate_tokens.py --all-agents --keycloak-url http://localhost:8080 """, ) parser.add_argument("--agent-name", type=str, help="Specific agent name to generate token for") parser.add_argument( "--all-agents", action="store_true", help="Generate tokens for all agents found in .oauth-tokens directory", ) parser.add_argument("--client-id", type=str, help="OAuth2 client ID (overrides config file)") parser.add_argument( "--client-secret", type=str, help="OAuth2 client secret (overrides config file)" ) parser.add_argument( "--keycloak-url", type=str, help="Keycloak server URL (overrides config file)" ) parser.add_argument( "--realm", type=str, default="mcp-gateway", help="Keycloak realm (default: mcp-gateway)" ) parser.add_argument( "--oauth-dir", type=str, help="OAuth tokens directory (default: ../../.oauth-tokens)" ) parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") args = parser.parse_args() # Validate arguments if not args.all_agents and not args.agent_name: parser.error("Must specify either --all-agents or --agent-name") if args.all_agents and args.agent_name: parser.error("Cannot specify both --all-agents and --agent-name") # Initialize token generator generator = TokenGenerator(verbose=args.verbose) # Determine oauth tokens directory oauth_tokens_dir = args.oauth_dir if oauth_tokens_dir is None: oauth_tokens_dir = os.path.join( os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".oauth-tokens" ) try: if args.all_agents: # Generate tokens for all agents success = generator.generate_tokens_for_all_agents( oauth_tokens_dir=oauth_tokens_dir, keycloak_url=args.keycloak_url, realm=args.realm ) else: # Generate token for specific agent success = generator.generate_token_for_agent( agent_name=args.agent_name, client_id=args.client_id, client_secret=args.client_secret, keycloak_url=args.keycloak_url, realm=args.realm, oauth_tokens_dir=oauth_tokens_dir, ) sys.exit(0 if success else 1) except KeyboardInterrupt: generator.warning("Operation interrupted by user") sys.exit(1) except Exception as e: generator.error(f"Unexpected error: {e}") sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: credentials-provider/oauth/.env.example ================================================ # ============================================================================= # MCP Gateway Registry - OAuth Environment Configuration # ============================================================================= # Copy this file to .env and update with your actual OAuth provider credentials # This file contains credentials for both ingress and egress OAuth flows # ============================================================================= # REGISTRY CONFIGURATION # ============================================================================= # Public URL where the MCP Gateway Registry is accessible REGISTRY_URL=https://your-domain.com # Registry URL (can be same as main .env) # Authentication is handled via Keycloak OAuth2 (no local admin password) # ============================================================================= # AUTH SERVER CONFIGURATION # ============================================================================= # Internal and external auth server URLs AUTH_SERVER_URL=http://auth-server:8888 AUTH_SERVER_EXTERNAL_URL=https://your-domain.com # ============================================================================= # AUTHENTICATION PROVIDER SELECTION # ============================================================================= # Choose authentication provider: cognito or keycloak AUTH_PROVIDER=keycloak # ============================================================================= # AWS COGNITO OAUTH2 CONFIGURATION # ============================================================================= # AWS Configuration AWS_REGION=us-east-1 INGRESS_OAUTH_USER_POOL_ID=us-east-1_XXXXXXXXX #INGRESS_OAUTH_USER_POOL_ID=us-east-1_YRy6fCXkS # Ingress OAuth App Client ID (copied from .env.agent working credentials) INGRESS_OAUTH_CLIENT_ID=your_ingress_client_id_here #alternative_client_id #INGRESS_OAUTH_CLIENT_ID=alternative_client_id_2 # Ingress OAuth App Client Secret (copied from .env.agent working credentials) INGRESS_OAUTH_CLIENT_SECRET=your_ingress_client_secret_here #alternative_secret #INGRESS_OAUTH_CLIENT_SECRET=alternative_secret_2 # ============================================================================= # KEYCLOAK OAUTH2 CONFIGURATION (if AUTH_PROVIDER=keycloak) # ============================================================================= # Keycloak server configuration KEYCLOAK_URL=https://your-domain.com/keycloak KEYCLOAK_REALM=mcp-gateway # Keycloak M2M Client Credentials (for ingress authentication) KEYCLOAK_M2M_CLIENT_ID=mcp-gateway-m2m KEYCLOAK_M2M_CLIENT_SECRET=your_keycloak_m2m_client_secret_here # ============================================================================= # EGRESS OAUTH CONFIGURATION (Optional - for external services) # ============================================================================= # Configure multiple external OAuth providers using numbered suffixes # Supports configurations _1, _2, _3, etc. (up to _100) # Configuration Set 1 - Example: Atlassian EGRESS_OAUTH_CLIENT_ID_1=your_atlassian_client_id_here EGRESS_OAUTH_CLIENT_SECRET_1=your_atlassian_client_secret_here EGRESS_OAUTH_REDIRECT_URI_1=http://localhost:9999/callback # IMPORTANT: This redirect URI MUST match exactly what you configure in your Atlassian OAuth app settings # EGRESS_OAUTH_SCOPE_1=read:confluence-content.all,write:confluence-content EGRESS_PROVIDER_NAME_1=atlassian EGRESS_MCP_SERVER_NAME_1=atlassian # Configuration Set 2 - Example: Google # EGRESS_OAUTH_CLIENT_ID_2=your_google_client_id_here # EGRESS_OAUTH_CLIENT_SECRET_2=your_google_client_secret_here # EGRESS_OAUTH_REDIRECT_URI_2=http://localhost:9999/callback # EGRESS_OAUTH_SCOPE_2=https://www.googleapis.com/auth/drive.readonly # EGRESS_PROVIDER_NAME_2=google # EGRESS_MCP_SERVER_NAME_2=google-drive # Configuration Set 3 - Example: GitHub # EGRESS_OAUTH_CLIENT_ID_3=your_github_client_id_here # EGRESS_OAUTH_CLIENT_SECRET_3=your_github_client_secret_here # EGRESS_OAUTH_REDIRECT_URI_3=http://localhost:9999/callback # EGRESS_OAUTH_SCOPE_3=repo,read:user # EGRESS_PROVIDER_NAME_3=github # EGRESS_MCP_SERVER_NAME_3=github-repos # Configuration Set 4 - Example: Microsoft # EGRESS_OAUTH_CLIENT_ID_4=your_microsoft_client_id_here # EGRESS_OAUTH_CLIENT_SECRET_4=your_microsoft_client_secret_here # EGRESS_OAUTH_REDIRECT_URI_4=http://localhost:9999/callback # EGRESS_OAUTH_SCOPE_4=https://graph.microsoft.com/mail.read # EGRESS_PROVIDER_NAME_4=microsoft # EGRESS_MCP_SERVER_NAME_4=outlook # ============================================================================= # CONFIGURATION NOTES # ============================================================================= # Provider Names Supported: # - atlassian: Atlassian Cloud (Confluence, Jira) # - google: Google services (Drive, Gmail, Calendar) # - github: GitHub repositories and issues # - microsoft: Microsoft 365 services # - bedrock-agentcore: Amazon Bedrock AgentCore services # Redirect URI Notes: # - CRITICAL: The redirect URI in this file MUST match exactly what you configure in your OAuth provider settings # - For local development: http://localhost:9999/callback (changed from 8080 to avoid Keycloak port conflicts) # - For production: https://your-domain.com/oauth/callback # - If URLs don't match exactly, OAuth flow will fail with "redirect_uri not registered" error # Scope Notes: # - If EGRESS_OAUTH_SCOPE_N is not specified, provider defaults will be used # - Each provider has different scope formats and requirements # - Consult provider documentation for available scopes # Security Notes: # - Keep this file secure and never commit real credentials # - Use environment-specific values for different deployments # - Rotate credentials regularly for production environments ================================================ FILE: credentials-provider/oauth/egress_oauth.py ================================================ #!/usr/bin/env python3 """ Egress OAuth Authentication Script This script handles OAuth authentication for egress (outbound) connections to external services. It supports multiple OAuth providers with Atlassian as the default. The script: 1. Validates required EGRESS OAuth environment variables 2. Performs OAuth authentication flow for external providers (Atlassian, Google, GitHub, etc.) 3. Saves tokens to {provider}-egress.json in the OAuth tokens directory 4. Does not generate MCP configuration files (handled by oauth_creds.sh) Environment Variables Required (with numbered configuration sets): - EGRESS_OAUTH_CLIENT_ID_N: OAuth Client ID for external provider - EGRESS_OAUTH_CLIENT_SECRET_N: OAuth Client Secret for external provider - EGRESS_OAUTH_REDIRECT_URI_N: OAuth Redirect URI (defaults to localhost:8080/callback) - EGRESS_OAUTH_SCOPE_N: OAuth scopes (optional, uses provider defaults) - EGRESS_PROVIDER_NAME_N: Provider name (atlassian, google, github, etc.) - EGRESS_MCP_SERVER_NAME_N: MCP server name for token file naming Where N is a configuration number from 1 to 100. Usage: python egress_oauth.py # Use Atlassian (default) python egress_oauth.py --provider google # Use Google python egress_oauth.py --provider atlassian --verbose # Atlassian with debug python egress_oauth.py --force # Force new token generation """ import argparse import json import logging import os import sys import time from pathlib import Path from typing import Any # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Try to load .env file if python-dotenv is available try: from dotenv import load_dotenv # Load .env from the same directory as this script env_file = Path(__file__).parent / ".env" if env_file.exists(): load_dotenv(env_file) logger.debug(f"Loaded environment variables from {env_file}") else: # Fallback: try parent directory (project root) env_file_parent = Path(__file__).parent.parent / ".env" if env_file_parent.exists(): load_dotenv(env_file_parent) logger.debug(f"Loaded environment variables from {env_file_parent}") else: # Final fallback: try current working directory load_dotenv() logger.debug("Tried to load .env from current working directory") except ImportError: logger.debug("python-dotenv not available, skipping .env loading") def _find_available_configurations() -> list[int]: """Find all available configuration sets (1-100) based on environment variables.""" available_configs = [] for i in range(1, 101): # Check configurations 1-100 required_vars = [ f"EGRESS_OAUTH_CLIENT_ID_{i}", f"EGRESS_OAUTH_CLIENT_SECRET_{i}", f"EGRESS_OAUTH_REDIRECT_URI_{i}", ] # Check if all required variables for this config set exist if all(os.getenv(var) for var in required_vars): available_configs.append(i) return available_configs def _validate_environment_variables() -> None: """Validate that at least one complete EGRESS OAuth configuration set is available.""" available_configs = _find_available_configurations() if not available_configs: logger.error("No complete EGRESS OAuth configuration sets found!") logger.error("Please set at least one complete configuration set with variables like:") logger.error(" EGRESS_OAUTH_CLIENT_ID_1=") logger.error(" EGRESS_OAUTH_CLIENT_SECRET_1=") logger.error(" EGRESS_OAUTH_REDIRECT_URI_1=") logger.error(" EGRESS_PROVIDER_NAME_1=") logger.error(" EGRESS_MCP_SERVER_NAME_1=") logger.error("\nConfiguration sets can be numbered from _1 to _100") raise SystemExit(1) logger.debug(f"Found {len(available_configs)} complete configuration sets: {available_configs}") def _run_generic_oauth_flow_for_config( config_num: int, provider: str, force_new: bool = False, verbose: bool = False ) -> dict[str, Any]: """Run the generic OAuth flow using a specific configuration set.""" import subprocess # nosec B404 # Get configuration-specific environment variables client_id = os.getenv(f"EGRESS_OAUTH_CLIENT_ID_{config_num}") client_secret = os.getenv(f"EGRESS_OAUTH_CLIENT_SECRET_{config_num}") redirect_uri = os.getenv(f"EGRESS_OAUTH_REDIRECT_URI_{config_num}") scope = os.getenv(f"EGRESS_OAUTH_SCOPE_{config_num}") if not all([client_id, client_secret, redirect_uri]): raise ValueError(f"Missing required OAuth configuration for set {config_num}") # Build command with configuration-specific parameters cmd = [ "python", str(Path(__file__).parent / "generic_oauth_flow.py"), "--provider", provider, "--client-id", client_id, "--client-secret", client_secret, "--redirect-uri", redirect_uri, ] if scope: cmd.extend(["--scope", scope]) if force_new: cmd.append("--force") if verbose: cmd.append("--verbose") logger.info(f"Running OAuth flow for provider: {provider} (config set {config_num})") logger.debug(f"Command: {cmd[0]} {cmd[1]} --provider {provider} [credentials redacted]") try: # Run the generic OAuth flow result = subprocess.run( # nosec B603 - internal script path, args from validated env vars cmd, capture_output=True, text=True, timeout=300, # 5 minute timeout ) if result.returncode != 0: logger.error(f"OAuth flow failed with exit code {result.returncode}") logger.error(f"stdout: {result.stdout}") logger.error(f"stderr: {result.stderr}") raise RuntimeError(f"Generic OAuth flow failed for {provider}") logger.debug("OAuth flow completed successfully") logger.debug(f"stdout: {result.stdout}") # Parse the JSON output from the OAuth flow import json # Extract JSON from stdout (last line should be the JSON output) output_lines = result.stdout.strip().split("\n") json_output = None for line in reversed(output_lines): try: json_output = json.loads(line) break except json.JSONDecodeError: continue if not json_output: raise RuntimeError("Could not parse JSON output from OAuth flow") return json_output except subprocess.TimeoutExpired: logger.error("OAuth flow timed out after 5 minutes") raise RuntimeError(f"OAuth flow timed out for {provider}") except Exception as e: logger.error(f"Error running OAuth flow: {e}") raise def _run_generic_oauth_flow( provider: str, force_new: bool = False, verbose: bool = False ) -> dict[str, Any]: """Run the generic OAuth flow using the existing script.""" import subprocess # nosec B404 # Build command cmd = ["python", str(Path(__file__).parent / "generic_oauth_flow.py"), "--provider", provider] if force_new: cmd.append("--force") if verbose: cmd.append("--verbose") logger.info(f"Running OAuth flow for provider: {provider}") logger.debug(f"Command: {' '.join(cmd)}") try: # Run the generic OAuth flow result = subprocess.run( # nosec B603 - internal script path, args from validated env vars cmd, capture_output=True, text=True, timeout=300, # 5 minute timeout ) if result.returncode != 0: logger.error(f"OAuth flow failed for {provider}") logger.error(f"STDOUT: {result.stdout}") logger.error(f"STDERR: {result.stderr}") raise RuntimeError(f"OAuth flow failed with return code {result.returncode}") logger.info(f"✅ OAuth flow completed successfully for {provider}") if verbose: logger.debug(f"OAuth flow output: {result.stdout}") # Parse the output to extract token information # The generic_oauth_flow.py saves tokens to ~/.oauth-tokens/ return _load_provider_tokens(provider) except subprocess.TimeoutExpired: logger.error(f"OAuth flow timed out for {provider}") raise except Exception as e: logger.error(f"Failed to run OAuth flow for {provider}: {e}") raise def _load_provider_tokens(provider: str) -> dict[str, Any]: """Load tokens for the specified provider from the OAuth tokens directory.""" try: token_dir = Path.cwd() / ".oauth-tokens" # Look for provider-specific token files pattern = f"oauth-{provider}-*.json" token_files = list(token_dir.glob(pattern)) if not token_files: raise FileNotFoundError(f"No token files found for provider {provider}") # Use the most recent token file latest_file = max(token_files, key=lambda f: f.stat().st_mtime) with open(latest_file) as f: token_data = json.load(f) logger.debug(f"Loaded tokens from: {latest_file}") return token_data except Exception as e: logger.error(f"Failed to load provider tokens for {provider}: {e}") raise def _save_egress_tokens( token_data: dict[str, Any], provider: str, mcp_server_name: str | None = None ) -> str: """Save egress tokens to provider-specific egress file.""" try: # Create .oauth-tokens directory in current working directory token_dir = Path.cwd() / ".oauth-tokens" token_dir.mkdir(exist_ok=True, mode=0o700) # Save to {provider}-{server_name}-egress.json if server name provided if mcp_server_name: egress_path = token_dir / f"{provider}-{mcp_server_name}-egress.json" else: # Save to {provider}-egress.json egress_path = token_dir / f"{provider}-egress.json" # Prepare token data for storage save_data = { "provider": provider, "access_token": token_data.get("access_token"), "refresh_token": token_data.get("refresh_token"), "expires_at": token_data.get("expires_at"), "expires_at_human": time.strftime( "%Y-%m-%d %H:%M:%S UTC", time.gmtime(token_data["expires_at"]) ) if token_data.get("expires_at") else None, "cloud_id": token_data.get("cloud_id"), # For Atlassian "scopes": token_data.get("scopes", []), "saved_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), "usage_notes": f"This token is for EGRESS authentication to {provider} external services", } with open(egress_path, "w") as f: json.dump(save_data, f, indent=2) # Secure the file egress_path.chmod(0o600) logger.info(f"📁 Saved egress tokens to: {egress_path}") return str(egress_path) except Exception as e: logger.error(f"Failed to save egress tokens: {e}") raise def _load_existing_tokens( provider: str = None, mcp_server_name: str | None = None ) -> dict[str, Any] | None: """Load existing egress tokens if they exist and are valid.""" try: # If provider specified, look for provider-specific file if provider: # Try provider-server specific file first if server name provided if mcp_server_name: egress_path = ( Path.cwd() / ".oauth-tokens" / f"{provider}-{mcp_server_name}-egress.json" ) if not egress_path.exists(): # Fallback to provider-only file egress_path = Path.cwd() / ".oauth-tokens" / f"{provider}-egress.json" else: egress_path = Path.cwd() / ".oauth-tokens" / f"{provider}-egress.json" else: # Fallback to generic egress.json for backward compatibility egress_path = Path.cwd() / ".oauth-tokens" / "egress.json" if not egress_path.exists(): return None with open(egress_path) as f: token_data = json.load(f) # Check if token is expired if token_data.get("expires_at"): expires_at = token_data["expires_at"] # Add 5 minute margin if time.time() + 300 >= expires_at: logger.info("Existing egress token is expired or will expire soon") return None logger.info("Found valid existing egress token") return token_data except Exception as e: logger.debug(f"Failed to load existing tokens: {e}") return None def _get_supported_providers() -> list[str]: """Get list of supported external providers (exclude cognito providers).""" try: import yaml yaml_path = Path(__file__).parent / "oauth_providers.yaml" if not yaml_path.exists(): # Fallback to known external providers return [ "atlassian", "google", "github", "microsoft", "slack", "discord", "linkedin", "spotify", "twitter", ] with open(yaml_path) as f: config = yaml.safe_load(f) providers = config.get("providers", {}) # Filter out cognito providers (those are for ingress) external_providers = [ name for name, config in providers.items() if not name.startswith("cognito") ] return external_providers except Exception as e: logger.debug(f"Failed to load providers list: {e}") # Fallback to known external providers return [ "atlassian", "google", "github", "microsoft", "slack", "discord", "linkedin", "spotify", "twitter", ] def main() -> int: """Main entry point.""" supported_providers = _get_supported_providers() parser = argparse.ArgumentParser( description="Egress OAuth Authentication for External Services", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=f""" Examples: python egress_oauth.py # Use Atlassian (default) python egress_oauth.py --provider google # Use Google python egress_oauth.py --provider github --verbose # GitHub with debug python egress_oauth.py --force # Force new token generation Supported Providers: {", ".join(supported_providers)} Environment Variables Required (numbered configuration sets 1-100): EGRESS_OAUTH_CLIENT_ID_N # OAuth Client ID for external provider EGRESS_OAUTH_CLIENT_SECRET_N # OAuth Client Secret for external provider EGRESS_OAUTH_REDIRECT_URI_N # OAuth Redirect URI (defaults to localhost:8080/callback) EGRESS_OAUTH_SCOPE_N # OAuth scopes (optional, uses provider defaults) EGRESS_PROVIDER_NAME_N # Provider name (atlassian, google, github, etc.) EGRESS_MCP_SERVER_NAME_N # MCP server name for token file naming Where N is a number from 1 to 100 (e.g., EGRESS_OAUTH_CLIENT_ID_1) """, ) parser.add_argument( "--provider", choices=supported_providers, default=None, help="External OAuth provider (if not specified, processes all available configurations)", ) parser.add_argument( "--mcp-server-name", type=str, default=None, help="MCP server name (e.g., jira, confluence) for provider-specific configs", ) parser.add_argument( "--config-set", type=int, default=None, help="Specific configuration set number (1-100) to process", ) parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose debug logging") parser.add_argument( "--force", "-f", action="store_true", help="Force new token generation, ignore existing valid tokens", ) args = parser.parse_args() if args.verbose: logging.getLogger().setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG) try: # Validate environment variables _validate_environment_variables() # Get available configurations available_configs = _find_available_configurations() # Determine which configurations to process if args.config_set: # Process specific configuration set if args.config_set not in available_configs: logger.error(f"Configuration set {args.config_set} not found or incomplete") return 1 configs_to_process = [args.config_set] elif args.provider: # Find configurations for specific provider configs_to_process = [] for config_num in available_configs: provider_name = os.getenv(f"EGRESS_PROVIDER_NAME_{config_num}") if provider_name == args.provider: configs_to_process.append(config_num) if not configs_to_process: logger.error(f"No configurations found for provider: {args.provider}") return 1 else: # Process all available configurations configs_to_process = available_configs logger.info( f"🔐 Processing {len(configs_to_process)} configuration(s): {configs_to_process}" ) success_count = 0 failure_count = 0 for config_num in configs_to_process: try: # Get configuration details provider = os.getenv(f"EGRESS_PROVIDER_NAME_{config_num}") server_name = os.getenv(f"EGRESS_MCP_SERVER_NAME_{config_num}") if not provider: logger.warning( f"Skipping config {config_num}: EGRESS_PROVIDER_NAME_{config_num} not set" ) continue logger.info( f"\n📋 Processing configuration {config_num}: {provider}" + (f" ({server_name})" if server_name else "") ) # Check for existing valid tokens (unless force is specified) if not args.force: existing_tokens = _load_existing_tokens(provider, server_name) if existing_tokens and existing_tokens.get("provider") == provider: server_info = f" ({server_name})" if server_name else "" logger.info( f"✅ Using existing valid egress token for {provider}{server_info}" ) logger.info( f"Token expires at: {existing_tokens.get('expires_at_human', 'Unknown')}" ) success_count += 1 continue # Run OAuth flow for this configuration token_data = _run_generic_oauth_flow_for_config( config_num=config_num, provider=provider, force_new=args.force, verbose=args.verbose, ) # Save tokens to {provider}-egress.json or {provider}-{server_name}-egress.json saved_path = _save_egress_tokens(token_data, provider, server_name) logger.info(f"✅ EGRESS OAuth authentication completed for {provider}!") logger.info(f"Tokens saved to: {saved_path}") success_count += 1 except Exception as e: logger.error(f"❌ Failed to process configuration {config_num}: {e}") if args.verbose: import traceback logger.error(traceback.format_exc()) failure_count += 1 # Summary logger.info(f"\n📊 Summary: {success_count} successful, {failure_count} failed") return 0 if failure_count == 0 else 1 except Exception as e: logger.error(f"❌ EGRESS OAuth authentication failed: {e}") if args.verbose: import traceback logger.error(traceback.format_exc()) return 1 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: credentials-provider/oauth/generic_oauth_flow.py ================================================ #!/usr/bin/env python3 """ Generic OAuth 2.0 Authorization Flow Script A standalone, generic OAuth 2.0 authorization script that can work with multiple providers including Atlassian, Google, GitHub, and others. Now powered by FastAPI for reliable callback handling. This script provides: 1. Multi-provider OAuth 2.0 support with configurable providers 2. FastAPI-based local callback server for reliable authorization code handling 3. Secure file-based token storage 4. Automatic token refresh functionality 5. PKCE support for enhanced security 6. Beautiful browser callback pages with auto-close functionality 7. Immediate token exchange during callback for better user experience 8. Comprehensive logging and error handling Usage: # Interactive mode (recommended for first-time users) python generic_oauth_flow.py # Command line mode python generic_oauth_flow.py --provider atlassian --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET python generic_oauth_flow.py --provider google --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET python generic_oauth_flow.py --config-file oauth_config.json # Force interactive mode even with partial args python generic_oauth_flow.py --interactive Environment variables are also supported: - EGRESS_OAUTH_CLIENT_ID - EGRESS_OAUTH_CLIENT_SECRET - EGRESS_OAUTH_REDIRECT_URI - EGRESS_OAUTH_SCOPE Dependencies: pip install requests pyyaml """ import argparse import base64 import hashlib import http.server import json import logging import os import secrets import socketserver import sys import threading import time import urllib.parse import webbrowser from dataclasses import dataclass from pathlib import Path from typing import Any # Removed keyring dependency - using file-based storage only import requests import yaml # Configure logging first logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(name)s - %(message)s" ) logger = logging.getLogger("generic-oauth") # Try to load .env file if python-dotenv is available try: from dotenv import load_dotenv # Load .env from the same directory as this script env_file = Path(__file__).parent / ".env" if env_file.exists(): load_dotenv(env_file) logger.debug(f"Loaded environment variables from {env_file}") else: # Fallback: try parent directory (project root) env_file_parent = Path(__file__).parent.parent / ".env" if env_file_parent.exists(): load_dotenv(env_file_parent) logger.debug(f"Loaded environment variables from {env_file_parent}") else: # Final fallback: try current working directory load_dotenv() logger.debug("Tried to load .env from current working directory") except ImportError: logger.debug("python-dotenv not available, skipping .env loading") def _validate_environment_variables() -> None: """Validate that all required INGRESS and EGRESS OAuth environment variables are set.""" required_ingress_vars = [ "INGRESS_OAUTH_USER_POOL_ID", "INGRESS_OAUTH_CLIENT_ID", "INGRESS_OAUTH_CLIENT_SECRET", ] required_egress_vars = [ "EGRESS_OAUTH_CLIENT_ID", "EGRESS_OAUTH_CLIENT_SECRET", "EGRESS_OAUTH_REDIRECT_URI", ] missing_vars = [] # Check INGRESS variables for var in required_ingress_vars: if not os.getenv(var): missing_vars.append(var) # Check EGRESS variables for var in required_egress_vars: if not os.getenv(var): missing_vars.append(var) if missing_vars: logger.error("Missing required environment variables:") for var in missing_vars: logger.error(f" - {var}") logger.error("\nPlease set the following environment variables:") logger.error("INGRESS OAuth variables (for MCP Gateway authentication):") for var in required_ingress_vars: if var in missing_vars: logger.error(f" export {var}=") logger.error("\nEGRESS OAuth variables (for external OAuth providers):") for var in required_egress_vars: if var in missing_vars: logger.error(f" export {var}=") logger.error("\nOr add them to your .env file") raise SystemExit(1) logger.debug("All required INGRESS and EGRESS OAuth environment variables are set") # Environment variable validation will be done conditionally in main() # Constants TOKEN_EXPIRY_MARGIN = 300 # 5 minutes in seconds # Removed keyring service name - using file-based storage only DEFAULT_REDIRECT_PORT = 8080 # Load OAuth provider configurations from YAML file def _load_oauth_providers() -> dict[str, Any]: """Load OAuth provider configurations from YAML file.""" yaml_path = Path(__file__).parent / "oauth_providers.yaml" # Fallback to embedded minimal config if YAML file doesn't exist if not yaml_path.exists(): logger.warning(f"OAuth providers YAML file not found at {yaml_path}") logger.warning("Using minimal embedded configuration") return { "atlassian": { "display_name": "Atlassian Cloud", "auth_url": "https://auth.atlassian.com/authorize", "token_url": "https://auth.atlassian.com/oauth/token", "user_info_url": "https://api.atlassian.com/oauth/token/accessible-resources", "scopes": ["read:jira-work", "write:jira-work", "offline_access"], "response_type": "code", "grant_type": "authorization_code", "audience": "api.atlassian.com", "requires_pkce": False, "additional_params": {"prompt": "consent"}, } } try: with open(yaml_path) as f: config = yaml.safe_load(f) providers = config.get("providers", {}) logger.debug(f"Loaded {len(providers)} OAuth providers from {yaml_path}") return providers except Exception as e: logger.error(f"Failed to load OAuth providers from YAML: {e}") return {} # Load OAuth provider configurations OAUTH_PROVIDERS = _load_oauth_providers() # Global variables for callback handling authorization_code = None received_state = None callback_received = False callback_error = None pkce_verifier = None oauth_config_global = None @dataclass class OAuthConfig: """OAuth 2.0 configuration for any provider.""" provider: str client_id: str client_secret: str redirect_uri: str scopes: list[str] provider_config: dict[str, Any] cloud_id: str | None = None refresh_token: str | None = None access_token: str | None = None expires_at: float | None = None additional_params: dict[str, str] | None = None @property def is_token_expired(self) -> bool: """Check if the access token is expired or will expire soon.""" if not self.access_token or not self.expires_at: return True return time.time() + TOKEN_EXPIRY_MARGIN >= self.expires_at def get_authorization_url(self, state: str, pkce_challenge: str | None = None) -> str: """Get the authorization URL for the OAuth 2.0 flow.""" params = { "client_id": self.client_id, "scope": " ".join(self.scopes), "redirect_uri": self.redirect_uri, "response_type": self.provider_config["response_type"], "state": state, } # Add provider-specific parameters if "audience" in self.provider_config: params["audience"] = self.provider_config["audience"] # Add PKCE challenge if required if pkce_challenge and self.provider_config.get("requires_pkce", False): params["code_challenge"] = pkce_challenge params["code_challenge_method"] = "S256" # Add any additional parameters if self.additional_params: params.update(self.additional_params) if "additional_params" in self.provider_config: params.update(self.provider_config["additional_params"]) return f"{self.provider_config['auth_url']}?{urllib.parse.urlencode(params)}" def exchange_code_for_tokens(self, code: str, pkce_verifier: str | None = None) -> bool: """Exchange the authorization code for access and refresh tokens.""" try: payload = { "grant_type": self.provider_config["grant_type"], "client_id": self.client_id, "client_secret": self.client_secret, "code": code, "redirect_uri": self.redirect_uri, } # Add PKCE verifier if required if pkce_verifier and self.provider_config.get("requires_pkce", False): payload["code_verifier"] = pkce_verifier headers = {"Accept": "application/json"} # Apply provider-specific headers if configured if "token_headers" in self.provider_config: headers.update(self.provider_config["token_headers"]) logger.info( f"Exchanging authorization code for tokens at {self.provider_config['token_url']}" ) response = requests.post( self.provider_config["token_url"], data=payload, headers=headers, timeout=30 ) logger.debug(f"Token exchange response status: {response.status_code}") if not response.ok: logger.error( f"Token exchange failed with status {response.status_code}. Response: {response.text}" ) return False token_data = response.json() if "access_token" not in token_data: logger.error( f"Access token not found in response. Keys found: {list(token_data.keys())}" ) return False self.access_token = token_data["access_token"] # Handle refresh token (not all providers support it) if "refresh_token" in token_data: self.refresh_token = token_data["refresh_token"] elif "offline_access" in self.scopes: logger.warning( "Refresh token not found despite 'offline_access' scope being included." ) # Set token expiry if "expires_in" in token_data: self.expires_at = time.time() + token_data["expires_in"] # Get provider-specific info (like cloud ID for Atlassian) self._get_provider_info() # Save the tokens self._save_tokens() logger.info("🎉 OAuth authorization flow completed successfully!") if self.expires_at: expires_in = int(self.expires_at - time.time()) logger.info(f"Access token expires in {expires_in} seconds") if self.cloud_id: logger.info(f"Retrieved Cloud ID: {self.cloud_id}") return True except requests.exceptions.RequestException as e: logger.error(f"Network error during token exchange: {e}") return False except json.JSONDecodeError as e: logger.error(f"Failed to decode JSON response: {e}") return False except Exception as e: logger.error(f"Failed to exchange code for tokens: {e}") return False def refresh_access_token(self) -> bool: """Refresh the access token using the refresh token.""" if not self.refresh_token: logger.error("No refresh token available") return False try: payload = { "grant_type": "refresh_token", "client_id": self.client_id, "client_secret": self.client_secret, "refresh_token": self.refresh_token, } logger.debug("Refreshing access token...") response = requests.post(self.provider_config["token_url"], data=payload, timeout=30) response.raise_for_status() token_data = response.json() self.access_token = token_data["access_token"] # Refresh token might be rotated if "refresh_token" in token_data: self.refresh_token = token_data["refresh_token"] if "expires_in" in token_data: self.expires_at = time.time() + token_data["expires_in"] self._save_tokens() logger.info("Successfully refreshed access token") return True except Exception as e: logger.error(f"Failed to refresh access token: {e}") return False def ensure_valid_token(self) -> bool: """Ensure the access token is valid, refreshing if necessary.""" if not self.is_token_expired: return True return self.refresh_access_token() def _get_provider_info(self) -> None: """Get provider-specific information (e.g., cloud ID for Atlassian).""" # Check if provider requires cloud ID from user info if ( self.provider_config.get("requires_cloud_id") and self.provider_config.get("cloud_id_from_user_info") and self.access_token ): try: headers = {"Authorization": f"Bearer {self.access_token}"} response = requests.get( self.provider_config["user_info_url"], headers=headers, timeout=30 ) response.raise_for_status() resources = response.json() if resources and len(resources) > 0: # Generic handling - assumes first resource has an 'id' field self.cloud_id = resources[0].get("id") if self.cloud_id: logger.debug(f"Found cloud ID for {self.provider}: {self.cloud_id}") else: logger.warning(f"No resources found for {self.provider}") except Exception as e: logger.error(f"Failed to get cloud ID for {self.provider}: {e}") # Removed keyring username method - using file-based storage only def _save_tokens(self) -> None: """Save the tokens securely using file-based storage.""" try: token_data = { "provider": self.provider, "refresh_token": self.refresh_token, "access_token": self.access_token, "expires_at": self.expires_at, "cloud_id": self.cloud_id, "scopes": self.scopes, } # Save to file self._save_tokens_to_file(token_data) except Exception as e: logger.error(f"Failed to save tokens: {e}") def _save_tokens_to_file(self, token_data: dict) -> None: """Save tokens to a file as fallback storage.""" try: # Create provider-specific directory structure (with backwards compatibility) primary_token_dir = Path.cwd() / ".oauth-tokens" primary_token_dir.mkdir(exist_ok=True, mode=0o700) # Primary token file with provider in name token_path = primary_token_dir / f"oauth-{self.provider}-{self.client_id}.json" # Save essential token data essential_token_data = { "provider": self.provider, "refresh_token": self.refresh_token, "access_token": self.access_token, "expires_at": self.expires_at, "cloud_id": self.cloud_id, } with open(token_path, "w") as f: json.dump(essential_token_data, f, indent=2) # Secure the file token_path.chmod(0o600) logger.info(f"📁 Saved OAuth tokens to: {token_path}") # Save a readable version with usage examples readable_token_path = ( primary_token_dir / f"oauth-{self.provider}-{self.client_id}-readable.json" ) readable_data = { "provider": self.provider, "provider_display_name": self.provider_config.get("display_name", self.provider), "client_id": self.client_id, "cloud_id": self.cloud_id, "scopes": self.scopes, "access_token": self.access_token, "refresh_token": self.refresh_token, "expires_at": self.expires_at, "expires_at_human": time.strftime( "%Y-%m-%d %H:%M:%S UTC", time.gmtime(self.expires_at) ) if self.expires_at else None, "saved_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), "usage_examples": { "curl_with_bearer": f"curl -H 'Authorization: Bearer {self.access_token}' {self.provider_config.get('user_info_url', '')}", "python_requests": f"headers = {{'Authorization': 'Bearer {self.access_token}'}}; requests.get('', headers=headers)", "token_file_location": f"The token is saved at: {token_path}", "vscode_mcp_config": f"VS Code MCP config saved at: {primary_token_dir}/vscode_mcp.json", "roocode_mcp_config": f"Roocode MCP config saved at: {primary_token_dir}/mcp.json", }, } with open(readable_token_path, "w") as f: json.dump(readable_data, f, indent=2) readable_token_path.chmod(0o600) logger.info(f"📄 Saved readable token info to: {readable_token_path}") # Create VS Code MCP configuration file for supported providers self._create_vscode_mcp_config(primary_token_dir) # Create Roocode MCP configuration file for supported providers self._create_roocode_mcp_config(primary_token_dir) except Exception as e: logger.error(f"Failed to save tokens to file: {e}") def _create_vscode_mcp_config(self, token_dir: Path) -> None: """Create VS Code MCP configuration file for supported providers.""" try: # Only create MCP config for providers that have MCP gateway support if self.provider not in ["atlassian"]: logger.debug(f"Skipping VS Code MCP config - {self.provider} not supported") return vscode_config_path = token_dir / "vscode_mcp.json" # Load environment variables for MCP Gateway configuration registry_url = os.getenv("REGISTRY_URL", "https://mcpgateway.ddns.net") aws_region = os.getenv("AWS_REGION", "us-east-1") user_pool_id = os.getenv("INGRESS_OAUTH_USER_POOL_ID") # Get the appropriate client ID - use the MCP Gateway client ID from env mcp_client_id = os.getenv("INGRESS_OAUTH_CLIENT_ID") # Get MCP Gateway auth token mcp_auth_token = os.getenv("MCP_SERVER1_AUTH_TOKEN", "") if mcp_auth_token.startswith('"') and mcp_auth_token.endswith('"'): mcp_auth_token = mcp_auth_token[1:-1] # Remove quotes # Create the VS Code MCP configuration mcp_config = {"mcp": {"servers": {}}} if self.provider == "atlassian": mcp_config["mcp"]["servers"]["atlassian"] = { "url": f"{registry_url}/atlassian/mcp", "headers": { # MCP Gateway authentication headers "X-Authorization": f"Bearer {mcp_auth_token}", "X-User-Pool-Id": user_pool_id, "X-Client-Id": mcp_client_id, "X-Region": aws_region, # Atlassian-specific headers "Authorization": f"Bearer {self.access_token}", "X-Atlassian-Cloud-Id": self.cloud_id or "", }, } # Save the VS Code MCP configuration with open(vscode_config_path, "w") as f: json.dump(mcp_config, f, indent=4) vscode_config_path.chmod(0o600) logger.info(f"🔧 Created VS Code MCP configuration: {vscode_config_path}") except Exception as e: logger.error(f"Failed to create VS Code MCP configuration: {e}") def _create_roocode_mcp_config(self, token_dir: Path) -> None: """Create Roocode MCP configuration file for supported providers.""" try: # Only create MCP config for providers that have MCP gateway support if self.provider not in ["atlassian"]: logger.debug(f"Skipping Roocode MCP config - {self.provider} not supported") return roocode_config_path = token_dir / "mcp.json" # Load environment variables for MCP Gateway configuration registry_url = os.getenv("REGISTRY_URL", "https://mcpgateway.ddns.net") aws_region = os.getenv("AWS_REGION", "us-east-1") user_pool_id = os.getenv("INGRESS_OAUTH_USER_POOL_ID") # Get the appropriate client ID - use the MCP Gateway client ID from env mcp_client_id = os.getenv("INGRESS_OAUTH_CLIENT_ID") # Get MCP Gateway auth token mcp_auth_token = os.getenv("MCP_SERVER1_AUTH_TOKEN", "") if mcp_auth_token.startswith('"') and mcp_auth_token.endswith('"'): mcp_auth_token = mcp_auth_token[1:-1] # Remove quotes # Create the Roocode MCP configuration mcp_config = {"mcpServers": {}} if self.provider == "atlassian": mcp_config["mcpServers"]["atlassian"] = { "type": "streamable-http", "url": f"{registry_url}/atlassian/mcp", "headers": { # MCP Gateway authentication headers "X-Authorization": f"Bearer {mcp_auth_token}", "X-User-Pool-Id": user_pool_id, "X-Client-Id": mcp_client_id, "X-Region": aws_region, # Atlassian-specific headers "Authorization": f"Bearer {self.access_token}", "X-Atlassian-Cloud-Id": self.cloud_id or "", }, "disabled": False, "alwaysAllow": [], } # Save the Roocode MCP configuration with open(roocode_config_path, "w") as f: json.dump(mcp_config, f, indent=2) roocode_config_path.chmod(0o600) logger.info(f"🔧 Created Roocode MCP configuration: {roocode_config_path}") except Exception as e: logger.error(f"Failed to create Roocode MCP configuration: {e}") @staticmethod def load_tokens(provider: str, client_id: str) -> dict[str, Any]: """Load tokens from file storage.""" # Try primary token file format first primary_tokens = OAuthConfig._load_tokens_from_file(provider, client_id) if primary_tokens: return primary_tokens return {} @staticmethod def _load_tokens_from_file(provider: str, client_id: str) -> dict[str, Any]: """Load tokens from primary file format.""" token_path = Path.cwd() / ".oauth-tokens" / f"oauth-{provider}-{client_id}.json" if not token_path.exists(): return {} try: with open(token_path) as f: token_data = json.load(f) logger.debug(f"Loaded OAuth tokens from file {token_path}") return token_data except Exception as e: logger.error(f"Failed to load tokens from file: {e}") return {} def generate_pkce_pair() -> tuple[str, str]: """Generate PKCE code verifier and challenge.""" # Generate code verifier (43-128 characters) code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("utf-8").rstrip("=") # Generate code challenge code_challenge = ( base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("utf-8")).digest()) .decode("utf-8") .rstrip("=") ) return code_verifier, code_challenge class CallbackHandler(http.server.BaseHTTPRequestHandler): """HTTP request handler for OAuth callback.""" def do_GET(self) -> None: """Handle GET requests (OAuth callback).""" global \ authorization_code, \ callback_received, \ callback_error, \ received_state, \ oauth_config_global parsed_path = urllib.parse.urlparse(self.path) logger.debug(f"CallbackHandler received GET request for: {self.path}") # Ignore favicon requests politely if parsed_path.path == "/favicon.ico": self.send_error(404, "File not found") logger.debug("CallbackHandler: Ignored /favicon.ico request.") return # Process only /callback path if parsed_path.path != "/callback": self.send_error(404, "Not Found: Only /callback is supported.") logger.warning( f"CallbackHandler: Received request for unexpected path: {parsed_path.path}" ) return # Parse the query parameters from the URL query = parsed_path.query params = urllib.parse.parse_qs(query) if "error" in params: callback_error = params["error"][0] callback_received = True logger.error(f"Authorization error from callback: {callback_error}") self._send_response(f"Authorization failed: {callback_error}", status=400) return if "code" in params: authorization_code = params["code"][0] if "state" in params: received_state = params["state"][0] callback_received = True logger.info("Authorization code and state received successfully via callback.") # Try immediate token exchange if config is available message = "Authorization successful! You can close this window now." if oauth_config_global: try: logger.info("Attempting immediate token exchange...") success = oauth_config_global.exchange_code_for_tokens( authorization_code, pkce_verifier ) if success: message = "Authorization successful! Tokens have been saved securely. You can close this window now." logger.info("🎉 Token exchange completed successfully during callback!") else: message = "Authorization received but token exchange failed. Check the logs for details." logger.error("Token exchange failed during callback") except Exception as e: logger.error(f"Error during immediate token exchange: {e}") message = "Authorization received but token exchange encountered an error. Check the logs for details." self._send_response(message) else: logger.error("Invalid callback: 'code' or 'error' parameter missing.") self._send_response("Invalid callback: Authorization code missing", status=400) def _send_response(self, message: str, status: int = 200) -> None: """Send response to the browser.""" html = f""" OAuth Authorization
{"✅" if status == 200 else "❌"}

{"OAuth Authorization Complete!" if status == 200 else "OAuth Authorization Failed"}

{message}
This window will close in 5 seconds...
""" # Encode the HTML content content = html.encode("utf-8") content_length = len(content) # Send HTTP response with proper headers self.send_response(status) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(content_length)) self.send_header("Connection", "close") self.send_header("Cache-Control", "no-cache, no-store, must-revalidate") self.send_header("Pragma", "no-cache") self.send_header("Expires", "0") self.end_headers() # Write content and ensure it's flushed self.wfile.write(content) self.wfile.flush() def log_message(self, format: str, *args) -> None: """Override to suppress default HTTP server logging.""" return def start_callback_server(port: int) -> socketserver.TCPServer: """Start a local server to receive the OAuth callback.""" handler = CallbackHandler httpd = socketserver.TCPServer(("localhost", port), handler) server_thread = threading.Thread(target=httpd.serve_forever) server_thread.daemon = True server_thread.start() logger.info(f"Started callback server on port {port}") return httpd def wait_for_callback(timeout: int = 300) -> bool: """Wait for the callback to be received.""" global callback_received, callback_error, authorization_code start_time = time.time() while not callback_received and (time.time() - start_time) < timeout: time.sleep(1) if not callback_received: logger.error(f"Timed out waiting for authorization callback after {timeout} seconds") logger.info("You can still visit the authorization URL and complete the flow manually") return False if callback_error: logger.error(f"Authorization error: {callback_error}") return False if not authorization_code: logger.error("No authorization code received") return False logger.info(f"Received authorization code: {authorization_code[:20]}...") return True def parse_redirect_uri(redirect_uri: str) -> tuple[str, int]: """Parse the redirect URI to extract host and port.""" parsed = urllib.parse.urlparse(redirect_uri) port = parsed.port or (443 if parsed.scheme == "https" else 80) return parsed.hostname, port def load_config_file(config_path: str) -> dict[str, Any]: """Load OAuth configuration from a JSON file.""" try: with open(config_path) as f: return json.load(f) except Exception as e: logger.error(f"Failed to load config file {config_path}: {e}") return {} def interactive_provider_selection() -> str: """Interactive provider selection menu.""" print("\n🔐 OAuth 2.0 Provider Selection") print("=" * 40) providers = list(OAUTH_PROVIDERS.keys()) for i, provider in enumerate(providers, 1): display_name = OAUTH_PROVIDERS[provider]["display_name"] is_m2m = OAUTH_PROVIDERS[provider].get("is_m2m", False) m2m_label = " [M2M/No Browser]" if is_m2m else "" print(f"{i}. {display_name} ({provider}){m2m_label}") while True: try: choice = input(f"\nSelect a provider (1-{len(providers)}): ").strip() if not choice: continue index = int(choice) - 1 if 0 <= index < len(providers): selected_provider = providers[index] print(f"✅ Selected: {OAUTH_PROVIDERS[selected_provider]['display_name']}") return selected_provider else: print(f"❌ Please enter a number between 1 and {len(providers)}") except ValueError: print("❌ Please enter a valid number") except KeyboardInterrupt: print("\n\n👋 Goodbye!") sys.exit(0) def interactive_input(prompt: str, required: bool = True, is_secret: bool = False) -> str: """Get interactive input with validation.""" import getpass while True: try: if is_secret: value = getpass.getpass(f"{prompt}: ").strip() else: value = input(f"{prompt}: ").strip() if value or not required: return value if required: print("❌ This field is required. Please enter a value.") except KeyboardInterrupt: print("\n\n👋 Goodbye!") sys.exit(0) def interactive_scopes_input(provider_config: dict[str, Any]) -> list[str]: """Interactive scopes selection.""" default_scopes = provider_config.get("scopes", []) print("\nOAuth Scopes") logger.info(f"Default scopes: {', '.join(default_scopes)}") custom_input = input( "Enter custom scopes (comma or space-separated) or press Enter for defaults: " ).strip() if custom_input: # Handle both comma-separated and space-separated scopes if "," in custom_input: custom_scopes = [scope.strip() for scope in custom_input.split(",")] else: custom_scopes = [scope.strip() for scope in custom_input.split()] return custom_scopes return default_scopes def interactive_configuration() -> dict[str, Any]: """Interactive configuration setup.""" print("\n🚀 Generic OAuth 2.0 Flow - Interactive Setup") print("=" * 50) print("This will help you set up OAuth 2.0 authentication with various providers.") print("You can press Ctrl+C at any time to exit.\n") # Provider selection provider = interactive_provider_selection() provider_config = OAUTH_PROVIDERS[provider] print(f"\n📝 Setting up {provider_config['display_name']} OAuth") print("=" * 40) # Client credentials print("\n🔑 Client Credentials") print("These can be obtained from your OAuth provider's developer console.") # Map of known provider console URLs provider_consoles = { "atlassian": "https://developer.atlassian.com/console/myapps/", "google": "https://console.developers.google.com/", "github": "https://github.com/settings/developers", "cognito": "https://console.aws.amazon.com/cognito/", "microsoft": "https://portal.azure.com/", "slack": "https://api.slack.com/apps", "discord": "https://discord.com/developers/applications", "linkedin": "https://www.linkedin.com/developers/apps", "spotify": "https://developer.spotify.com/dashboard/", "twitter": "https://developer.twitter.com/en/portal/dashboard", } if provider in provider_consoles: print(f" • {provider_config['display_name']}: {provider_consoles[provider]}") client_id = interactive_input("\nClient ID", required=True) client_secret = interactive_input("Client Secret", required=True, is_secret=True) # Redirect URI (skip for M2M providers) if not provider_config.get("is_m2m", False): print("\n🔄 Redirect URI") # Try to get public IP for better remote access try: import subprocess # nosec B404 public_ip = ( subprocess.check_output(["curl", "-s", "http://checkip.amazonaws.com/"]) .decode() .strip() ) suggested_redirect = f"http://{public_ip}:{DEFAULT_REDIRECT_PORT}/callback" print(f"Suggested (for remote access): {suggested_redirect}") except: suggested_redirect = f"http://localhost:{DEFAULT_REDIRECT_PORT}/callback" print(f"Default (localhost): {suggested_redirect}") custom_redirect = input("Enter custom redirect URI or press Enter for suggested: ").strip() redirect_uri = custom_redirect if custom_redirect else suggested_redirect else: # M2M flow doesn't need redirect URI redirect_uri = "urn:ietf:wg:oauth:2.0:oob" # Standard placeholder for M2M # Scopes scopes = interactive_scopes_input(provider_config) # Provider-specific configuration for templates additional_config = {} # Check if provider requires template variables if "requires_template_vars" in provider_config: print(f"\n⚙️ Additional Configuration for {provider_config['display_name']}") for var_name in provider_config["requires_template_vars"]: # Get default value if available default_value = provider_config.get("template_var_defaults", {}).get(var_name) # Format the prompt prompt = var_name.replace("_", " ").title() if default_value: prompt = f"{prompt} (default: {default_value})" # Get input or use default value = interactive_input(prompt, required=False) if not value and default_value: value = default_value elif not value: value = interactive_input(f"{prompt} (required)", required=True) additional_config[var_name] = value # Summary (redacted for security) from ..utils import redact_sensitive_value print("\n📋 Configuration Summary") print("=" * 30) print(f"Provider: {provider_config['display_name']}") print(f"Client ID: {redact_sensitive_value(client_id, 8)}") print(f"Client Secret: {redact_sensitive_value(client_secret, 8)}") print(f"Redirect URI: {redirect_uri}") print(f"Scopes: {', '.join(scopes)}") if additional_config: for key, value in additional_config.items(): # Redact sensitive values in additional config display_value = value if any( sensitive in key.lower() for sensitive in ["secret", "password", "token", "key"] ): display_value = redact_sensitive_value(str(value), 8) print(f"{key.replace('_', ' ').title()}: {display_value}") # Confirmation confirm = input("\n✅ Proceed with OAuth flow? (y/N): ").strip().lower() if confirm != "y": print("❌ Cancelled by user") sys.exit(0) return { "provider": provider, "client_id": client_id, "client_secret": client_secret, "redirect_uri": redirect_uri, "scopes": scopes, **additional_config, } def run_m2m_flow(config: OAuthConfig) -> bool: """Run the M2M (client credentials) OAuth 2.0 flow. Args: config: OAuth configuration Returns: bool: True if successful, False otherwise """ try: # Prepare the token request payload = { "grant_type": "client_credentials", "client_id": config.client_id, "client_secret": config.client_secret, } # Add scopes if specified (only if non-empty) if config.scopes and len(config.scopes) > 0: payload["scope"] = " ".join(config.scopes) headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", } logger.info(f"Requesting M2M token from {config.provider_config['token_url']}") logger.debug( f"Using client_id: {config.client_id[:10]}..." if config.client_id else "No client_id" ) logger.debug(f"Scopes: {config.scopes}") response = requests.post( config.provider_config["token_url"], data=payload, headers=headers, timeout=30 ) if not response.ok: logger.error( f"M2M token request failed with status {response.status_code}. Response: {response.text}" ) return False token_data = response.json() if "access_token" not in token_data: logger.error( f"Access token not found in M2M response. Keys found: {list(token_data.keys())}" ) return False config.access_token = token_data["access_token"] # M2M tokens typically don't have refresh tokens if "refresh_token" in token_data: config.refresh_token = token_data["refresh_token"] # Set token expiry if "expires_in" in token_data: config.expires_at = time.time() + token_data["expires_in"] # Save the tokens config._save_tokens() logger.info( f"🎉 M2M token obtained successfully for {config.provider_config['display_name']}!" ) if config.expires_at: expires_in = int(config.expires_at - time.time()) logger.info(f"Token expires in: {expires_in} seconds") return True except requests.exceptions.RequestException as e: logger.error(f"Network error during M2M token request: {e}") return False except Exception as e: logger.error(f"Failed to obtain M2M token: {e}") return False def run_oauth_flow(config: OAuthConfig, force_new: bool = False) -> bool: """Run the OAuth 2.0 authorization flow. Args: config: OAuth configuration force_new: If True, delete existing tokens and force new authorization """ # Check if this is an M2M provider if config.provider_config.get("is_m2m", False): logger.info("Provider configured for M2M/Client Credentials flow") return run_m2m_flow(config) global \ pkce_verifier, \ authorization_code, \ received_state, \ callback_received, \ callback_error, \ oauth_config_global # Reset global variables authorization_code = None received_state = None callback_received = False callback_error = None oauth_config_global = config # Make config available to callback handler # Handle force delete of existing tokens if force_new: logger.info("🗑️ Force delete requested - removing existing tokens") _delete_existing_tokens(config.provider, config.client_id) # Check for existing valid tokens (skip if force_new) if not force_new: token_data = OAuthConfig.load_tokens(config.provider, config.client_id) if token_data: config.refresh_token = token_data.get("refresh_token") config.access_token = token_data.get("access_token") config.expires_at = token_data.get("expires_at") config.cloud_id = token_data.get("cloud_id") if config.access_token and not config.is_token_expired: logger.info("Found valid existing access token") return True elif config.refresh_token: logger.info("Found refresh token, attempting to refresh access token") if config.refresh_access_token(): return True # Generate state for CSRF protection state = secrets.token_urlsafe(16) # Generate PKCE pair if required pkce_challenge = None if config.provider_config.get("requires_pkce", False): pkce_verifier, pkce_challenge = generate_pkce_pair() logger.debug("Generated PKCE challenge for enhanced security") # Start local callback server if using localhost hostname, port = parse_redirect_uri(config.redirect_uri) httpd = None if hostname and hostname.lower() in ["localhost", "127.0.0.1"]: try: httpd = start_callback_server(port) except OSError as e: logger.error(f"Failed to start callback server: {e}") logger.error(f"Make sure port {port} is available") return False # Get the authorization URL auth_url = config.get_authorization_url(state, pkce_challenge) # Open the browser for authorization logger.info(f"Opening browser for {config.provider_config['display_name']} authorization") logger.info("If the browser doesn't open automatically, visit this URL:") logger.info(auth_url) webbrowser.open(auth_url) # Wait for the callback logger.info("Waiting for authorization callback...") callback_success = wait_for_callback() # Clean up global config reference oauth_config_global = None if not callback_success: if httpd: httpd.shutdown() return False # Verify state to prevent CSRF attacks if received_state != state: logger.warning(f"State mismatch! Expected: {state}, Received: {received_state}") logger.warning("This might be from a previous authorization attempt. Continuing anyway...") # Don't fail on state mismatch in case of VS Code port forwarding or browser refresh else: logger.info("CSRF state verified successfully") # Check if token exchange already happened in the callback if config.access_token: logger.info("Token exchange was already completed during callback") success = True else: # Exchange the code for tokens if not done already logger.info("Exchanging authorization code for tokens...") success = config.exchange_code_for_tokens(authorization_code, pkce_verifier) if httpd: httpd.shutdown() if success: logger.info( f"🎉 {config.provider_config['display_name']} OAuth authorization completed successfully!" ) # Display useful information logger.info("\n📋 Configuration Summary:") logger.info(f"Provider: {config.provider_config['display_name']}") logger.info(f"Client ID: {config.client_id}") logger.info(f"Scopes: {', '.join(config.scopes)}") if config.cloud_id: logger.info(f"Cloud ID: {config.cloud_id}") if config.expires_at: expires_in = int(config.expires_at - time.time()) logger.info(f"Token expires in: {expires_in} seconds") logger.info("\n💡 Tokens have been saved securely and can be used by other applications") return success def _delete_existing_tokens(provider: str, client_id: str) -> None: """Delete existing tokens from all storage locations.""" deleted_files = [] # Keyring deletion removed - using file-based storage only # Delete primary token file primary_token_path = Path.cwd() / ".oauth-tokens" / f"oauth-{provider}-{client_id}.json" if primary_token_path.exists(): primary_token_path.unlink() deleted_files.append(str(primary_token_path)) logger.debug(f"Deleted primary token file: {primary_token_path}") # Delete readable token file readable_token_path = ( Path.cwd() / ".oauth-tokens" / f"oauth-{provider}-{client_id}-readable.json" ) if readable_token_path.exists(): readable_token_path.unlink() deleted_files.append(str(readable_token_path)) logger.debug(f"Deleted readable token file: {readable_token_path}") if deleted_files: logger.info(f"🗑️ Deleted {len(deleted_files)} existing token file(s)") for file_path in deleted_files: logger.debug(f" - {file_path}") else: logger.info("🗑️ No existing token files found to delete") def main() -> int: """Main entry point.""" parser = argparse.ArgumentParser( description="Generic OAuth 2.0 Authorization Flow", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python generic_oauth_flow.py --provider atlassian --client-id YOUR_ID --client-secret YOUR_SECRET python generic_oauth_flow.py --provider google --client-id YOUR_ID --client-secret YOUR_SECRET python generic_oauth_flow.py --provider cognito_m2m --client-id YOUR_ID --client-secret YOUR_SECRET # M2M flow python generic_oauth_flow.py --config-file oauth_config.json python generic_oauth_flow.py --provider atlassian --force # Force new auth, delete existing tokens python generic_oauth_flow.py # Interactive mode Supported providers: """ + ", ".join(OAUTH_PROVIDERS.keys()), ) parser.add_argument("--provider", choices=list(OAUTH_PROVIDERS.keys()), help="OAuth provider") parser.add_argument("--client-id", help="OAuth Client ID") parser.add_argument("--client-secret", help="OAuth Client Secret") parser.add_argument( "--redirect-uri", help="OAuth Redirect URI (default: http://localhost:8080/callback)" ) parser.add_argument("--scope", nargs="*", help="OAuth Scopes (space-separated)") parser.add_argument("--config-file", help="JSON configuration file") parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose logging") parser.add_argument( "--interactive", "-i", action="store_true", help="Force interactive mode even if some args are provided", ) parser.add_argument( "--force", "-f", action="store_true", help="Force new OAuth flow by deleting existing tokens", ) args = parser.parse_args() if args.verbose: logging.getLogger().setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG) # Load configuration from file if provided config_data = {} if args.config_file: config_data = load_config_file(args.config_file) # Check if we should use interactive mode use_interactive = args.interactive or ( not args.provider and not args.client_id and not args.client_secret and not args.config_file ) if use_interactive: # Show welcome message for truly interactive mode (no args at all) if not any([args.provider, args.client_id, args.client_secret, args.config_file]): print("🚀 Welcome to the Generic OAuth 2.0 Flow!") print("No arguments provided, starting interactive setup...\n") # Interactive configuration try: interactive_config = interactive_configuration() config_data.update(interactive_config) except KeyboardInterrupt: print("\n\n👋 Goodbye!") return 0 # Get configuration from args, config file, environment, or interactive input provider = args.provider or config_data.get("provider") or os.getenv("EGRESS_OAUTH_PROVIDER") # For Cognito providers, use INGRESS credentials (for MCP Gateway auth) # For other providers, use EGRESS credentials (for external OAuth providers) if provider and provider.startswith("cognito"): client_id = ( args.client_id or config_data.get("client_id") or os.getenv("INGRESS_OAUTH_CLIENT_ID") ) client_secret = ( args.client_secret or config_data.get("client_secret") or os.getenv("INGRESS_OAUTH_CLIENT_SECRET") ) logger.info("Using INGRESS OAuth credentials for Cognito provider") else: client_id = ( args.client_id or config_data.get("client_id") or os.getenv("EGRESS_OAUTH_CLIENT_ID") ) client_secret = ( args.client_secret or config_data.get("client_secret") or os.getenv("EGRESS_OAUTH_CLIENT_SECRET") ) logger.info("Using EGRESS OAuth credentials for external provider") redirect_uri = ( args.redirect_uri or config_data.get("redirect_uri") or os.getenv("EGRESS_OAUTH_REDIRECT_URI") or f"http://localhost:{DEFAULT_REDIRECT_PORT}/callback" ) # Handle scopes scopes = None if args.scope: scopes = args.scope elif config_data.get("scopes"): scopes = config_data["scopes"] elif os.getenv("EGRESS_OAUTH_SCOPE"): scopes = os.getenv("EGRESS_OAUTH_SCOPE").split() # Validate required arguments (only if not using interactive mode) if not use_interactive: missing = [] if not provider: missing.append("provider") if not client_id: missing.append("client-id") if not client_secret: missing.append("client-secret") if missing: logger.error(f"Missing required arguments: {', '.join(missing)}") logger.info("💡 Tip: Run without arguments for interactive mode!") parser.print_help() return 1 # Only validate environment variables if we're relying on them # (i.e., when not using command-line args or config file) if ( not args.provider and not args.client_id and not args.client_secret and not args.config_file and not use_interactive ): _validate_environment_variables() if provider not in OAUTH_PROVIDERS: logger.error(f"Unsupported provider: {provider}") logger.error(f"Supported providers: {', '.join(OAUTH_PROVIDERS.keys())}") return 1 # Get provider configuration provider_config = OAUTH_PROVIDERS[provider].copy() # Use provider default scopes if none specified if not scopes: scopes = provider_config["scopes"] # Handle provider-specific URL templating if "requires_template_vars" in provider_config: template_vars = {} for var_name in provider_config["requires_template_vars"]: # Try to get value from config_data or environment value = config_data.get(var_name) or os.getenv(var_name.upper()) # Special handling for Cognito domain - derive from user pool ID if not provided if not value and var_name == "domain" and provider in ["cognito", "cognito_m2m"]: # Try to derive domain from INGRESS_OAUTH_USER_POOL_ID user_pool_id = os.getenv("INGRESS_OAUTH_USER_POOL_ID") if user_pool_id: # Use user pool ID without underscores as domain (standard Cognito format) value = user_pool_id.replace("_", "") logger.info(f"Derived Cognito domain from user pool ID: {value}") # Use default if available and no value found if not value and "template_var_defaults" in provider_config: value = provider_config["template_var_defaults"].get(var_name) if not value: if use_interactive: logger.error( f"'{var_name}' configuration was not completed properly for provider '{provider}'" ) else: display_name = str(provider_config.get("display_name", provider)) logger.error(f"'{var_name}' is required for {display_name}") logger.error( f"Set {var_name.upper()} environment variable or add '{var_name}' to config file" ) # Provide helpful hint for Cognito domain if var_name == "domain" and provider in ["cognito", "cognito_m2m"]: logger.error( "Hint: You can also set INGRESS_OAUTH_USER_POOL_ID and the domain will be derived automatically" ) return 1 template_vars[var_name] = value # Update URLs with template variables for key in ["auth_url", "token_url", "user_info_url"]: if "{" in provider_config.get(key, ""): provider_config[key] = provider_config[key].format(**template_vars) # Create OAuth configuration oauth_config = OAuthConfig( provider=provider, client_id=client_id, client_secret=client_secret, redirect_uri=redirect_uri, scopes=scopes, provider_config=provider_config, additional_params=config_data.get("additional_params"), ) # Ensure scopes is a list for proper processing if isinstance(scopes, str): scopes = scopes.split() # Update OAuth configuration with corrected scopes oauth_config.scopes = scopes # Check for critical scopes (generic check for offline_access) if "offline_access" in provider_config.get("scopes", []) and "offline_access" not in scopes: display_name = str(provider_config.get("display_name", provider)) logger.warning(f"WARNING: 'offline_access' scope is recommended for {display_name}!") logger.warning("Without this scope, refresh tokens may not be issued.") if use_interactive: proceed = input("\nDo you want to proceed anyway? (y/N): ") else: proceed = input("Do you want to proceed anyway? (y/n): ") if proceed.lower() != "y": return 1 # Run the OAuth flow success = run_oauth_flow(oauth_config, force_new=args.force) # Output token data as JSON if successful (for integration with other scripts) # This stdout output is consumed by egress_oauth.py and other scripts in the pipeline if success and oauth_config.access_token: token_output = { "provider": oauth_config.provider, "access_token": oauth_config.access_token, # nosec - intentional stdout output for script integration "refresh_token": oauth_config.refresh_token, "expires_at": oauth_config.expires_at, "cloud_id": oauth_config.cloud_id, "scopes": oauth_config.scopes, } print(json.dumps(token_output)) # noqa: T201 - intentional stdout for script piping return 0 if success else 1 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: credentials-provider/oauth/ingress_oauth.py ================================================ #!/usr/bin/env python3 """ Ingress OAuth Authentication Script This script handles OAuth authentication for ingress (inbound) connections to the MCP Gateway. It supports Cognito, Keycloak, and Entra ID M2M (Machine-to-Machine) authentication based on AUTH_PROVIDER. The script: 1. Validates required INGRESS OAuth environment variables 2. Performs M2M authentication using client_credentials grant 3. Saves tokens to ingress.json in the OAuth tokens directory 4. Does not generate MCP configuration files (handled by oauth_creds.sh) Environment Variables Required: For AUTH_PROVIDER=cognito (default): - INGRESS_OAUTH_USER_POOL_ID: Cognito User Pool ID - INGRESS_OAUTH_CLIENT_ID: Cognito App Client ID for M2M - INGRESS_OAUTH_CLIENT_SECRET: Cognito App Client Secret for M2M - AWS_REGION: AWS region (defaults to us-east-1) For AUTH_PROVIDER=keycloak: - KEYCLOAK_URL: Keycloak server URL - KEYCLOAK_REALM: Keycloak realm name - KEYCLOAK_M2M_CLIENT_ID: Keycloak M2M client ID - KEYCLOAK_M2M_CLIENT_SECRET: Keycloak M2M client secret For AUTH_PROVIDER=entra: - ENTRA_TENANT_ID: Azure AD Tenant ID (GUID) - ENTRA_CLIENT_ID: App Registration Client ID (GUID) - ENTRA_CLIENT_SECRET: App Registration Client Secret Usage: python ingress_oauth.py python ingress_oauth.py --verbose python ingress_oauth.py --force # Force new token generation """ import argparse import json import logging import os import sys import time from pathlib import Path from typing import Any import requests # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Try to load .env file if python-dotenv is available try: from dotenv import load_dotenv # Load .env from the same directory as this script env_file = Path(__file__).parent / ".env" if env_file.exists(): load_dotenv(env_file) logger.debug(f"Loaded environment variables from {env_file}") else: # Fallback: try parent directory (project root) env_file_parent = Path(__file__).parent.parent / ".env" if env_file_parent.exists(): load_dotenv(env_file_parent) logger.debug(f"Loaded environment variables from {env_file_parent}") else: # Final fallback: try current working directory load_dotenv() logger.debug("Tried to load .env from current working directory") except ImportError: logger.debug("python-dotenv not available, skipping .env loading") def _validate_environment_variables() -> None: """Validate that all required INGRESS OAuth environment variables are set.""" auth_provider = os.getenv("AUTH_PROVIDER", "cognito").lower() if auth_provider == "keycloak": required_vars = [ "KEYCLOAK_URL", "KEYCLOAK_REALM", "KEYCLOAK_M2M_CLIENT_ID", "KEYCLOAK_M2M_CLIENT_SECRET", ] elif auth_provider == "entra": required_vars = ["ENTRA_TENANT_ID", "ENTRA_CLIENT_ID", "ENTRA_CLIENT_SECRET"] else: # cognito (default) required_vars = [ "INGRESS_OAUTH_USER_POOL_ID", "INGRESS_OAUTH_CLIENT_ID", "INGRESS_OAUTH_CLIENT_SECRET", ] missing_vars = [] for var in required_vars: if not os.getenv(var): missing_vars.append(var) if missing_vars: logger.error(f"Missing required INGRESS OAuth environment variables for {auth_provider}:") for var in missing_vars: logger.error(f" - {var}") logger.error("\nPlease set the following environment variables:") for var in missing_vars: logger.error(f" export {var}=") logger.error("\nOr add them to your .env file") raise SystemExit(1) logger.debug(f"All required INGRESS OAuth environment variables are set for {auth_provider}") def _get_cognito_domain(user_pool_id: str, region: str) -> str: """Generate Cognito domain from user pool ID.""" # Use user pool ID without underscores as domain (standard Cognito format) domain = user_pool_id.replace("_", "") return f"https://{domain}.auth.{region}.amazoncognito.com" def _perform_keycloak_m2m_authentication( client_id: str, client_secret: str, keycloak_url: str, realm: str ) -> dict[str, Any]: """Perform M2M (client credentials) OAuth 2.0 authentication with Keycloak.""" try: # Generate token URL for Keycloak token_url = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token" # Prepare the token request payload = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, } headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", } logger.info(f"Requesting M2M token from {token_url}") logger.debug(f"Using client_id: {client_id[:10]}..." if client_id else "No client_id") response = requests.post(token_url, data=payload, headers=headers, timeout=30) if not response.ok: logger.error( f"M2M token request failed with status {response.status_code}. Response: {response.text}" ) raise ValueError(f"Token request failed: {response.text}") token_data = response.json() if "access_token" not in token_data: logger.error( f"Access token not found in M2M response. Keys found: {list(token_data.keys())}" ) raise ValueError("No access token in response") # Calculate expiry time expires_at = None if "expires_in" in token_data: expires_at = time.time() + token_data["expires_in"] else: # Fallback: assume 10800 seconds (3 hours) validity if not specified logger.warning("No expires_in in token response, assuming 10800 seconds validity") expires_at = time.time() + 10800 token_data["expires_in"] = 10800 # Prepare result result = { "access_token": token_data["access_token"], "refresh_token": token_data.get( "refresh_token" ), # M2M typically doesn't have refresh tokens "expires_at": expires_at, "token_type": token_data.get("token_type", "Bearer"), # nosec B105 - OAuth2 standard token type per RFC 6750 "provider": "keycloak_m2m", "client_id": client_id, "keycloak_url": keycloak_url, "realm": realm, } logger.info("M2M token obtained successfully!") if expires_at: expires_in = int(expires_at - time.time()) logger.info(f"Token expires in: {expires_in} seconds") return result except requests.exceptions.RequestException as e: logger.error(f"Network error during M2M token request: {e}") raise except Exception as e: logger.error(f"Failed to obtain M2M token: {e}") raise def _perform_entra_m2m_authentication( tenant_id: str, client_id: str, client_secret: str ) -> dict[str, Any]: """Perform M2M (client credentials) OAuth 2.0 authentication with Microsoft Entra ID.""" try: # Generate token URL for Entra ID token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" # Prepare the token request payload = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": f"api://{client_id}/.default", } headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", } logger.info(f"Requesting M2M token from {token_url}") logger.debug(f"Using client_id: {client_id[:10]}..." if client_id else "No client_id") response = requests.post(token_url, data=payload, headers=headers, timeout=30) if not response.ok: logger.error( f"M2M token request failed with status {response.status_code}. Response: {response.text}" ) raise ValueError(f"Token request failed: {response.text}") token_data = response.json() if "access_token" not in token_data: logger.error( f"Access token not found in M2M response. Keys found: {list(token_data.keys())}" ) raise ValueError("No access token in response") # Calculate expiry time expires_at = None if "expires_in" in token_data: expires_at = time.time() + token_data["expires_in"] else: # Fallback: assume 3599 seconds (1 hour) validity if not specified logger.warning("No expires_in in token response, assuming 3599 seconds validity") expires_at = time.time() + 3599 token_data["expires_in"] = 3599 # Prepare result result = { "access_token": token_data["access_token"], "refresh_token": token_data.get( "refresh_token" ), # M2M typically doesn't have refresh tokens "expires_at": expires_at, "token_type": token_data.get("token_type", "Bearer"), # nosec B105 - OAuth2 standard token type per RFC 6750 "provider": "entra_m2m", "client_id": client_id, "tenant_id": tenant_id, } logger.info("M2M token obtained successfully!") if expires_at: expires_in = int(expires_at - time.time()) logger.info(f"Token expires in: {expires_in} seconds") return result except requests.exceptions.RequestException as e: logger.error(f"Network error during M2M token request: {e}") raise except Exception as e: logger.error(f"Failed to obtain M2M token: {e}") raise def _perform_m2m_authentication( client_id: str, client_secret: str, user_pool_id: str, region: str ) -> dict[str, Any]: """Perform M2M (client credentials) OAuth 2.0 authentication with Cognito.""" try: # Generate token URL cognito_domain = _get_cognito_domain(user_pool_id, region) token_url = f"{cognito_domain}/oauth2/token" # Prepare the token request payload = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, } # Note: For Cognito M2M tokens, the expiry time is controlled by the # User Pool Resource Server settings, not the client request. # The token validity period should be configured in the AWS Console # under Cognito User Pool > App Integration > Resource Servers # to set the desired 10800 seconds (3 hours) validity. headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", } logger.info(f"Requesting M2M token from {token_url}") logger.debug(f"Using client_id: {client_id[:10]}..." if client_id else "No client_id") response = requests.post(token_url, data=payload, headers=headers, timeout=30) if not response.ok: logger.error( f"M2M token request failed with status {response.status_code}. Response: {response.text}" ) raise ValueError(f"Token request failed: {response.text}") token_data = response.json() if "access_token" not in token_data: logger.error( f"Access token not found in M2M response. Keys found: {list(token_data.keys())}" ) raise ValueError("No access token in response") # Calculate expiry time expires_at = None if "expires_in" in token_data: expires_at = time.time() + token_data["expires_in"] else: # Fallback: assume 10800 seconds (3 hours) validity if not specified logger.warning("No expires_in in token response, assuming 10800 seconds validity") expires_at = time.time() + 10800 token_data["expires_in"] = 10800 # Prepare result result = { "access_token": token_data["access_token"], "refresh_token": token_data.get( "refresh_token" ), # M2M typically doesn't have refresh tokens "expires_at": expires_at, "token_type": token_data.get("token_type", "Bearer"), # nosec B105 - OAuth2 standard token type per RFC 6750 "provider": "cognito_m2m", "client_id": client_id, "user_pool_id": user_pool_id, "region": region, } logger.info("M2M token obtained successfully!") if expires_at: expires_in = int(expires_at - time.time()) logger.info(f"Token expires in: {expires_in} seconds") return result except requests.exceptions.RequestException as e: logger.error(f"Network error during M2M token request: {e}") raise except Exception as e: logger.error(f"Failed to obtain M2M token: {e}") raise def _save_ingress_tokens(token_data: dict[str, Any]) -> str: """Save ingress tokens to ingress.json file.""" try: # Create .oauth-tokens directory in current working directory token_dir = Path.cwd() / ".oauth-tokens" token_dir.mkdir(exist_ok=True, mode=0o700) # Save to ingress.json ingress_path = token_dir / "ingress.json" # Prepare token data for storage based on provider provider = token_data.get("provider", "cognito_m2m") save_data = { "provider": provider, "access_token": token_data["access_token"], "refresh_token": token_data.get("refresh_token"), "expires_at": token_data.get("expires_at"), "expires_at_human": time.strftime( "%Y-%m-%d %H:%M:%S UTC", time.gmtime(token_data["expires_at"]) ) if token_data.get("expires_at") else None, "token_type": token_data.get("token_type", "Bearer"), # nosec B105 - OAuth2 standard token type per RFC 6750 "client_id": token_data["client_id"], "saved_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()), } # Add provider-specific fields if provider == "keycloak_m2m": save_data.update( { "keycloak_url": token_data["keycloak_url"], "realm": token_data["realm"], "usage_notes": "This token is for INGRESS authentication to the MCP Gateway (Keycloak M2M)", } ) elif provider == "entra_m2m": save_data.update( { "tenant_id": token_data["tenant_id"], "usage_notes": "This token is for INGRESS authentication to the MCP Gateway (Entra ID M2M)", } ) else: # cognito_m2m save_data.update( { "user_pool_id": token_data["user_pool_id"], "region": token_data["region"], "usage_notes": "This token is for INGRESS authentication to the MCP Gateway (Cognito M2M)", } ) with open(ingress_path, "w") as f: json.dump(save_data, f, indent=2) # Secure the file ingress_path.chmod(0o600) logger.info(f"Saved ingress tokens to: {ingress_path}") return str(ingress_path) except Exception as e: logger.error(f"Failed to save ingress tokens: {e}") raise def _load_existing_tokens() -> dict[str, Any] | None: """Load existing ingress tokens if they exist and are valid.""" try: ingress_path = Path.cwd() / ".oauth-tokens" / "ingress.json" if not ingress_path.exists(): return None with open(ingress_path) as f: token_data = json.load(f) # Check if token is expired if token_data.get("expires_at"): expires_at = token_data["expires_at"] # Add 5 minute margin if time.time() + 300 >= expires_at: logger.info("Existing ingress token is expired or will expire soon") return None logger.info("Found valid existing ingress token") return token_data except Exception as e: logger.debug(f"Failed to load existing tokens: {e}") return None def main() -> int: """Main entry point.""" parser = argparse.ArgumentParser( description="Ingress OAuth Authentication for MCP Gateway (Cognito, Keycloak, or Entra ID M2M)", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python ingress_oauth.py # Generate ingress token python ingress_oauth.py --verbose # With debug logging python ingress_oauth.py --force # Force new token generation Environment Variables Required: For AUTH_PROVIDER=cognito (default): INGRESS_OAUTH_USER_POOL_ID # Cognito User Pool ID INGRESS_OAUTH_CLIENT_ID # Cognito Client ID for M2M INGRESS_OAUTH_CLIENT_SECRET # Cognito Client Secret for M2M AWS_REGION # AWS region (optional, defaults to us-east-1) For AUTH_PROVIDER=keycloak: KEYCLOAK_URL # Keycloak server URL KEYCLOAK_REALM # Keycloak realm name KEYCLOAK_M2M_CLIENT_ID # Keycloak M2M client ID KEYCLOAK_M2M_CLIENT_SECRET # Keycloak M2M client secret For AUTH_PROVIDER=entra: ENTRA_TENANT_ID # Azure AD Tenant ID (GUID) ENTRA_CLIENT_ID # App Registration Client ID (GUID) ENTRA_CLIENT_SECRET # App Registration Client Secret """, ) parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose debug logging") parser.add_argument( "--force", "-f", action="store_true", help="Force new token generation, ignore existing valid tokens", ) args = parser.parse_args() if args.verbose: logging.getLogger().setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG) try: # Validate environment variables _validate_environment_variables() # Determine authentication provider auth_provider = os.getenv("AUTH_PROVIDER", "cognito").lower() logger.info(f"Starting INGRESS OAuth authentication ({auth_provider} M2M)") # Check for existing valid tokens (unless force is specified) if not args.force: existing_tokens = _load_existing_tokens() if existing_tokens: logger.info("Using existing valid ingress token") logger.info( f"Token expires at: {existing_tokens.get('expires_at_human', 'Unknown')}" ) return 0 # Perform M2M authentication based on provider if auth_provider == "keycloak": # Get Keycloak configuration from environment client_id = os.getenv("KEYCLOAK_M2M_CLIENT_ID") client_secret = os.getenv("KEYCLOAK_M2M_CLIENT_SECRET") keycloak_url = ( os.getenv("KEYCLOAK_ADMIN_URL") or os.getenv("KEYCLOAK_EXTERNAL_URL") or os.getenv("KEYCLOAK_URL") ) realm = os.getenv("KEYCLOAK_REALM") logger.info(f"Keycloak URL: {keycloak_url}") logger.info(f"Realm: {realm}") logger.info(f"Client ID: {client_id[:10]}...") token_data = _perform_keycloak_m2m_authentication( client_id=client_id, client_secret=client_secret, keycloak_url=keycloak_url, realm=realm, ) elif auth_provider == "entra": # Get Entra ID configuration from environment tenant_id = os.getenv("ENTRA_TENANT_ID") client_id = os.getenv("ENTRA_CLIENT_ID") client_secret = os.getenv("ENTRA_CLIENT_SECRET") logger.info(f"Tenant ID: {tenant_id}") logger.info(f"Client ID: {client_id[:10]}...") token_data = _perform_entra_m2m_authentication( tenant_id=tenant_id, client_id=client_id, client_secret=client_secret ) else: # cognito (default) # Get Cognito configuration from environment client_id = os.getenv("INGRESS_OAUTH_CLIENT_ID") client_secret = os.getenv("INGRESS_OAUTH_CLIENT_SECRET") user_pool_id = os.getenv("INGRESS_OAUTH_USER_POOL_ID") region = os.getenv("AWS_REGION", "us-east-1") logger.info(f"User Pool ID: {user_pool_id}") logger.info(f"Client ID: {client_id[:10]}...") logger.info(f"Region: {region}") token_data = _perform_m2m_authentication( client_id=client_id, client_secret=client_secret, user_pool_id=user_pool_id, region=region, ) # Save tokens saved_path = _save_ingress_tokens(token_data) logger.info("INGRESS OAuth authentication completed successfully!") logger.info(f"Tokens saved to: {saved_path}") return 0 except Exception as e: logger.error(f"ERROR: INGRESS OAuth authentication failed: {e}") if args.verbose: import traceback logger.error(traceback.format_exc()) return 1 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: credentials-provider/oauth/oauth_providers.yaml ================================================ # OAuth 2.0 Provider Configurations # This file contains OAuth provider configurations for various services # Each provider has required fields and optional fields providers: atlassian: display_name: "Atlassian Cloud" auth_url: "https://auth.atlassian.com/authorize" token_url: "https://auth.atlassian.com/oauth/token" user_info_url: "https://api.atlassian.com/oauth/token/accessible-resources" scopes: # Original minimal scopes (commented for fallback) # - "read:jira-work" # - "write:jira-work" # - "read:confluence-space.summary" # - "offline_access" # Expanded comprehensive scopes for full Atlassian access - "offline_access" - "write:confluence-content" - "read:confluence-space.summary" - "write:confluence-space" - "write:confluence-file" - "read:confluence-props" - "write:confluence-props" - "manage:confluence-configuration" - "read:confluence-content.all" - "read:confluence-content.summary" - "search:confluence" - "read:confluence-content.permission" - "read:confluence-user" - "read:confluence-groups" - "write:confluence-groups" - "readonly:content.attachment:confluence" - "read:jira-work" - "manage:jira-project" - "manage:jira-configuration" - "read:jira-user" - "write:jira-work" - "manage:jira-webhook" - "manage:jira-data-provider" - "read:servicedesk-request" - "manage:servicedesk-customer" - "write:servicedesk-request" - "read:servicemanagement-insight-objects" - "read:me" - "read:account" - "report:personal-data" - "write:component:compass" - "read:scorecard:compass" - "write:scorecard:compass" - "read:component:compass" - "read:event:compass" - "write:event:compass" - "read:metric:compass" - "write:metric:compass" - "read:backup:brie" - "write:backup:brie" - "read:restore:brie" - "write:restore:brie" - "read:account:brie" - "write:storage:brie" response_type: "code" grant_type: "authorization_code" audience: "api.atlassian.com" requires_pkce: false additional_params: prompt: "consent" # Provider-specific fields requires_cloud_id: true cloud_id_from_user_info: true google: display_name: "Google" auth_url: "https://accounts.google.com/o/oauth2/v2/auth" token_url: "https://oauth2.googleapis.com/token" user_info_url: "https://www.googleapis.com/oauth2/v2/userinfo" scopes: - "openid" - "email" - "profile" response_type: "code" grant_type: "authorization_code" requires_pkce: true additional_params: access_type: "offline" approval_prompt: "force" github: display_name: "GitHub" auth_url: "https://github.com/login/oauth/authorize" token_url: "https://github.com/login/oauth/access_token" user_info_url: "https://api.github.com/user" scopes: - "read:user" - "user:email" response_type: "code" grant_type: "authorization_code" requires_pkce: false additional_params: {} # Provider-specific headers token_headers: Accept: "application/json" cognito: display_name: "Amazon Cognito" # These URLs use templates that will be filled in at runtime auth_url: "https://{domain}.auth.{region}.amazoncognito.com/oauth2/authorize" token_url: "https://{domain}.auth.{region}.amazoncognito.com/oauth2/token" user_info_url: "https://{domain}.auth.{region}.amazoncognito.com/oauth2/userInfo" scopes: - "openid" - "email" - "profile" response_type: "code" grant_type: "authorization_code" requires_pkce: true additional_params: {} # Template variables required for this provider requires_template_vars: - domain - region template_var_defaults: region: "us-east-1" cognito_m2m: display_name: "Amazon Cognito (M2M/Client Credentials)" # M2M flow doesn't use authorization URL auth_url: "" token_url: "https://{domain}.auth.{region}.amazoncognito.com/oauth2/token" user_info_url: "" # M2M tokens don't have user info scopes: [] # M2M scopes are defined per client in Cognito - leave empty to use client defaults response_type: "" # Not used in M2M flow grant_type: "client_credentials" requires_pkce: false additional_params: {} # Template variables required for this provider requires_template_vars: - domain - region template_var_defaults: region: "us-east-1" # M2M specific configuration is_m2m: true supports_browser_flow: false microsoft: display_name: "Microsoft" auth_url: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize" token_url: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" user_info_url: "https://graph.microsoft.com/v1.0/me" scopes: - "openid" - "email" - "profile" - "offline_access" response_type: "code" grant_type: "authorization_code" requires_pkce: true additional_params: {} requires_template_vars: - tenant template_var_defaults: tenant: "common" slack: display_name: "Slack" auth_url: "https://slack.com/oauth/v2/authorize" token_url: "https://slack.com/api/oauth.v2.access" user_info_url: "https://slack.com/api/users.identity" scopes: - "identity.basic" - "identity.email" response_type: "code" grant_type: "authorization_code" requires_pkce: false additional_params: {} discord: display_name: "Discord" auth_url: "https://discord.com/oauth2/authorize" token_url: "https://discord.com/api/oauth2/token" user_info_url: "https://discord.com/api/users/@me" scopes: - "identify" - "email" response_type: "code" grant_type: "authorization_code" requires_pkce: false additional_params: {} linkedin: display_name: "LinkedIn" auth_url: "https://www.linkedin.com/oauth/v2/authorization" token_url: "https://www.linkedin.com/oauth/v2/accessToken" user_info_url: "https://api.linkedin.com/v2/me" scopes: - "r_liteprofile" - "r_emailaddress" response_type: "code" grant_type: "authorization_code" requires_pkce: false additional_params: {} spotify: display_name: "Spotify" auth_url: "https://accounts.spotify.com/authorize" token_url: "https://accounts.spotify.com/api/token" user_info_url: "https://api.spotify.com/v1/me" scopes: - "user-read-email" - "user-read-private" response_type: "code" grant_type: "authorization_code" requires_pkce: true additional_params: {} twitter: display_name: "Twitter/X" auth_url: "https://twitter.com/i/oauth2/authorize" token_url: "https://api.twitter.com/2/oauth2/token" user_info_url: "https://api.twitter.com/2/users/me" scopes: - "tweet.read" - "users.read" - "offline.access" response_type: "code" grant_type: "authorization_code" requires_pkce: true additional_params: code_challenge_method: "S256" # Configuration metadata metadata: version: "1.0" description: "OAuth 2.0 provider configurations for multiple services" last_updated: "2025-08-12" ================================================ FILE: credentials-provider/okta/__init__.py ================================================ """Okta credential provider utilities.""" ================================================ FILE: credentials-provider/okta/get_m2m_token.py ================================================ """Get Okta M2M token using client credentials flow. This script obtains a JWT token from Okta using OAuth2 client credentials grant. The token is saved to a temporary file and the file path is printed. """ import argparse import json import logging import os import sys import tempfile import jwt import requests # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def _get_okta_domain() -> str: """Get Okta domain from CLI arg or environment variable. Returns: Okta domain (e.g., integrator-9917255.okta.com) Raises: ValueError: If domain not provided """ domain = os.getenv("OKTA_DOMAIN") if domain: return domain.replace("https://", "").rstrip("/") raise ValueError("Okta domain must be provided via --okta-domain or OKTA_DOMAIN env var") def _get_client_id() -> str: """Get client ID from CLI arg or environment variable. Returns: Okta client ID Raises: ValueError: If client ID not provided """ client_id = os.getenv("OKTA_CLIENT_ID") if client_id: return client_id raise ValueError("Client ID must be provided via --client-id or OKTA_CLIENT_ID env var") def _get_client_secret() -> str: """Get client secret from CLI arg or environment variable. Returns: Okta client secret Raises: ValueError: If client secret not provided """ client_secret = os.getenv("OKTA_CLIENT_SECRET") if client_secret: return client_secret raise ValueError( "Client secret must be provided via --client-secret or OKTA_CLIENT_SECRET env var" ) def _request_m2m_token( okta_domain: str, client_id: str, client_secret: str, scope: str, auth_server_id: str | None = None, ) -> dict[str, str]: """Request M2M token from Okta using client credentials. Args: okta_domain: Okta domain (e.g., integrator-9917255.okta.com) client_id: OAuth2 client ID client_secret: OAuth2 client secret scope: OAuth2 scopes (space-separated) auth_server_id: Optional custom authorization server ID (e.g., aus1108sx6pwGzb8T698) Returns: Token response dictionary with access_token, token_type, expires_in Raises: ValueError: If token request fails """ # Use custom auth server if provided, otherwise use org auth server if auth_server_id: token_url = f"https://{okta_domain}/oauth2/{auth_server_id}/v1/token" else: token_url = f"https://{okta_domain}/oauth2/v1/token" logger.info(f"Requesting M2M token from {token_url}") data = { "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, "scope": scope, } headers = { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json", } try: response = requests.post( token_url, data=data, headers=headers, timeout=30, ) # Log response details for debugging if response.status_code != 200: try: error_data = response.json() logger.error(f"Okta error response: {json.dumps(error_data, indent=2)}") except Exception: logger.error(f"Okta error response (non-JSON): {response.text}") response.raise_for_status() token_data = response.json() logger.info( f"Successfully obtained M2M token, expires in {token_data.get('expires_in', 'unknown')} seconds" ) return token_data except requests.RequestException as e: logger.error(f"Failed to get M2M token: {e}") raise ValueError(f"M2M token request failed: {e}") def _decode_token(access_token: str) -> dict[str, str]: """Decode JWT token without verification to display claims. Args: access_token: JWT access token string Returns: Dictionary of decoded token claims """ try: claims = jwt.decode(access_token, options={"verify_signature": False}) return claims except Exception as e: logger.warning(f"Failed to decode token: {e}") return {} def _display_decoded_token(claims: dict[str, str]) -> None: """Display decoded token claims in a readable format. Args: claims: Dictionary of decoded JWT claims """ if not claims: return print("\n" + "=" * 60) print("DECODED JWT TOKEN CLAIMS") print("=" * 60) print(json.dumps(claims, indent=2)) print("\n" + "=" * 60) print("KEY INFORMATION") print("=" * 60) print(f"Client ID (cid): {claims.get('cid', 'N/A')}") print(f"Subject (sub): {claims.get('sub', 'N/A')}") print(f"Issuer (iss): {claims.get('iss', 'N/A')}") print(f"Audience (aud): {claims.get('aud', 'N/A')}") print(f"Scopes (scp): {claims.get('scp', [])}") print(f"Groups: {claims.get('groups', [])}") # Display expiration info if "exp" in claims and "iat" in claims: from datetime import datetime exp_time = datetime.fromtimestamp(claims["exp"]) iat_time = datetime.fromtimestamp(claims["iat"]) lifetime_hours = (claims["exp"] - claims["iat"]) / 3600 print(f"\nIssued at: {iat_time} UTC") print(f"Expires at: {exp_time} UTC") print(f"Lifetime: {lifetime_hours:.1f} hours") print("=" * 60 + "\n") def _save_token_to_file(token_data: dict[str, str]) -> str: """Save token data to temporary file. Args: token_data: Token response dictionary Returns: Path to temporary file containing token """ # Create temporary file with secure permissions (0600) fd, temp_path = tempfile.mkstemp( prefix="okta_m2m_token_", suffix=".json", dir="/tmp", ) try: # Write token data as JSON with os.fdopen(fd, "w") as f: json.dump(token_data, f, indent=2) # Ensure file has restrictive permissions os.chmod(temp_path, 0o600) logger.info(f"Token saved to {temp_path}") return temp_path except Exception as e: # Clean up on error try: os.unlink(temp_path) except Exception: pass raise ValueError(f"Failed to save token to file: {e}") def main() -> None: """Main function to get Okta M2M token and save to file.""" parser = argparse.ArgumentParser( description="Get Okta M2M token using client credentials flow", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Example usage: # Using environment variables export OKTA_DOMAIN=integrator-9917255.okta.com export OKTA_CLIENT_ID=0oa1100req1AzfKaY698 export OKTA_CLIENT_SECRET=EiZC6S2dyaWJ_qKmuToJ1KuZooVwOpGH4qF3N4Eao6YTFueAShId595ot9AyYCC6 uv run python -m credentials-provider.okta.get_m2m_token # Using CLI arguments uv run python -m credentials-provider.okta.get_m2m_token \\ --okta-domain integrator-9917255.okta.com \\ --client-id 0oa1100req1AzfKaY698 \\ --client-secret EiZC6S2dyaWJ_qKmuToJ1KuZooVwOpGH4qF3N4Eao6YTFueAShId595ot9AyYCC6 \\ --scope "openid email profile" """, ) parser.add_argument( "--okta-domain", type=str, help="Okta domain (e.g., integrator-9917255.okta.com). Can also use OKTA_DOMAIN env var.", ) parser.add_argument( "--client-id", type=str, help="OAuth2 client ID. Can also use OKTA_CLIENT_ID env var.", ) parser.add_argument( "--client-secret", type=str, help="OAuth2 client secret. Can also use OKTA_CLIENT_SECRET env var.", ) parser.add_argument( "--scope", type=str, default="openid", help="OAuth2 scopes (space-separated). Default: openid", ) parser.add_argument( "--auth-server-id", type=str, help="Custom authorization server ID (e.g., aus1108sx6pwGzb8T698). If not provided, uses org auth server.", ) parser.add_argument( "--show-token", action="store_true", help="Display decoded token claims (default: True)", default=True, ) parser.add_argument( "--no-show-token", action="store_true", help="Do not display decoded token claims", ) parser.add_argument( "--debug", action="store_true", help="Enable debug logging", ) args = parser.parse_args() # Set debug logging if requested if args.debug: logging.getLogger().setLevel(logging.DEBUG) try: # Get configuration from CLI args or environment variables okta_domain = args.okta_domain or _get_okta_domain() client_id = args.client_id or _get_client_id() client_secret = args.client_secret or _get_client_secret() # Request M2M token from Okta token_data = _request_m2m_token( okta_domain=okta_domain, client_id=client_id, client_secret=client_secret, scope=args.scope, auth_server_id=args.auth_server_id, ) # Decode and display token if requested show_token = args.show_token and not args.no_show_token if show_token and "access_token" in token_data: claims = _decode_token(token_data["access_token"]) _display_decoded_token(claims) # Save token to temporary file token_file_path = _save_token_to_file(token_data) # Print the file path print(f"Token saved to: {token_file_path}") except ValueError as e: logger.error(f"Error: {e}") sys.exit(1) except Exception as e: logger.exception(f"Unexpected error: {e}") sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: credentials-provider/token_refresher.py ================================================ #!/usr/bin/env python3 """ OAuth Token Refresher Service This service monitors OAuth tokens in the .oauth-tokens directory and automatically refreshes them before they expire. It runs continuously in the background, checking tokens every configurable interval (default 5 minutes). Usage: uv run python credentials-provider/token_refresher.py # Run with defaults uv run python credentials-provider/token_refresher.py --interval 300 # Check every 5 minutes uv run python credentials-provider/token_refresher.py --buffer 3600 # Refresh 1 hour before expiry uv run python credentials-provider/token_refresher.py --once # Run once and exit uv run python credentials-provider/token_refresher.py --once --force # Force refresh all tokens once and exit nohup uv run python credentials-provider/token_refresher.py > token_refresher.log 2>&1 & # Run in background """ import argparse import json import logging import os import signal import subprocess # nosec B404 import sys import tempfile import time from pathlib import Path import psutil # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def _load_env_file() -> None: """Load environment variables from .env file in project root.""" # Get the project root directory (parent of credentials-provider) script_dir = Path(__file__).parent project_root = script_dir.parent env_file = project_root / ".env" if env_file.exists(): try: with open(env_file) as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: key, value = line.split("=", 1) # Remove quotes if present value = value.strip('"').strip("'") os.environ[key] = value logger.debug(f"Loaded environment variables from {env_file}") except Exception as e: logger.warning(f"Failed to load .env file: {e}") else: logger.debug(f"No .env file found at {env_file}") # Configuration constants DEFAULT_CHECK_INTERVAL = 300 # 5 minutes in seconds DEFAULT_EXPIRY_BUFFER = 3600 # 1 hour buffer before expiry # Process management PIDFILE_NAME = "token_refresher.pid" # Dynamically determine paths relative to this script's location SCRIPT_DIR = Path(__file__).parent PROJECT_ROOT = SCRIPT_DIR.parent OAUTH_TOKENS_DIR = PROJECT_ROOT / ".oauth-tokens" CREDENTIALS_PROVIDER_DIR = SCRIPT_DIR # Files to ignore during token refresh (derived files that get regenerated) IGNORED_FILES = { "mcp.json", "vscode_mcp.json", "*readable*", # Any file with "readable" in the name } def _should_ignore_file(filename: str) -> bool: """ Check if a token file should be ignored. Args: filename: Name of the token file Returns: True if file should be ignored, False otherwise """ # Check exact matches if filename in {"mcp.json", "vscode_mcp.json"}: return True # Check for "readable" in filename if "readable" in filename.lower(): return True return False def _parse_token_file(filepath: Path) -> dict | None: """ Parse a token JSON file and extract relevant information. Args: filepath: Path to the token file Returns: Token data dict or None if file cannot be parsed """ try: with open(filepath) as f: data = json.load(f) # Validate required fields if "expires_at" not in data: logger.debug(f"No expires_at field in {filepath.name}") return None return data except (OSError, json.JSONDecodeError) as e: logger.warning(f"Failed to parse {filepath.name}: {e}") return None def _get_all_tokens() -> list[tuple[Path, dict]]: """ Get all valid token files regardless of expiration status. Returns: List of (filepath, token_data) tuples for all valid tokens """ if not OAUTH_TOKENS_DIR.exists(): logger.error("OAuth tokens directory not found") return [] all_tokens = [] for filepath in OAUTH_TOKENS_DIR.glob("*.json"): # Skip ignored files if _should_ignore_file(filepath.name): logger.debug(f"Ignoring file: {filepath.name}") continue # Parse token file token_data = _parse_token_file(filepath) if not token_data: continue logger.info(f"Found token file: {filepath.name}") logger.debug(f"Reading token from: {filepath.absolute()}") all_tokens.append((filepath, token_data)) return all_tokens def _get_expiring_tokens(buffer_seconds: int = DEFAULT_EXPIRY_BUFFER) -> list[tuple[Path, dict]]: """ Find all tokens that are expired or will expire within the buffer period. Args: buffer_seconds: Number of seconds before expiry to trigger refresh Returns: List of (filepath, token_data) tuples for expiring tokens """ if not OAUTH_TOKENS_DIR.exists(): logger.error(f"OAuth tokens directory not found: {OAUTH_TOKENS_DIR}") return [] current_time = time.time() expiring_tokens = [] for filepath in OAUTH_TOKENS_DIR.glob("*.json"): # Skip ignored files if _should_ignore_file(filepath.name): logger.debug(f"Ignoring file: {filepath.name}") continue # Parse token file token_data = _parse_token_file(filepath) if not token_data: continue logger.debug(f"Reading token from: {filepath.absolute()}") # Check expiration expires_at = token_data.get("expires_at", 0) # Convert ISO timestamp to Unix timestamp if needed if isinstance(expires_at, str): try: from datetime import datetime # Parse ISO timestamp and convert to Unix timestamp expires_at_dt = datetime.fromisoformat(expires_at.replace("Z", "+00:00")) expires_at = expires_at_dt.timestamp() except (ValueError, AttributeError) as e: logger.warning( f"Could not parse expires_at timestamp '{expires_at}' in {filepath.name}: {e}" ) continue time_until_expiry = expires_at - current_time if time_until_expiry <= buffer_seconds: hours_until_expiry = time_until_expiry / 3600 if time_until_expiry <= 0: logger.warning( f"Token EXPIRED: {filepath.name} (expired {-hours_until_expiry:.1f} hours ago)" ) else: logger.info( f"Token expiring soon: {filepath.name} (expires in {hours_until_expiry:.1f} hours)" ) logger.debug(f"Will refresh token at: {filepath.absolute()}") expiring_tokens.append((filepath, token_data)) return expiring_tokens def _determine_refresh_method(token_data: dict, filename: str) -> str | None: """ Determine which refresh method to use based on token data. Args: token_data: Parsed token data filename: Token filename Returns: Refresh method ('agentcore' or 'oauth') or None if cannot determine """ provider = token_data.get("provider", "").lower() # Check for AgentCore/Bedrock tokens if "bedrock" in provider or "agentcore" in provider: return "agentcore" # Check for OAuth providers (including Keycloak and Cognito M2M) oauth_providers = ["atlassian", "google", "github", "microsoft", "oauth", "keycloak", "cognito"] if any(p in provider for p in oauth_providers): return "oauth" # Try to infer from filename if "bedrock" in filename.lower() or "agentcore" in filename.lower(): return "agentcore" if "egress" in filename.lower() or "ingress" in filename.lower(): return "oauth" logger.warning(f"Cannot determine refresh method for {filename} with provider '{provider}'") return None def _refresh_agentcore_token(token_data: dict, filename: str) -> bool: """ Refresh a Bedrock AgentCore token using generate_access_token.py. Args: token_data: Current token data filename: Token filename Returns: True if refresh successful, False otherwise """ script_path = CREDENTIALS_PROVIDER_DIR / "agentcore-auth" / "generate_access_token.py" if not script_path.exists(): logger.error(f"AgentCore refresh script not found: {script_path}") return False try: # Extract server name from filename if possible # Format: bedrock-agentcore-{server_name}-egress.json server_name = None if filename.startswith("bedrock-agentcore-") and filename.endswith("-egress.json"): server_name = filename.replace("bedrock-agentcore-", "").replace("-egress.json", "") logger.info(f"Refreshing AgentCore token for: {server_name or 'default'}") # Run the refresh script using uv run cmd = ["uv", "run", "python", str(script_path)] if server_name: # The script might accept server-specific parameters # Check the script for available options pass logger.debug(f"Running AgentCore refresh command: {' '.join(cmd)}") logger.debug(f"Working directory: {PROJECT_ROOT.absolute()}") result = subprocess.run( # nosec B603 - internal script path via uv run, no user input cmd, cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=30 ) if result.returncode == 0: logger.info(f"Successfully refreshed AgentCore token: {filename}") return True else: logger.error(f"Failed to refresh AgentCore token: {result.stderr}") return False except subprocess.TimeoutExpired: logger.error(f"Timeout refreshing AgentCore token: {filename}") return False except Exception as e: logger.error(f"Error refreshing AgentCore token {filename}: {e}") return False def _refresh_oauth_token(token_data: dict, filename: str) -> bool: """ Refresh a generic OAuth token using egress_oauth.py or ingress_oauth.py. Args: token_data: Current token data filename: Token filename Returns: True if refresh successful, False otherwise """ # Determine which OAuth script to use provider = token_data.get("provider", "atlassian") if "ingress" in filename.lower() or provider == "keycloak": script_name = "ingress_oauth.py" # Ingress supports both Cognito and Keycloak M2M, doesn't use --provider argument use_provider_arg = False else: script_name = "egress_oauth.py" # Default to egress use_provider_arg = True script_path = CREDENTIALS_PROVIDER_DIR / "oauth" / script_name if not script_path.exists(): logger.error(f"OAuth refresh script not found: {script_path}") return False try: logger.info(f"Refreshing OAuth token for provider: {provider}") # Build command based on script type cmd = ["uv", "run", "python", str(script_path)] # Only add --provider for egress OAuth (not ingress) # Ingress OAuth auto-detects Cognito vs Keycloak based on AUTH_PROVIDER env var if use_provider_arg: cmd.extend(["--provider", provider]) logger.debug(f"Running OAuth refresh command: {' '.join(cmd)}") logger.debug(f"Working directory: {PROJECT_ROOT.absolute()}") # For Keycloak and Cognito M2M tokens, we don't typically have refresh tokens # The client_credentials flow will generate a new token if provider in ["keycloak_m2m", "cognito_m2m"]: logger.info(f"M2M token detected ({provider}), using client_credentials flow") elif "refresh_token" in token_data: logger.info("Refresh token available, script will handle refresh flow") result = subprocess.run( # nosec B603 - internal script path via uv run, no user input cmd, cwd=PROJECT_ROOT, capture_output=True, text=True, timeout=60, # OAuth flow might take longer ) if result.returncode == 0: logger.info(f"Successfully refreshed OAuth token: {filename}") return True else: logger.error(f"Failed to refresh OAuth token: {result.stderr}") return False except subprocess.TimeoutExpired: logger.error(f"Timeout refreshing OAuth token: {filename}") return False except Exception as e: logger.error(f"Error refreshing OAuth token {filename}: {e}") return False def _refresh_token(filepath: Path, token_data: dict) -> bool: """ Refresh a single token based on its type. Args: filepath: Path to the token file token_data: Parsed token data Returns: True if refresh successful, False otherwise """ filename = filepath.name refresh_method = _determine_refresh_method(token_data, filename) if not refresh_method: logger.error(f"Cannot determine how to refresh {filename}") return False if refresh_method == "agentcore": return _refresh_agentcore_token(token_data, filename) elif refresh_method == "oauth": return _refresh_oauth_token(token_data, filename) else: logger.error(f"Unknown refresh method: {refresh_method}") return False def _scan_noauth_services() -> list[dict]: """ Scan registry servers and find services with auth_scheme: none. Returns: List of no-auth service configurations """ registry_dir = PROJECT_ROOT / "registry" / "servers" noauth_services = [] if not registry_dir.exists(): logger.warning(f"Registry servers directory not found: {registry_dir}") return [] logger.debug(f"Scanning for no-auth services in: {registry_dir}") for json_file in registry_dir.glob("*.json"): # Skip server_state.json if json_file.name == "server_state.json": continue try: with open(json_file) as f: server_config = json.load(f) # Backward-compatible read: prefer auth_scheme, fall back to auth_type auth_scheme = server_config.get("auth_scheme", server_config.get("auth_type", "none")) if auth_scheme == "none": # Extract relevant service information service = { "server_name": server_config.get("server_name", "Unknown"), "path": server_config.get("path", ""), "proxy_pass_url": server_config.get("proxy_pass_url", ""), "supported_transports": server_config.get( "supported_transports", ["streamable-http"] ), "description": server_config.get("description", ""), "file_name": json_file.name, } noauth_services.append(service) logger.debug(f"Found no-auth service: {service['server_name']} ({service['path']})") except (OSError, json.JSONDecodeError) as e: logger.warning(f"Failed to parse {json_file.name}: {e}") continue return noauth_services def _regenerate_mcp_configs() -> bool: """ Regenerate MCP configuration files (mcp.json and vscode_mcp.json) after token refresh. Returns: True if regeneration successful, False otherwise """ logger.info("Regenerating MCP configuration files...") try: # Check for required files ingress_file = OAUTH_TOKENS_DIR / "ingress.json" has_ingress = ingress_file.exists() # Find all egress token files egress_files = [] for file_path in OAUTH_TOKENS_DIR.glob("*-egress.json"): if file_path.is_file(): egress_files.append(file_path) logger.debug(f"Found egress token file: {file_path.name}") # Scan for no-auth services noauth_services = _scan_noauth_services() logger.info(f"Found {len(noauth_services)} no-auth services to include") if not has_ingress and not egress_files and not noauth_services: logger.warning( "No token files or no-auth services found, skipping MCP configuration generation" ) return True # Generate both configurations vscode_success = _generate_vscode_config( has_ingress, ingress_file, egress_files, noauth_services ) roocode_success = _generate_roocode_config( has_ingress, ingress_file, egress_files, noauth_services ) if vscode_success and roocode_success: logger.info("MCP configuration files regenerated successfully") return True else: logger.error("Failed to regenerate some MCP configuration files") return False except Exception as e: logger.error(f"Error regenerating MCP configs: {e}") return False def _get_ingress_headers(ingress_file: Path) -> dict[str, str]: """ Extract ingress authentication headers from token file. Args: ingress_file: Path to ingress token file Returns: Dictionary of ingress headers """ headers = {} # Check AUTH_PROVIDER from environment auth_provider = os.environ.get("AUTH_PROVIDER", "") if auth_provider == "keycloak": # When using Keycloak, get token from agent token file agent_token_file = OAUTH_TOKENS_DIR / "agent-ai-coding-assistant-m2m-token.json" if agent_token_file.exists(): try: with open(agent_token_file) as f: agent_data = json.load(f) if agent_data and agent_data.get("access_token"): logger.debug("Using Keycloak agent token for ingress authentication") headers = {"X-Authorization": f"Bearer {agent_data.get('access_token', '')}"} # Add Keycloak-specific headers headers.update( { "X-Client-Id": agent_data.get("client_id", ""), "X-Keycloak-Realm": agent_data.get("keycloak_realm", ""), "X-Keycloak-URL": agent_data.get("keycloak_url", ""), } ) logger.debug( f"Using Keycloak agent headers: client_id={agent_data.get('client_id', '')}" ) return headers except (OSError, json.JSONDecodeError) as e: logger.warning(f"Failed to read Keycloak agent token file: {e}") # Fall back to ingress file if agent token not available logger.warning("Keycloak agent token not available, falling back to ingress token") # Default behavior: use ingress file if ingress_file.exists(): try: with open(ingress_file) as f: ingress_data = json.load(f) # Always include the access token headers["X-Authorization"] = f"Bearer {ingress_data.get('access_token', '')}" # Add provider-specific headers provider = ingress_data.get("provider", "cognito_m2m") logger.debug(f"Detected ingress provider: {provider}") if provider == "keycloak_m2m": # Keycloak-specific headers headers.update( { "X-Client-Id": ingress_data.get("client_id", ""), "X-Keycloak-Realm": ingress_data.get("realm", ""), "X-Keycloak-URL": ingress_data.get("keycloak_url", ""), } ) logger.debug(f"Using Keycloak headers: realm={ingress_data.get('realm', '')}") else: # cognito_m2m (default) # Cognito-specific headers headers.update( { "X-User-Pool-Id": ingress_data.get("user_pool_id", ""), "X-Client-Id": ingress_data.get("client_id", ""), "X-Region": ingress_data.get("region", "us-east-1"), } ) logger.debug( f"Using Cognito headers: pool_id={ingress_data.get('user_pool_id', '')}" ) except (OSError, json.JSONDecodeError) as e: logger.warning(f"Failed to read ingress file: {e}") return headers def _create_egress_server_config( egress_file: Path, ingress_headers: dict[str, str], registry_url: str, config_type: str = "vscode", ) -> tuple[str, dict]: """ Create server configuration from egress token file. Args: egress_file: Path to egress token file ingress_headers: Ingress authentication headers registry_url: Base registry URL config_type: Either "vscode" or "roocode" Returns: Tuple of (server_key, server_config) """ try: with open(egress_file) as f: egress_data = json.load(f) except (OSError, json.JSONDecodeError) as e: logger.warning(f"Failed to read egress file {egress_file.name}: {e}") return None, None provider = egress_data.get("provider", "") token = egress_data.get("access_token", "") cloud_id = egress_data.get("cloud_id", "") # Determine server key and URL if provider == "atlassian": server_key = "atlassian" headers = {"Authorization": f"Bearer {token}"} if cloud_id: headers["X-Atlassian-Cloud-Id"] = cloud_id if ingress_headers: headers.update(ingress_headers) url = f"{registry_url}/atlassian/mcp" elif provider == "bedrock-agentcore": # Extract server name from filename filename = egress_file.name if filename.startswith("bedrock-agentcore-") and filename.endswith("-egress.json"): server_key = filename.replace("bedrock-agentcore-", "").replace("-egress.json", "") else: server_key = "sre-gateway" headers = {"Authorization": f"Bearer {token}"} if ingress_headers: headers.update(ingress_headers) url = f"{registry_url}/{server_key}/mcp" else: # Generic provider server_key = provider headers = {"Authorization": f"Bearer {token}"} if ingress_headers: headers.update(ingress_headers) url = f"{registry_url}/{provider}/mcp" # Create config based on type if config_type == "vscode": server_config = {"url": url, "headers": headers} else: # roocode server_config = { "type": "streamable-http", "url": url, "headers": headers, "disabled": False, "alwaysAllow": [], } return server_key, server_config def _create_noauth_server_config( service: dict, ingress_headers: dict[str, str], registry_url: str, config_type: str = "vscode" ) -> tuple[str, dict]: """ Create server configuration for no-auth service. Args: service: No-auth service information ingress_headers: Ingress authentication headers registry_url: Base registry URL config_type: Either "vscode" or "roocode" Returns: Tuple of (server_key, server_config) """ # Use path as server key (remove leading and trailing slashes) server_key = service["path"].strip("/") if not server_key: return None, None # Construct service URL path = service["path"].rstrip("/") service_url = f"{registry_url}{path}/mcp" # Create config based on type if config_type == "vscode": server_config = {"url": service_url} if ingress_headers: server_config["headers"] = ingress_headers else: # roocode # Determine transport type supported_transports = service.get("supported_transports", ["streamable-http"]) transport_type = supported_transports[0] if supported_transports else "streamable-http" server_config = { "type": transport_type, "url": service_url, "disabled": False, "alwaysAllow": [], } if ingress_headers: server_config["headers"] = ingress_headers return server_key, server_config def _generate_vscode_config( has_ingress: bool, ingress_file: Path, egress_files: list[Path], noauth_services: list[dict] = None, ) -> bool: """ Generate VS Code MCP configuration file. Args: has_ingress: Whether ingress token is available ingress_file: Path to ingress token file egress_files: List of egress token file paths noauth_services: List of no-auth service configurations Returns: True if generation successful, False otherwise """ config_file = OAUTH_TOKENS_DIR / "vscode_mcp.json" try: with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as temp_file: temp_path = temp_file.name # Default registry URL registry_url = os.getenv("REGISTRY_URL", "https://mcpgateway.ddns.net") # Initialize configuration config = {"mcp": {"servers": {}}} # Get ingress headers ingress_headers = _get_ingress_headers(ingress_file) if has_ingress else {} # Process egress files for egress_file in egress_files: server_key, server_config = _create_egress_server_config( egress_file, ingress_headers, registry_url, "vscode" ) if server_key and server_config: config["mcp"]["servers"][server_key] = server_config logger.debug(f"Added egress service {server_key} to VS Code config") # Process no-auth services if noauth_services: for service in noauth_services: server_key, server_config = _create_noauth_server_config( service, ingress_headers, registry_url, "vscode" ) # Skip if already added or invalid if not server_key or server_key in config["mcp"]["servers"]: continue config["mcp"]["servers"][server_key] = server_config logger.debug(f"Added no-auth service {server_key} to VS Code config") # Write JSON to temp file json.dump(config, temp_file, indent=2) # Move temp file to final location and set permissions os.rename(temp_path, config_file) os.chmod(config_file, 0o600) logger.info(f"Generated VS Code MCP config: {config_file}") logger.debug(f"VS Code config written to: {config_file.absolute()}") return True except Exception as e: logger.error(f"Error generating VS Code MCP config: {e}") if "temp_path" in locals(): try: os.unlink(temp_path) except Exception as e: logger.debug(f"Failed to clean up temp file: {e}") return False def _generate_roocode_config( has_ingress: bool, ingress_file: Path, egress_files: list[Path], noauth_services: list[dict] = None, ) -> bool: """ Generate Roocode MCP configuration file. Args: has_ingress: Whether ingress token is available ingress_file: Path to ingress token file egress_files: List of egress token file paths noauth_services: List of no-auth service configurations Returns: True if generation successful, False otherwise """ config_file = OAUTH_TOKENS_DIR / "mcp.json" try: with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as temp_file: temp_path = temp_file.name # Default registry URL registry_url = os.getenv("REGISTRY_URL", "https://mcpgateway.ddns.net") # Initialize configuration config = {"mcpServers": {}} # Get ingress headers ingress_headers = _get_ingress_headers(ingress_file) if has_ingress else {} # Process egress files for egress_file in egress_files: server_key, server_config = _create_egress_server_config( egress_file, ingress_headers, registry_url, "roocode" ) if server_key and server_config: config["mcpServers"][server_key] = server_config logger.debug(f"Added egress service {server_key} to Roocode config") # Process no-auth services if noauth_services: for service in noauth_services: server_key, server_config = _create_noauth_server_config( service, ingress_headers, registry_url, "roocode" ) # Skip if already added or invalid if not server_key or server_key in config["mcpServers"]: continue config["mcpServers"][server_key] = server_config logger.debug(f"Added no-auth service {server_key} to Roocode config") # Write JSON to temp file json.dump(config, temp_file, indent=2) # Move temp file to final location and set permissions os.rename(temp_path, config_file) os.chmod(config_file, 0o600) logger.info(f"Generated Roocode MCP config: {config_file}") logger.debug(f"Roocode config written to: {config_file.absolute()}") return True except Exception as e: logger.error(f"Error generating Roocode MCP config: {e}") if "temp_path" in locals(): try: os.unlink(temp_path) except Exception as e: logger.debug(f"Failed to clean up temp file: {e}") return False def _run_refresh_cycle( buffer_seconds: int = DEFAULT_EXPIRY_BUFFER, force_refresh: bool = False ) -> None: """ Run a single refresh cycle, checking and refreshing expiring tokens. Args: buffer_seconds: Number of seconds before expiry to trigger refresh force_refresh: If True, refresh all tokens regardless of expiration """ logger.info("Starting token refresh cycle...") logger.debug(f"Token directory: {OAUTH_TOKENS_DIR.absolute()}") # Find expiring tokens if force_refresh: expiring_tokens = _get_all_tokens() logger.info("Force refresh enabled - will refresh all tokens") else: expiring_tokens = _get_expiring_tokens(buffer_seconds) if not expiring_tokens: logger.info("No tokens need refreshing") return logger.info(f"Found {len(expiring_tokens)} token(s) needing refresh") # Refresh each expiring token success_count = 0 for filepath, token_data in expiring_tokens: logger.info(f"Attempting to refresh: {filepath.name}") logger.debug(f"Processing token file: {filepath.absolute()}") if _refresh_token(filepath, token_data): success_count += 1 logger.info(f"Token successfully updated at: {filepath.absolute()}") else: logger.error(f"Failed to refresh: {filepath.name}") logger.error(f"Failed token location: {filepath.absolute()}") logger.info( f"Refresh cycle complete: {success_count}/{len(expiring_tokens)} tokens refreshed successfully" ) # Regenerate MCP configuration files if any tokens were refreshed if success_count > 0: logger.info("Regenerating MCP configuration files after token refresh...") if _regenerate_mcp_configs(): logger.info("MCP configuration files updated successfully") else: logger.error("Failed to update MCP configuration files") def _get_pidfile_path() -> Path: """ Get the path to the PID file for the token refresher service. Returns: Path to the PID file """ return PROJECT_ROOT / "token_refresher.pid" def _write_pidfile() -> None: """ Write the current process PID to the PID file. """ pidfile = _get_pidfile_path() with open(pidfile, "w") as f: f.write(str(os.getpid())) logger.debug(f"PID file written: {pidfile}") def _remove_pidfile() -> None: """ Remove the PID file if it exists. """ pidfile = _get_pidfile_path() try: if pidfile.exists(): pidfile.unlink() logger.debug(f"PID file removed: {pidfile}") except Exception as e: logger.warning(f"Failed to remove PID file: {e}") def _kill_existing_instance() -> bool: """ Kill any existing token refresher instance if running. Returns: True if an existing instance was killed, False if none was found """ pidfile = _get_pidfile_path() if not pidfile.exists(): logger.debug("No PID file found, no existing instance to kill") return False try: with open(pidfile) as f: old_pid = int(f.read().strip()) # Check if process exists and is a token refresher if psutil.pid_exists(old_pid): try: process = psutil.Process(old_pid) cmdline = " ".join(process.cmdline()) # Check if it's actually our token refresher process if "token_refresher.py" in cmdline: logger.info(f"Found existing token refresher instance (PID: {old_pid})") logger.info(f"Killing existing instance: {cmdline}") # Try graceful shutdown first process.terminate() try: process.wait(timeout=5) logger.info(f"Gracefully terminated existing instance (PID: {old_pid})") except psutil.TimeoutExpired: # Force kill if graceful shutdown fails logger.warning(f"Graceful shutdown failed, force killing PID: {old_pid}") process.kill() process.wait() logger.info(f"Force killed existing instance (PID: {old_pid})") return True else: logger.debug(f"PID {old_pid} exists but is not a token refresher process") except (psutil.NoSuchProcess, psutil.AccessDenied) as e: logger.debug(f"Could not access process {old_pid}: {e}") else: logger.debug(f"PID {old_pid} no longer exists") # Clean up stale PID file _remove_pidfile() return False except (ValueError, FileNotFoundError) as e: logger.debug(f"Invalid or missing PID file: {e}") _remove_pidfile() return False except Exception as e: logger.error(f"Error checking for existing instance: {e}") return False def _setup_signal_handlers() -> None: """ Set up signal handlers for graceful shutdown. """ def signal_handler(signum, frame): logger.info(f"Received signal {signum}, shutting down gracefully...") _remove_pidfile() sys.exit(0) signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGINT, signal_handler) def main(): """Main entry point for the token refresher service.""" parser = argparse.ArgumentParser( description="OAuth Token Refresher Service", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Run with default settings (check every 5 minutes, refresh 1 hour before expiry) uv run python credentials-provider/token_refresher.py # Check every 10 minutes uv run python credentials-provider/token_refresher.py --interval 600 # Refresh tokens 2 hours before expiry uv run python credentials-provider/token_refresher.py --buffer 7200 # Run once and exit (for testing) uv run python credentials-provider/token_refresher.py --once # Force refresh all tokens once and exit uv run python credentials-provider/token_refresher.py --once --force # Run in background with logging nohup uv run python credentials-provider/token_refresher.py > token_refresher.log 2>&1 & """, ) parser.add_argument( "--interval", type=int, default=DEFAULT_CHECK_INTERVAL, help=f"Check interval in seconds (default: {DEFAULT_CHECK_INTERVAL})", ) parser.add_argument( "--buffer", type=int, default=DEFAULT_EXPIRY_BUFFER, help=f"Refresh tokens this many seconds before expiry (default: {DEFAULT_EXPIRY_BUFFER})", ) parser.add_argument("--once", action="store_true", help="Run once and exit (for testing)") parser.add_argument( "--force", action="store_true", help="Force refresh all tokens regardless of expiration status", ) parser.add_argument("--debug", action="store_true", help="Enable debug logging") parser.add_argument( "--no-kill", action="store_true", help="Do not kill existing instance (will exit if one is running)", ) args = parser.parse_args() # Load environment variables from .env file _load_env_file() # Set debug logging if requested if args.debug: logging.getLogger().setLevel(logging.DEBUG) # Handle existing instances if not args.once: # Only check for existing instances in continuous mode if args.no_kill: pidfile = _get_pidfile_path() if pidfile.exists(): try: with open(pidfile) as f: existing_pid = int(f.read().strip()) if psutil.pid_exists(existing_pid): logger.error( f"Another token refresher instance is already running (PID: {existing_pid})" ) logger.error( "Use --no-kill flag to prevent automatic killing, or stop the existing instance first" ) sys.exit(1) except Exception as e: logger.debug(f"Invalid PID file, continuing: {e}") else: # Kill existing instance if found killed = _kill_existing_instance() if killed: logger.info("Existing instance terminated, starting new instance") time.sleep(1) # Brief pause to ensure cleanup logger.info("=" * 60) logger.info("OAuth Token Refresher Service Starting") logger.info(f"Check interval: {args.interval} seconds") logger.info(f"Expiry buffer: {args.buffer} seconds ({args.buffer / 3600:.1f} hours)") logger.info("OAuth tokens directory is configured") logger.info("=" * 60) # Set up signal handlers and PID file for continuous mode if not args.once: _setup_signal_handlers() _write_pidfile() try: # Run once or continuously if args.once: logger.info("Running single refresh cycle...") _run_refresh_cycle(args.buffer, args.force) else: logger.info("Starting continuous monitoring...") while True: try: _run_refresh_cycle(args.buffer, args.force) logger.info(f"Sleeping for {args.interval} seconds...") time.sleep(args.interval) except KeyboardInterrupt: logger.info("Received interrupt signal, shutting down...") break except Exception as e: logger.error(f"Unexpected error in refresh cycle: {e}") logger.info(f"Continuing after error, sleeping for {args.interval} seconds...") time.sleep(args.interval) finally: # Clean up PID file if not args.once: _remove_pidfile() logger.info("Token Refresher Service stopped") if __name__ == "__main__": main() ================================================ FILE: credentials-provider/utils.py ================================================ """ Utility functions for credential providers. """ def redact_sensitive_value(value: str, show_chars: int = 8) -> str: """ Redact sensitive values like tokens, secrets, and passwords. Args: value: The sensitive value to redact show_chars: Number of characters to show before redacting (default: 8) Returns: Redacted string showing only first N characters followed by asterisks Example: >>> redact_sensitive_value("abc123xyz789", 8) "abc123xy********" """ if not value or len(value) <= show_chars: return "*" * len(value) if value else "" return value[:show_chars] + "*" * (len(value) - show_chars) def redact_credentials_in_text(text: str, show_chars: int = 8) -> str: """ Redact common credential patterns in text output. Args: text: Text that may contain credentials show_chars: Number of characters to show before redacting Returns: Text with credentials redacted """ import re # Patterns to redact (case insensitive) patterns = [ r'(access_token["\s]*[:=]["\s]*)([^"\s]+)', r'(client_secret["\s]*[:=]["\s]*)([^"\s]+)', r'(secret["\s]*[:=]["\s]*)([^"\s]+)', r'(password["\s]*[:=]["\s]*)([^"\s]+)', r'(token["\s]*[:=]["\s]*)([^"\s]+)', ] result = text for pattern in patterns: def replace_match(match): prefix = match.group(1) value = match.group(2) redacted = redact_sensitive_value(value, show_chars) return f"{prefix}{redacted}" result = re.sub(pattern, replace_match, result, flags=re.IGNORECASE) return result ================================================ FILE: docker/502.html ================================================ MCP Gateway Registry - Starting Up

MCP Gateway Registry

The registry is starting up and will be available momentarily. This page will automatically refresh.

Initializing services...

The backend services are warming up.

This typically completes within 30-60 seconds.

================================================ FILE: docker/Dockerfile.auth ================================================ # Auth Dockerfile - multi-stage build for smaller image and better caching # ===== BUILD STAGE ===== FROM python:3.14-slim AS builder ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 # Install build dependencies (only needed for compiling wheels) RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ git \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Install uv and create venv (rarely changes) RUN pip install --no-cache-dir uv && \ uv venv .venv --python 3.14 # Copy ONLY the dependency manifest first to leverage Docker layer caching. # Dependencies are reinstalled only when pyproject.toml changes, not on every code change. COPY auth_server/pyproject.toml /app/pyproject.toml RUN . .venv/bin/activate && \ uv pip install --requirement pyproject.toml # ===== RUNTIME STAGE ===== FROM python:3.14-slim ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 # Install only runtime system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Copy the pre-built virtual environment from builder COPY --from=builder /app/.venv /app/.venv # Copy entrypoint script early (rarely changes, avoids cache bust from code changes) COPY docker/auth-entrypoint.sh /app/auth-entrypoint.sh RUN chmod +x /app/auth-entrypoint.sh # Now copy the actual application code (this layer changes frequently but deps are cached above) COPY auth_server/ /app/ COPY registry/ /app/registry/ # Create logs and certs directories RUN mkdir -p /app/logs && \ mkdir -p /app/certs # Expose port EXPOSE 8888 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ CMD curl -f http://localhost:8888/health || exit 1 # Create non-root user for security (CIS Docker Benchmark 4.1) RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser appuser # Set ownership of application files, venv, logs, certs, and entrypoint RUN chown -R appuser:appuser /app /app/.venv /app/logs /app/certs /app/auth-entrypoint.sh # Switch to non-root user USER appuser # Start the auth server via entrypoint ENTRYPOINT ["/app/auth-entrypoint.sh"] ================================================ FILE: docker/Dockerfile.mcp-server ================================================ # Generic MCP Server Dockerfile for servers in the servers/ directory # Each server must have pyproject.toml and server.py # Build context can be either the server directory or repo root (for servers needing registry module) FROM python:3.14-slim ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ UV_NO_CACHE=1 # Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ git \ build-essential \ netcat-openbsd \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Create non-root user early (before creating any app files) RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser appuser WORKDIR /app # Install uv as root (global tool) RUN pip install uv # Create /app directory with correct ownership from the start RUN chown appuser:appuser /app # Switch to non-root user for all subsequent operations USER appuser # Setup Python environment as appuser (avoids need to chown .venv later) RUN uv venv .venv --python 3.14 # Install CPU-only PyTorch first to avoid GPU dependencies (for sentence-transformers) RUN . .venv/bin/activate && \ uv pip install --index-url https://download.pytorch.org/whl/cpu \ "torch>=2.0.0" \ "torchvision" # Build arg for server directory (when building from repo root) ARG SERVER_DIR # Switch back to root temporarily for COPY operations (Docker requires root for COPY) USER root # Copy server files - handle both build contexts # If SERVER_DIR is set (building from root), copy from that directory # Otherwise copy from current context (building from server directory) COPY --chown=appuser:appuser ${SERVER_DIR:-.}/ /app/ # Copy registry module for embeddings support (only when building from root with SERVER_DIR set) # This is required for servers that use the embeddings client (like mcpgw) # Use a conditional copy that won't fail if registry doesn't exist COPY --chown=root:root . /tmp/build-context/ RUN if [ -d /tmp/build-context/registry ]; then \ cp -r /tmp/build-context/registry /app/registry && \ chown -R appuser:appuser /app/registry && \ echo "Registry module copied successfully"; \ else \ echo "Registry module not found in build context (expected for non-mcpgw servers)"; \ fi && \ rm -rf /tmp/build-context # Switch back to appuser for package installation USER appuser # Install dependencies from pyproject.toml (copied from SERVER_PATH) RUN . .venv/bin/activate && \ if [ -f /app/pyproject.toml ]; then \ uv pip install --requirement /app/pyproject.toml; \ fi # Expose default port (can be overridden by environment variable) EXPOSE 8000 # Health check (generic for all MCP servers) HEALTHCHECK --interval=500s --timeout=10s --start-period=30s --retries=3 \ CMD nc -z localhost ${PORT:-8000} || exit 1 # Switch to root to create entrypoint script USER root # Create entrypoint script that handles environment setup and runs server.py RUN echo '#!/bin/bash\n\ set -e\n\ \n\ # Set default port\n\ SERVER_PORT=${PORT:-8000}\n\ \n\ # Create .env file if needed (for servers that require it)\n\ if [ ! -z "$POLYGON_API_KEY" ]; then\n\ echo "POLYGON_API_KEY=$POLYGON_API_KEY" > /app/.env\n\ fi\n\ \n\ if [ ! -z "$REGISTRY_BASE_URL" ]; then\n\ echo "REGISTRY_BASE_URL=$REGISTRY_BASE_URL" > /app/.env\n\ fi\n\ \n\ # Activate virtual environment and run the server\n\ source .venv/bin/activate\n\ exec python server.py --port $SERVER_PORT' > /entrypoint.sh && \ chmod +x /entrypoint.sh && \ chown appuser:appuser /entrypoint.sh # Switch to non-root user for runtime (no chown needed - files already owned by appuser) USER appuser ENTRYPOINT ["/entrypoint.sh"] ================================================ FILE: docker/Dockerfile.mcp-server-cpu ================================================ # Generic MCP Server Dockerfile - CPU-only PyTorch variant for smaller image size # For servers that need sentence-transformers (like mcpgw) # Using ECR Public Gallery to avoid Docker Hub rate limits FROM public.ecr.aws/docker/library/python:3.14-slim # Build arg for server directory (when building from repo root) ARG SERVER_DIR ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ UV_NO_CACHE=1 # Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ git \ build-essential \ netcat-openbsd \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Create non-root user early (before creating any app files) RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser appuser WORKDIR /app # Install uv as root (global tool) RUN pip install uv # Create /app with correct ownership RUN chown appuser:appuser /app # Switch to appuser for venv creation (avoids need to chown .venv later) USER appuser # Setup Python environment as appuser # Install CPU-only PyTorch FIRST from the CPU wheel index RUN uv venv .venv --python 3.14 && \ . .venv/bin/activate && \ uv pip install torch --index-url https://download.pytorch.org/whl/cpu # Switch back to root for COPY operations USER root # Copy server files with correct ownership COPY --chown=appuser:appuser ${SERVER_DIR:-.}/ /app/ # Copy registry module for embeddings support (only when building from root with SERVER_DIR set) COPY --chown=root:root . /tmp/build-context/ RUN if [ -d /tmp/build-context/registry ]; then \ cp -r /tmp/build-context/registry /app/registry && \ chown -R appuser:appuser /app/registry && \ echo "Registry module copied successfully"; \ else \ echo "Registry module not found in build context (expected for non-mcpgw servers)"; \ fi && \ rm -rf /tmp/build-context # Switch to appuser for package installation USER appuser # Install remaining dependencies from pyproject.toml RUN . .venv/bin/activate && \ if [ -f /app/pyproject.toml ]; then \ uv pip install --requirement /app/pyproject.toml; \ fi # Expose default port (can be overridden by environment variable) EXPOSE 8000 # Health check (generic for all MCP servers) HEALTHCHECK --interval=500s --timeout=10s --start-period=30s --retries=3 \ CMD nc -z localhost ${PORT:-8000} || exit 1 # Switch to root to create entrypoint script USER root # Create entrypoint script RUN echo '#!/bin/bash\n\ set -e\n\ SERVER_PORT=${PORT:-8000}\n\ if [ ! -z "$POLYGON_API_KEY" ]; then\n\ echo "POLYGON_API_KEY=$POLYGON_API_KEY" > /app/.env\n\ fi\n\ if [ ! -z "$REGISTRY_BASE_URL" ]; then\n\ echo "REGISTRY_BASE_URL=$REGISTRY_BASE_URL" > /app/.env\n\ echo "REGISTRY_USERNAME=$REGISTRY_USERNAME" >> /app/.env\n\ echo "REGISTRY_PASSWORD=$REGISTRY_PASSWORD" >> /app/.env\n\ fi\n\ source .venv/bin/activate\n\ exec python server.py --port $SERVER_PORT' > /entrypoint.sh && \ chmod +x /entrypoint.sh && \ chown appuser:appuser /entrypoint.sh # Switch to non-root user for runtime (no chown needed - files already owned by appuser) USER appuser ENTRYPOINT ["/entrypoint.sh"] ================================================ FILE: docker/Dockerfile.mcp-server-light ================================================ # Lightweight MCP Server Dockerfile for simple servers in the servers/ directory # Each server must have pyproject.toml and server.py # Use this for servers that don't need PyTorch or the registry module (e.g., currenttime, fininfo) # For servers needing embeddings/sentence-transformers, use Dockerfile.mcp-server instead FROM python:3.14-slim # Build arg for server directory (when building from repo root) ARG SERVER_DIR ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ UV_NO_CACHE=1 # Install minimal system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ netcat-openbsd \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Create non-root user early (before creating any app files) RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser appuser WORKDIR /app # Install uv as root (global tool) RUN pip install uv # Create /app with correct ownership RUN chown appuser:appuser /app # Switch to appuser for venv creation (avoids need to chown .venv later) USER appuser # Setup Python environment as appuser RUN uv venv .venv --python 3.14 # Switch back to root for COPY operations USER root # Copy server files with correct ownership COPY --chown=appuser:appuser ${SERVER_DIR:-.}/ /app/ # Switch to appuser for package installation USER appuser # Install dependencies from pyproject.toml RUN . .venv/bin/activate && \ if [ -f /app/pyproject.toml ]; then \ uv pip install --requirement /app/pyproject.toml; \ fi # Expose default port (can be overridden by environment variable) EXPOSE 8000 # Health check (generic for all MCP servers) HEALTHCHECK --interval=500s --timeout=10s --start-period=30s --retries=3 \ CMD nc -z localhost ${PORT:-8000} || exit 1 # Switch to root to create entrypoint script USER root # Create entrypoint script that handles environment setup and runs server.py RUN echo '#!/bin/bash\n\ set -e\n\ \n\ # Set default port\n\ SERVER_PORT=${PORT:-8000}\n\ \n\ # Create .env file if needed (for servers that require it)\n\ if [ ! -z "$POLYGON_API_KEY" ]; then\n\ echo "POLYGON_API_KEY=$POLYGON_API_KEY" > /app/.env\n\ fi\n\ \n\ # Activate virtual environment and run the server\n\ source .venv/bin/activate\n\ exec python server.py --port $SERVER_PORT' > /entrypoint.sh && \ chmod +x /entrypoint.sh && \ chown appuser:appuser /entrypoint.sh # Switch to non-root user for runtime (no chown needed - files already owned by appuser) USER appuser ENTRYPOINT ["/entrypoint.sh"] ================================================ FILE: docker/Dockerfile.metrics-db ================================================ # Pin to specific version for reproducibility FROM public.ecr.aws/docker/library/alpine:3.19 ENV SQLITE_DB_PATH=/var/lib/sqlite/metrics.db # Install SQLite as root RUN apk add --no-cache sqlite # Create database directory RUN mkdir -p /var/lib/sqlite # Create non-root user for security (alpine syntax) RUN adduser -D -u 1000 appuser && \ chown -R appuser:appuser /var/lib/sqlite # Switch to non-root user USER appuser # Health check: Verify SQLite database is accessible and functional HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD sqlite3 ${SQLITE_DB_PATH} 'SELECT 1;' || exit 1 # Initialize database and keep container running CMD ["sh", "-c", "sqlite3 ${SQLITE_DB_PATH} 'CREATE TABLE IF NOT EXISTS _health (id INTEGER);' && tail -f /dev/null"] ================================================ FILE: docker/Dockerfile.registry ================================================ # Registry Dockerfile - Multi-stage build with frontend and backend stages # ===== FRONTEND BUILD STAGE ===== FROM node:20-slim AS frontend-builder WORKDIR /app/frontend # Copy package files and install dependencies (cached unless package files change) COPY frontend/package.json frontend/package-lock.json* ./ RUN npm install --legacy-peer-deps # Copy frontend source and build COPY frontend/ ./ RUN npm run build # ===== BACKEND BUILD STAGE ===== FROM python:3.14-slim AS backend-builder ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ DEBIAN_FRONTEND=noninteractive # Install build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ git \ ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Install uv and create venv (rarely changes) RUN pip install --no-cache-dir uv && \ uv venv .venv --python 3.14 # Install CPU-only PyTorch first from the dedicated index (large, rarely changes) RUN . .venv/bin/activate && \ uv pip install --index-url https://download.pytorch.org/whl/cpu \ "torch>=2.0.0" \ "torchvision" # Copy ONLY the dependency manifest to leverage Docker layer caching. # This layer only rebuilds when pyproject.toml changes. COPY pyproject.toml /app/pyproject.toml # Install all remaining deps from pyproject.toml in one step (no duplicate inline list) RUN . .venv/bin/activate && \ uv pip install --requirement pyproject.toml # Copy only the application source directories needed (NOT the entire repo) COPY registry/ /app/registry/ COPY auth_server/ /app/auth_server/ COPY api/ /app/api/ COPY scripts/ /app/scripts/ COPY cli/examples/ /app/cli/examples/ # Install the registry package in editable mode RUN . .venv/bin/activate && uv pip install -e . # Copy scopes.yml to /app/config/ to avoid EFS mount overwriting it RUN mkdir -p /app/config && cp /app/auth_server/scopes.yml /app/config/scopes.yml # ===== FINAL RUNTIME STAGE ===== FROM python:3.14-slim AS runtime ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ DEBIAN_FRONTEND=noninteractive # Build argument for version ARG BUILD_VERSION="1.0.0" # Install runtime dependencies including nginx with lua module RUN apt-get update && apt-get install -y --no-install-recommends \ nginx \ nginx-extras \ lua-cjson \ curl \ procps \ openssl \ ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Create non-root user EARLY for security (CIS Docker Benchmark 4.1) # This allows us to use --chown in COPY commands, avoiding slow chown -R later RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser appuser # Copy Python virtual environment from backend builder (large but stable layer) # Use --chown to set ownership during copy (much faster than chown -R afterward) COPY --from=backend-builder --chown=appuser:appuser /app/.venv /app/.venv # Copy pyproject.toml (needed for editable install metadata) COPY --from=backend-builder --chown=appuser:appuser /app/pyproject.toml /app/pyproject.toml # Copy static config/scripts directly from build context (not via backend-builder) # These rarely change and don't depend on the Python build COPY --chown=appuser:appuser docker/nginx_rev_proxy_http_only.conf /app/docker/nginx_rev_proxy_http_only.conf COPY --chown=appuser:appuser docker/nginx_rev_proxy_http_and_https.conf /app/docker/nginx_rev_proxy_http_and_https.conf COPY --chown=appuser:appuser docker/lua/ /app/docker/lua/ # Copy entrypoint early (rarely changes) COPY --chown=appuser:appuser docker/registry-entrypoint.sh /app/registry-entrypoint.sh RUN chmod +x /app/registry-entrypoint.sh # Copy application code from backend builder COPY --from=backend-builder --chown=appuser:appuser /app/registry /app/registry COPY --from=backend-builder --chown=appuser:appuser /app/auth_server /app/auth_server COPY --from=backend-builder --chown=appuser:appuser /app/api /app/api COPY --from=backend-builder --chown=appuser:appuser /app/config /app/config COPY --from=backend-builder --chown=appuser:appuser /app/scripts /app/scripts COPY --from=backend-builder --chown=appuser:appuser /app/cli/examples /app/cli/examples # Copy built frontend from frontend builder COPY --from=frontend-builder --chown=appuser:appuser /app/frontend/build /app/frontend/build # Create directories and set ownership # Note: /app files already have correct ownership via --chown on COPY commands above. # Only chown directories that are created here (not covered by COPY --chown). RUN mkdir -p /app/logs && \ mkdir -p /app/certs && \ mkdir -p /app/security_scans /app/skill_security_scans /app/agent_security_scans && \ mkdir -p /etc/nginx/lua/virtual_mappings && \ rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default && \ mkdir -p /var/lib/nginx/body /var/lib/nginx/proxy /var/lib/nginx/fastcgi /var/lib/nginx/uwsgi /var/lib/nginx/scgi && \ mkdir -p /var/log/nginx && \ mkdir -p /run/nginx && \ chown -R appuser:appuser /app/logs /app/certs /app/security_scans /app/skill_security_scans /app/agent_security_scans /etc/nginx /var/log/nginx /var/lib/nginx /run/nginx # Expose ports for nginx (HTTP/HTTPS on high ports for non-root) and registry EXPOSE 8080 8443 7860 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD curl -f http://localhost:7860/health || exit 1 ARG BUILD_VERSION="1.0.0" ENV BUILD_VERSION=$BUILD_VERSION # Switch to non-root user USER appuser ENTRYPOINT ["/app/registry-entrypoint.sh"] ================================================ FILE: docker/Dockerfile.registry-cpu ================================================ # Registry Dockerfile - CPU-only PyTorch variant for smaller image size # Using ECR Public Gallery to avoid Docker Hub rate limits FROM public.ecr.aws/docker/library/python:3.14-slim ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ DEBIAN_FRONTEND=noninteractive # Install system dependencies including nginx with lua module and Node.js RUN apt-get update && apt-get install -y --no-install-recommends \ nginx \ nginx-extras \ lua-cjson \ curl \ procps \ openssl \ git \ build-essential \ ca-certificates \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y nodejs \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* WORKDIR /app # Build argument for version (will be set at build time from git) ARG BUILD_VERSION="1.0.0" ENV BUILD_VERSION=$BUILD_VERSION # Install CPU-only PyTorch first from the CPU wheel index RUN pip install uv && \ uv venv .venv --python 3.14 && \ . .venv/bin/activate && \ uv pip install torch --index-url https://download.pytorch.org/whl/cpu && \ uv pip install \ "fastapi>=0.115.12" \ "itsdangerous>=2.2.0" \ "jinja2>=3.1.6" \ "mcp>=1.6.0" \ "pydantic>=2.11.3" \ "httpx>=0.27.0" \ "python-dotenv>=1.1.0" \ "python-multipart>=0.0.20" \ "uvicorn[standard]>=0.34.2" \ "faiss-cpu>=1.7.4" \ "sentence-transformers>=2.2.2" \ "websockets>=15.0.1" \ "scikit-learn>=1.3.0" \ "huggingface-hub[cli,hf_xet]>=0.31.1" \ "hf_xet>=0.1.0" \ "cisco-ai-mcp-scanner==3.2.3" \ "cryptography>=40.0.0" # Copy the application code COPY . /app/ # Copy nginx configurations (both HTTP-only and HTTP+HTTPS versions) COPY docker/nginx_rev_proxy_http_only.conf /app/docker/nginx_rev_proxy_http_only.conf COPY docker/nginx_rev_proxy_http_and_https.conf /app/docker/nginx_rev_proxy_http_and_https.conf # Build React frontend WORKDIR /app/frontend COPY frontend/package.json ./ RUN npm install --legacy-peer-deps COPY frontend/ ./ RUN npm run build # Return to app directory WORKDIR /app # Install the registry package RUN . .venv/bin/activate && uv pip install -e . # Download the sentence-transformers embeddings model during build # This adds ~90MB to the image but eliminates runtime download requirement RUN mkdir -p /app/registry/models && \ . .venv/bin/activate && \ python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2').save('/app/registry/models/all-MiniLM-L6-v2')" # Create logs and certs directories RUN mkdir -p /app/logs && \ mkdir -p /app/certs && \ mkdir -p /app/security_scans # Create nginx lua directories and remove default sites (needed by entrypoint script) RUN mkdir -p /etc/nginx/lua/virtual_mappings && \ rm -f /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default && \ mkdir -p /var/lib/nginx/body /var/lib/nginx/proxy /var/lib/nginx/fastcgi /var/lib/nginx/uwsgi /var/lib/nginx/scgi && \ mkdir -p /var/log/nginx && \ mkdir -p /run/nginx # Expose ports for nginx (HTTP/HTTPS on high ports for non-root) and registry EXPOSE 8080 8443 7860 # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD curl -f http://localhost:7860/health || exit 1 # Entrypoint script COPY docker/registry-entrypoint.sh /app/registry-entrypoint.sh RUN chmod +x /app/registry-entrypoint.sh # Create non-root user for security (CIS Docker Benchmark 4.1) RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser appuser # Set ownership for runtime-writable directories and entrypoint # Note: Use targeted chown instead of chown -R /app to avoid slow recursive # ownership change on .venv (thousands of files that don't need write access) RUN chown -R appuser:appuser /app/logs /app/certs /app/security_scans /app/registry /etc/nginx /var/log/nginx /var/lib/nginx /run/nginx && \ chown appuser:appuser /app /app/registry-entrypoint.sh # Switch to non-root user USER appuser ENTRYPOINT ["/app/registry-entrypoint.sh"] ================================================ FILE: docker/Dockerfile.scopes-init ================================================ # Pin to specific version for reproducibility (Security best practice) FROM public.ecr.aws/docker/library/busybox:1.36 # Copy scopes.yml into the container COPY auth_server/scopes.yml /scopes.yml # Create a script to copy the file to the mount point RUN printf '#!/bin/sh\nset -e\n\necho "Starting scopes.yml initialization..."\necho "Source file: /scopes.yml"\necho "Destination: /mnt/scopes.yml"\n\nif [ ! -f /scopes.yml ]; then\n echo "ERROR: /scopes.yml not found!"\n exit 1\nfi\n\nif [ ! -w /mnt ]; then\n echo "ERROR: /mnt is not writable!"\n ls -la /mnt\n exit 1\nfi\n\ncp /scopes.yml /mnt/scopes.yml\n\nif [ ! -f /mnt/scopes.yml ]; then\n echo "ERROR: Failed to copy scopes.yml to /mnt/"\n exit 1\nfi\n\nchmod 644 /mnt/scopes.yml\n\necho "Successfully copied scopes.yml to EFS mount"\necho "File size: $(wc -c < /mnt/scopes.yml) bytes"\necho "Scopes initialization complete!"\n' > /copy-scopes.sh && chmod +x /copy-scopes.sh # Create non-root user for security (busybox syntax) RUN adduser -D -u 1000 appuser && \ chown appuser:appuser /scopes.yml /copy-scopes.sh WORKDIR / # Switch to non-root user USER appuser ENTRYPOINT ["/copy-scopes.sh"] ================================================ FILE: docker/auth-entrypoint.sh ================================================ #!/bin/bash set -e # Exit immediately if a command exits with a non-zero status. echo "Starting Auth Server Setup..." # --- DocumentDB CA Bundle Download --- if [[ "${DOCUMENTDB_HOST}" == *"docdb-elastic.amazonaws.com"* ]]; then echo "Detected DocumentDB Elastic cluster" echo "Downloading DocumentDB Elastic CA bundle..." CA_BUNDLE_URL="https://www.amazontrust.com/repository/SFSRootCAG2.pem" CA_BUNDLE_PATH="/app/certs/global-bundle.pem" if [ ! -f "$CA_BUNDLE_PATH" ]; then curl -fsSL "$CA_BUNDLE_URL" -o "$CA_BUNDLE_PATH" echo "DocumentDB Elastic CA bundle (SFSRootCAG2.pem) downloaded successfully to $CA_BUNDLE_PATH" fi elif [[ "${DOCUMENTDB_HOST}" == *"docdb.amazonaws.com"* ]]; then echo "Detected regular DocumentDB cluster" echo "Downloading regular DocumentDB CA bundle..." CA_BUNDLE_URL="https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem" CA_BUNDLE_PATH="/app/certs/global-bundle.pem" if [ ! -f "$CA_BUNDLE_PATH" ]; then curl -fsSL "$CA_BUNDLE_URL" -o "$CA_BUNDLE_PATH" echo "DocumentDB CA bundle (global-bundle.pem) downloaded successfully to $CA_BUNDLE_PATH" fi else echo "No DocumentDB host detected or DOCUMENTDB_HOST is empty - skipping CA bundle download" fi # --- Wait for MongoDB Replica Set --- if [ -n "$DOCUMENTDB_HOST" ]; then echo "Waiting for MongoDB replica set at ${DOCUMENTDB_HOST}:${DOCUMENTDB_PORT:-27017}..." source /app/.venv/bin/activate python3 -c " import pymongo, os, time, sys host = os.getenv('DOCUMENTDB_HOST', 'mongodb') port = int(os.getenv('DOCUMENTDB_PORT', '27017')) user = os.getenv('DOCUMENTDB_USERNAME', '') pwd = os.getenv('DOCUMENTDB_PASSWORD', '') backend = os.getenv('STORAGE_BACKEND', 'mongodb-ce') use_tls = os.getenv('DOCUMENTDB_USE_TLS', 'true').lower() == 'true' ca_file = os.getenv('DOCUMENTDB_TLS_CA_FILE', '/app/certs/global-bundle.pem') auth = 'SCRAM-SHA-256' if backend == 'mongodb-ce' else 'SCRAM-SHA-1' if user and pwd: uri = f'mongodb://{user}:{pwd}@{host}:{port}/?authMechanism={auth}&authSource=admin' else: uri = f'mongodb://{host}:{port}/' # Prepare TLS options tls_options = {} if use_tls: tls_options['tls'] = True tls_options['tlsCAFile'] = ca_file while True: try: c = pymongo.MongoClient(uri, serverSelectionTimeoutMS=5000, connectTimeoutMS=5000, **tls_options) c.admin.command('ping') try: st = c.admin.command('replSetGetStatus') ready = [m for m in st['members'] if m['state'] in [1, 2]] total = len(st['members']) if st['ok'] == 1 and len(ready) == total: print(f'MongoDB replica set ready ({len(ready)}/{total} members)') c.close() break print(f'Waiting for replica set: {len(ready)}/{total} ready') except pymongo.errors.OperationFailure: # Standalone mode (no replica set) - ping succeeded so we're good print('MongoDB is ready (standalone mode)') c.close() break except Exception as e: print(f'MongoDB not ready yet: {e}') time.sleep(5) " deactivate echo "MongoDB is ready." fi echo "Starting Auth Server..." cd /app source .venv/bin/activate exec uvicorn server:app --host 0.0.0.0 --port 8888 --proxy-headers --forwarded-allow-ips='*' ================================================ FILE: docker/keycloak/Dockerfile ================================================ FROM quay.io/keycloak/keycloak:23.0 as builder ENV KC_HEALTH_ENABLED=true ENV KC_METRICS_ENABLED=true ENV KC_FEATURES=token-exchange ENV KC_DB=mysql WORKDIR /opt/keycloak RUN keytool -genkeypair -storepass password -storetype PKCS12 -keyalg RSA -keysize 2048 -dname "CN=server" -alias server -ext "SAN:c=DNS:localhost,IP:127.0.0.1" -keystore conf/server.keystore RUN /opt/keycloak/bin/kc.sh build FROM quay.io/keycloak/keycloak:23.0 COPY --from=builder /opt/keycloak/ /opt/keycloak/ WORKDIR /opt/keycloak # Health check for keycloak ready endpoint HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ CMD curl -f http://localhost:8080/health/ready || exit 1 # Switch to keycloak user (provided by base image, UID 1000) USER keycloak # configuring email listener to send emails for specific events ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start", "--optimized"] ================================================ FILE: docker/lua/capture_body.lua ================================================ -- capture_body.lua: Read request body and encode it in X-Body header for auth_request local cjson = require "cjson" -- Read the request body ngx.req.read_body() local body_data = ngx.req.get_body_data() if body_data then -- Strip newlines to prevent breaking HTTP header format -- (JSON whitespace is insignificant per RFC 8259, so this is safe) local clean_body = body_data:gsub("[\r\n]+", " ") -- Set the X-Body header with the cleaned body data ngx.req.set_header("X-Body", clean_body) ngx.log(ngx.INFO, "Captured request body (" .. string.len(body_data) .. " bytes) for auth validation") else ngx.log(ngx.INFO, "No request body found") end ================================================ FILE: docker/lua/emit_metrics.lua ================================================ -- emit_metrics.lua: Capture MCP request metrics in log_by_lua phase (no network I/O) local ok, cjson = pcall(require, "cjson") if not ok then return end local metrics = ngx.shared.metrics_buffer if not metrics then return end -- Skip buffering when no collector is configured (avoids pointless writes that TTL-expire) local metrics_url = os.getenv("METRICS_SERVICE_URL") or "" if metrics_url == "" then return end -- Extract server name from first URI path segment: //... local server_name = ngx.var.uri:match("^/([^/]+)/") if not server_name then return end -- Parse JSON-RPC body from X-Body header (set by capture_body.lua in rewrite phase) local method = "unknown" local tool_name = "" local body = ngx.req.get_headers()["X-Body"] if body then local dok, parsed = pcall(cjson.decode, body) if dok and parsed.method then method = parsed.method if method == "tools/call" and parsed.params and parsed.params.name then tool_name = parsed.params.name end end end local entry = cjson.encode({ m = method, s = server_name, t = tool_name, c = ngx.req.get_headers()["X-Client-Name"] or "unknown", ok = ngx.status < 400, d = (tonumber(ngx.var.upstream_header_time) or tonumber(ngx.var.request_time) or 0) * 1000, }) local key = "m:" .. ngx.now() .. ":" .. ngx.worker.pid() .. ":" .. math.random(1, 999999) local set_ok, set_err = metrics:set(key, entry, 300) if not set_ok then ngx.log(ngx.ERR, "metrics emit: shared dict full, dropping metric: ", set_err) end ================================================ FILE: docker/lua/flush_metrics.lua ================================================ -- flush_metrics.lua: Background timer flushes shared dict buffer to collector endpoint local ok, cjson = pcall(require, "cjson") if not ok then return end local api_key = os.getenv("METRICS_API_KEY") or "" local metrics_url = os.getenv("METRICS_SERVICE_URL") or "" if metrics_url == "" then ngx.log(ngx.WARN, "metrics flush: DISABLED (METRICS_SERVICE_URL not set)") return end -- Only http:// is supported (raw TCP cosocket, no TLS) if metrics_url:sub(1, 8) == "https://" then ngx.log(ngx.ERR, "metrics flush: DISABLED -- METRICS_SERVICE_URL uses https:// which is not supported (use http:// for internal service-to-service)") return end if api_key == "" then ngx.log(ngx.WARN, "metrics flush: METRICS_API_KEY not set, requests may be rejected by metrics-service") end local host, port = metrics_url:match("http://([^:/]+):?(%d*)") port = tonumber(port) or 80 local function flush() local buf = ngx.shared.metrics_buffer if not buf then return end local keys = buf:get_keys(1024) if #keys == 0 then return end if #keys == 1024 then ngx.log(ngx.WARN, "metrics flush: buffer at capacity (1024 keys), some metrics may be lost") end local batch = {} local to_delete = {} for _, key in ipairs(keys) do if key:sub(1, 2) == "m:" then local val = buf:get(key) if val then local dok, e = pcall(cjson.decode, val) if dok then batch[#batch + 1] = { type = "tool_execution", value = 1.0, duration_ms = e.d, dimensions = { method = e.m, server_name = e.s, tool_name = e.t, client_name = e.c, success = tostring(e.ok), }, metadata = {}, } to_delete[#to_delete + 1] = key end end end end if #batch == 0 then return end local payload = cjson.encode({ service = "nginx", version = "1.0.0", metrics = batch, }) local sock = ngx.socket.tcp() sock:settimeout(5000) local conn_ok, err = sock:connect(host, port) if not conn_ok then ngx.log(ngx.ERR, "metrics flush: connect failed: ", err) return end local req = "POST /metrics HTTP/1.1\r\n" .. "Host: " .. host .. "\r\n" .. "Content-Type: application/json\r\n" .. "X-API-Key: " .. api_key .. "\r\n" .. "Content-Length: " .. #payload .. "\r\n" .. "Connection: close\r\n\r\n" .. payload local send_ok, err = sock:send(req) if not send_ok then ngx.log(ngx.ERR, "metrics flush: send failed: ", err) sock:close() return end local line = sock:receive("*l") sock:close() if line and line:match("200") then for _, key in ipairs(to_delete) do buf:delete(key) end if #batch > 1 then ngx.log(ngx.INFO, "metrics flush: sent ", #batch, " metrics") end else ngx.log(ngx.ERR, "metrics flush: bad response: ", line or "nil") end end local function schedule() local ok, err = ngx.timer.every(5, function(premature) if premature then return end local pok, perr = pcall(flush) if not pok then ngx.log(ngx.ERR, "metrics flush error: ", perr) end end) if not ok then ngx.log(ngx.ERR, "metrics flush: failed to create timer: ", err) end end if ngx.worker.id() == 0 then ngx.log(ngx.WARN, "metrics flush: starting on worker 0, host=", host, " port=", port, " api_key_len=", #api_key) schedule() end ================================================ FILE: docker/lua/virtual_router.lua ================================================ -- virtual_router.lua: JSON-RPC router for Virtual MCP Servers -- Routes tools/list, tools/call, resources/list, resources/read, -- prompts/list, prompts/get, ping, and initialize requests to the correct backend. -- Implements per-client session management with two-tier cache: -- L1: ngx.shared.virtual_server_map (30s TTL, per-worker fast path) -- L2: MongoDB via /_internal/sessions/ FastAPI endpoints local cjson = require "cjson" -- Ensure empty Lua tables serialize as JSON arrays [] not objects {} local empty_array_mt = cjson.empty_array_mt -- Extract JSON from an SSE-formatted response body. -- SSE format: "event: message\ndata: {json}\n\n" -- If the body is already raw JSON, return it as-is. local function _parse_sse_body(body) if not body or body == "" then return nil end -- If it starts with '{' or '[', it's already raw JSON local first_char = string.sub(body, 1, 1) if first_char == "{" or first_char == "[" then return body end -- Extract the last "data: " line (SSE format) local json_data = nil for line in string.gmatch(body, "[^\r\n]+") do local data = string.match(line, "^data:%s*(.+)") if data then json_data = data end end return json_data end -- Force a table to serialize as a JSON array (handles empty tables -> [] not {}) local function _as_json_array(t) if type(t) ~= "table" then if cjson.empty_array then return cjson.empty_array end return setmetatable({}, empty_array_mt) end if next(t) == nil then if cjson.empty_array then return cjson.empty_array end end return setmetatable(t, empty_array_mt) end local _M = {} -- Shared dict for L1 session cache and mapping cache local session_cache = ngx.shared.virtual_server_map -- Cache TTL constants local MAPPING_CACHE_TTL = 10 local SESSION_CACHE_TTL = 30 local ENRICHED_CACHE_TTL = 60 -- Supported MCP protocol versions (newest first for negotiation) local SUPPORTED_PROTOCOL_VERSIONS = { ["2025-11-25"] = true, ["2025-06-18"] = true, ["2025-03-26"] = true, ["2024-11-05"] = true, } local LATEST_PROTOCOL_VERSION = "2025-11-25" -- Ensure inputSchema has "type": "object" as required by MCP spec local function _ensure_mcp_schema(schema) if not schema or type(schema) ~= "table" then return { type = "object", properties = {} } end if schema.type == "object" then return schema end if not schema.type then schema.type = "object" return schema end -- Non-object type: wrap it return { type = "object", properties = { value = schema } } end -- Read and cache virtual server mapping from JSON file local function _get_mapping(server_id) local cache_key = "mapping:" .. server_id local cached = session_cache:get(cache_key) if cached then local ok, mapping = pcall(cjson.decode, cached) if ok then return mapping end ngx.log(ngx.WARN, "Failed to decode cached mapping for server_id=", server_id) end -- Read from file local path = "/etc/nginx/lua/virtual_mappings/" .. server_id .. ".json" local f, err = io.open(path, "r") if not f then ngx.log(ngx.ERR, "Could not open mapping file: ", path, " error: ", tostring(err)) return nil end local content = f:read("*a") f:close() local ok, mapping = pcall(cjson.decode, content) if not ok then ngx.log(ngx.ERR, "Failed to parse mapping JSON for server_id=", server_id) return nil end -- Cache in shared dict (TTL 10 seconds to reduce stale data after reload) session_cache:set(cache_key, content, MAPPING_CACHE_TTL) return mapping end -- Build a JSON-RPC error response local function _jsonrpc_error(id, code, message) return cjson.encode({ jsonrpc = "2.0", id = id, error = { code = code, message = message, }, }) end -- Build a JSON-RPC success response local function _jsonrpc_result(id, result) return cjson.encode({ jsonrpc = "2.0", id = id, result = result, }) end -- Check if user scopes satisfy required scopes local function _has_scopes(user_scopes_str, required_scopes) if not required_scopes or #required_scopes == 0 then return true end if not user_scopes_str or user_scopes_str == "" then return false end -- Parse space-separated scopes into a set local user_scopes = {} for scope in string.gmatch(user_scopes_str, "%S+") do user_scopes[scope] = true end -- Check all required scopes are present for _, required in ipairs(required_scopes) do if not user_scopes[required] then return false end end return true end -- Initialize a backend MCP server and extract its session ID local function _initialize_backend(backend_location) local init_body = cjson.encode({ jsonrpc = "2.0", id = "init-" .. (ngx.var.request_id or "0"), method = "initialize", params = { protocolVersion = LATEST_PROTOCOL_VERSION, capabilities = {}, clientInfo = { name = "mcp-gateway-virtual-router", version = "1.0.0", }, }, }) -- Clear the client's Mcp-Session-Id so the backend sees a fresh -- initialize request instead of trying to resume a vs-* session. ngx.req.set_header("Mcp-Session-Id", "") -- MCP spec requires Accept header listing both content types ngx.req.set_header("Accept", "application/json, text/event-stream") local res = ngx.location.capture(backend_location, { method = ngx.HTTP_POST, body = init_body, }) if not res or res.status ~= 200 then ngx.log(ngx.ERR, "Backend initialize failed for ", backend_location, " status=", res and res.status or "nil") return nil end -- Extract Mcp-Session-Id from backend response headers local backend_session_id = nil if res.header then backend_session_id = res.header["Mcp-Session-Id"] or res.header["mcp-session-id"] end return backend_session_id end -- Get or create a backend session for a given client session + backend location. -- Two-tier cache: L1 shared dict (30s) -> L2 MongoDB -> initialize backend local function _get_backend_session(client_session_id, backend_location, server_id) local session_key = client_session_id .. ":" .. backend_location local cache_key = "bsess:" .. session_key -- L1: shared dict fast path local session_id = session_cache:get(cache_key) if session_id then return session_id end -- L2: MongoDB via internal FastAPI API local res = ngx.location.capture("/_internal/sessions/backend/" .. session_key, { method = ngx.HTTP_GET, }) if res and res.status == 200 then local ok, data = pcall(cjson.decode, res.body) if ok and data.backend_session_id then -- Populate L1 cache session_cache:set(cache_key, data.backend_session_id, SESSION_CACHE_TTL) return data.backend_session_id end end -- L2 miss: initialize the backend to get a session ngx.log(ngx.INFO, "Initializing backend session for ", session_key) session_id = _initialize_backend(backend_location) if session_id then -- Store in L2 (MongoDB) local user_id = ngx.var.auth_user or ngx.var.auth_username or "anonymous" local store_body = cjson.encode({ backend_session_id = session_id, client_session_id = client_session_id, user_id = user_id, virtual_server_path = "/virtual/" .. server_id, }) ngx.location.capture("/_internal/sessions/backend/" .. session_key, { method = ngx.HTTP_PUT, body = store_body, }) -- Populate L1 cache session_cache:set(cache_key, session_id, SESSION_CACHE_TTL) end return session_id end -- Invalidate a backend session from both L1 and L2 caches local function _invalidate_backend_session(client_session_id, backend_location) local session_key = client_session_id .. ":" .. backend_location local cache_key = "bsess:" .. session_key -- Remove from L1 session_cache:delete(cache_key) -- Remove from L2 ngx.location.capture("/_internal/sessions/backend/" .. session_key, { method = ngx.HTTP_DELETE, }) end -- Collect unique backend locations from a mapping's tools array local function _collect_backend_locations(mapping) local locations = {} local seen = {} if mapping.tools then for _, tool in ipairs(mapping.tools) do local loc = tool.backend_location if loc and not seen[loc] then seen[loc] = true locations[#locations + 1] = loc end end end return locations end -- Fetch tools/list from a single backend via ngx.location.capture. -- Returns the tools array from the backend, or empty table on failure. -- On stale session error (status >= 400), invalidates and retries once. local function _fetch_backend_tools_list(backend_location, client_session_id, server_id) local req_body = cjson.encode({ jsonrpc = "2.0", id = "tl-" .. (ngx.var.request_id or "0"), method = "tools/list", params = {}, }) -- Get backend session local backend_session_id = nil if client_session_id then backend_session_id = _get_backend_session(client_session_id, backend_location, server_id) end if backend_session_id then ngx.req.set_header("Mcp-Session-Id", backend_session_id) else ngx.req.set_header("Mcp-Session-Id", "") end local res = ngx.location.capture(backend_location, { method = ngx.HTTP_POST, body = req_body, }) -- Stale session retry if res and res.status >= 400 and client_session_id and backend_session_id then ngx.log(ngx.WARN, "Backend tools/list returned ", res.status, " for ", backend_location, " -- retrying with fresh session") _invalidate_backend_session(client_session_id, backend_location) local new_session_id = _get_backend_session(client_session_id, backend_location, server_id) if new_session_id then ngx.req.set_header("Mcp-Session-Id", new_session_id) else ngx.req.set_header("Mcp-Session-Id", "") end res = ngx.location.capture(backend_location, { method = ngx.HTTP_POST, body = req_body, }) end if not res or res.status ~= 200 then ngx.log(ngx.ERR, "Failed to fetch tools/list from ", backend_location, " status=", res and res.status or "nil") return {} end -- Backend may respond with SSE format (text/event-stream) or raw JSON local json_body = _parse_sse_body(res.body) if not json_body then ngx.log(ngx.ERR, "Empty or unparseable tools/list response from ", backend_location) return {} end local ok, data = pcall(cjson.decode, json_body) if not ok then ngx.log(ngx.ERR, "Failed to parse tools/list response from ", backend_location) return {} end if data.result and data.result.tools then return data.result.tools end return {} end -- Handle tools/list method - proxy to backends for full metadata, with cache local function _handle_tools_list(request_id, mapping, user_scopes_str, client_session_id, server_id) -- Enforce server-level required_scopes before processing if not _has_scopes(user_scopes_str, mapping.required_scopes) then return _jsonrpc_error(request_id, -32603, "Access denied: missing required server scopes") end -- Build a set of allowed tools from the mapping (display_name -> mapping entry) local allowed_tools = {} if mapping.tools then for _, tool in ipairs(mapping.tools) do allowed_tools[tool.original_name or tool.name] = tool end end -- L1 cache check: enriched tools for this server local enriched_tools = nil local enriched_cache_key = "tools_enriched:" .. (server_id or "unknown") local cached_enriched = session_cache:get(enriched_cache_key) if cached_enriched then local ok, cached = pcall(cjson.decode, cached_enriched) if ok then enriched_tools = cached end end -- On cache miss, fetch from backends if not enriched_tools then enriched_tools = {} local backend_locations = _collect_backend_locations(mapping) local fetch_ok = false for _, backend_loc in ipairs(backend_locations) do local backend_tools = _fetch_backend_tools_list(backend_loc, client_session_id, server_id) if #backend_tools > 0 then fetch_ok = true end for _, bt in ipairs(backend_tools) do local mapping_entry = allowed_tools[bt.name] if mapping_entry then -- Use the mapping's display name (alias) instead of original name local display_name = mapping_entry.name -- Use mapping's description if non-empty (override), else backend's local desc = mapping_entry.description if not desc or desc == "" then desc = bt.description or "" end enriched_tools[#enriched_tools + 1] = { name = display_name, description = desc, inputSchema = _ensure_mcp_schema(bt.inputSchema or bt.input_schema), required_scopes = mapping_entry.required_scopes, } end end end -- Fallback: if all backend fetches failed, use mapping file metadata if not fetch_ok then ngx.log(ngx.WARN, "All backend tools/list fetches failed for server=", server_id, " -- falling back to mapping file metadata") enriched_tools = {} if mapping.tools then for _, tool in ipairs(mapping.tools) do enriched_tools[#enriched_tools + 1] = { name = tool.name, description = tool.description or "", inputSchema = _ensure_mcp_schema(tool.inputSchema), required_scopes = tool.required_scopes, } end end end -- Cache enriched tools (pre-scope-filtered, 60s TTL) local ok_enc, encoded = pcall(cjson.encode, enriched_tools) if ok_enc then session_cache:set(enriched_cache_key, encoded, ENRICHED_CACHE_TTL) end end -- Scope filter: filter cached tools by user's scopes at request time local tools = setmetatable({}, empty_array_mt) for _, tool in ipairs(enriched_tools) do if _has_scopes(user_scopes_str, tool.required_scopes) then tools[#tools + 1] = { name = tool.name, description = tool.description or "", inputSchema = _ensure_mcp_schema(tool.inputSchema), } end end return _jsonrpc_result(request_id, { tools = _as_json_array(tools) }) end -- Generic helper to proxy list methods (resources/list, prompts/list) to all backends. -- Aggregates results from all backends into a single array. -- Caches with key "{method}:{server_id}", 60s TTL. -- Returns the aggregated array and a lookup map (item_key_value -> backend_location). local function _proxy_list_to_backends(method_name, result_key, mapping, client_session_id, server_id) -- Cache check local cache_key = method_name .. ":" .. (server_id or "unknown") local cached = session_cache:get(cache_key) if cached then local ok, data = pcall(cjson.decode, cached) if ok then -- Ensure decoded items is always a JSON array (empty table from cache loses metatable) if data.items and #data.items == 0 then data.items = setmetatable({}, empty_array_mt) end return data.items, data.lookup end end local aggregated = setmetatable({}, empty_array_mt) local lookup = {} local backend_locations = _collect_backend_locations(mapping) for _, backend_loc in ipairs(backend_locations) do local req_body = cjson.encode({ jsonrpc = "2.0", id = "pl-" .. (ngx.var.request_id or "0"), method = method_name, params = {}, }) -- Get backend session local backend_session_id = nil if client_session_id then backend_session_id = _get_backend_session(client_session_id, backend_loc, server_id) end if backend_session_id then ngx.req.set_header("Mcp-Session-Id", backend_session_id) else ngx.req.set_header("Mcp-Session-Id", "") end local res = ngx.location.capture(backend_loc, { method = ngx.HTTP_POST, body = req_body, }) -- Stale session retry if res and res.status >= 400 and client_session_id and backend_session_id then ngx.log(ngx.WARN, "Backend ", method_name, " returned ", res.status, " for ", backend_loc, " -- retrying with fresh session") _invalidate_backend_session(client_session_id, backend_loc) local new_session_id = _get_backend_session(client_session_id, backend_loc, server_id) if new_session_id then ngx.req.set_header("Mcp-Session-Id", new_session_id) else ngx.req.set_header("Mcp-Session-Id", "") end res = ngx.location.capture(backend_loc, { method = ngx.HTTP_POST, body = req_body, }) end if res and res.status == 200 then local json_body = _parse_sse_body(res.body) local ok, data = pcall(cjson.decode, json_body or "") if ok and data.result and data.result[result_key] then for _, item in ipairs(data.result[result_key]) do aggregated[#aggregated + 1] = item -- Build lookup: for resources, key on "uri"; for prompts, key on "name" local lookup_key = nil if result_key == "resources" and item.uri then lookup_key = item.uri elseif result_key == "prompts" and item.name then lookup_key = item.name end if lookup_key then lookup[lookup_key] = backend_loc end end end else ngx.log(ngx.WARN, "Backend ", method_name, " failed for ", backend_loc, " status=", res and res.status or "nil") end end -- Cache aggregated results and lookup map local cache_data = { items = aggregated, lookup = lookup } local ok_enc, encoded = pcall(cjson.encode, cache_data) if ok_enc then session_cache:set(cache_key, encoded, ENRICHED_CACHE_TTL) end return aggregated, lookup end -- Proxy a single request to a specific backend with session management and stale retry. -- Returns the response body directly. Used for tools/call, resources/read, prompts/get. local function _proxy_to_backend(request_id, method_name, proxied_params, backend_location, client_session_id, server_id, backend_version, label) local proxied_body = cjson.encode({ jsonrpc = "2.0", id = request_id, method = method_name, params = proxied_params, }) -- Get or create backend session local backend_session_id = nil if client_session_id then backend_session_id = _get_backend_session(client_session_id, backend_location, server_id) end -- Set version header if pinned if backend_version then ngx.req.set_header("X-MCP-Server-Version", backend_version) end -- Set the backend session header for the subrequest proxy if backend_session_id then ngx.req.set_header("Mcp-Session-Id", backend_session_id) else ngx.req.set_header("Mcp-Session-Id", "") end local res = ngx.location.capture(backend_location, { method = ngx.HTTP_POST, body = proxied_body, }) if not res then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32603, "Backend request failed for " .. (label or method_name))) return end -- Stale session retry: if backend returns an error that looks like a session issue, -- invalidate the session and retry once if res.status >= 400 and client_session_id and backend_session_id then ngx.log(ngx.WARN, "Backend returned ", res.status, " for ", label or method_name, " session=", backend_session_id, " -- retrying with fresh session") -- Invalidate stale session _invalidate_backend_session(client_session_id, backend_location) -- Get a fresh session (will re-initialize the backend) local new_session_id = _get_backend_session(client_session_id, backend_location, server_id) if new_session_id then ngx.req.set_header("Mcp-Session-Id", new_session_id) else ngx.req.set_header("Mcp-Session-Id", "") end -- Retry the request res = ngx.location.capture(backend_location, { method = ngx.HTTP_POST, body = proxied_body, }) if not res then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32603, "Backend request failed after retry for " .. (label or method_name))) return end end -- Forward backend response ngx.status = res.status if res.header and res.header["Content-Type"] then ngx.header["Content-Type"] = res.header["Content-Type"] else ngx.header["Content-Type"] = "application/json" end ngx.print(res.body) end -- Validate a client session ID against MongoDB (L2). -- Uses L1 cache to avoid repeated DB lookups. -- Returns true if valid, false otherwise. local function _validate_client_session(client_session_id) if not client_session_id or client_session_id == "" then return false end -- L1: fast path check (cache valid sessions for SESSION_CACHE_TTL) local cache_key = "csess_valid:" .. client_session_id local cached = session_cache:get(cache_key) if cached == "1" then return true end -- L2: validate via internal FastAPI endpoint local res = ngx.location.capture( "/_internal/sessions/client/" .. client_session_id, { method = ngx.HTTP_GET } ) if res and res.status == 200 then session_cache:set(cache_key, "1", SESSION_CACHE_TTL) return true end return false end -- Negotiate protocol version: if client's version is supported, echo it back; -- otherwise respond with our latest supported version. local function _negotiate_protocol_version(client_version) if client_version and SUPPORTED_PROTOCOL_VERSIONS[client_version] then return client_version end return LATEST_PROTOCOL_VERSION end -- Handle initialize method - create client session, return MCP capabilities local function _handle_initialize(request_id, server_id, params) local user_id = ngx.var.auth_user or ngx.var.auth_username or "anonymous" local virtual_path = "/virtual/" .. server_id -- Create client session in MongoDB via internal API local body = cjson.encode({ user_id = user_id, virtual_server_path = virtual_path, }) local res = ngx.location.capture("/_internal/sessions/client", { method = ngx.HTTP_POST, body = body, }) local client_session_id = nil if res and res.status == 201 then local ok, data = pcall(cjson.decode, res.body) if ok then client_session_id = data.client_session_id end end -- Set Mcp-Session-Id response header so client includes it in future requests if client_session_id then ngx.header["Mcp-Session-Id"] = client_session_id ngx.log(ngx.INFO, "Created client session ", client_session_id, " for user=", user_id, " server=", server_id) else ngx.log(ngx.WARN, "Failed to create client session for server=", server_id) end -- Negotiate protocol version with client local client_version = params and params.protocolVersion local negotiated_version = _negotiate_protocol_version(client_version) local result = { protocolVersion = negotiated_version, capabilities = { tools = { listChanged = false, }, }, serverInfo = { name = "mcp-gateway-virtual-server", version = "1.0.0", }, } return _jsonrpc_result(request_id, result) end -- Handle tools/call method - proxy to the correct backend with session management local function _handle_tools_call(request_id, mapping, params, user_scopes_str, client_session_id, server_id) -- Enforce server-level required_scopes before processing if not _has_scopes(user_scopes_str, mapping.required_scopes) then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32603, "Access denied: missing required server scopes")) return end local tool_name = params and params.name if not tool_name then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32602, "Missing tool name in params")) return end -- Look up tool in backend map local tool_info = mapping.tool_backend_map and mapping.tool_backend_map[tool_name] if not tool_info then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32601, "Tool not found: " .. tool_name)) return end -- Enforce per-tool scopes if mapping.tools then for _, tool_entry in ipairs(mapping.tools) do if tool_entry.name == tool_name then if not _has_scopes(user_scopes_str, tool_entry.required_scopes) then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32603, "Access denied: missing required scopes for tool: " .. tool_name)) return end break end end end -- Rewrite tool name to original if aliased local original_name = tool_info.original_name or tool_name local backend_location = tool_info.backend_location if not backend_location then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32603, "No backend location for tool: " .. tool_name)) return end -- Build the proxied params with original tool name local proxied_params = {} if params then for k, v in pairs(params) do proxied_params[k] = v end end proxied_params.name = original_name -- Proxy to backend with session management _proxy_to_backend( request_id, "tools/call", proxied_params, backend_location, client_session_id, server_id, tool_info.backend_version, "tool:" .. tool_name ) end -- Handle resources/read - proxy to the backend that owns the resource local function _handle_resources_read(request_id, params, mapping, client_session_id, server_id) local uri = params and params.uri if not uri then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32602, "Missing resource uri in params")) return end -- Look up which backend owns this resource from cached resources/list local _, lookup = _proxy_list_to_backends("resources/list", "resources", mapping, client_session_id, server_id) local backend_loc = lookup and lookup[uri] if not backend_loc then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32601, "Resource not found: " .. uri)) return end _proxy_to_backend( request_id, "resources/read", params, backend_loc, client_session_id, server_id, nil, "resource:" .. uri ) end -- Handle prompts/get - proxy to the backend that owns the prompt local function _handle_prompts_get(request_id, params, mapping, client_session_id, server_id) local name = params and params.name if not name then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32602, "Missing prompt name in params")) return end -- Look up which backend owns this prompt from cached prompts/list local _, lookup = _proxy_list_to_backends("prompts/list", "prompts", mapping, client_session_id, server_id) local backend_loc = lookup and lookup[name] if not backend_loc then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32601, "Prompt not found: " .. name)) return end _proxy_to_backend( request_id, "prompts/get", params, backend_loc, client_session_id, server_id, nil, "prompt:" .. name ) end -- Main entry point function _M.route() -- Per MCP Streamable HTTP 2025-11-25 spec, the client MUST include Accept header -- listing both application/json and text/event-stream. Set this on the request so -- all ngx.location.capture subrequests to backends inherit it. ngx.req.set_header("Accept", "application/json, text/event-stream") local request_method = ngx.var.request_method -- Handle HTTP GET: per MCP Streamable HTTP 2025-11-25 spec section 3.3, -- the server MUST either return Content-Type: text/event-stream or HTTP 405. -- We do not support server-initiated SSE streams. if request_method == "GET" then ngx.status = 405 ngx.header["Allow"] = "POST" return end -- Handle HTTP DELETE: session termination per MCP spec. -- Return 405 Method Not Allowed to indicate we don't support client-initiated termination. if request_method == "DELETE" then ngx.status = 405 ngx.header["Content-Type"] = "application/json" ngx.header["Allow"] = "POST, GET" return end -- Only POST is accepted for JSON-RPC messages if request_method ~= "POST" then ngx.status = 405 ngx.header["Content-Type"] = "application/json" ngx.header["Allow"] = "POST, GET, DELETE" return end -- Read request body ngx.req.read_body() local body = ngx.req.get_body_data() if not body then ngx.status = 400 ngx.header["Content-Type"] = "application/json" ngx.say(_jsonrpc_error(nil, -32700, "Empty request body")) return end -- Parse JSON-RPC message local ok, request = pcall(cjson.decode, body) if not ok then ngx.status = 400 ngx.header["Content-Type"] = "application/json" ngx.say(_jsonrpc_error(nil, -32700, "Parse error")) return end local request_id = request.id local method = request.method local params = request.params -- Get virtual server ID from nginx variable local server_id = ngx.var.virtual_server_id if not server_id or server_id == "" then ngx.status = 500 ngx.header["Content-Type"] = "application/json" ngx.say(_jsonrpc_error(request_id, -32603, "Virtual server ID not configured")) return end -- Detect JSON-RPC notifications (no "id" field) vs requests (have "id" field). -- Per MCP Streamable HTTP spec, notifications and responses MUST get HTTP 202 Accepted -- with no body. Only JSON-RPC requests get a JSON-RPC response. local is_notification = (request_id == nil) and (method ~= nil) -- Handle notifications: return 202 Accepted with no body per MCP spec if is_notification then if method == "notifications/initialized" then ngx.log(ngx.INFO, "Received initialized notification for server=", server_id) elseif method == "notifications/cancelled" then ngx.log(ngx.INFO, "Received cancelled notification for server=", server_id) else ngx.log(ngx.INFO, "Received notification method=", method, " for server=", server_id) end ngx.status = 202 return end -- Handle initialize: generate a client session and return capabilities if method == "initialize" then ngx.status = 200 ngx.header["Content-Type"] = "application/json" ngx.say(_handle_initialize(request_id, server_id, params)) return end -- Handle ping: simple echo (no mapping needed) if method == "ping" then ngx.status = 200 ngx.header["Content-Type"] = "application/json" ngx.say(_jsonrpc_result(request_id, {})) return end -- Get client session ID from request header (set during initialize) local client_session_id = ngx.var.http_mcp_session_id -- Validate client session: per MCP spec, servers that require a session ID -- SHOULD respond with 400 Bad Request to requests without a valid Mcp-Session-Id. -- Initialize and ping are exempt; notifications already handled above with 202. if not _validate_client_session(client_session_id) then ngx.status = 400 ngx.header["Content-Type"] = "application/json" ngx.say(_jsonrpc_error(request_id, -32600, "Missing or invalid Mcp-Session-Id. Send an initialize request first.")) return end -- Load mapping for all other methods local mapping = _get_mapping(server_id) if not mapping then ngx.status = 500 ngx.header["Content-Type"] = "application/json" ngx.say(_jsonrpc_error(request_id, -32603, "Virtual server mapping not found")) return end -- Get user scopes from auth local user_scopes_str = ngx.var.auth_scopes or "" -- Route based on method ngx.header["Content-Type"] = "application/json" if method == "tools/list" then ngx.status = 200 ngx.say(_handle_tools_list(request_id, mapping, user_scopes_str, client_session_id, server_id)) elseif method == "tools/call" then _handle_tools_call(request_id, mapping, params, user_scopes_str, client_session_id, server_id) elseif method == "resources/list" then -- Enforce server-level required_scopes if not _has_scopes(user_scopes_str, mapping.required_scopes) then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32603, "Access denied: missing required server scopes")) return end local resources = _proxy_list_to_backends("resources/list", "resources", mapping, client_session_id, server_id) ngx.status = 200 ngx.say(_jsonrpc_result(request_id, { resources = _as_json_array(resources) })) elseif method == "resources/read" then -- Enforce server-level required_scopes if not _has_scopes(user_scopes_str, mapping.required_scopes) then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32603, "Access denied: missing required server scopes")) return end _handle_resources_read(request_id, params, mapping, client_session_id, server_id) elseif method == "prompts/list" then -- Enforce server-level required_scopes if not _has_scopes(user_scopes_str, mapping.required_scopes) then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32603, "Access denied: missing required server scopes")) return end local prompts = _proxy_list_to_backends("prompts/list", "prompts", mapping, client_session_id, server_id) ngx.status = 200 ngx.say(_jsonrpc_result(request_id, { prompts = _as_json_array(prompts) })) elseif method == "prompts/get" then -- Enforce server-level required_scopes if not _has_scopes(user_scopes_str, mapping.required_scopes) then ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32603, "Access denied: missing required server scopes")) return end _handle_prompts_get(request_id, params, mapping, client_session_id, server_id) else ngx.status = 200 ngx.say(_jsonrpc_error(request_id, -32601, "Method not found: " .. tostring(method))) end end -- Execute routing _M.route() ================================================ FILE: docker/nginx_rev_proxy_http_and_https.conf ================================================ # Nginx configuration directive for handling long server names server_names_hash_bucket_size 128; # Increase header buffer sizes for large OAuth tokens (Auth0, Entra ID) large_client_header_buffers 4 32k; proxy_buffer_size 16k; proxy_buffers 4 16k; # Variables hash configuration - needed for large number of auth_request_set variables # With multiple location blocks each setting 5+ variables, we need larger hash tables variables_hash_max_size 2048; variables_hash_bucket_size 128; # Lua shared dictionary for metrics collection (10MB) lua_shared_dict metrics_buffer 10m; # Lua shared dictionary for virtual server routing mappings (2MB) lua_shared_dict virtual_server_map 2m; # Background flush of metrics buffer to metrics-service init_worker_by_lua_file /etc/nginx/lua/flush_metrics.lua; # Map to determine the real client scheme # Uses X-Forwarded-Proto from ALB/load balancer if present, otherwise falls back to $scheme # This is critical for OAuth2 redirect URIs when HTTPS termination happens at the ALB map $http_x_forwarded_proto $real_scheme { default $scheme; https https; http http; } # Map to determine the real client port # Uses X-Forwarded-Port from ALB/load balancer if present, otherwise maps internal # container ports (8080/8443) to their standard external equivalents (80/443) # This is critical for Keycloak URL generation when running behind a reverse proxy map $http_x_forwarded_port $real_port { default $http_x_forwarded_port; "" $standard_port; } # Map internal container listen ports to standard external ports map $server_port $standard_port { 8080 80; 8443 443; default $server_port; } {{VERSION_MAP}} # First server block now directly handles HTTP requests instead of redirecting server { listen 8080; # {{ADDITIONAL_SERVER_NAMES}} is replaced with custom domains/IPs for gateway access server_name localhost {{ADDITIONAL_SERVER_NAMES}}; # Custom error page for 502 Bad Gateway (shown during backend startup) error_page 502 /502.html; location = /502.html { root /usr/share/nginx/html; internal; } # Add this to trigger the named location for 403 errors error_page 403 = @forbidden_error; # Registered MCP server locations (generated dynamically) {{LOCATION_BLOCKS}} {{REGISTRY_ONLY_BLOCK}} # Internal session management for virtual MCP server router location /_internal/sessions/ { internal; proxy_pass http://127.0.0.1:7860/api/internal/sessions/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Content-Type application/json; } # Virtual MCP server locations (generated dynamically) {{VIRTUAL_SERVER_BLOCKS}} # Serve static files directly from nginx (more efficient than proxying) location /static/ { alias /app/frontend/build/static/; expires 1y; add_header Cache-Control "public, immutable"; } location = /favicon.ico { alias /app/frontend/build/favicon.ico; expires 1y; add_header Cache-Control "public, immutable"; } # Route for Cost Explorer service location / { proxy_pass http://127.0.0.1:7860/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; } # Auth validation endpoint - passes entire request to auth server location = /validate { internal; proxy_pass http://auth-server:8888/validate; # Pass original request info proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URL $scheme://$host$request_uri; # Extract and pass Cognito config headers from original request proxy_set_header X-User-Pool-Id $http_x_user_pool_id; proxy_set_header X-Client-Id $http_x_client_id; proxy_set_header X-Region $http_x_region; proxy_set_header X-Authorization $http_x_authorization; # Pass all original headers (including Authorization and X-Body from Lua) proxy_pass_request_headers on; # Short timeouts for auth validation proxy_connect_timeout 10s; proxy_read_timeout 10s; proxy_send_timeout 10s; } # OAuth2 Cognito callback endpoint location /oauth2/callback/cognito { proxy_pass http://auth-server:8888/oauth2/callback/cognito; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Cognito login endpoint location /oauth2/login/cognito { proxy_pass http://auth-server:8888/oauth2/login/cognito; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 Cognito logout endpoint location /oauth2/logout/ { proxy_pass http://auth-server:8888/oauth2/logout/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; proxy_pass_request_headers on; } # OAuth2 Entra ID callback endpoint location /oauth2/callback/entra { proxy_pass http://auth-server:8888/oauth2/callback/entra; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through cookies, query parameters, and headers proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Entra ID login endpoint location /oauth2/login/entra { proxy_pass http://auth-server:8888/oauth2/login/entra; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 Okta callback endpoint location /oauth2/callback/okta { proxy_pass http://auth-server:8888/oauth2/callback/okta; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through cookies, query parameters, and headers proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Okta login endpoint location /oauth2/login/okta { proxy_pass http://auth-server:8888/oauth2/login/okta; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 GitHub callback endpoint location /oauth2/callback/github { proxy_pass http://auth-server:8888/oauth2/callback/github; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 GitHub login endpoint location /oauth2/login/github { proxy_pass http://auth-server:8888/oauth2/login/github; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 Google callback endpoint location /oauth2/callback/google { proxy_pass http://auth-server:8888/oauth2/callback/google; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Google login endpoint location /oauth2/login/google { proxy_pass http://auth-server:8888/oauth2/login/google; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 Auth0 callback endpoint location /oauth2/callback/auth0 { proxy_pass http://auth-server:8888/oauth2/callback/auth0; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Auth0 login endpoint location /oauth2/login/auth0 { proxy_pass http://auth-server:8888/oauth2/login/auth0; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # Anthropic MCP Registry API - Public REST API with JWT authentication location {{ROOT_PATH}}/{{ANTHROPIC_API_VERSION}}/ { # Authenticate request via auth server (validates JWT Bearer tokens) auth_request /validate; # Capture auth server response headers auth_request_set $auth_user $upstream_http_x_user; auth_request_set $auth_username $upstream_http_x_username; auth_request_set $auth_client_id $upstream_http_x_client_id; auth_request_set $auth_scopes $upstream_http_x_scopes; auth_request_set $auth_method $upstream_http_x_auth_method; auth_request_set $auth_groups $upstream_http_x_groups; # Proxy to registry service proxy_pass http://127.0.0.1:7860/{{ANTHROPIC_API_VERSION}}/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Forward validated auth context to FastAPI proxy_set_header X-User $auth_user; proxy_set_header X-Username $auth_username; proxy_set_header X-Client-Id $auth_client_id; proxy_set_header X-Scopes $auth_scopes; proxy_set_header X-Auth-Method $auth_method; proxy_set_header X-Groups $auth_groups; # Pass through original Authorization header proxy_set_header Authorization $http_authorization; # Pass all request headers proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; # Buffering proxy_buffering on; proxy_buffer_size 4k; proxy_buffers 8 4k; # Handle auth errors error_page 401 = @auth_error; error_page 403 = @forbidden_error; # CORS headers (for browser clients) add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-User-Pool-Id, X-Client-Id, X-Region, X-Authorization' always; add_header 'Access-Control-Allow-Credentials' 'true' always; # Handle preflight OPTIONS requests if ($request_method = OPTIONS) { add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Length' 0; return 204; } } # A2A Agent API - Internal API with JWT authentication # Public API endpoints (no authentication required) # /api/auth/me requires authentication (exact match takes precedence over prefix) location = {{ROOT_PATH}}/api/auth/me { # Authenticate request via auth server (validates JWT Bearer tokens) auth_request /validate; # Capture auth server response headers auth_request_set $auth_user $upstream_http_x_user; auth_request_set $auth_username $upstream_http_x_username; auth_request_set $auth_client_id $upstream_http_x_client_id; auth_request_set $auth_scopes $upstream_http_x_scopes; auth_request_set $auth_method $upstream_http_x_auth_method; auth_request_set $auth_groups $upstream_http_x_groups; # Proxy to FastAPI service proxy_pass http://127.0.0.1:7860/api/auth/me; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Forward validated auth context to FastAPI proxy_set_header X-User $auth_user; proxy_set_header X-Username $auth_username; proxy_set_header X-Client-Id $auth_client_id; proxy_set_header X-Scopes $auth_scopes; proxy_set_header X-Auth-Method $auth_method; proxy_set_header X-Groups $auth_groups; # Pass through original Authorization header proxy_set_header Authorization $http_authorization; # Pass all request headers proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; # Handle auth errors error_page 401 = @auth_error; error_page 403 = @forbidden_error; } # Public auth endpoints - no authentication required (priority prefix match) location ^~ {{ROOT_PATH}}/api/auth { proxy_pass http://127.0.0.1:7860; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # Public health endpoint - no authentication required (priority prefix match) location ^~ {{ROOT_PATH}}/api/health { proxy_pass http://127.0.0.1:7860; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # Public version endpoint - no authentication required (priority prefix match) location ^~ {{ROOT_PATH}}/api/version { proxy_pass http://127.0.0.1:7860; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } location {{ROOT_PATH}}/api/ { # Authenticate request via auth server (validates JWT Bearer tokens) auth_request /validate; # Capture auth server response headers auth_request_set $auth_user $upstream_http_x_user; auth_request_set $auth_username $upstream_http_x_username; auth_request_set $auth_client_id $upstream_http_x_client_id; auth_request_set $auth_scopes $upstream_http_x_scopes; auth_request_set $auth_method $upstream_http_x_auth_method; auth_request_set $auth_groups $upstream_http_x_groups; # Proxy to FastAPI service proxy_pass http://127.0.0.1:7860/api/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Forward validated auth context to FastAPI proxy_set_header X-User $auth_user; proxy_set_header X-Username $auth_username; proxy_set_header X-Client-Id $auth_client_id; proxy_set_header X-Scopes $auth_scopes; proxy_set_header X-Auth-Method $auth_method; proxy_set_header X-Groups $auth_groups; # Pass through original Authorization header proxy_set_header Authorization $http_authorization; # Pass all request headers proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; # Buffering proxy_buffering on; proxy_buffer_size 4k; proxy_buffers 8 4k; # Handle auth errors error_page 401 = @auth_error; error_page 403 = @forbidden_error; } # {{KEYCLOAK_LOCATIONS_START}} # Keycloak proxy location /keycloak/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Keycloak specific headers proxy_set_header X-Forwarded-Port $real_port; proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; } # Keycloak realms proxy (for authentication endpoints) location /realms/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/realms/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Keycloak specific headers proxy_set_header X-Forwarded-Port $real_port; proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; } # Keycloak resources proxy (for CSS, JS, images) location /resources/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/resources/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Static resource caching expires 1h; add_header Cache-Control "public, immutable"; } # OAuth2 Keycloak callback endpoint location /oauth2/callback/keycloak { proxy_pass http://auth-server:8888/oauth2/callback/keycloak; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Keycloak login endpoint location /oauth2/login/keycloak { proxy_pass http://auth-server:8888/oauth2/login/keycloak; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # {{KEYCLOAK_LOCATIONS_END}} # Error handlers for authentication failures location @auth_error { return 401 '{"error": "Authentication required"}'; add_header Content-Type application/json; add_header Connection close; } location @forbidden_error { default_type application/json; return 403 '{"error": "Access forbidden"}'; add_header Content-Type application/json always; add_header Connection close always; } error_log /var/log/nginx/error.log debug; } # Keep the HTTPS server for clients that prefer it server { listen 8443 ssl; # {{ADDITIONAL_SERVER_NAMES}} is replaced with custom domains/IPs for gateway access server_name localhost {{ADDITIONAL_SERVER_NAMES}}; # SSL Configuration - requires user-provided certificates # Mount certificates to /etc/ssl/certs/fullchain.pem and /etc/ssl/private/privkey.pem ssl_certificate /etc/ssl/certs/fullchain.pem; ssl_certificate_key /etc/ssl/private/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; # Stronger cipher suite ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; # Custom error page for 502 Bad Gateway (shown during backend startup) error_page 502 /502.html; location = /502.html { root /usr/share/nginx/html; internal; } # Add this to trigger the named location for 403 errors error_page 403 = @forbidden_error; # Auth validation endpoint - passes entire request to auth server location = /validate { internal; proxy_pass http://auth-server:8888/validate; # Pass original request info proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URL $scheme://$host$request_uri; # Extract and pass Cognito config headers from original request proxy_set_header X-User-Pool-Id $http_x_user_pool_id; proxy_set_header X-Client-Id $http_x_client_id; proxy_set_header X-Region $http_x_region; proxy_set_header X-Authorization $http_x_authorization; # Pass all original headers (including Authorization and X-Body from Lua) proxy_pass_request_headers on; # Short timeouts for auth validation proxy_connect_timeout 10s; proxy_read_timeout 10s; proxy_send_timeout 10s; } # OAuth2 Cognito callback endpoint location /oauth2/callback/cognito { proxy_pass http://auth-server:8888/oauth2/callback/cognito; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Cognito login endpoint location /oauth2/login/cognito { proxy_pass http://auth-server:8888/oauth2/login/cognito; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 Cognito logout endpoint location /oauth2/logout/ { proxy_pass http://auth-server:8888/oauth2/logout/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; proxy_pass_request_headers on; } # OAuth2 Entra ID callback endpoint (HTTPS) location /oauth2/callback/entra { proxy_pass http://auth-server:8888/oauth2/callback/entra; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through cookies, query parameters, and headers proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Entra ID login endpoint (HTTPS) location /oauth2/login/entra { proxy_pass http://auth-server:8888/oauth2/login/entra; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 Okta callback endpoint (HTTPS) location /oauth2/callback/okta { proxy_pass http://auth-server:8888/oauth2/callback/okta; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through cookies, query parameters, and headers proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Okta login endpoint (HTTPS) location /oauth2/login/okta { proxy_pass http://auth-server:8888/oauth2/login/okta; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 GitHub callback endpoint (HTTPS) location /oauth2/callback/github { proxy_pass http://auth-server:8888/oauth2/callback/github; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 GitHub login endpoint (HTTPS) location /oauth2/login/github { proxy_pass http://auth-server:8888/oauth2/login/github; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 Google callback endpoint (HTTPS) location /oauth2/callback/google { proxy_pass http://auth-server:8888/oauth2/callback/google; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Google login endpoint (HTTPS) location /oauth2/login/google { proxy_pass http://auth-server:8888/oauth2/login/google; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 Auth0 callback endpoint (HTTPS) location /oauth2/callback/auth0 { proxy_pass http://auth-server:8888/oauth2/callback/auth0; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Auth0 login endpoint (HTTPS) location /oauth2/login/auth0 { proxy_pass http://auth-server:8888/oauth2/login/auth0; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # {{KEYCLOAK_LOCATIONS_START}} # Keycloak proxy (HTTPS) location /keycloak/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Keycloak specific headers proxy_set_header X-Forwarded-Port $real_port; proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; } # Keycloak realms proxy (HTTPS - for authentication endpoints) location /realms/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/realms/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Keycloak specific headers proxy_set_header X-Forwarded-Port $real_port; proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; } # Keycloak resources proxy (HTTPS - for CSS, JS, images) location /resources/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/resources/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Static resource caching expires 1h; add_header Cache-Control "public, immutable"; } # OAuth2 Keycloak callback endpoint (HTTPS) location /oauth2/callback/keycloak { proxy_pass http://auth-server:8888/oauth2/callback/keycloak; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Keycloak login endpoint (HTTPS) location /oauth2/login/keycloak { proxy_pass http://auth-server:8888/oauth2/login/keycloak; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Pass through query parameters and headers proxy_pass_request_headers on; } # {{KEYCLOAK_LOCATIONS_END}} # Anthropic MCP Registry API {{ANTHROPIC_API_VERSION}} - Public REST API with JWT authentication (HTTPS) location {{ROOT_PATH}}/{{ANTHROPIC_API_VERSION}}/ { # Authenticate request via auth server (validates JWT Bearer tokens) auth_request /validate; # Capture auth server response headers auth_request_set $auth_user $upstream_http_x_user; auth_request_set $auth_username $upstream_http_x_username; auth_request_set $auth_client_id $upstream_http_x_client_id; auth_request_set $auth_scopes $upstream_http_x_scopes; auth_request_set $auth_method $upstream_http_x_auth_method; auth_request_set $auth_groups $upstream_http_x_groups; # Proxy to registry service proxy_pass http://127.0.0.1:7860/{{ANTHROPIC_API_VERSION}}/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Forward validated auth context to FastAPI proxy_set_header X-User $auth_user; proxy_set_header X-Username $auth_username; proxy_set_header X-Client-Id $auth_client_id; proxy_set_header X-Scopes $auth_scopes; proxy_set_header X-Auth-Method $auth_method; proxy_set_header X-Groups $auth_groups; # Pass through original Authorization header proxy_set_header Authorization $http_authorization; # Pass all request headers proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; # Buffering proxy_buffering on; proxy_buffer_size 4k; proxy_buffers 8 4k; # Handle auth errors error_page 401 = @auth_error; error_page 403 = @forbidden_error; # CORS headers (for browser clients) add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-User-Pool-Id, X-Client-Id, X-Region, X-Authorization' always; add_header 'Access-Control-Allow-Credentials' 'true' always; # Handle preflight OPTIONS requests if ($request_method = OPTIONS) { add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Length' 0; return 204; } } # A2A Agent API - Internal API with JWT authentication (HTTPS) # Public API endpoints (no authentication required) # /api/auth/me requires authentication (exact match takes precedence over prefix) location = {{ROOT_PATH}}/api/auth/me { # Authenticate request via auth server (validates JWT Bearer tokens) auth_request /validate; # Capture auth server response headers auth_request_set $auth_user $upstream_http_x_user; auth_request_set $auth_username $upstream_http_x_username; auth_request_set $auth_client_id $upstream_http_x_client_id; auth_request_set $auth_scopes $upstream_http_x_scopes; auth_request_set $auth_method $upstream_http_x_auth_method; auth_request_set $auth_groups $upstream_http_x_groups; # Proxy to FastAPI service proxy_pass http://127.0.0.1:7860/api/auth/me; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Forward validated auth context to FastAPI proxy_set_header X-User $auth_user; proxy_set_header X-Username $auth_username; proxy_set_header X-Client-Id $auth_client_id; proxy_set_header X-Scopes $auth_scopes; proxy_set_header X-Auth-Method $auth_method; proxy_set_header X-Groups $auth_groups; # Pass through original Authorization header proxy_set_header Authorization $http_authorization; # Pass all request headers proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; # Handle auth errors error_page 401 = @auth_error; error_page 403 = @forbidden_error; } # Public auth endpoints - no authentication required (priority prefix match) location ^~ {{ROOT_PATH}}/api/auth { proxy_pass http://127.0.0.1:7860; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # Public health endpoint - no authentication required (priority prefix match) location ^~ {{ROOT_PATH}}/api/health { proxy_pass http://127.0.0.1:7860; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # Public version endpoint - no authentication required (priority prefix match) location ^~ {{ROOT_PATH}}/api/version { proxy_pass http://127.0.0.1:7860; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } location {{ROOT_PATH}}/api/ { # Authenticate request via auth server (validates JWT Bearer tokens) auth_request /validate; # Capture auth server response headers auth_request_set $auth_user $upstream_http_x_user; auth_request_set $auth_username $upstream_http_x_username; auth_request_set $auth_client_id $upstream_http_x_client_id; auth_request_set $auth_scopes $upstream_http_x_scopes; auth_request_set $auth_method $upstream_http_x_auth_method; auth_request_set $auth_groups $upstream_http_x_groups; # Proxy to FastAPI service proxy_pass http://127.0.0.1:7860/api/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; # Forward validated auth context to FastAPI proxy_set_header X-User $auth_user; proxy_set_header X-Username $auth_username; proxy_set_header X-Client-Id $auth_client_id; proxy_set_header X-Scopes $auth_scopes; proxy_set_header X-Auth-Method $auth_method; proxy_set_header X-Groups $auth_groups; # Pass through original Authorization header proxy_set_header Authorization $http_authorization; # Pass all request headers proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; # Buffering proxy_buffering on; proxy_buffer_size 4k; proxy_buffers 8 4k; # Handle auth errors error_page 401 = @auth_error; error_page 403 = @forbidden_error; } # Registered MCP server locations (generated dynamically) {{LOCATION_BLOCKS}} {{REGISTRY_ONLY_BLOCK}} # Internal session management for virtual MCP server router location /_internal/sessions/ { internal; proxy_pass http://127.0.0.1:7860/api/internal/sessions/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Content-Type application/json; } # Virtual MCP server locations (generated dynamically) {{VIRTUAL_SERVER_BLOCKS}} # Serve static files directly from nginx (more efficient than proxying) location /static/ { alias /app/frontend/build/static/; expires 1y; add_header Cache-Control "public, immutable"; } location = /favicon.ico { alias /app/frontend/build/favicon.ico; expires 1y; add_header Cache-Control "public, immutable"; } # Catch-all: proxy to registry FastAPI location / { proxy_pass http://127.0.0.1:7860/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; } # Error handlers for authentication failures location @auth_error { return 401 '{"error": "Authentication required"}'; add_header Content-Type application/json; add_header Connection close; } location @forbidden_error { default_type application/json; return 403 '{"error": "Access forbidden"}'; add_header Content-Type application/json always; add_header Connection close always; } error_log /var/log/nginx/error.log debug; } ================================================ FILE: docker/nginx_rev_proxy_http_only.conf ================================================ # Nginx configuration directive for handling long server names server_names_hash_bucket_size 128; # Increase header buffer sizes for large OAuth tokens (Auth0, Entra ID) large_client_header_buffers 4 32k; proxy_buffer_size 16k; proxy_buffers 4 16k; # Variables hash configuration - needed for large number of auth_request_set variables # With multiple location blocks each setting 5+ variables, we need larger hash tables variables_hash_max_size 2048; variables_hash_bucket_size 128; # Lua shared dictionary for metrics collection (10MB) lua_shared_dict metrics_buffer 10m; # Lua shared dictionary for virtual server routing mappings (2MB) lua_shared_dict virtual_server_map 2m; # Background flush of metrics buffer to metrics-service init_worker_by_lua_file /etc/nginx/lua/flush_metrics.lua; # DNS resolver for ECS Cloud Map service discovery # Uses AWS VPC DNS resolver for dynamic service resolution # This enables runtime DNS resolution for upstream services resolver 169.254.169.253 valid=10s ipv6=off; # Map to preserve X-Forwarded-Proto from upstream (ALB/CloudFront) or use $scheme as fallback # This is critical for HTTPS detection when behind ALB/CloudFront map $http_x_forwarded_proto $forwarded_proto { default $http_x_forwarded_proto; "" $scheme; } # Map to determine the real client port # Uses X-Forwarded-Port from ALB/load balancer if present, otherwise maps internal # container ports (8080/8443) to their standard external equivalents (80/443) # This is critical for Keycloak URL generation when running behind a reverse proxy map $http_x_forwarded_port $real_port { default $http_x_forwarded_port; "" $standard_port; } # Map internal container listen ports to standard external ports map $server_port $standard_port { 8080 80; 8443 443; default $server_port; } {{VERSION_MAP}} # First server block now directly handles HTTP requests instead of redirecting server { listen 8080; # {{ADDITIONAL_SERVER_NAMES}} is replaced with custom domains/IPs for gateway access server_name localhost {{ADDITIONAL_SERVER_NAMES}}; # Custom error page for 502 Bad Gateway (shown during backend startup) error_page 502 /502.html; location = /502.html { root /usr/share/nginx/html; internal; } # Add this to trigger the named location for 403 errors error_page 403 = @forbidden_error; # /api/auth/me requires authentication (exact match takes precedence over prefix) location = {{ROOT_PATH}}/api/auth/me { # Authenticate request via auth server (validates JWT Bearer tokens) auth_request /validate; # Capture auth server response headers auth_request_set $auth_user $upstream_http_x_user; auth_request_set $auth_username $upstream_http_x_username; auth_request_set $auth_client_id $upstream_http_x_client_id; auth_request_set $auth_scopes $upstream_http_x_scopes; auth_request_set $auth_method $upstream_http_x_auth_method; auth_request_set $auth_groups $upstream_http_x_groups; # Proxy to FastAPI service proxy_pass http://127.0.0.1:7860/api/auth/me; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Forward validated auth context to FastAPI proxy_set_header X-User $auth_user; proxy_set_header X-Username $auth_username; proxy_set_header X-Client-Id $auth_client_id; proxy_set_header X-Scopes $auth_scopes; proxy_set_header X-Auth-Method $auth_method; proxy_set_header X-Groups $auth_groups; # Pass through original Authorization header proxy_set_header Authorization $http_authorization; # Pass all request headers proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; # Handle auth errors error_page 401 = @auth_error; error_page 403 = @forbidden_error; } # Public auth endpoints - no authentication required (priority prefix match) location ^~ {{ROOT_PATH}}/api/auth { proxy_pass http://127.0.0.1:7860; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; proxy_pass_request_headers on; proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # Public health endpoint - no authentication required (priority prefix match) location ^~ {{ROOT_PATH}}/api/health { proxy_pass http://127.0.0.1:7860; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; proxy_pass_request_headers on; proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # Public version endpoint - no authentication required (priority prefix match) location ^~ {{ROOT_PATH}}/api/version { proxy_pass http://127.0.0.1:7860; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; proxy_pass_request_headers on; proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # Protected API endpoints - require authentication location {{ROOT_PATH}}/api/ { # Authenticate request via auth server (validates JWT Bearer tokens) auth_request /validate; # Capture auth server response headers auth_request_set $auth_user $upstream_http_x_user; auth_request_set $auth_username $upstream_http_x_username; auth_request_set $auth_client_id $upstream_http_x_client_id; auth_request_set $auth_scopes $upstream_http_x_scopes; auth_request_set $auth_method $upstream_http_x_auth_method; auth_request_set $auth_groups $upstream_http_x_groups; # Proxy to FastAPI service proxy_pass http://127.0.0.1:7860/api/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Forward validated auth context to FastAPI proxy_set_header X-User $auth_user; proxy_set_header X-Username $auth_username; proxy_set_header X-Client-Id $auth_client_id; proxy_set_header X-Scopes $auth_scopes; proxy_set_header X-Auth-Method $auth_method; proxy_set_header X-Groups $auth_groups; # Pass through original Authorization header proxy_set_header Authorization $http_authorization; # Pass all request headers proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; } # Registered MCP server locations (generated dynamically) {{LOCATION_BLOCKS}} {{REGISTRY_ONLY_BLOCK}} # Internal session management for virtual MCP server router location /_internal/sessions/ { internal; proxy_pass http://127.0.0.1:7860/api/internal/sessions/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header Content-Type application/json; } # Virtual MCP server locations (generated dynamically) {{VIRTUAL_SERVER_BLOCKS}} # Serve static files directly from nginx (more efficient than proxying) location /static/ { alias /app/frontend/build/static/; expires 1y; add_header Cache-Control "public, immutable"; } location = /favicon.ico { alias /app/frontend/build/favicon.ico; expires 1y; add_header Cache-Control "public, immutable"; } # Catch-all: proxy to registry FastAPI location / { proxy_pass http://127.0.0.1:7860/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; } # Auth validation endpoint - passes entire request to auth server location = /validate { internal; proxy_pass http://auth-server:8888/validate; # Pass original request info proxy_set_header X-Original-URI $request_uri; proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URL $scheme://$host$request_uri; # Extract and pass Cognito config headers from original request proxy_set_header X-User-Pool-Id $http_x_user_pool_id; proxy_set_header X-Client-Id $http_x_client_id; proxy_set_header X-Region $http_x_region; # Forward Authorization header as X-Authorization (auth-server expects this) proxy_set_header X-Authorization $http_x_authorization; # Pass all original headers (including Authorization and X-Body from Lua) proxy_pass_request_headers on; # Short timeouts for auth validation proxy_connect_timeout 10s; proxy_read_timeout 10s; proxy_send_timeout 10s; } # OAuth2 Cognito callback endpoint location /oauth2/callback/cognito { proxy_pass http://auth-server:8888/oauth2/callback/cognito; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Cognito login endpoint location /oauth2/login/cognito { proxy_pass http://auth-server:8888/oauth2/login/cognito; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 Cognito logout endpoint location /oauth2/logout/ { proxy_pass http://auth-server:8888/oauth2/logout/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; proxy_pass_request_headers on; } # OAuth2 Entra ID callback endpoint location /oauth2/callback/entra { proxy_pass http://auth-server:8888/oauth2/callback/entra; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through cookies, query parameters, and headers proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Entra ID login endpoint location /oauth2/login/entra { proxy_pass http://auth-server:8888/oauth2/login/entra; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 Okta callback endpoint location /oauth2/callback/okta { proxy_pass http://auth-server:8888/oauth2/callback/okta; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through cookies, query parameters, and headers proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Okta login endpoint location /oauth2/login/okta { proxy_pass http://auth-server:8888/oauth2/login/okta; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 GitHub callback endpoint location /oauth2/callback/github { proxy_pass http://auth-server:8888/oauth2/callback/github; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 GitHub login endpoint location /oauth2/login/github { proxy_pass http://auth-server:8888/oauth2/login/github; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 Google callback endpoint location /oauth2/callback/google { proxy_pass http://auth-server:8888/oauth2/callback/google; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Google login endpoint location /oauth2/login/google { proxy_pass http://auth-server:8888/oauth2/login/google; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through query parameters and headers proxy_pass_request_headers on; } # OAuth2 Auth0 callback endpoint location /oauth2/callback/auth0 { proxy_pass http://auth-server:8888/oauth2/callback/auth0; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Auth0 login endpoint location /oauth2/login/auth0 { proxy_pass http://auth-server:8888/oauth2/login/auth0; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through query parameters and headers proxy_pass_request_headers on; } # Anthropic MCP Registry API - Public REST API with JWT authentication location {{ROOT_PATH}}/{{ANTHROPIC_API_VERSION}}/ { # Authenticate request via auth server (validates JWT Bearer tokens) auth_request /validate; # Capture auth server response headers auth_request_set $auth_user $upstream_http_x_user; auth_request_set $auth_username $upstream_http_x_username; auth_request_set $auth_client_id $upstream_http_x_client_id; auth_request_set $auth_scopes $upstream_http_x_scopes; auth_request_set $auth_method $upstream_http_x_auth_method; # Proxy to registry service proxy_pass http://127.0.0.1:7860/{{ANTHROPIC_API_VERSION}}/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Forward validated auth context to FastAPI proxy_set_header X-User $auth_user; proxy_set_header X-Username $auth_username; proxy_set_header X-Client-Id $auth_client_id; proxy_set_header X-Scopes $auth_scopes; proxy_set_header X-Auth-Method $auth_method; proxy_set_header X-Groups $auth_groups; # Pass through original Authorization header proxy_set_header Authorization $http_authorization; # Pass all request headers proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; # Buffering proxy_buffering on; proxy_buffer_size 4k; proxy_buffers 8 4k; # Handle auth errors error_page 401 = @auth_error; error_page 403 = @forbidden_error; # CORS headers (for browser clients) add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-User-Pool-Id, X-Client-Id, X-Region, X-Authorization' always; add_header 'Access-Control-Allow-Credentials' 'true' always; # Handle preflight OPTIONS requests if ($request_method = OPTIONS) { add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Length' 0; return 204; } } # A2A Agent API - Public REST API with JWT authentication # This mirrors the Anthropic API pattern for agent discovery and management location {{ROOT_PATH}}/v0.1/agents/ { # Authenticate request via auth server (validates JWT Bearer tokens) auth_request /validate; # Capture auth server response headers auth_request_set $auth_user $upstream_http_x_user; auth_request_set $auth_username $upstream_http_x_username; auth_request_set $auth_client_id $upstream_http_x_client_id; auth_request_set $auth_scopes $upstream_http_x_scopes; auth_request_set $auth_method $upstream_http_x_auth_method; # Proxy to registry service (FastAPI backend) proxy_pass http://127.0.0.1:7860/v0.1/agents/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Forward validated auth context to FastAPI proxy_set_header X-User $auth_user; proxy_set_header X-Username $auth_username; proxy_set_header X-Client-Id $auth_client_id; proxy_set_header X-Scopes $auth_scopes; proxy_set_header X-Auth-Method $auth_method; proxy_set_header X-Groups $auth_groups; # Pass through original Authorization header proxy_set_header Authorization $http_authorization; # Pass all request headers proxy_pass_request_headers on; # Timeouts proxy_connect_timeout 10s; proxy_send_timeout 30s; proxy_read_timeout 30s; # Buffering proxy_buffering on; proxy_buffer_size 4k; proxy_buffers 8 4k; # Handle auth errors error_page 401 = @auth_error; error_page 403 = @forbidden_error; # CORS headers (for browser clients) add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-User-Pool-Id, X-Client-Id, X-Region, X-Authorization' always; add_header 'Access-Control-Allow-Credentials' 'true' always; # Handle preflight OPTIONS requests if ($request_method = OPTIONS) { add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Length' 0; return 204; } } # {{KEYCLOAK_LOCATIONS_START}} # Keycloak proxy location /keycloak/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Keycloak specific headers proxy_set_header X-Forwarded-Port $real_port; proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; } # Keycloak realms proxy (for authentication endpoints) location /realms/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/realms/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Keycloak specific headers proxy_set_header X-Forwarded-Port $real_port; proxy_buffer_size 128k; proxy_buffers 4 256k; proxy_busy_buffers_size 256k; } # Keycloak resources proxy (for CSS, JS, images) location /resources/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/resources/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Static resource caching expires 1h; add_header Cache-Control "public, immutable"; } # OAuth2 Keycloak callback endpoint location /oauth2/callback/keycloak { proxy_pass http://auth-server:8888/oauth2/callback/keycloak; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through all headers for OAuth2 flow proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Keycloak login endpoint location /oauth2/login/keycloak { proxy_pass http://auth-server:8888/oauth2/login/keycloak; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $forwarded_proto; # Pass through query parameters and headers proxy_pass_request_headers on; } # {{KEYCLOAK_LOCATIONS_END}} # Error handlers for authentication failures location @auth_error { return 401 '{"error": "Authentication required"}'; add_header Content-Type application/json; add_header Connection close; } location @forbidden_error { default_type application/json; return 403 '{"error": "Access forbidden"}'; add_header Content-Type application/json always; add_header Connection close always; } error_log /var/log/nginx/error.log debug; } ================================================ FILE: docker/registry-entrypoint.sh ================================================ #!/bin/bash set -e # Exit immediately if a command exits with a non-zero status. echo "Starting Registry Service Setup..." # --- DocumentDB CA Bundle Download (needed for both init mode and normal mode) --- if [[ "${DOCUMENTDB_HOST}" == *"docdb-elastic.amazonaws.com"* ]]; then echo "Detected DocumentDB Elastic cluster" echo "Downloading DocumentDB Elastic CA bundle..." CA_BUNDLE_URL="https://www.amazontrust.com/repository/SFSRootCAG2.pem" CA_BUNDLE_PATH="/app/certs/global-bundle.pem" if [ ! -f "$CA_BUNDLE_PATH" ]; then curl -fsSL "$CA_BUNDLE_URL" -o "$CA_BUNDLE_PATH" echo "DocumentDB Elastic CA bundle (SFSRootCAG2.pem) downloaded successfully to $CA_BUNDLE_PATH" fi elif [[ "${DOCUMENTDB_HOST}" == *"docdb.amazonaws.com"* ]]; then echo "Detected regular DocumentDB cluster" echo "Downloading regular DocumentDB CA bundle..." CA_BUNDLE_URL="https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem" CA_BUNDLE_PATH="/app/certs/global-bundle.pem" if [ ! -f "$CA_BUNDLE_PATH" ]; then curl -fsSL "$CA_BUNDLE_URL" -o "$CA_BUNDLE_PATH" echo "DocumentDB CA bundle (global-bundle.pem) downloaded successfully to $CA_BUNDLE_PATH" fi fi # Check if we're in init mode (for running DocumentDB initialization scripts) if [ "$RUN_INIT_SCRIPTS" = "true" ]; then echo "Running in init mode - executing initialization scripts..." exec "$@" fi # --- Wait for MongoDB Replica Set --- if [ -n "$DOCUMENTDB_HOST" ]; then echo "Waiting for MongoDB replica set at ${DOCUMENTDB_HOST}:${DOCUMENTDB_PORT:-27017}..." source /app/.venv/bin/activate python3 -c " import pymongo, os, time, sys host = os.getenv('DOCUMENTDB_HOST', 'mongodb') port = int(os.getenv('DOCUMENTDB_PORT', '27017')) user = os.getenv('DOCUMENTDB_USERNAME', '') pwd = os.getenv('DOCUMENTDB_PASSWORD', '') backend = os.getenv('STORAGE_BACKEND', 'mongodb-ce') use_tls = os.getenv('DOCUMENTDB_USE_TLS', 'true').lower() == 'true' ca_file = os.getenv('DOCUMENTDB_TLS_CA_FILE', '/app/certs/global-bundle.pem') auth = 'SCRAM-SHA-256' if backend == 'mongodb-ce' else 'SCRAM-SHA-1' if user and pwd: uri = f'mongodb://{user}:{pwd}@{host}:{port}/?authMechanism={auth}&authSource=admin' else: uri = f'mongodb://{host}:{port}/' # Prepare TLS options tls_options = {} if use_tls: tls_options['tls'] = True tls_options['tlsCAFile'] = ca_file while True: try: c = pymongo.MongoClient(uri, serverSelectionTimeoutMS=5000, connectTimeoutMS=5000, **tls_options) c.admin.command('ping') try: st = c.admin.command('replSetGetStatus') ready = [m for m in st['members'] if m['state'] in [1, 2]] total = len(st['members']) if st['ok'] == 1 and len(ready) == total: print(f'MongoDB replica set ready ({len(ready)}/{total} members)') c.close() break print(f'Waiting for replica set: {len(ready)}/{total} ready') except pymongo.errors.OperationFailure: # Standalone mode (no replica set) - ping succeeded so we're good print('MongoDB is ready (standalone mode)') c.close() break except Exception as e: print(f'MongoDB not ready yet: {e}') time.sleep(5) " deactivate echo "MongoDB is ready." fi # --- Environment Variable Setup --- echo "Setting up environment variables..." # Get deployment mode (default: with-gateway) DEPLOYMENT_MODE="${DEPLOYMENT_MODE:-with-gateway}" REGISTRY_MODE="${REGISTRY_MODE:-full}" echo "============================================================" echo "Starting MCP Gateway Registry" echo " DEPLOYMENT_MODE: ${DEPLOYMENT_MODE}" echo " REGISTRY_MODE: ${REGISTRY_MODE}" if [ "$DEPLOYMENT_MODE" = "registry-only" ]; then echo " Note: Dynamic MCP server location blocks will NOT be generated" fi echo "============================================================" # Generate secret key if not provided if [ -z "$SECRET_KEY" ]; then SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(32))') fi # Create .env file for registry REGISTRY_ENV_FILE="/app/registry/.env" echo "Creating Registry .env file..." echo "SECRET_KEY=${SECRET_KEY}" > "$REGISTRY_ENV_FILE" echo "Registry .env created." # DocumentDB CA Bundle already downloaded at the beginning of this script # --- SSL Certificate Check --- # These paths match REGISTRY_CONSTANTS.SSL_CERT_PATH and SSL_KEY_PATH in registry/constants.py SSL_CERT_PATH="/etc/ssl/certs/fullchain.pem" SSL_KEY_PATH="/etc/ssl/private/privkey.pem" echo "Checking for SSL certificates..." if [ ! -f "$SSL_CERT_PATH" ] || [ ! -f "$SSL_KEY_PATH" ]; then echo "==========================================" echo "SSL certificates not found - HTTPS will not be available" echo "==========================================" echo "" echo "To enable HTTPS, mount your certificates to:" echo " - $SSL_CERT_PATH" echo " - $SSL_KEY_PATH" echo "" echo "Example for docker-compose.yml:" echo " volumes:" echo " - /path/to/fullchain.pem:/etc/ssl/certs/fullchain.pem:ro" echo " - /path/to/privkey.pem:/etc/ssl/private/privkey.pem:ro" echo "" echo "HTTP server will be available on port 80" echo "==========================================" else echo "==========================================" echo "SSL certificates found - HTTPS enabled" echo "==========================================" echo "Certificate: $SSL_CERT_PATH" echo "Private key: $SSL_KEY_PATH" echo "HTTPS server will be available on port 443" echo "==========================================" fi # --- Lua Module Setup --- echo "Setting up Lua support for nginx..." LUA_SCRIPTS_DIR="/etc/nginx/lua" mkdir -p "$LUA_SCRIPTS_DIR" mkdir -p "$LUA_SCRIPTS_DIR/virtual_mappings" # Copy Lua scripts from the docker/lua directory (standalone files, not heredocs) LUA_SOURCE_DIR="/app/docker/lua" cp "$LUA_SOURCE_DIR/capture_body.lua" "$LUA_SCRIPTS_DIR/capture_body.lua" cp "$LUA_SOURCE_DIR/virtual_router.lua" "$LUA_SCRIPTS_DIR/virtual_router.lua" cp "$LUA_SOURCE_DIR/emit_metrics.lua" "$LUA_SCRIPTS_DIR/emit_metrics.lua" cp "$LUA_SOURCE_DIR/flush_metrics.lua" "$LUA_SCRIPTS_DIR/flush_metrics.lua" echo "Lua scripts copied from $LUA_SOURCE_DIR to $LUA_SCRIPTS_DIR." # --- Nginx Configuration --- echo "Preparing Nginx configuration..." # Pass environment variables through to Lua workers (nginx strips them by default) for envvar in METRICS_API_KEY METRICS_SERVICE_URL; do grep -q "^env ${envvar};" /etc/nginx/nginx.conf 2>/dev/null || \ sed -i "1i env ${envvar};" /etc/nginx/nginx.conf done # Raise main-context error_log to 'warn' so Lua init_worker/timer messages # (e.g. flush_metrics.lua startup confirmation and connection errors) are visible. # The default nginx.conf ships with 'error' level which suppresses WARN/INFO. sed -i 's|error_log /var/log/nginx/error.log;|error_log /var/log/nginx/error.log warn;|' /etc/nginx/nginx.conf # Remove default nginx site to prevent conflicts with our config echo "Removing default nginx site configuration..." rm -f /etc/nginx/sites-enabled/default rm -f /etc/nginx/sites-available/default # Template paths matching REGISTRY_CONSTANTS in registry/constants.py NGINX_TEMPLATE_HTTP_ONLY="/app/docker/nginx_rev_proxy_http_only.conf" NGINX_TEMPLATE_HTTP_AND_HTTPS="/app/docker/nginx_rev_proxy_http_and_https.conf" NGINX_CONFIG_PATH="/etc/nginx/conf.d/nginx_rev_proxy.conf" # Check if SSL certificates exist and use appropriate config if [ ! -f "$SSL_CERT_PATH" ] || [ ! -f "$SSL_KEY_PATH" ]; then echo "Using HTTP-only Nginx configuration (no SSL certificates)..." cp "$NGINX_TEMPLATE_HTTP_ONLY" "$NGINX_CONFIG_PATH" echo "HTTP-only Nginx configuration installed." else echo "Using HTTP + HTTPS Nginx configuration (SSL certificates found)..." cp "$NGINX_TEMPLATE_HTTP_AND_HTTPS" "$NGINX_CONFIG_PATH" echo "HTTP + HTTPS Nginx configuration installed." fi # --- Embeddings Configuration --- # Get embeddings configuration from environment or use defaults EMBEDDINGS_PROVIDER="${EMBEDDINGS_PROVIDER:-sentence-transformers}" EMBEDDINGS_MODEL_NAME="${EMBEDDINGS_MODEL_NAME:-all-MiniLM-L6-v2}" EMBEDDINGS_MODEL_DIMENSIONS="${EMBEDDINGS_MODEL_DIMENSIONS:-384}" echo "Embeddings Configuration:" echo " Provider: $EMBEDDINGS_PROVIDER" echo " Model: $EMBEDDINGS_MODEL_NAME" echo " Dimensions: $EMBEDDINGS_MODEL_DIMENSIONS" # Only check for local model if using sentence-transformers if [ "$EMBEDDINGS_PROVIDER" = "sentence-transformers" ]; then EMBEDDINGS_MODEL_DIR="/app/registry/models/$EMBEDDINGS_MODEL_NAME" echo "Checking for sentence-transformers model..." if [ ! -d "$EMBEDDINGS_MODEL_DIR" ] || [ -z "$(ls -A "$EMBEDDINGS_MODEL_DIR")" ]; then echo "==========================================" echo "WARNING: Embeddings model not found!" echo "==========================================" echo "" echo "The registry requires the sentence-transformers model to function properly." echo "Please download the model to: $EMBEDDINGS_MODEL_DIR" echo "" echo "Run this command to download the model:" echo " docker run --rm -v \$(pwd)/models:/models huggingface/transformers-pytorch-cpu python -c \"from sentence_transformers import SentenceTransformer; SentenceTransformer('sentence-transformers/$EMBEDDINGS_MODEL_NAME').save('/models/$EMBEDDINGS_MODEL_NAME')\"" echo "" echo "Or see the README for alternative download methods." echo "==========================================" else echo "Embeddings model found at $EMBEDDINGS_MODEL_DIR" fi elif [ "$EMBEDDINGS_PROVIDER" = "litellm" ]; then echo "Using LiteLLM provider - no local model download required" echo "Model: $EMBEDDINGS_MODEL_NAME" if [[ "$EMBEDDINGS_MODEL_NAME" == bedrock/* ]]; then echo "Bedrock model will use AWS credential chain for authentication" elif [ ! -z "$EMBEDDINGS_API_KEY" ]; then echo "API key configured for cloud embeddings" else echo "WARNING: No EMBEDDINGS_API_KEY set for cloud provider" fi fi # --- Environment Variable Substitution for MCP Server Auth Tokens --- echo "Processing MCP Server configuration files..." for i in $(seq 1 99); do env_var_name="MCP_SERVER${i}_AUTH_TOKEN" env_var_value=$(eval echo \$$env_var_name) if [ ! -z "$env_var_value" ]; then echo "Found $env_var_name, substituting in server JSON files..." # Replace the literal environment variable name with its value in all JSON files find /app/registry/servers -name "*.json" -type f -exec sed -i "s|$env_var_name|$env_var_value|g" {} \; fi done echo "MCP Server configuration processing completed." # --- Start Background Services --- # Export embeddings configuration for the registry service export EMBEDDINGS_PROVIDER=$EMBEDDINGS_PROVIDER export EMBEDDINGS_MODEL_NAME=$EMBEDDINGS_MODEL_NAME export EMBEDDINGS_MODEL_DIMENSIONS=$EMBEDDINGS_MODEL_DIMENSIONS echo "Starting MCP Registry in the background..." cd /app source /app/.venv/bin/activate uvicorn registry.main:app --host 0.0.0.0 --port 7860 --proxy-headers --forwarded-allow-ips='*' & echo "MCP Registry started." # Wait for nginx config to be generated (check that placeholders are replaced) echo "Waiting for nginx configuration to be generated..." WAIT_TIME=0 MAX_WAIT=120 while [ $WAIT_TIME -lt $MAX_WAIT ]; do if [ -f "/etc/nginx/conf.d/nginx_rev_proxy.conf" ]; then # Check if placeholders have been replaced if ! grep -q "{{ADDITIONAL_SERVER_NAMES}}" "/etc/nginx/conf.d/nginx_rev_proxy.conf" && \ ! grep -q "{{ANTHROPIC_API_VERSION}}" "/etc/nginx/conf.d/nginx_rev_proxy.conf" && \ ! grep -q "{{LOCATION_BLOCKS}}" "/etc/nginx/conf.d/nginx_rev_proxy.conf" && \ ! grep -q "{{VIRTUAL_SERVER_BLOCKS}}" "/etc/nginx/conf.d/nginx_rev_proxy.conf"; then echo "Nginx configuration generated successfully" break fi fi sleep 2 WAIT_TIME=$((WAIT_TIME + 2)) done if [ $WAIT_TIME -ge $MAX_WAIT ]; then echo "WARNING: Timeout waiting for nginx configuration. Starting nginx anyway..." fi # Resolve METRICS_SERVICE_URL hostname to IPv4 before nginx starts. # Lua cosockets use the nginx resolver (VPC DNS 169.254.169.253), which cannot # resolve Service Connect names (only the Envoy sidecar can). By substituting # the hostname with its IPv4 Service Connect VIP (127.255.0.x) in the env var, # flush_metrics.lua connects directly to the IP, bypassing DNS entirely. if [ -n "$METRICS_SERVICE_URL" ]; then metrics_host=$(echo "$METRICS_SERVICE_URL" | sed 's|http://||;s|:.*||') if ! echo "$metrics_host" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then resolved=$(getent ahostsv4 "$metrics_host" 2>/dev/null | head -1 | awk '{print $1}') if [ -n "$resolved" ]; then export METRICS_SERVICE_URL=$(echo "$METRICS_SERVICE_URL" | sed "s|$metrics_host|$resolved|") echo "Resolved METRICS_SERVICE_URL: $metrics_host -> $resolved ($METRICS_SERVICE_URL)" else echo "WARNING: Could not resolve $metrics_host to IPv4 -- metrics flush may fail" fi fi fi # Add FQDN aliases for Service Connect entries in /etc/hosts. # Service Connect only registers short names (e.g., "auth-server"), but servers # may be registered with Cloud Map FQDNs (e.g., "auth-server.mcp-gateway.local"). # The Python health checker resolves proxy_pass_url hostnames via system DNS, # which only finds /etc/hosts entries. Adding FQDN aliases ensures both short # names and FQDNs resolve to the IPv4 Service Connect VIP. # Gated on SERVICE_CONNECT_NAMESPACE -- only set in ECS Terraform deployments. if [ -n "${SERVICE_CONNECT_NAMESPACE:-}" ]; then if [ -w /etc/hosts ]; then fqdn_count=0 grep '^127\.255\.0\.' /etc/hosts | while read -r ip name _rest; do echo "$ip ${name}.${SERVICE_CONNECT_NAMESPACE}" >> /etc/hosts fqdn_count=$((fqdn_count + 1)) done echo "Added FQDN aliases for Service Connect entries (namespace: ${SERVICE_CONNECT_NAMESPACE})" else echo "INFO: /etc/hosts not writable (ECS Fargate), FQDN aliases skipped" echo " Short names and IPs will still work via Service Connect" fi fi echo "Starting Nginx..." # Create /run/nginx directory for pid file (tmpfs mount overwrites Dockerfile creation) mkdir -p /run/nginx # Change pid file location to writable directory for non-root user sed -i 's|pid /run/nginx.pid;|pid /run/nginx/nginx.pid;|' /etc/nginx/nginx.conf nginx echo "Registry service fully started. Keeping container alive..." # Keep the container running indefinitely tail -f /dev/null ================================================ FILE: docker-compose.dhi.yml ================================================ # Docker Hardened Images (DHI) Override # # This file overrides the default docker-compose.yml to use Docker Hardened # Images from dhi.io for improved security posture. DHI images are hardened # versions of standard Docker Hub images with reduced attack surface. # # Prerequisites: # - Access to dhi.io registry # - Authenticated via: docker login dhi.io # # Usage: # docker compose -f docker-compose.yml -f docker-compose.dhi.yml up -d # # Note: The default docker-compose.yml uses standard public Docker Hub images # and works without any registry authentication. services: mongodb: image: dhi.io/mongodb:8-debian13-dev command: ["--replSet", "rs0", "--bind_ip", "127.0.0.1,mongodb"] prometheus: image: dhi.io/prometheus:3.9 user: "nobody" grafana: image: dhi.io/grafana:12 user: "472" # Note: Keycloak DHI image (dhi.io/keycloak:26) has a read-only filesystem # and only supports 'start --optimized'. It is not compatible with the # start-dev mode or dynamic KC_ environment variables used in this compose # setup. Use the standard quay.io/keycloak image for local development. keycloak-db: image: dhi.io/postgres:16-alpine3.22 ================================================ FILE: docker-compose.podman.yml ================================================ version: '3.8' services: # Registry service (includes nginx, SSL, FAISS, models) # PODMAN VERSION: Uses non-privileged ports for rootless operation registry: build: context: . dockerfile: docker/Dockerfile.registry args: BUILD_VERSION: ${BUILD_VERSION:-1.0.0} environment: # Deployment Mode Configuration - DEPLOYMENT_MODE=${DEPLOYMENT_MODE:-with-gateway} - REGISTRY_MODE=${REGISTRY_MODE:-full} # Tab visibility overrides (AND-ed with REGISTRY_MODE) - SHOW_SERVERS_TAB=${SHOW_SERVERS_TAB:-true} - SHOW_VIRTUAL_SERVERS_TAB=${SHOW_VIRTUAL_SERVERS_TAB:-true} - SHOW_SKILLS_TAB=${SHOW_SKILLS_TAB:-true} - SHOW_AGENTS_TAB=${SHOW_AGENTS_TAB:-true} - GATEWAY_ADDITIONAL_SERVER_NAMES=${GATEWAY_ADDITIONAL_SERVER_NAMES:-} # Registry Card Configuration - REGISTRY_URL=${REGISTRY_URL:-http://localhost} - REGISTRY_NAME=${REGISTRY_NAME:-AI Registry} - REGISTRY_ORGANIZATION_NAME=${REGISTRY_ORGANIZATION_NAME:-ACME Inc.} - REGISTRY_DESCRIPTION=${REGISTRY_DESCRIPTION:-} - REGISTRY_CONTACT_EMAIL=${REGISTRY_CONTACT_EMAIL:-} - REGISTRY_CONTACT_URL=${REGISTRY_CONTACT_URL:-} - SECRET_KEY=${SECRET_KEY} - AUTH_SERVER_URL=${AUTH_SERVER_URL} - AUTH_SERVER_EXTERNAL_URL=${AUTH_SERVER_EXTERNAL_URL} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET} - GITHUB_ENABLED=${GITHUB_ENABLED:-false} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - GOOGLE_ENABLED=${GOOGLE_ENABLED:-false} - COGNITO_CLIENT_ID=${COGNITO_CLIENT_ID} - COGNITO_CLIENT_SECRET=${COGNITO_CLIENT_SECRET} - COGNITO_USER_POOL_ID=${COGNITO_USER_POOL_ID} - COGNITO_ENABLED=${COGNITO_ENABLED:-false} - AWS_REGION=${AWS_REGION:-us-east-1} - HEALTH_CHECK_INTERVAL_SECONDS=${HEALTH_CHECK_INTERVAL_SECONDS:-30} - SRE_GATEWAY_AUTH_TOKEN=${SRE_GATEWAY_AUTH_TOKEN} - ATLASSIAN_AUTH_TOKEN=${ATLASSIAN_AUTH_TOKEN} # Metrics configuration - METRICS_SERVICE_URL=http://metrics-service:8890 - METRICS_API_KEY=${METRICS_API_KEY_REGISTRY} - METRICS_API_KEY_NGINX=${METRICS_API_KEY_REGISTRY} # Keycloak configuration - AUTH_PROVIDER=${AUTH_PROVIDER:-cognito} - KEYCLOAK_ENABLED=${KEYCLOAK_ENABLED:-false} - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-mcp-gateway} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-mcp-gateway-web} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} - KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN:-admin} - KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD} - KEYCLOAK_M2M_CLIENT_ID=${KEYCLOAK_M2M_CLIENT_ID} - KEYCLOAK_M2M_CLIENT_SECRET=${KEYCLOAK_M2M_CLIENT_SECRET} # Entra ID configuration - ENTRA_TENANT_ID=${ENTRA_TENANT_ID} - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID} - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET} - ENTRA_ENABLED=${ENTRA_ENABLED:-false} # External Registry Configuration - EXTERNAL_REGISTRY_TAGS=${EXTERNAL_REGISTRY_TAGS:-anthropic-registry,workday-asor} - ASOR_ACCESS_TOKEN=${ASOR_ACCESS_TOKEN} - ASOR_CLIENT_CREDENTIALS=${ASOR_CLIENT_CREDENTIALS} # Security Scanning Configuration - SECURITY_SCAN_ENABLED=${SECURITY_SCAN_ENABLED:-true} - SECURITY_SCAN_ON_REGISTRATION=${SECURITY_SCAN_ON_REGISTRATION:-true} - SECURITY_BLOCK_UNSAFE_SERVERS=${SECURITY_BLOCK_UNSAFE_SERVERS:-true} - SECURITY_ANALYZERS=${SECURITY_ANALYZERS:-yara} - SECURITY_SCAN_TIMEOUT=${SECURITY_SCAN_TIMEOUT:-60} - SECURITY_ADD_PENDING_TAG=${SECURITY_ADD_PENDING_TAG:-true} - MCP_SCANNER_LLM_API_KEY=${MCP_SCANNER_LLM_API_KEY} # GitHub Private Repository Access (SKILL.md fetching) - GITHUB_PAT=${GITHUB_PAT:-} - GITHUB_APP_ID=${GITHUB_APP_ID:-} - GITHUB_APP_INSTALLATION_ID=${GITHUB_APP_INSTALLATION_ID:-} - GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY:-} - GITHUB_EXTRA_HOSTS=${GITHUB_EXTRA_HOSTS:-} - GITHUB_API_BASE_URL=${GITHUB_API_BASE_URL:-https://api.github.com} # Storage Backend Configuration - STORAGE_BACKEND=${STORAGE_BACKEND:-file} # DocumentDB/MongoDB Configuration (when STORAGE_BACKEND=documentdb) - DOCUMENTDB_HOST=${DOCUMENTDB_HOST:-mongodb} - DOCUMENTDB_PORT=${DOCUMENTDB_PORT:-27017} - DOCUMENTDB_USERNAME=${DOCUMENTDB_USERNAME} - DOCUMENTDB_PASSWORD=${DOCUMENTDB_PASSWORD} - DOCUMENTDB_DATABASE=${DOCUMENTDB_DATABASE:-mcp_registry} - DOCUMENTDB_NAMESPACE=${DOCUMENTDB_NAMESPACE:-default} - DOCUMENTDB_USE_TLS=${DOCUMENTDB_USE_TLS:-false} - DOCUMENTDB_TLS_CA_FILE=${DOCUMENTDB_TLS_CA_FILE:-} - DOCUMENTDB_USE_IAM=${DOCUMENTDB_USE_IAM:-false} - DOCUMENTDB_REPLICA_SET=${DOCUMENTDB_REPLICA_SET:-rs0} - DOCUMENTDB_READ_PREFERENCE=${DOCUMENTDB_READ_PREFERENCE:-secondaryPreferred} # Embeddings Configuration - EMBEDDINGS_PROVIDER=${EMBEDDINGS_PROVIDER:-sentence-transformers} - EMBEDDINGS_MODEL_NAME=${EMBEDDINGS_MODEL_NAME:-all-MiniLM-L6-v2} - EMBEDDINGS_MODEL_DIMENSIONS=${EMBEDDINGS_MODEL_DIMENSIONS:-384} - EMBEDDINGS_API_KEY=${EMBEDDINGS_API_KEY} - EMBEDDINGS_API_BASE=${EMBEDDINGS_API_BASE} - EMBEDDINGS_AWS_REGION=${EMBEDDINGS_AWS_REGION:-us-east-1} # ANS (Agent Name Service) Configuration - ANS_INTEGRATION_ENABLED=${ANS_INTEGRATION_ENABLED:-false} - ANS_API_ENDPOINT=${ANS_API_ENDPOINT:-https://api.godaddy.com} - ANS_API_KEY=${ANS_API_KEY:-} - ANS_API_SECRET=${ANS_API_SECRET:-} - ANS_API_TIMEOUT_SECONDS=${ANS_API_TIMEOUT_SECONDS:-30} - ANS_SYNC_INTERVAL_HOURS=${ANS_SYNC_INTERVAL_HOURS:-6} - ANS_VERIFICATION_CACHE_TTL_SECONDS=${ANS_VERIFICATION_CACHE_TTL_SECONDS:-3600} # Podman/local: allow the dashboard to call /api/* using the session cookie # (disables nginx auth_request for /api/*; FastAPI still enforces auth) - NGINX_DISABLE_API_AUTH_REQUEST=${NGINX_DISABLE_API_AUTH_REQUEST:-true} # Federation static token auth - FEDERATION_STATIC_TOKEN_AUTH_ENABLED=${FEDERATION_STATIC_TOKEN_AUTH_ENABLED:-false} - FEDERATION_STATIC_TOKEN=${FEDERATION_STATIC_TOKEN:-} # Auth server config (mirrored for config panel visibility) - OAUTH_STORE_TOKENS_IN_SESSION=${OAUTH_STORE_TOKENS_IN_SESSION:-false} - REGISTRY_STATIC_TOKEN_AUTH_ENABLED=${REGISTRY_STATIC_TOKEN_AUTH_ENABLED:-false} - REGISTRY_API_TOKEN=${REGISTRY_API_TOKEN:-} - REGISTRY_API_KEYS=${REGISTRY_API_KEYS:-} # Registration Webhook - REGISTRATION_WEBHOOK_URL=${REGISTRATION_WEBHOOK_URL:-} - REGISTRATION_WEBHOOK_AUTH_HEADER=${REGISTRATION_WEBHOOK_AUTH_HEADER:-Authorization} - REGISTRATION_WEBHOOK_AUTH_TOKEN=${REGISTRATION_WEBHOOK_AUTH_TOKEN:-} - REGISTRATION_WEBHOOK_TIMEOUT_SECONDS=${REGISTRATION_WEBHOOK_TIMEOUT_SECONDS:-10} # Registration Gate (Admission Control) - REGISTRATION_GATE_ENABLED=${REGISTRATION_GATE_ENABLED:-false} - REGISTRATION_GATE_URL=${REGISTRATION_GATE_URL:-} - REGISTRATION_GATE_AUTH_TYPE=${REGISTRATION_GATE_AUTH_TYPE:-none} - REGISTRATION_GATE_AUTH_CREDENTIAL=${REGISTRATION_GATE_AUTH_CREDENTIAL:-} - REGISTRATION_GATE_AUTH_HEADER_NAME=${REGISTRATION_GATE_AUTH_HEADER_NAME:-X-Api-Key} - REGISTRATION_GATE_TIMEOUT_SECONDS=${REGISTRATION_GATE_TIMEOUT_SECONDS:-5} - REGISTRATION_GATE_MAX_RETRIES=${REGISTRATION_GATE_MAX_RETRIES:-2} # M2M Direct Registration - M2M_DIRECT_REGISTRATION_ENABLED=${M2M_DIRECT_REGISTRATION_ENABLED:-true} - MAX_TOKENS_PER_USER_PER_HOUR=${MAX_TOKENS_PER_USER_PER_HOUR:-100} # Telemetry Configuration # Disable all: set MCP_TELEMETRY_DISABLED=1 to disable all telemetry (startup ping + heartbeat) # Heartbeat opt-out: set MCP_TELEMETRY_OPT_OUT=1 to disable daily heartbeat only # Heartbeat interval: set MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES=1440 (default: 1440 = 24h) # Endpoint: set TELEMETRY_ENDPOINT= to use a self-hosted collector # Debug: set TELEMETRY_DEBUG=true to log payloads without sending - MCP_TELEMETRY_DISABLED=${MCP_TELEMETRY_DISABLED:-} - MCP_TELEMETRY_OPT_OUT=${MCP_TELEMETRY_OPT_OUT:-} - MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES=${MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES:-1440} - TELEMETRY_DEBUG=${TELEMETRY_DEBUG:-false} # Application Log Configuration - APP_LOG_MAX_BYTES=${APP_LOG_MAX_BYTES:-52428800} - APP_LOG_BACKUP_COUNT=${APP_LOG_BACKUP_COUNT:-5} - APP_LOG_CENTRALIZED_ENABLED=${APP_LOG_CENTRALIZED_ENABLED:-true} - APP_LOG_CENTRALIZED_TTL_DAYS=${APP_LOG_CENTRALIZED_TTL_DAYS:-1} - APP_LOG_MONGODB_BUFFER_SIZE=${APP_LOG_MONGODB_BUFFER_SIZE:-50} - APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS=${APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS:-5.0} - APP_LOG_LEVEL=${APP_LOG_LEVEL:-INFO} - APP_LOG_EXCLUDED_LOGGERS=${APP_LOG_EXCLUDED_LOGGERS:-uvicorn.access,httpx,pymongo,motor} ports: - "80:8080" # Map host 80 to container 8080 (non-root nginx) - "443:8443" # Map host 443 to container 8443 (non-root nginx) - "7860:7860" volumes: - ${HOME}/mcp-gateway/servers:/app/registry/servers - ${HOME}/mcp-gateway/agents:/app/registry/agents - ${HOME}/mcp-gateway/models:/app/registry/models - ${HOME}/mcp-gateway/logs:/app/logs - ${HOME}/mcp-gateway/security_scans:/app/security_scans - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/auth_server/scopes.yml - ${HOME}/mcp-gateway/federation.json:/app/config/federation.json - ${HOME}/mcp-gateway/ssl:/etc/ssl:ro security_opt: - no-new-privileges:true cap_drop: - ALL depends_on: auth-server: condition: service_started metrics-service: condition: service_healthy mongodb-init: condition: service_completed_successfully restart: unless-stopped # Metrics Collection Service metrics-service: build: context: metrics-service dockerfile: Dockerfile environment: - METRICS_SERVICE_PORT=8890 - METRICS_SERVICE_HOST=0.0.0.0 - SQLITE_DB_PATH=/var/lib/sqlite/metrics.db - METRICS_RETENTION_DAYS=90 - METRICS_API_KEY_AUTH=${METRICS_API_KEY_AUTH_SERVER} - METRICS_API_KEY_REGISTRY=${METRICS_API_KEY_REGISTRY} - METRICS_API_KEY_MCPGW=${METRICS_API_KEY_MCPGW_SERVER} - OTEL_SERVICE_NAME=mcp-metrics-service - OTEL_PROMETHEUS_ENABLED=true - OTEL_PROMETHEUS_PORT=9465 - OTEL_OTLP_ENDPOINT=${OTEL_OTLP_ENDPOINT:-} - OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-} - OTEL_OTLP_EXPORT_INTERVAL_MS=${OTEL_OTLP_EXPORT_INTERVAL_MS:-30000} - OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=${OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE:-cumulative} - METRICS_RATE_LIMIT=1000 ports: - "8890:8890" - "9465:9465" # Prometheus metrics endpoint volumes: - metrics-db-data:/var/lib/sqlite - ${HOME}/mcp-gateway/logs:/app/logs depends_on: - metrics-db restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8890/health"] interval: 30s timeout: 10s retries: 3 # Auth service (separate and scalable) auth-server: build: context: . dockerfile: docker/Dockerfile.auth environment: - REGISTRY_URL=${REGISTRY_URL} - SECRET_KEY=${SECRET_KEY} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET} - GITHUB_ENABLED=${GITHUB_ENABLED:-false} - COGNITO_CLIENT_ID=${COGNITO_CLIENT_ID} - COGNITO_CLIENT_SECRET=${COGNITO_CLIENT_SECRET} - COGNITO_USER_POOL_ID=${COGNITO_USER_POOL_ID} - COGNITO_DOMAIN=${COGNITO_DOMAIN:-auto} - COGNITO_ENABLED=${COGNITO_ENABLED:-false} - AWS_REGION=${AWS_REGION:-us-east-1} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - GOOGLE_ENABLED=${GOOGLE_ENABLED:-false} # Metrics configuration - METRICS_SERVICE_URL=http://metrics-service:8890 - METRICS_API_KEY=${METRICS_API_KEY_AUTH_SERVER} # Keycloak configuration - AUTH_PROVIDER=${AUTH_PROVIDER:-cognito} # 'cognito' or 'keycloak' - KEYCLOAK_ENABLED=${KEYCLOAK_ENABLED:-true} # Enable Keycloak by default - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} # Podman note: host port 8080 is used by the registry UI (8080->80), # so Keycloak is exposed on 18080->8080 by default in this Podman compose. - KEYCLOAK_EXTERNAL_URL=${KEYCLOAK_EXTERNAL_URL:-http://localhost:18080} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-mcp-gateway} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-mcp-gateway-web} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} - KEYCLOAK_M2M_CLIENT_ID=${KEYCLOAK_M2M_CLIENT_ID:-mcp-gateway-m2m} - KEYCLOAK_M2M_CLIENT_SECRET=${KEYCLOAK_M2M_CLIENT_SECRET} # Entra ID configuration - ENTRA_TENANT_ID=${ENTRA_TENANT_ID} - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID} - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET} - ENTRA_ENABLED=${ENTRA_ENABLED:-false} # Okta configuration - OKTA_DOMAIN=${OKTA_DOMAIN:-} - OKTA_CLIENT_ID=${OKTA_CLIENT_ID:-} - OKTA_CLIENT_SECRET=${OKTA_CLIENT_SECRET:-} - OKTA_M2M_CLIENT_ID=${OKTA_M2M_CLIENT_ID:-} - OKTA_M2M_CLIENT_SECRET=${OKTA_M2M_CLIENT_SECRET:-} - OKTA_API_TOKEN=${OKTA_API_TOKEN:-} - OKTA_AUTH_SERVER_ID=${OKTA_AUTH_SERVER_ID:-} # Storage Backend Configuration - STORAGE_BACKEND=${STORAGE_BACKEND:-file} # DocumentDB/MongoDB Configuration (when STORAGE_BACKEND=documentdb or mongodb-ce) - DOCUMENTDB_HOST=${DOCUMENTDB_HOST:-mongodb} - DOCUMENTDB_PORT=${DOCUMENTDB_PORT:-27017} - DOCUMENTDB_USERNAME=${DOCUMENTDB_USERNAME} - DOCUMENTDB_PASSWORD=${DOCUMENTDB_PASSWORD} - DOCUMENTDB_DATABASE=${DOCUMENTDB_DATABASE:-mcp_registry} - DOCUMENTDB_NAMESPACE=${DOCUMENTDB_NAMESPACE:-default} - DOCUMENTDB_USE_TLS=${DOCUMENTDB_USE_TLS:-false} - DOCUMENTDB_TLS_CA_FILE=${DOCUMENTDB_TLS_CA_FILE} - DOCUMENTDB_USE_IAM=${DOCUMENTDB_USE_IAM:-false} - DOCUMENTDB_REPLICA_SET=${DOCUMENTDB_REPLICA_SET:-rs0} - DOCUMENTDB_READ_PREFERENCE=${DOCUMENTDB_READ_PREFERENCE:-secondaryPreferred} # Registry API static token auth - REGISTRY_STATIC_TOKEN_AUTH_ENABLED=${REGISTRY_STATIC_TOKEN_AUTH_ENABLED:-false} - REGISTRY_API_TOKEN=${REGISTRY_API_TOKEN:-} - REGISTRY_API_KEYS=${REGISTRY_API_KEYS:-} # OAuth token storage in session (set to false for Entra ID large tokens) - OAUTH_STORE_TOKENS_IN_SESSION=${OAUTH_STORE_TOKENS_IN_SESSION:-false} # Application Log Configuration - APP_LOG_MAX_BYTES=${APP_LOG_MAX_BYTES:-52428800} - APP_LOG_BACKUP_COUNT=${APP_LOG_BACKUP_COUNT:-5} - APP_LOG_CENTRALIZED_ENABLED=${APP_LOG_CENTRALIZED_ENABLED:-true} - APP_LOG_CENTRALIZED_TTL_DAYS=${APP_LOG_CENTRALIZED_TTL_DAYS:-1} - APP_LOG_MONGODB_BUFFER_SIZE=${APP_LOG_MONGODB_BUFFER_SIZE:-50} - APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS=${APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS:-5.0} - APP_LOG_LEVEL=${APP_LOG_LEVEL:-INFO} - APP_LOG_EXCLUDED_LOGGERS=${APP_LOG_EXCLUDED_LOGGERS:-uvicorn.access,httpx,pymongo,motor} ports: - "8888:8888" volumes: - ${HOME}/mcp-gateway/logs:/app/logs # - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/scopes.yml depends_on: metrics-service: condition: service_healthy mongodb-init: condition: service_completed_successfully restart: unless-stopped # Current Time MCP Server currenttime-server: build: context: servers/currenttime dockerfile: ../../docker/Dockerfile.mcp-server environment: - PORT=8000 - MCP_TRANSPORT=streamable-http ports: - "8000:8000" restart: unless-stopped # Financial Info MCP Server fininfo-server: build: context: servers/fininfo dockerfile: ../../docker/Dockerfile.mcp-server environment: - PORT=8001 - SECRET_KEY=${SECRET_KEY} volumes: - ${HOME}/mcp-gateway/secrets/fininfo/:/app/fininfo/ ports: - "8001:8001" restart: unless-stopped # MCP Gateway Server mcpgw-server: build: context: . dockerfile: docker/Dockerfile.mcp-server args: SERVER_DIR: servers/mcpgw environment: - HOST=0.0.0.0 - PORT=8003 - REGISTRY_BASE_URL=http://registry:8080 volumes: - ${HOME}/mcp-gateway/servers:/app/registry/servers - ${HOME}/mcp-gateway/models:/app/registry/models - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/auth_server/scopes.yml ports: - "8003:8003" depends_on: - registry restart: unless-stopped # Real Server Fake Tools MCP Server realserverfaketools-server: build: context: servers/realserverfaketools dockerfile: ../../docker/Dockerfile.mcp-server environment: - PORT=8002 ports: - "8002:8002" restart: unless-stopped # SQLite container for metrics database metrics-db: image: alpine:latest volumes: - metrics-db-data:/var/lib/sqlite command: ["sh", "-c", "apk add --no-cache sqlite && mkdir -p /var/lib/sqlite && sqlite3 /var/lib/sqlite/metrics.db 'CREATE TABLE IF NOT EXISTS _health (id INTEGER);' && tail -f /dev/null"] security_opt: - no-new-privileges:true cap_drop: - ALL restart: unless-stopped healthcheck: test: ["CMD", "sqlite3", "/var/lib/sqlite/metrics.db", ".tables"] interval: 30s timeout: 10s retries: 3 # Prometheus for metrics collection prometheus: image: prom/prometheus:latest ports: - "9090:9090" volumes: - ./config/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--web.console.libraries=/etc/prometheus/console_libraries' - '--web.console.templates=/etc/prometheus/consoles' - '--storage.tsdb.retention.time=200h' - '--web.enable-lifecycle' security_opt: - no-new-privileges:true cap_drop: - ALL restart: unless-stopped # Grafana for metrics visualization grafana: image: grafana/grafana:12.3.1 ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?Set GRAFANA_ADMIN_PASSWORD in .env} - GF_USERS_ALLOW_SIGN_UP=false - GF_AUTH_ANONYMOUS_ENABLED=false volumes: - grafana-data:/var/lib/grafana - ./config/grafana/dashboards:/etc/grafana/provisioning/dashboards - ./config/grafana/datasources:/etc/grafana/provisioning/datasources security_opt: - no-new-privileges:true cap_drop: - ALL depends_on: - prometheus restart: unless-stopped # PostgreSQL database for Keycloak keycloak-db: image: postgres:16-alpine environment: POSTGRES_DB: keycloak POSTGRES_USER: keycloak POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak} volumes: - keycloak_db_data:/var/lib/postgresql/data restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -U keycloak"] interval: 10s timeout: 5s retries: 5 # Keycloak Identity Provider keycloak: image: quay.io/keycloak/keycloak:25.0 command: start-dev # Use 'start' for production with proper SSL environment: # Database configuration KC_DB: postgres KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak KC_DB_USERNAME: keycloak KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak} # Admin credentials KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} # HTTP configuration KC_HTTP_ENABLED: 'true' KC_HTTP_PORT: 8080 KC_HOSTNAME_STRICT: 'false' KC_HOSTNAME_STRICT_HTTPS: 'false' KC_PROXY: edge # Running behind nginx # Frontend URL for external JWT issuer # Podman note: host port 8080 is used by the registry UI (8080->80), # so Keycloak is exposed on 18080->8080 by default in this Podman compose. KC_FRONTEND_URL: ${KEYCLOAK_EXTERNAL_URL:-http://localhost:18080} # Features KC_FEATURES: token-exchange,admin-api # Logging KC_LOG_LEVEL: INFO # Health endpoints (required for /health/ready) KC_HEALTH_ENABLED: 'true' ports: - "18080:8080" depends_on: keycloak-db: condition: service_healthy volumes: - ./keycloak/themes:/opt/keycloak/themes - ./keycloak/providers:/opt/keycloak/providers - ./keycloak/import:/opt/keycloak/data/import restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"] interval: 30s timeout: 10s retries: 3 start_period: 60s # MongoDB Community Edition 8.2 (alternative to DocumentDB for local development) # Vector search is implemented in application code (see search_repository.py) # Running without authentication for local development simplicity mongodb: image: mongo:8.2 container_name: mcp-mongodb command: mongod --replSet rs0 --bind_ip 127.0.0.1,mongodb ports: - "27017:27017" volumes: - mongodb-data:/data/db - mongodb-config:/data/configdb security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - SETUID # Required by gosu to switch to mongodb user at startup - SETGID # Required by gosu to switch to mongodb group at startup - CHOWN # Required by entrypoint to fix /data/db ownership - DAC_OVERRIDE # Required by entrypoint to read /data/db before chown healthcheck: test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] interval: 60s timeout: 5s retries: 5 start_period: 20s restart: unless-stopped # MongoDB initialization (creates replica set, indexes, and loads admin scope) mongodb-init: image: python:3.14-slim container_name: mcp-mongodb-init depends_on: mongodb: condition: service_healthy environment: - DOCUMENTDB_HOST=mongodb - DOCUMENTDB_PORT=27017 - DOCUMENTDB_DATABASE=${DOCUMENTDB_DATABASE:-mcp_registry} - DOCUMENTDB_USERNAME=${DOCUMENTDB_USERNAME:-admin} - DOCUMENTDB_PASSWORD=${DOCUMENTDB_PASSWORD:-admin} - DOCUMENTDB_NAMESPACE=${DOCUMENTDB_NAMESPACE:-default} - ENTRA_GROUP_ADMIN_ID=${ENTRA_GROUP_ADMIN_ID:-} volumes: - ./scripts/init-mongodb-ce.py:/app/scripts/init-mongodb-ce.py:ro - ./scripts/registry-admins.json:/app/scripts/registry-admins.json:ro command: > sh -c " pip install --quiet motor pymongo && python /app/scripts/init-mongodb-ce.py " restart: "no" volumes: ssl_data: keycloak_db_data: metrics-db-data: prometheus-data: grafana-data: mongodb-data: mongodb-config: ================================================ FILE: docker-compose.prebuilt.yml ================================================ version: '3.8' # DocumentDB Initialization: # For AWS DocumentDB Elastic Cluster setup, run the initialization script: # export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com # export DOCUMENTDB_USERNAME=admin # export DOCUMENTDB_PASSWORD=yourpassword # ./scripts/init-documentdb.sh # # Then set STORAGE_BACKEND=documentdb in your environment and restart services. # Note: DocumentDB is a managed AWS service and runs outside of Docker. services: # Registry service (includes nginx, SSL, FAISS, models) - using pre-built image registry: image: ${DOCKERHUB_ORG:-mcpgateway}/registry:${REGISTRY_VERSION:-latest} environment: # Deployment Mode Configuration - DEPLOYMENT_MODE=${DEPLOYMENT_MODE:-with-gateway} - REGISTRY_MODE=${REGISTRY_MODE:-full} # Tab visibility overrides (AND-ed with REGISTRY_MODE) - SHOW_SERVERS_TAB=${SHOW_SERVERS_TAB:-true} - SHOW_VIRTUAL_SERVERS_TAB=${SHOW_VIRTUAL_SERVERS_TAB:-true} - SHOW_SKILLS_TAB=${SHOW_SKILLS_TAB:-true} - SHOW_AGENTS_TAB=${SHOW_AGENTS_TAB:-true} - GATEWAY_ADDITIONAL_SERVER_NAMES=${GATEWAY_ADDITIONAL_SERVER_NAMES:-} # Registry Card Configuration - REGISTRY_URL=${REGISTRY_URL:-http://localhost} - REGISTRY_NAME=${REGISTRY_NAME:-AI Registry} - REGISTRY_ORGANIZATION_NAME=${REGISTRY_ORGANIZATION_NAME:-ACME Inc.} - REGISTRY_DESCRIPTION=${REGISTRY_DESCRIPTION:-} - REGISTRY_CONTACT_EMAIL=${REGISTRY_CONTACT_EMAIL:-} - REGISTRY_CONTACT_URL=${REGISTRY_CONTACT_URL:-} - BUILD_VERSION=${BUILD_VERSION:-1.0.0} - SECRET_KEY=${SECRET_KEY} - AUTH_SERVER_URL=${AUTH_SERVER_URL} - AUTH_SERVER_EXTERNAL_URL=${AUTH_SERVER_EXTERNAL_URL} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET} - GITHUB_ENABLED=${GITHUB_ENABLED:-false} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - GOOGLE_ENABLED=${GOOGLE_ENABLED:-false} - COGNITO_CLIENT_ID=${COGNITO_CLIENT_ID} - COGNITO_CLIENT_SECRET=${COGNITO_CLIENT_SECRET} - COGNITO_USER_POOL_ID=${COGNITO_USER_POOL_ID} - COGNITO_ENABLED=${COGNITO_ENABLED:-false} - AWS_REGION=${AWS_REGION:-us-east-1} - HEALTH_CHECK_INTERVAL_SECONDS=${HEALTH_CHECK_INTERVAL_SECONDS:-30} - SRE_GATEWAY_AUTH_TOKEN=${SRE_GATEWAY_AUTH_TOKEN} - ATLASSIAN_AUTH_TOKEN=${ATLASSIAN_AUTH_TOKEN} # Metrics configuration - METRICS_SERVICE_URL=http://metrics-service:8890 - METRICS_API_KEY=${METRICS_API_KEY_REGISTRY} - METRICS_API_KEY_NGINX=${METRICS_API_KEY_REGISTRY} # Keycloak configuration - AUTH_PROVIDER=${AUTH_PROVIDER:-cognito} - KEYCLOAK_ENABLED=${KEYCLOAK_ENABLED:-false} - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-mcp-gateway} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-mcp-gateway-web} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} - KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN:-admin} - KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD} - KEYCLOAK_M2M_CLIENT_ID=${KEYCLOAK_M2M_CLIENT_ID} - KEYCLOAK_M2M_CLIENT_SECRET=${KEYCLOAK_M2M_CLIENT_SECRET} # Entra ID configuration - ENTRA_TENANT_ID=${ENTRA_TENANT_ID} - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID} - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET} - ENTRA_ENABLED=${ENTRA_ENABLED:-false} # External Registry Configuration - EXTERNAL_REGISTRY_TAGS=${EXTERNAL_REGISTRY_TAGS:-anthropic-registry,workday-asor} - ASOR_ACCESS_TOKEN=${ASOR_ACCESS_TOKEN} - ASOR_CLIENT_CREDENTIALS=${ASOR_CLIENT_CREDENTIALS} # Security Scanning Configuration - SECURITY_SCAN_ENABLED=${SECURITY_SCAN_ENABLED:-true} - SECURITY_SCAN_ON_REGISTRATION=${SECURITY_SCAN_ON_REGISTRATION:-true} - SECURITY_BLOCK_UNSAFE_SERVERS=${SECURITY_BLOCK_UNSAFE_SERVERS:-true} - SECURITY_ANALYZERS=${SECURITY_ANALYZERS:-yara} - SECURITY_SCAN_TIMEOUT=${SECURITY_SCAN_TIMEOUT:-60} - SECURITY_ADD_PENDING_TAG=${SECURITY_ADD_PENDING_TAG:-true} - MCP_SCANNER_LLM_API_KEY=${MCP_SCANNER_LLM_API_KEY} # GitHub Private Repository Access (SKILL.md fetching) - GITHUB_PAT=${GITHUB_PAT:-} - GITHUB_APP_ID=${GITHUB_APP_ID:-} - GITHUB_APP_INSTALLATION_ID=${GITHUB_APP_INSTALLATION_ID:-} - GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY:-} - GITHUB_EXTRA_HOSTS=${GITHUB_EXTRA_HOSTS:-} - GITHUB_API_BASE_URL=${GITHUB_API_BASE_URL:-https://api.github.com} # Storage Backend Configuration - STORAGE_BACKEND=${STORAGE_BACKEND:-file} # DocumentDB/MongoDB Configuration (when STORAGE_BACKEND=documentdb) - DOCUMENTDB_HOST=${DOCUMENTDB_HOST:-mongodb} - DOCUMENTDB_PORT=${DOCUMENTDB_PORT:-27017} - DOCUMENTDB_USERNAME=${DOCUMENTDB_USERNAME} - DOCUMENTDB_PASSWORD=${DOCUMENTDB_PASSWORD} - DOCUMENTDB_DATABASE=${DOCUMENTDB_DATABASE:-mcp_registry} - DOCUMENTDB_NAMESPACE=${DOCUMENTDB_NAMESPACE:-default} - DOCUMENTDB_USE_TLS=${DOCUMENTDB_USE_TLS:-false} - DOCUMENTDB_TLS_CA_FILE=${DOCUMENTDB_TLS_CA_FILE:-} - DOCUMENTDB_USE_IAM=${DOCUMENTDB_USE_IAM:-false} - DOCUMENTDB_REPLICA_SET=${DOCUMENTDB_REPLICA_SET:-rs0} - DOCUMENTDB_READ_PREFERENCE=${DOCUMENTDB_READ_PREFERENCE:-secondaryPreferred} # Embeddings Configuration - EMBEDDINGS_PROVIDER=${EMBEDDINGS_PROVIDER:-sentence-transformers} - EMBEDDINGS_MODEL_NAME=${EMBEDDINGS_MODEL_NAME:-all-MiniLM-L6-v2} - EMBEDDINGS_MODEL_DIMENSIONS=${EMBEDDINGS_MODEL_DIMENSIONS:-384} - EMBEDDINGS_API_KEY=${EMBEDDINGS_API_KEY} - EMBEDDINGS_API_BASE=${EMBEDDINGS_API_BASE} - EMBEDDINGS_AWS_REGION=${EMBEDDINGS_AWS_REGION:-us-east-1} # ANS (Agent Name Service) Configuration - ANS_INTEGRATION_ENABLED=${ANS_INTEGRATION_ENABLED:-false} - ANS_API_ENDPOINT=${ANS_API_ENDPOINT:-https://api.godaddy.com} - ANS_API_KEY=${ANS_API_KEY:-} - ANS_API_SECRET=${ANS_API_SECRET:-} - ANS_API_TIMEOUT_SECONDS=${ANS_API_TIMEOUT_SECONDS:-30} - ANS_SYNC_INTERVAL_HOURS=${ANS_SYNC_INTERVAL_HOURS:-6} - ANS_VERIFICATION_CACHE_TTL_SECONDS=${ANS_VERIFICATION_CACHE_TTL_SECONDS:-3600} # Federation static token auth - FEDERATION_STATIC_TOKEN_AUTH_ENABLED=${FEDERATION_STATIC_TOKEN_AUTH_ENABLED:-false} - FEDERATION_STATIC_TOKEN=${FEDERATION_STATIC_TOKEN:-} # Auth server config (mirrored for config panel visibility) - OAUTH_STORE_TOKENS_IN_SESSION=${OAUTH_STORE_TOKENS_IN_SESSION:-false} - REGISTRY_STATIC_TOKEN_AUTH_ENABLED=${REGISTRY_STATIC_TOKEN_AUTH_ENABLED:-false} - REGISTRY_API_TOKEN=${REGISTRY_API_TOKEN:-} - REGISTRY_API_KEYS=${REGISTRY_API_KEYS:-} # Registration Webhook - REGISTRATION_WEBHOOK_URL=${REGISTRATION_WEBHOOK_URL:-} - REGISTRATION_WEBHOOK_AUTH_HEADER=${REGISTRATION_WEBHOOK_AUTH_HEADER:-Authorization} - REGISTRATION_WEBHOOK_AUTH_TOKEN=${REGISTRATION_WEBHOOK_AUTH_TOKEN:-} - REGISTRATION_WEBHOOK_TIMEOUT_SECONDS=${REGISTRATION_WEBHOOK_TIMEOUT_SECONDS:-10} # Registration Gate (Admission Control) - REGISTRATION_GATE_ENABLED=${REGISTRATION_GATE_ENABLED:-false} - REGISTRATION_GATE_URL=${REGISTRATION_GATE_URL:-} - REGISTRATION_GATE_AUTH_TYPE=${REGISTRATION_GATE_AUTH_TYPE:-none} - REGISTRATION_GATE_AUTH_CREDENTIAL=${REGISTRATION_GATE_AUTH_CREDENTIAL:-} - REGISTRATION_GATE_AUTH_HEADER_NAME=${REGISTRATION_GATE_AUTH_HEADER_NAME:-X-Api-Key} - REGISTRATION_GATE_TIMEOUT_SECONDS=${REGISTRATION_GATE_TIMEOUT_SECONDS:-5} - REGISTRATION_GATE_MAX_RETRIES=${REGISTRATION_GATE_MAX_RETRIES:-2} # M2M Direct Registration - M2M_DIRECT_REGISTRATION_ENABLED=${M2M_DIRECT_REGISTRATION_ENABLED:-true} - MAX_TOKENS_PER_USER_PER_HOUR=${MAX_TOKENS_PER_USER_PER_HOUR:-100} # Telemetry Configuration # Disable all: set MCP_TELEMETRY_DISABLED=1 to disable all telemetry (startup ping + heartbeat) # Heartbeat opt-out: set MCP_TELEMETRY_OPT_OUT=1 to disable daily heartbeat only # Heartbeat interval: set MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES=1440 (default: 1440 = 24h) # Endpoint: set TELEMETRY_ENDPOINT= to use a self-hosted collector # Debug: set TELEMETRY_DEBUG=true to log payloads without sending - MCP_TELEMETRY_DISABLED=${MCP_TELEMETRY_DISABLED:-} - MCP_TELEMETRY_OPT_OUT=${MCP_TELEMETRY_OPT_OUT:-} - MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES=${MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES:-1440} - TELEMETRY_DEBUG=${TELEMETRY_DEBUG:-false} # Application Log Configuration - APP_LOG_MAX_BYTES=${APP_LOG_MAX_BYTES:-52428800} - APP_LOG_BACKUP_COUNT=${APP_LOG_BACKUP_COUNT:-5} - APP_LOG_CENTRALIZED_ENABLED=${APP_LOG_CENTRALIZED_ENABLED:-true} - APP_LOG_CENTRALIZED_TTL_DAYS=${APP_LOG_CENTRALIZED_TTL_DAYS:-1} - APP_LOG_MONGODB_BUFFER_SIZE=${APP_LOG_MONGODB_BUFFER_SIZE:-50} - APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS=${APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS:-5.0} - APP_LOG_LEVEL=${APP_LOG_LEVEL:-INFO} - APP_LOG_EXCLUDED_LOGGERS=${APP_LOG_EXCLUDED_LOGGERS:-uvicorn.access,httpx,pymongo,motor} ports: - "80:8080" # Map host 80 to container 8080 (non-root nginx) - "443:8443" # Map host 443 to container 8443 (non-root nginx) - "7860:7860" volumes: - ${HOME}/mcp-gateway/servers:/app/registry/servers - ${HOME}/mcp-gateway/agents:/app/registry/agents - ${HOME}/mcp-gateway/models:/app/registry/models - ${HOME}/mcp-gateway/logs:/app/logs - ${HOME}/mcp-gateway/security_scans:/app/security_scans - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/auth_server/scopes.yml - ${HOME}/mcp-gateway/federation.json:/app/config/federation.json - ${HOME}/mcp-gateway/ssl:/etc/ssl:ro security_opt: - no-new-privileges:true cap_drop: - ALL depends_on: auth-server: condition: service_started metrics-service: condition: service_healthy mongodb-init: condition: service_completed_successfully restart: unless-stopped # Metrics Collection Service - using pre-built image metrics-service: image: ${DOCKERHUB_ORG:-mcpgateway}/metrics-service:${METRICS_VERSION:-latest} environment: - METRICS_SERVICE_PORT=8890 - METRICS_SERVICE_HOST=0.0.0.0 - SQLITE_DB_PATH=/var/lib/sqlite/metrics.db - METRICS_RETENTION_DAYS=90 - METRICS_API_KEY_AUTH=${METRICS_API_KEY_AUTH_SERVER} - METRICS_API_KEY_REGISTRY=${METRICS_API_KEY_REGISTRY} - METRICS_API_KEY_MCPGW=${METRICS_API_KEY_MCPGW_SERVER} - OTEL_SERVICE_NAME=mcp-metrics-service - OTEL_PROMETHEUS_ENABLED=true - OTEL_PROMETHEUS_PORT=9465 - OTEL_OTLP_ENDPOINT=${OTEL_OTLP_ENDPOINT:-} - OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-} - OTEL_OTLP_EXPORT_INTERVAL_MS=${OTEL_OTLP_EXPORT_INTERVAL_MS:-30000} - OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=${OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE:-cumulative} - METRICS_RATE_LIMIT=1000 ports: - "8890:8890" - "9465:9465" # Prometheus metrics endpoint volumes: - metrics-db-data:/var/lib/sqlite - ${HOME}/mcp-gateway/logs:/app/logs security_opt: - no-new-privileges:true cap_drop: - ALL depends_on: - metrics-db restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8890/health"] interval: 30s timeout: 10s retries: 3 # Auth service (separate and scalable) - using pre-built image auth-server: image: ${DOCKERHUB_ORG:-mcpgateway}/auth-server:${AUTH_SERVER_VERSION:-latest} environment: - REGISTRY_URL=${REGISTRY_URL} - SECRET_KEY=${SECRET_KEY} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET} - GITHUB_ENABLED=${GITHUB_ENABLED:-false} - COGNITO_CLIENT_ID=${COGNITO_CLIENT_ID} - COGNITO_CLIENT_SECRET=${COGNITO_CLIENT_SECRET} - COGNITO_USER_POOL_ID=${COGNITO_USER_POOL_ID} - COGNITO_DOMAIN=${COGNITO_DOMAIN:-auto} - COGNITO_ENABLED=${COGNITO_ENABLED:-false} - AWS_REGION=${AWS_REGION:-us-east-1} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - GOOGLE_ENABLED=${GOOGLE_ENABLED:-false} # Metrics configuration - METRICS_SERVICE_URL=http://metrics-service:8890 - METRICS_API_KEY=${METRICS_API_KEY_AUTH_SERVER} # Keycloak configuration - AUTH_PROVIDER=${AUTH_PROVIDER:-cognito} # 'cognito' or 'keycloak' - KEYCLOAK_ENABLED=${KEYCLOAK_ENABLED:-false} - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} - KEYCLOAK_EXTERNAL_URL=${KEYCLOAK_EXTERNAL_URL:-http://localhost:8080} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-mcp-gateway} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-mcp-gateway-web} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} - KEYCLOAK_M2M_CLIENT_ID=${KEYCLOAK_M2M_CLIENT_ID:-mcp-gateway-m2m} - KEYCLOAK_M2M_CLIENT_SECRET=${KEYCLOAK_M2M_CLIENT_SECRET} # Entra ID configuration - ENTRA_TENANT_ID=${ENTRA_TENANT_ID} - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID} - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET} - ENTRA_ENABLED=${ENTRA_ENABLED:-false} # Okta configuration - OKTA_DOMAIN=${OKTA_DOMAIN:-} - OKTA_CLIENT_ID=${OKTA_CLIENT_ID:-} - OKTA_CLIENT_SECRET=${OKTA_CLIENT_SECRET:-} - OKTA_M2M_CLIENT_ID=${OKTA_M2M_CLIENT_ID:-} - OKTA_M2M_CLIENT_SECRET=${OKTA_M2M_CLIENT_SECRET:-} - OKTA_API_TOKEN=${OKTA_API_TOKEN:-} - OKTA_AUTH_SERVER_ID=${OKTA_AUTH_SERVER_ID:-} # Storage Backend Configuration - STORAGE_BACKEND=${STORAGE_BACKEND:-file} # DocumentDB/MongoDB Configuration (when STORAGE_BACKEND=documentdb or mongodb-ce) - DOCUMENTDB_HOST=${DOCUMENTDB_HOST:-mongodb} - DOCUMENTDB_PORT=${DOCUMENTDB_PORT:-27017} - DOCUMENTDB_USERNAME=${DOCUMENTDB_USERNAME} - DOCUMENTDB_PASSWORD=${DOCUMENTDB_PASSWORD} - DOCUMENTDB_DATABASE=${DOCUMENTDB_DATABASE:-mcp_registry} - DOCUMENTDB_NAMESPACE=${DOCUMENTDB_NAMESPACE:-default} - DOCUMENTDB_USE_TLS=${DOCUMENTDB_USE_TLS:-false} - DOCUMENTDB_TLS_CA_FILE=${DOCUMENTDB_TLS_CA_FILE} - DOCUMENTDB_USE_IAM=${DOCUMENTDB_USE_IAM:-false} - DOCUMENTDB_REPLICA_SET=${DOCUMENTDB_REPLICA_SET:-rs0} - DOCUMENTDB_READ_PREFERENCE=${DOCUMENTDB_READ_PREFERENCE:-secondaryPreferred} # Registry API static token auth - REGISTRY_STATIC_TOKEN_AUTH_ENABLED=${REGISTRY_STATIC_TOKEN_AUTH_ENABLED:-false} - REGISTRY_API_TOKEN=${REGISTRY_API_TOKEN:-} - REGISTRY_API_KEYS=${REGISTRY_API_KEYS:-} # OAuth token storage in session (set to false for Entra ID large tokens) - OAUTH_STORE_TOKENS_IN_SESSION=${OAUTH_STORE_TOKENS_IN_SESSION:-false} # Application Log Configuration - APP_LOG_MAX_BYTES=${APP_LOG_MAX_BYTES:-52428800} - APP_LOG_BACKUP_COUNT=${APP_LOG_BACKUP_COUNT:-5} - APP_LOG_CENTRALIZED_ENABLED=${APP_LOG_CENTRALIZED_ENABLED:-true} - APP_LOG_CENTRALIZED_TTL_DAYS=${APP_LOG_CENTRALIZED_TTL_DAYS:-1} - APP_LOG_MONGODB_BUFFER_SIZE=${APP_LOG_MONGODB_BUFFER_SIZE:-50} - APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS=${APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS:-5.0} - APP_LOG_LEVEL=${APP_LOG_LEVEL:-INFO} - APP_LOG_EXCLUDED_LOGGERS=${APP_LOG_EXCLUDED_LOGGERS:-uvicorn.access,httpx,pymongo,motor} ports: - "8888:8888" volumes: - ${HOME}/mcp-gateway/logs:/app/logs # - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/scopes.yml security_opt: - no-new-privileges:true cap_drop: - ALL depends_on: metrics-service: condition: service_healthy mongodb-init: condition: service_completed_successfully restart: unless-stopped # Current Time MCP Server - using pre-built image currenttime-server: image: ${DOCKERHUB_ORG:-mcpgateway}/currenttime-server:${CURRENTTIME_VERSION:-latest} environment: - PORT=8000 - MCP_TRANSPORT=streamable-http ports: - "8000:8000" security_opt: - no-new-privileges:true cap_drop: - ALL restart: unless-stopped # Financial Info MCP Server - using pre-built image fininfo-server: image: ${DOCKERHUB_ORG:-mcpgateway}/fininfo-server:${FININFO_VERSION:-latest} environment: - PORT=8001 - SECRET_KEY=${SECRET_KEY} volumes: - ${HOME}/mcp-gateway/secrets/fininfo/:/app/fininfo/ ports: - "8001:8001" security_opt: - no-new-privileges:true cap_drop: - ALL restart: unless-stopped # MCP Gateway Server - using pre-built image mcpgw-server: image: ${DOCKERHUB_ORG:-mcpgateway}/mcpgw-server:${MCPGW_VERSION:-latest} environment: - HOST=0.0.0.0 - PORT=8003 - REGISTRY_BASE_URL=http://registry:8080 volumes: - ${HOME}/mcp-gateway/servers:/app/registry/servers - ${HOME}/mcp-gateway/models:/app/registry/models - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/auth_server/scopes.yml ports: - "8003:8003" security_opt: - no-new-privileges:true cap_drop: - ALL depends_on: - registry restart: unless-stopped # Real Server Fake Tools MCP Server - using pre-built image realserverfaketools-server: image: ${DOCKERHUB_ORG:-mcpgateway}/realserverfaketools-server:${REALSERVERFAKETOOLS_VERSION:-latest} environment: - PORT=8002 ports: - "8002:8002" security_opt: - no-new-privileges:true cap_drop: - ALL restart: unless-stopped # SQLite container for metrics database - using mirrored pre-built image metrics-db: image: ${DOCKERHUB_ORG:-mcpgateway}/alpine:${ALPINE_VERSION:-latest} volumes: - metrics-db-data:/var/lib/sqlite command: ["sh", "-c", "apk add --no-cache sqlite && mkdir -p /var/lib/sqlite && sqlite3 /var/lib/sqlite/metrics.db 'CREATE TABLE IF NOT EXISTS _health (id INTEGER);' && tail -f /dev/null"] security_opt: - no-new-privileges:true cap_drop: - ALL restart: unless-stopped healthcheck: test: ["CMD", "sqlite3", "/var/lib/sqlite/metrics.db", ".tables"] interval: 30s timeout: 10s retries: 3 # Prometheus for metrics collection - using mirrored pre-built image prometheus: image: ${DOCKERHUB_ORG:-mcpgateway}/prometheus:${PROMETHEUS_VERSION:-latest} ports: - "9090:9090" volumes: - ./config/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--web.console.libraries=/etc/prometheus/console_libraries' - '--web.console.templates=/etc/prometheus/consoles' - '--storage.tsdb.retention.time=200h' - '--web.enable-lifecycle' security_opt: - no-new-privileges:true cap_drop: - ALL restart: unless-stopped # Grafana for metrics visualization - using mirrored pre-built image grafana: image: ${DOCKERHUB_ORG:-mcpgateway}/grafana:${GRAFANA_VERSION:-latest} ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?Set GRAFANA_ADMIN_PASSWORD in .env} - GF_USERS_ALLOW_SIGN_UP=false - GF_AUTH_ANONYMOUS_ENABLED=false volumes: - grafana-data:/var/lib/grafana - ./config/grafana/dashboards:/etc/grafana/provisioning/dashboards - ./config/grafana/datasources:/etc/grafana/provisioning/datasources security_opt: - no-new-privileges:true cap_drop: - ALL depends_on: - prometheus restart: unless-stopped # PostgreSQL database for Keycloak - using mirrored pre-built image keycloak-db: image: ${DOCKERHUB_ORG:-mcpgateway}/postgres:${POSTGRES_VERSION:-latest} environment: POSTGRES_DB: keycloak POSTGRES_USER: keycloak POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak} volumes: - keycloak_db_data:/var/lib/postgresql/data restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -U keycloak"] interval: 10s timeout: 5s retries: 5 # Keycloak Identity Provider - using mirrored pre-built image keycloak: image: ${DOCKERHUB_ORG:-mcpgateway}/keycloak:${KEYCLOAK_VERSION:-latest} command: start-dev # Use 'start' for production with proper SSL environment: # Database configuration KC_DB: postgres KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak KC_DB_USERNAME: keycloak KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak} # Admin credentials KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} # HTTP configuration KC_HTTP_ENABLED: 'true' KC_HTTP_PORT: 8080 KC_HOSTNAME_STRICT: 'false' KC_HOSTNAME_STRICT_HTTPS: 'false' KC_PROXY: edge # Running behind nginx # Frontend URL for external JWT issuer KC_FRONTEND_URL: ${KEYCLOAK_EXTERNAL_URL:-http://localhost:8080} # Features KC_FEATURES: token-exchange,admin-api # Logging KC_LOG_LEVEL: INFO ports: - "8080:8080" security_opt: - no-new-privileges:true cap_drop: - ALL depends_on: keycloak-db: condition: service_healthy volumes: - ./keycloak/themes:/opt/keycloak/themes - ./keycloak/providers:/opt/keycloak/providers - ./keycloak/import:/opt/keycloak/data/import restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"] interval: 30s timeout: 10s retries: 3 start_period: 60s # MongoDB keyfile initialization (generates keyfile for replica set authentication) # This runs once before MongoDB starts to create the keyfile with correct permissions mongodb-keyfile-init: image: alpine:latest container_name: mcp-mongodb-keyfile-init volumes: - mongodb-keyfile:/keyfile command: > sh -c " if [ ! -f /keyfile/replica.key ]; then echo 'Generating MongoDB keyfile...'; apk add --no-cache openssl; openssl rand -base64 756 > /keyfile/replica.key; chmod 400 /keyfile/replica.key; chown 999:999 /keyfile/replica.key; echo 'Keyfile generated successfully'; else echo 'Keyfile already exists, skipping generation'; fi " restart: "no" # MongoDB Community Edition 8.2 (alternative to DocumentDB for local development) # Vector search is implemented in application code (see search_repository.py) # Running with authentication enabled for security mongodb: image: ${DOCKERHUB_ORG:-mcpgateway}/mongo:${MONGODB_VERSION:-latest} container_name: mcp-mongodb command: mongod --replSet rs0 --bind_ip 127.0.0.1,mongodb --auth --keyFile /keyfile/replica.key environment: - MONGO_INITDB_ROOT_USERNAME=${DOCUMENTDB_USERNAME:-admin} - MONGO_INITDB_ROOT_PASSWORD=${DOCUMENTDB_PASSWORD:-admin} ports: - "27017:27017" volumes: - mongodb-data:/data/db - mongodb-config:/data/configdb - mongodb-keyfile:/keyfile:ro cap_drop: - ALL cap_add: - SETUID - SETGID - CHOWN - DAC_OVERRIDE depends_on: mongodb-keyfile-init: condition: service_completed_successfully healthcheck: test: ["CMD", "mongosh", "-u", "${DOCUMENTDB_USERNAME:-admin}", "-p", "${DOCUMENTDB_PASSWORD:-admin}", "--authenticationDatabase", "admin", "--eval", "db.adminCommand('ping')"] interval: 60s timeout: 5s retries: 5 start_period: 30s restart: unless-stopped # MongoDB initialization (creates replica set, indexes, and loads admin scope) mongodb-init: image: python:3.14-slim container_name: mcp-mongodb-init security_opt: - no-new-privileges:true cap_drop: - ALL depends_on: mongodb: condition: service_healthy environment: - DOCUMENTDB_HOST=mongodb - DOCUMENTDB_PORT=27017 - DOCUMENTDB_DATABASE=${DOCUMENTDB_DATABASE:-mcp_registry} - DOCUMENTDB_USERNAME=${DOCUMENTDB_USERNAME:-admin} - DOCUMENTDB_PASSWORD=${DOCUMENTDB_PASSWORD:-admin} - DOCUMENTDB_NAMESPACE=${DOCUMENTDB_NAMESPACE:-default} - ENTRA_GROUP_ADMIN_ID=${ENTRA_GROUP_ADMIN_ID:-} volumes: - ./scripts/init-mongodb-ce.py:/app/scripts/init-mongodb-ce.py:ro - ./scripts/registry-admins.json:/app/scripts/registry-admins.json:ro - ./scripts/mcp-registry-admin.json:/app/scripts/mcp-registry-admin.json:ro - ./scripts/mcp-servers-unrestricted-read.json:/app/scripts/mcp-servers-unrestricted-read.json:ro - ./scripts/mcp-servers-unrestricted-execute.json:/app/scripts/mcp-servers-unrestricted-execute.json:ro command: > sh -c " pip install --quiet motor pymongo && python /app/scripts/init-mongodb-ce.py " restart: "no" volumes: ssl_data: keycloak_db_data: metrics-db-data: prometheus-data: grafana-data: mongodb-data: mongodb-config: mongodb-keyfile: ================================================ FILE: docker-compose.yml ================================================ version: '3.8' # DocumentDB Initialization: # For AWS DocumentDB Elastic Cluster setup, run the initialization script: # export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com # export DOCUMENTDB_USERNAME=admin # export DOCUMENTDB_PASSWORD=yourpassword # ./scripts/init-documentdb.sh # # Then set STORAGE_BACKEND=documentdb in your environment and restart services. # Note: DocumentDB is a managed AWS service and runs outside of Docker. services: # MongoDB Community Edition 8.2 (alternative to DocumentDB for local development) # Vector search is implemented in application code (see search_repository.py) # Running without authentication for local development simplicity mongodb: image: mongo:8.2 container_name: mcp-mongodb command: mongod --replSet rs0 --bind_ip 127.0.0.1,mongodb ports: - "27017:27017" volumes: - mongodb-data:/data/db - mongodb-config:/data/configdb security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - SETUID # Required by gosu to switch to mongodb user at startup - SETGID # Required by gosu to switch to mongodb group at startup - CHOWN # Required by entrypoint to fix /data/db ownership - DAC_OVERRIDE # Required by entrypoint to read /data/db before chown healthcheck: test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] interval: 60s timeout: 5s retries: 5 start_period: 20s restart: unless-stopped # MongoDB initialization (creates replica set, indexes, and loads admin scope) mongodb-init: image: python:3.14-slim container_name: mcp-mongodb-init depends_on: mongodb: condition: service_healthy environment: - DOCUMENTDB_HOST=mongodb - DOCUMENTDB_PORT=27017 - DOCUMENTDB_DATABASE=${DOCUMENTDB_DATABASE:-mcp_registry} - DOCUMENTDB_USERNAME=${DOCUMENTDB_USERNAME:-} - DOCUMENTDB_PASSWORD=${DOCUMENTDB_PASSWORD:-} - DOCUMENTDB_NAMESPACE=${DOCUMENTDB_NAMESPACE:-default} - ENTRA_GROUP_ADMIN_ID=${ENTRA_GROUP_ADMIN_ID:-} volumes: - ./scripts/init-mongodb-ce.py:/app/scripts/init-mongodb-ce.py:ro - ./scripts/registry-admins.json:/app/scripts/registry-admins.json:ro - ./scripts/mcp-registry-admin.json:/app/scripts/mcp-registry-admin.json:ro - ./scripts/mcp-servers-unrestricted-read.json:/app/scripts/mcp-servers-unrestricted-read.json:ro - ./scripts/mcp-servers-unrestricted-execute.json:/app/scripts/mcp-servers-unrestricted-execute.json:ro command: > sh -c " pip install --quiet motor pymongo && python /app/scripts/init-mongodb-ce.py " restart: "no" # Registry service (includes nginx, SSL, FAISS, models) registry: build: context: . dockerfile: docker/Dockerfile.registry args: BUILD_VERSION: ${BUILD_VERSION:-1.0.0} environment: # Deployment Mode Configuration - DEPLOYMENT_MODE=${DEPLOYMENT_MODE:-with-gateway} - REGISTRY_MODE=${REGISTRY_MODE:-full} # Tab visibility overrides (AND-ed with REGISTRY_MODE) - SHOW_SERVERS_TAB=${SHOW_SERVERS_TAB:-true} - SHOW_VIRTUAL_SERVERS_TAB=${SHOW_VIRTUAL_SERVERS_TAB:-true} - SHOW_SKILLS_TAB=${SHOW_SKILLS_TAB:-true} - SHOW_AGENTS_TAB=${SHOW_AGENTS_TAB:-true} - GATEWAY_ADDITIONAL_SERVER_NAMES=${GATEWAY_ADDITIONAL_SERVER_NAMES:-} # Registry Card Configuration - REGISTRY_URL=${REGISTRY_URL:-http://localhost} - REGISTRY_NAME=${REGISTRY_NAME:-AI Registry} - REGISTRY_ORGANIZATION_NAME=${REGISTRY_ORGANIZATION_NAME:-ACME Inc.} - REGISTRY_DESCRIPTION=${REGISTRY_DESCRIPTION:-} - REGISTRY_CONTACT_EMAIL=${REGISTRY_CONTACT_EMAIL:-} - REGISTRY_CONTACT_URL=${REGISTRY_CONTACT_URL:-} - SECRET_KEY=${SECRET_KEY} - AUTH_SERVER_URL=${AUTH_SERVER_URL} - AUTH_SERVER_EXTERNAL_URL=${AUTH_SERVER_EXTERNAL_URL} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET} - GITHUB_ENABLED=${GITHUB_ENABLED:-false} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - GOOGLE_ENABLED=${GOOGLE_ENABLED:-false} - COGNITO_CLIENT_ID=${COGNITO_CLIENT_ID} - COGNITO_CLIENT_SECRET=${COGNITO_CLIENT_SECRET} - COGNITO_USER_POOL_ID=${COGNITO_USER_POOL_ID} - COGNITO_ENABLED=${COGNITO_ENABLED:-false} - AWS_REGION=${AWS_REGION:-us-east-1} - HEALTH_CHECK_INTERVAL_SECONDS=${HEALTH_CHECK_INTERVAL_SECONDS:-30} - SRE_GATEWAY_AUTH_TOKEN=${SRE_GATEWAY_AUTH_TOKEN} - ATLASSIAN_AUTH_TOKEN=${ATLASSIAN_AUTH_TOKEN} # Metrics configuration - METRICS_SERVICE_URL=http://metrics-service:8890 - METRICS_API_KEY=${METRICS_API_KEY_REGISTRY} - METRICS_API_KEY_NGINX=${METRICS_API_KEY_REGISTRY} # Keycloak configuration - AUTH_PROVIDER=${AUTH_PROVIDER:-cognito} - KEYCLOAK_ENABLED=${KEYCLOAK_ENABLED:-false} - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} - KEYCLOAK_EXTERNAL_URL=${KEYCLOAK_EXTERNAL_URL:-http://localhost:8080} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-mcp-gateway} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-mcp-gateway-web} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} - KEYCLOAK_ADMIN=${KEYCLOAK_ADMIN:-admin} - KEYCLOAK_ADMIN_PASSWORD=${KEYCLOAK_ADMIN_PASSWORD} - KEYCLOAK_M2M_CLIENT_ID=${KEYCLOAK_M2M_CLIENT_ID} - KEYCLOAK_M2M_CLIENT_SECRET=${KEYCLOAK_M2M_CLIENT_SECRET} # Entra ID configuration - ENTRA_TENANT_ID=${ENTRA_TENANT_ID} - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID} - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET} - ENTRA_ENABLED=${ENTRA_ENABLED:-false} # IdP group filtering (applies to all identity providers) - IDP_GROUP_FILTER_PREFIX=${IDP_GROUP_FILTER_PREFIX:-} # Okta configuration - OKTA_DOMAIN=${OKTA_DOMAIN:-} - OKTA_CLIENT_ID=${OKTA_CLIENT_ID:-} - OKTA_CLIENT_SECRET=${OKTA_CLIENT_SECRET:-} - OKTA_M2M_CLIENT_ID=${OKTA_M2M_CLIENT_ID:-} - OKTA_M2M_CLIENT_SECRET=${OKTA_M2M_CLIENT_SECRET:-} - OKTA_API_TOKEN=${OKTA_API_TOKEN:-} - OKTA_AUTH_SERVER_ID=${OKTA_AUTH_SERVER_ID:-} # Auth0 configuration - AUTH0_DOMAIN=${AUTH0_DOMAIN} - AUTH0_CLIENT_ID=${AUTH0_CLIENT_ID} - AUTH0_CLIENT_SECRET=${AUTH0_CLIENT_SECRET} - AUTH0_AUDIENCE=${AUTH0_AUDIENCE:-} - AUTH0_GROUPS_CLAIM=${AUTH0_GROUPS_CLAIM:-https://mcp-gateway/groups} - AUTH0_ENABLED=${AUTH0_ENABLED:-false} - AUTH0_M2M_CLIENT_ID=${AUTH0_M2M_CLIENT_ID:-} - AUTH0_M2M_CLIENT_SECRET=${AUTH0_M2M_CLIENT_SECRET:-} # External Registry Configuration - EXTERNAL_REGISTRY_TAGS=${EXTERNAL_REGISTRY_TAGS:-anthropic-registry,workday-asor} - ASOR_ACCESS_TOKEN=${ASOR_ACCESS_TOKEN} - ASOR_CLIENT_CREDENTIALS=${ASOR_CLIENT_CREDENTIALS} # Security Scanning Configuration - SECURITY_SCAN_ENABLED=${SECURITY_SCAN_ENABLED:-true} - SECURITY_SCAN_ON_REGISTRATION=${SECURITY_SCAN_ON_REGISTRATION:-true} - SECURITY_BLOCK_UNSAFE_SERVERS=${SECURITY_BLOCK_UNSAFE_SERVERS:-true} - SECURITY_ANALYZERS=${SECURITY_ANALYZERS:-yara} - SECURITY_SCAN_TIMEOUT=${SECURITY_SCAN_TIMEOUT:-60} - SECURITY_ADD_PENDING_TAG=${SECURITY_ADD_PENDING_TAG:-true} - MCP_SCANNER_LLM_API_KEY=${MCP_SCANNER_LLM_API_KEY} # GitHub Private Repository Access (SKILL.md fetching) - GITHUB_PAT=${GITHUB_PAT:-} - GITHUB_APP_ID=${GITHUB_APP_ID:-} - GITHUB_APP_INSTALLATION_ID=${GITHUB_APP_INSTALLATION_ID:-} - GITHUB_APP_PRIVATE_KEY=${GITHUB_APP_PRIVATE_KEY:-} - GITHUB_EXTRA_HOSTS=${GITHUB_EXTRA_HOSTS:-} - GITHUB_API_BASE_URL=${GITHUB_API_BASE_URL:-https://api.github.com} # Storage Backend Configuration - STORAGE_BACKEND=${STORAGE_BACKEND:-file} # DocumentDB/MongoDB Configuration (when STORAGE_BACKEND=documentdb) - DOCUMENTDB_HOST=${DOCUMENTDB_HOST:-mongodb} - DOCUMENTDB_PORT=${DOCUMENTDB_PORT:-27017} - DOCUMENTDB_USERNAME=${DOCUMENTDB_USERNAME} - DOCUMENTDB_PASSWORD=${DOCUMENTDB_PASSWORD} - DOCUMENTDB_DATABASE=${DOCUMENTDB_DATABASE:-mcp_registry} - DOCUMENTDB_NAMESPACE=${DOCUMENTDB_NAMESPACE:-default} - DOCUMENTDB_USE_TLS=${DOCUMENTDB_USE_TLS:-false} - DOCUMENTDB_TLS_CA_FILE=${DOCUMENTDB_TLS_CA_FILE:-} - DOCUMENTDB_USE_IAM=${DOCUMENTDB_USE_IAM:-false} - DOCUMENTDB_REPLICA_SET=${DOCUMENTDB_REPLICA_SET:-rs0} - DOCUMENTDB_READ_PREFERENCE=${DOCUMENTDB_READ_PREFERENCE:-secondaryPreferred} # Embeddings Configuration - EMBEDDINGS_PROVIDER=${EMBEDDINGS_PROVIDER:-sentence-transformers} - EMBEDDINGS_MODEL_NAME=${EMBEDDINGS_MODEL_NAME:-all-MiniLM-L6-v2} - EMBEDDINGS_MODEL_DIMENSIONS=${EMBEDDINGS_MODEL_DIMENSIONS:-384} - EMBEDDINGS_API_KEY=${EMBEDDINGS_API_KEY} - EMBEDDINGS_API_BASE=${EMBEDDINGS_API_BASE} - EMBEDDINGS_AWS_REGION=${EMBEDDINGS_AWS_REGION:-us-east-1} # ANS (Agent Name Service) Configuration - ANS_INTEGRATION_ENABLED=${ANS_INTEGRATION_ENABLED:-false} - ANS_API_ENDPOINT=${ANS_API_ENDPOINT:-https://api.godaddy.com} - ANS_API_KEY=${ANS_API_KEY:-} - ANS_API_SECRET=${ANS_API_SECRET:-} - ANS_API_TIMEOUT_SECONDS=${ANS_API_TIMEOUT_SECONDS:-30} - ANS_SYNC_INTERVAL_HOURS=${ANS_SYNC_INTERVAL_HOURS:-6} - ANS_VERIFICATION_CACHE_TTL_SECONDS=${ANS_VERIFICATION_CACHE_TTL_SECONDS:-3600} # Federation Peer Sync Configuration - FEDERATION_TOKEN_ENDPOINT=${FEDERATION_TOKEN_ENDPOINT} - FEDERATION_CLIENT_ID=${FEDERATION_CLIENT_ID} - FEDERATION_CLIENT_SECRET=${FEDERATION_CLIENT_SECRET} # Federation Token Encryption (for encrypting federation_token before storage) - FEDERATION_ENCRYPTION_KEY=${FEDERATION_ENCRYPTION_KEY:-} # Federation static token auth - FEDERATION_STATIC_TOKEN_AUTH_ENABLED=${FEDERATION_STATIC_TOKEN_AUTH_ENABLED:-false} - FEDERATION_STATIC_TOKEN=${FEDERATION_STATIC_TOKEN:-} # Auth server config (mirrored for config panel visibility) - OAUTH_STORE_TOKENS_IN_SESSION=${OAUTH_STORE_TOKENS_IN_SESSION:-false} - REGISTRY_STATIC_TOKEN_AUTH_ENABLED=${REGISTRY_STATIC_TOKEN_AUTH_ENABLED:-false} - REGISTRY_API_TOKEN=${REGISTRY_API_TOKEN:-} - REGISTRY_API_KEYS=${REGISTRY_API_KEYS:-} # Registration Webhook - REGISTRATION_WEBHOOK_URL=${REGISTRATION_WEBHOOK_URL:-} - REGISTRATION_WEBHOOK_AUTH_HEADER=${REGISTRATION_WEBHOOK_AUTH_HEADER:-Authorization} - REGISTRATION_WEBHOOK_AUTH_TOKEN=${REGISTRATION_WEBHOOK_AUTH_TOKEN:-} - REGISTRATION_WEBHOOK_TIMEOUT_SECONDS=${REGISTRATION_WEBHOOK_TIMEOUT_SECONDS:-10} # Registration Gate (Admission Control) - REGISTRATION_GATE_ENABLED=${REGISTRATION_GATE_ENABLED:-false} - REGISTRATION_GATE_URL=${REGISTRATION_GATE_URL:-} - REGISTRATION_GATE_AUTH_TYPE=${REGISTRATION_GATE_AUTH_TYPE:-none} - REGISTRATION_GATE_AUTH_CREDENTIAL=${REGISTRATION_GATE_AUTH_CREDENTIAL:-} - REGISTRATION_GATE_AUTH_HEADER_NAME=${REGISTRATION_GATE_AUTH_HEADER_NAME:-X-Api-Key} - REGISTRATION_GATE_TIMEOUT_SECONDS=${REGISTRATION_GATE_TIMEOUT_SECONDS:-5} - REGISTRATION_GATE_MAX_RETRIES=${REGISTRATION_GATE_MAX_RETRIES:-2} # M2M Direct Registration - M2M_DIRECT_REGISTRATION_ENABLED=${M2M_DIRECT_REGISTRATION_ENABLED:-true} - MAX_TOKENS_PER_USER_PER_HOUR=${MAX_TOKENS_PER_USER_PER_HOUR:-100} # OpenTelemetry / OTLP (mirrored for config panel visibility) - OTEL_OTLP_ENDPOINT=${OTEL_OTLP_ENDPOINT:-} - OTEL_OTLP_EXPORT_INTERVAL_MS=${OTEL_OTLP_EXPORT_INTERVAL_MS:-30000} - OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=${OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE:-cumulative} # Telemetry Configuration # Disable all: set MCP_TELEMETRY_DISABLED=1 to disable all telemetry (startup ping + heartbeat) # Heartbeat opt-out: set MCP_TELEMETRY_OPT_OUT=1 to disable daily heartbeat only # Heartbeat interval: set MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES=1440 (default: 1440 = 24h) # Endpoint: set TELEMETRY_ENDPOINT= to use a self-hosted collector # Debug: set TELEMETRY_DEBUG=true to log payloads without sending - MCP_TELEMETRY_DISABLED=${MCP_TELEMETRY_DISABLED:-} - MCP_TELEMETRY_OPT_OUT=${MCP_TELEMETRY_OPT_OUT:-} - MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES=${MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES:-1440} - TELEMETRY_DEBUG=${TELEMETRY_DEBUG:-false} - DISABLE_AI_REGISTRY_TOOLS_SERVER=${DISABLE_AI_REGISTRY_TOOLS_SERVER:-false} # Application Log Configuration (Issue #886) - APP_LOG_MAX_BYTES=${APP_LOG_MAX_BYTES:-52428800} - APP_LOG_BACKUP_COUNT=${APP_LOG_BACKUP_COUNT:-5} - APP_LOG_CENTRALIZED_ENABLED=${APP_LOG_CENTRALIZED_ENABLED:-true} - APP_LOG_CENTRALIZED_TTL_DAYS=${APP_LOG_CENTRALIZED_TTL_DAYS:-1} - APP_LOG_MONGODB_BUFFER_SIZE=${APP_LOG_MONGODB_BUFFER_SIZE:-50} - APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS=${APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS:-5.0} - APP_LOG_LEVEL=${APP_LOG_LEVEL:-INFO} - APP_LOG_EXCLUDED_LOGGERS=${APP_LOG_EXCLUDED_LOGGERS:-uvicorn.access,httpx,pymongo,motor} ports: - "80:8080" # Map host 80 to container 8080 (non-root nginx) - "443:8443" # Map host 443 to container 8443 (non-root nginx) - "7860:7860" security_opt: - no-new-privileges:true cap_drop: - ALL volumes: # User-managed content (bind mounts) - ${HOME}/mcp-gateway/servers:/app/registry/servers - ${HOME}/mcp-gateway/agents:/app/registry/agents - ${HOME}/mcp-gateway/models:/app/registry/models - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/auth_server/scopes.yml - ${HOME}/mcp-gateway/federation.json:/app/config/federation.json - ${HOME}/mcp-gateway/ssl:/etc/ssl:ro - ${HOME}/.aws:/root/.aws:ro # Application-managed (named volumes with proper permissions) - registry-logs:/app/logs - registry-scans:/app/security_scans depends_on: auth-server: condition: service_started metrics-service: condition: service_healthy mongodb-init: condition: service_completed_successfully restart: unless-stopped # Metrics Collection Service metrics-service: build: context: metrics-service dockerfile: Dockerfile environment: - METRICS_SERVICE_PORT=8890 - METRICS_SERVICE_HOST=0.0.0.0 - SQLITE_DB_PATH=/var/lib/sqlite/metrics.db - METRICS_RETENTION_DAYS=90 - METRICS_API_KEY_AUTH=${METRICS_API_KEY_AUTH_SERVER} - METRICS_API_KEY_REGISTRY=${METRICS_API_KEY_REGISTRY} - METRICS_API_KEY_MCPGW=${METRICS_API_KEY_MCPGW_SERVER} - OTEL_SERVICE_NAME=mcp-metrics-service - OTEL_PROMETHEUS_ENABLED=true - OTEL_PROMETHEUS_PORT=9465 - METRICS_RATE_LIMIT=1000 - OTEL_OTLP_ENDPOINT=${OTEL_OTLP_ENDPOINT:-} - OTEL_EXPORTER_OTLP_HEADERS=${OTEL_EXPORTER_OTLP_HEADERS:-} - OTEL_OTLP_EXPORT_INTERVAL_MS=${OTEL_OTLP_EXPORT_INTERVAL_MS:-30000} - OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=${OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE:-cumulative} ports: - "8890:8890" - "9465:9465" # Prometheus metrics endpoint security_opt: - no-new-privileges:true cap_drop: - ALL volumes: - metrics-db-data:/var/lib/sqlite # Application-managed (named volume with proper permissions) - metrics-logs:/app/logs depends_on: - metrics-db restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8890/health"] interval: 30s timeout: 10s retries: 3 # Auth service (separate and scalable) auth-server: build: context: . dockerfile: docker/Dockerfile.auth environment: # Registry Configuration - REGISTRY_URL=${REGISTRY_URL:-http://localhost} - REGISTRY_NAME=${REGISTRY_NAME:-AI Registry} - REGISTRY_ORGANIZATION_NAME=${REGISTRY_ORGANIZATION_NAME:-ACME Inc.} - AUTH_SERVER_EXTERNAL_URL=${AUTH_SERVER_EXTERNAL_URL:-http://localhost:8888} - SECRET_KEY=${SECRET_KEY} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET} - GITHUB_ENABLED=${GITHUB_ENABLED:-false} - COGNITO_CLIENT_ID=${COGNITO_CLIENT_ID} - COGNITO_CLIENT_SECRET=${COGNITO_CLIENT_SECRET} - COGNITO_USER_POOL_ID=${COGNITO_USER_POOL_ID} - COGNITO_DOMAIN=${COGNITO_DOMAIN:-auto} - COGNITO_ENABLED=${COGNITO_ENABLED:-false} - AWS_REGION=${AWS_REGION:-us-east-1} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - GOOGLE_ENABLED=${GOOGLE_ENABLED:-false} # Metrics configuration - METRICS_SERVICE_URL=http://metrics-service:8890 - METRICS_API_KEY=${METRICS_API_KEY_AUTH_SERVER} # Keycloak configuration - AUTH_PROVIDER=${AUTH_PROVIDER:-cognito} # 'cognito' or 'keycloak' - KEYCLOAK_ENABLED=${KEYCLOAK_ENABLED:-true} # Enable Keycloak by default - KEYCLOAK_URL=${KEYCLOAK_URL:-http://keycloak:8080} - KEYCLOAK_EXTERNAL_URL=${KEYCLOAK_EXTERNAL_URL:-http://localhost:8080} - KEYCLOAK_REALM=${KEYCLOAK_REALM:-mcp-gateway} - KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID:-mcp-gateway-web} - KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET} - KEYCLOAK_M2M_CLIENT_ID=${KEYCLOAK_M2M_CLIENT_ID:-mcp-gateway-m2m} - KEYCLOAK_M2M_CLIENT_SECRET=${KEYCLOAK_M2M_CLIENT_SECRET} # Entra ID configuration - ENTRA_TENANT_ID=${ENTRA_TENANT_ID} - ENTRA_CLIENT_ID=${ENTRA_CLIENT_ID} - ENTRA_CLIENT_SECRET=${ENTRA_CLIENT_SECRET} - ENTRA_ENABLED=${ENTRA_ENABLED:-false} # IdP group filtering (applies to all identity providers) - IDP_GROUP_FILTER_PREFIX=${IDP_GROUP_FILTER_PREFIX:-} # Okta configuration - OKTA_DOMAIN=${OKTA_DOMAIN:-} - OKTA_CLIENT_ID=${OKTA_CLIENT_ID:-} - OKTA_CLIENT_SECRET=${OKTA_CLIENT_SECRET:-} - OKTA_M2M_CLIENT_ID=${OKTA_M2M_CLIENT_ID:-} - OKTA_M2M_CLIENT_SECRET=${OKTA_M2M_CLIENT_SECRET:-} - OKTA_API_TOKEN=${OKTA_API_TOKEN:-} - OKTA_AUTH_SERVER_ID=${OKTA_AUTH_SERVER_ID:-} # Auth0 configuration - AUTH0_DOMAIN=${AUTH0_DOMAIN} - AUTH0_CLIENT_ID=${AUTH0_CLIENT_ID} - AUTH0_CLIENT_SECRET=${AUTH0_CLIENT_SECRET} - AUTH0_AUDIENCE=${AUTH0_AUDIENCE:-} - AUTH0_GROUPS_CLAIM=${AUTH0_GROUPS_CLAIM:-https://mcp-gateway/groups} - AUTH0_ENABLED=${AUTH0_ENABLED:-false} - AUTH0_M2M_CLIENT_ID=${AUTH0_M2M_CLIENT_ID:-} - AUTH0_M2M_CLIENT_SECRET=${AUTH0_M2M_CLIENT_SECRET:-} # Storage Backend Configuration - STORAGE_BACKEND=${STORAGE_BACKEND:-file} # DocumentDB/MongoDB Configuration (when STORAGE_BACKEND=documentdb or mongodb-ce) - DOCUMENTDB_HOST=${DOCUMENTDB_HOST:-mongodb} - DOCUMENTDB_PORT=${DOCUMENTDB_PORT:-27017} - DOCUMENTDB_USERNAME=${DOCUMENTDB_USERNAME} - DOCUMENTDB_PASSWORD=${DOCUMENTDB_PASSWORD} - DOCUMENTDB_DATABASE=${DOCUMENTDB_DATABASE:-mcp_registry} - DOCUMENTDB_NAMESPACE=${DOCUMENTDB_NAMESPACE:-default} - DOCUMENTDB_USE_TLS=${DOCUMENTDB_USE_TLS:-false} - DOCUMENTDB_TLS_CA_FILE=${DOCUMENTDB_TLS_CA_FILE} - DOCUMENTDB_USE_IAM=${DOCUMENTDB_USE_IAM:-false} - DOCUMENTDB_REPLICA_SET=${DOCUMENTDB_REPLICA_SET:-rs0} - DOCUMENTDB_READ_PREFERENCE=${DOCUMENTDB_READ_PREFERENCE:-secondaryPreferred} # Registry API static token auth (IdP-independent access) - REGISTRY_STATIC_TOKEN_AUTH_ENABLED=${REGISTRY_STATIC_TOKEN_AUTH_ENABLED:-false} - REGISTRY_API_TOKEN=${REGISTRY_API_TOKEN:-} - REGISTRY_API_KEYS=${REGISTRY_API_KEYS:-} # Federation static token auth (scoped access for peer registries) - FEDERATION_STATIC_TOKEN_AUTH_ENABLED=${FEDERATION_STATIC_TOKEN_AUTH_ENABLED:-false} - FEDERATION_STATIC_TOKEN=${FEDERATION_STATIC_TOKEN:-} # OAuth token storage in session (set to false for Entra ID large tokens) - OAUTH_STORE_TOKENS_IN_SESSION=${OAUTH_STORE_TOKENS_IN_SESSION:-false} # Application Log Configuration (Issue #886) - APP_LOG_MAX_BYTES=${APP_LOG_MAX_BYTES:-52428800} - APP_LOG_BACKUP_COUNT=${APP_LOG_BACKUP_COUNT:-5} - APP_LOG_CENTRALIZED_ENABLED=${APP_LOG_CENTRALIZED_ENABLED:-true} - APP_LOG_CENTRALIZED_TTL_DAYS=${APP_LOG_CENTRALIZED_TTL_DAYS:-1} - APP_LOG_MONGODB_BUFFER_SIZE=${APP_LOG_MONGODB_BUFFER_SIZE:-50} - APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS=${APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS:-5.0} - APP_LOG_LEVEL=${APP_LOG_LEVEL:-INFO} - APP_LOG_EXCLUDED_LOGGERS=${APP_LOG_EXCLUDED_LOGGERS:-uvicorn.access,httpx,pymongo,motor} ports: - "8888:8888" security_opt: - no-new-privileges:true cap_drop: - ALL volumes: # Application-managed (named volume with proper permissions) - auth-logs:/app/logs # - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/scopes.yml depends_on: metrics-service: condition: service_healthy mongodb-init: condition: service_completed_successfully restart: unless-stopped # Current Time MCP Server currenttime-server: build: context: . dockerfile: docker/Dockerfile.mcp-server-light args: SERVER_DIR: servers/currenttime environment: - HOST=0.0.0.0 - PORT=8000 - MCP_TRANSPORT=streamable-http ports: - "8000:8000" restart: unless-stopped # Financial Info MCP Server fininfo-server: build: context: . dockerfile: docker/Dockerfile.mcp-server-light args: SERVER_DIR: servers/fininfo environment: - PORT=8001 - SECRET_KEY=${SECRET_KEY} volumes: - ${HOME}/mcp-gateway/secrets/fininfo/:/app/fininfo/ ports: - "8001:8001" restart: unless-stopped # MCP Gateway Server mcpgw-server: build: context: . dockerfile: docker/Dockerfile.mcp-server args: SERVER_DIR: servers/mcpgw environment: - HOST=0.0.0.0 - PORT=8003 - REGISTRY_BASE_URL=http://registry:8080 volumes: - ${HOME}/mcp-gateway/servers:/app/registry/servers - ${HOME}/mcp-gateway/models:/app/registry/models - ${HOME}/mcp-gateway/auth_server/scopes.yml:/app/auth_server/scopes.yml ports: - "8003:8003" depends_on: - registry restart: unless-stopped # Real Server Fake Tools MCP Server realserverfaketools-server: build: context: . dockerfile: docker/Dockerfile.mcp-server-light args: SERVER_DIR: servers/realserverfaketools environment: - PORT=8002 ports: - "8002:8002" restart: unless-stopped # SQLite container for metrics database metrics-db: build: context: . dockerfile: docker/Dockerfile.metrics-db security_opt: - no-new-privileges:true cap_drop: - ALL volumes: - metrics-db-data:/var/lib/sqlite restart: unless-stopped healthcheck: test: ["CMD", "sqlite3", "/var/lib/sqlite/metrics.db", ".tables"] interval: 30s timeout: 10s retries: 3 # Prometheus for metrics collection prometheus: image: prom/prometheus:latest ports: - "9090:9090" volumes: - ./config/prometheus.yml:/etc/prometheus/prometheus.yml - prometheus-data:/prometheus command: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--web.console.libraries=/etc/prometheus/console_libraries' - '--web.console.templates=/etc/prometheus/consoles' - '--storage.tsdb.retention.time=200h' - '--web.enable-lifecycle' restart: unless-stopped # Grafana for metrics visualization grafana: image: grafana/grafana:12.3.1 ports: - "3000:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:?Set GRAFANA_ADMIN_PASSWORD in .env} - GF_USERS_ALLOW_SIGN_UP=false - GF_AUTH_ANONYMOUS_ENABLED=false volumes: - grafana-data:/var/lib/grafana - ./config/grafana/dashboards:/etc/grafana/provisioning/dashboards - ./config/grafana/datasources:/etc/grafana/provisioning/datasources depends_on: - prometheus restart: unless-stopped # PostgreSQL database for Keycloak keycloak-db: image: postgres:16-alpine environment: POSTGRES_DB: keycloak POSTGRES_USER: keycloak POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak} volumes: - keycloak_db_data:/var/lib/postgresql/data restart: unless-stopped healthcheck: test: ["CMD-SHELL", "pg_isready -U keycloak"] interval: 10s timeout: 5s retries: 5 # Keycloak Identity Provider keycloak: image: quay.io/keycloak/keycloak:25.0 command: start-dev # Use 'start' for production with proper SSL environment: # Database configuration KC_DB: postgres KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak KC_DB_USERNAME: keycloak KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD:-keycloak} # Admin credentials KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin} KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} # HTTP configuration KC_HTTP_ENABLED: 'true' KC_HTTP_PORT: 8080 KC_HOSTNAME_STRICT: 'false' KC_HOSTNAME_STRICT_HTTPS: 'false' KC_PROXY: edge # Running behind nginx # Frontend URL for external JWT issuer KC_FRONTEND_URL: ${KEYCLOAK_EXTERNAL_URL:-http://localhost:8080} # Features KC_FEATURES: token-exchange,admin-api # Logging KC_LOG_LEVEL: INFO ports: - "8080:8080" security_opt: - no-new-privileges:true cap_drop: - ALL depends_on: keycloak-db: condition: service_healthy volumes: - ./keycloak/themes:/opt/keycloak/themes - ./keycloak/providers:/opt/keycloak/providers - ./keycloak/import:/opt/keycloak/data/import restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"] interval: 30s timeout: 10s retries: 3 start_period: 60s volumes: ssl_data: keycloak_db_data: metrics-db-data: prometheus-data: grafana-data: mongodb-data: mongodb-config: # Application logs and scans (managed by containers, proper permissions) registry-logs: registry-scans: auth-logs: metrics-logs: ================================================ FILE: docs/FEATURES.md ================================================ # MCP Gateway & Registry - Feature Overview This document provides a comprehensive overview of the MCP Gateway & Registry solution capabilities, designed for stakeholder presentations, marketing materials, and solution demonstrations. ## Core Problem Solved - **Multi-Platform AI Tool Integration**: Unified gateway for accessing tools across different MCP servers, eliminating the need to manage multiple connections and authentication schemes - **Centralized Tool Catalog**: Registry acts as a comprehensive catalog of available tools for developers, AI agents, and knowledge workers - **Dynamic Tool Discovery**: Intelligent routing based on natural language queries and semantic matching, reducing configuration overhead ## Registry & Management - **Centralized Server Registry**: MongoDB/DocumentDB-backed configuration for all MCP servers and their capabilities - **Dynamic Tool Catalog**: Real-time discovery of available tools across registered servers - **MCP Server Version Routing**: Run multiple versions of the same server behind a single gateway endpoint with instant rollback, version pinning, and deprecation lifecycle - **Custom Metadata**: Add rich custom metadata to servers and agents for organization, compliance, and integration tracking, fully searchable via semantic search - **Server & Agent Rating System**: 5-star rating widget with aggregate scoring, one rating per user, and rotating buffer - **Health Monitoring**: Built-in health checks and status monitoring for all registered services - **Scalable Architecture**: Docker-based deployment with horizontal scaling support ## Agent Registry & A2A Communication - **A2A Protocol Support**: Agent registration, discovery, and direct agent-to-agent communication - **Agent Security Scanning**: Integrated scanning using Cisco AI Defense A2A Scanner with YARA pattern matching and heuristic threat detection - **Agent Discovery API**: Semantic search API for dynamic agent composition at runtime - **Agent Cards & Metadata**: Rich metadata for agent capabilities, skills, and authentication schemes ## Authentication & Security - **Multi-Provider OAuth 2.0/OIDC Support**: Keycloak, Microsoft Entra ID, AWS Cognito integration - **Multi-Provider IAM**: Harmonized API for user and group management across identity providers - **Static Token Auth**: IdP-independent API access for Registry endpoints using static API keys, designed for CI/CD pipelines and trusted network environments - **Enterprise SSO Ready**: Seamless integration with existing identity providers including Microsoft Entra ID - **Service Principal Support**: M2M service accounts with OAuth2 Client Credentials flow for AI agent identity - **Fine-Grained Access Control**: Scopes define which MCP servers, methods, tools, and agents each user can access - **Self-Signed JWT Tokens**: Human users can generate tokens for CLI tools and AI coding assistants - **Secure Token Management**: OAuth token refresh and validation with centralized session management - **MCP Server Security Scanning**: Integrated vulnerability scanning with Cisco AI Defense MCP Scanner - **Compliance Audit Logging**: Comprehensive audit logs for all API and MCP access events with TTL-based retention, credential masking, and admin UI for compliance monitoring ## Intelligent Tool Discovery - **Hybrid Search**: Combined vector similarity with tokenized keyword matching for servers, tools, and agents - **Semantic Search**: HNSW vector search using sentence transformers or LiteLLM-supported providers - **Unified Search**: Single endpoint searches across MCP servers, tools, and A2A agents - **Tag-Based Filtering**: Multi-tag filtering with AND logic for precise tool selection - **Flexible Embeddings**: Local sentence-transformers, OpenAI, Amazon Bedrock Titan, or any LiteLLM-supported provider - **Performance Optimized**: Configurable result limits and caching for fast response times ## Developer Experience - **MCP Registry CLI**: Claude Code-like conversational interface for registry management with real-time token status and cost tracking - **Registry Management API**: Programmatic API for managing servers, groups, and users with Python client - **Multiple Client Libraries**: Python agent with extensible authentication - **Comprehensive Documentation**: Setup guides, API documentation, and integration examples - **Testing Framework**: 850+ pytest tests (unit, integration, E2E) with GitHub Actions CI - **Development Tools**: Docker Compose for local development and testing ## Federation & External Registries - **Peer-to-Peer Registry Federation**: Connect MCP Gateway Registry instances for bidirectional server and agent sync with static token or OAuth2 authentication - **Federation UI**: VS Code-style Settings page for managing peer registries, sync modes (all, whitelist, tag filter), and monitoring sync status - **Federated Registry**: Import servers and agents from external registries - **Anthropic MCP Registry**: Import curated MCP servers with API compatibility - **Workday ASOR**: Import AI agents from Agent System of Record - **Automatic Sync**: Scheduled synchronization with external registries and peer registries - **Amazon Bedrock AgentCore**: Gateway support with dual authentication ## Enterprise Integration - **Container-Ready Deployment**: Docker Hub images with pre-built containers - **AWS ECS Production Deployment**: Multi-AZ Fargate deployment with ALB, auto-scaling, CloudWatch, and Terraform - **Flexible Deployment Modes**: CloudFront Only, Custom Domain with Route53/ACM, or CloudFront + Custom Domain - **Reverse Proxy Architecture**: Nginx-based ingress with SSL termination - **DocumentDB & MongoDB CE Storage**: Distributed storage with HNSW vector search - **Real-Time Metrics & Observability**: Grafana dashboards with SQLite and OpenTelemetry integration - **Configuration Management**: Environment-based configuration with validation ## Technical Specifications - **Protocol Compliance**: Full MCP (Model Context Protocol) specification support - **A2A Protocol**: Agent-to-Agent protocol support for autonomous agent ecosystems - **High Performance**: Async/await architecture with concurrent request handling - **Extensible Design**: Plugin architecture for custom authentication providers - **Cross-Platform**: Linux, macOS, Windows support with consistent APIs ## Deployment Options - **Pre-built Images**: Deploy instantly with Docker Hub images - **Quick Start**: Docker Compose setup in minutes - **AWS ECS Fargate**: Production deployment with Terraform - **Cloud Native**: Kubernetes manifests and cloud deployment guides - **Local Development**: MongoDB CE with full-featured local development - **Podman Support**: Rootless container deployment for macOS and Linux ## Use Cases Supported - **AI Agent Orchestration**: Centralized tool access for autonomous agents - **Agent-to-Agent Communication**: Direct peer-to-peer agent communication through unified registry - **CI/CD Integration**: Static token auth for automated pipelines without IdP dependency - **Enterprise Tool Consolidation**: Single gateway for diverse internal tools - **Development Team Productivity**: Unified interface for developer tools and services - **Research & Analytics**: Streamlined access to data processing and analysis tools - **Customer Support**: Integrated access to support tools and knowledge bases ## Competitive Advantages - **Zero Vendor Lock-in**: Open architecture supporting any MCP-compliant server - **Unified Agent & Server Registry**: Single control plane for both MCP servers and AI agents - **Minimal Configuration**: Automatic tool discovery reduces setup complexity - **Enterprise Security**: Authentication and authorization with multiple IdP support - **Developer Friendly**: Clear APIs, CLI tools, and comprehensive documentation - **Cost Effective**: Reduces integration overhead and maintenance complexity ================================================ FILE: docs/OBSERVABILITY.md ================================================ # MCP Gateway Observability Guide This guide covers how to access and query metrics collected by the MCP Gateway metrics service. ## Table of Contents - [Architecture Overview](#architecture-overview) - [Accessing Metrics](#accessing-metrics) - [SQLite Database Queries](#sqlite-database-queries) - [OpenTelemetry Metrics](#opentelemetry-metrics) - [Configuring OpenTelemetry Collector](#configuring-opentelemetry-collector) - [Grafana Dashboards](#grafana-dashboards) ## Architecture Overview The MCP Gateway collects comprehensive metrics through a dual-path observability system: 1. **SQLite Storage**: All metrics are stored in specialized database tables for detailed querying and analysis 2. **OpenTelemetry Export**: Metrics are simultaneously exported to OpenTelemetry for real-time monitoring via Prometheus and Grafana ### Metrics Collection Flow ``` Auth Server Middleware → Metrics Service API → Dual Path: ├─> SQLite Database (detailed storage) └─> OpenTelemetry (Prometheus/Grafana) ``` ### Database Tables - **`auth_metrics`**: Authentication requests and validation - **`tool_metrics`**: Tool execution details (calls, methods, client info) - **`discovery_metrics`**: Tool discovery/search queries - **`metrics`**: Raw metrics data (all types) - **`api_keys`**: API key management for metrics service ## Accessing Metrics ### Access SQLite Database The metrics database is stored in a Docker volume and accessed via the `metrics-db` container: ```bash # Connect to the metrics-db container docker compose exec metrics-db sh # Access SQLite database sqlite3 /var/lib/sqlite/metrics.db # Enable better formatting .mode column .headers on ``` ### Alternative: Copy Database Locally ```bash # Copy database from container to host docker compose cp metrics-db:/var/lib/sqlite/metrics.db ./metrics.db # Install sqlite3 locally if needed sudo apt-get install -y sqlite3 # Query locally sqlite3 ./metrics.db ``` ## SQLite Database Queries ### Database Overview #### List All Tables ```sql .tables ``` **Output:** ``` _health auth_metrics metrics api_keys discovery_metrics tool_metrics ``` #### Count Metrics by Table ```sql SELECT 'auth_metrics' as table_name, COUNT(*) as count FROM auth_metrics UNION ALL SELECT 'tool_metrics', COUNT(*) FROM tool_metrics UNION ALL SELECT 'discovery_metrics', COUNT(*) FROM discovery_metrics UNION ALL SELECT 'metrics', COUNT(*) FROM metrics; ``` **Sample Output:** ``` table_name count ----------------- ----- auth_metrics 212 tool_metrics 183 discovery_metrics 0 metrics 475 ``` ### Authentication Metrics #### Recent Auth Requests ```sql SELECT datetime(timestamp) as time, server, success, method, duration_ms, user_hash, error_code FROM auth_metrics ORDER BY timestamp DESC LIMIT 20; ``` **Sample Output:** ``` time server success method duration_ms user_hash error_code ------------------- ------------------- ------- ------- ---------------- --------- ---------- 2025-10-02 04:43:22 mcpgw 0 unknown 14.0132130181883 500 2025-10-02 04:43:22 currenttime 0 unknown 13.9779029996134 500 2025-10-02 04:43:22 realserverfaketools 0 unknown 12.8724499954842 500 2025-10-02 04:43:22 sre-gateway 0 unknown 8.54846101719886 500 ``` #### Auth Success Rate by Server ```sql SELECT server, COUNT(*) as total, SUM(success) as successful, ROUND(100.0 * SUM(success) / COUNT(*), 2) as success_pct, ROUND(AVG(duration_ms), 2) as avg_ms FROM auth_metrics GROUP BY server ORDER BY total DESC; ``` #### Hourly Request Volume (Last 24 Hours) ```sql SELECT strftime('%Y-%m-%d %H:00', timestamp) as hour, COUNT(*) as requests FROM auth_metrics WHERE timestamp > datetime('now', '-24 hours') GROUP BY hour ORDER BY hour DESC; ``` ### Tool Execution Metrics #### Recent Tool Executions ```sql SELECT datetime(timestamp) as time, tool_name, server_name, success, ROUND(duration_ms, 2) as dur_ms, method, client_name FROM tool_metrics ORDER BY timestamp DESC LIMIT 20; ``` **Sample Output:** ``` time tool_name server_name success dur_ms method client_name ------------------- ---------- ------------------- ------- ------ ---------- ----------- 2025-10-02 04:43:22 initialize mcpgw 0 14.01 initialize claude-code 2025-10-02 04:43:22 initialize currenttime 0 13.98 initialize claude-code 2025-10-02 04:43:22 initialize fininfo 0 10.47 initialize claude-code 2025-10-02 04:42:59 initialize currenttime 0 7.61 initialize Roo Code 2025-10-02 04:42:59 initialize mcpgw 0 10.24 initialize Roo Code ``` #### Tool Usage Summary ```sql SELECT tool_name, COUNT(*) as calls, SUM(success) as successful, ROUND(AVG(duration_ms), 2) as avg_ms, COUNT(DISTINCT client_name) as unique_clients FROM tool_metrics GROUP BY tool_name ORDER BY calls DESC; ``` #### Client Usage Statistics ```sql SELECT client_name, client_version, COUNT(*) as calls, COUNT(DISTINCT tool_name) as unique_tools, COUNT(DISTINCT server_name) as unique_servers FROM tool_metrics WHERE client_name IS NOT NULL GROUP BY client_name, client_version ORDER BY calls DESC; ``` #### Slowest Tool Executions ```sql SELECT tool_name, server_name, ROUND(duration_ms, 2) as duration_ms, datetime(timestamp) as time, success FROM tool_metrics ORDER BY duration_ms DESC LIMIT 20; ``` **Sample Output:** ``` tool_name server_name duration_ms time success ------------------------- ------------------- ----------- ------------------- ------- initialize mcpgw 637.67 2025-10-02 03:32:51 0 initialize fininfo 73.62 2025-10-02 03:08:40 0 initialize sre-gateway 45.2 2025-10-02 03:15:49 0 initialize sre-gateway 39.86 2025-10-02 03:42:27 0 initialize realserverfaketools 36.31 2025-10-02 03:42:27 0 ``` #### Error Analysis ```sql SELECT error_code, COUNT(*) as count, GROUP_CONCAT(DISTINCT tool_name) as affected_tools FROM tool_metrics WHERE success = 0 AND error_code IS NOT NULL GROUP BY error_code ORDER BY count DESC; ``` ### Tool Discovery Metrics #### Recent Discovery Queries ```sql SELECT datetime(timestamp) as time, query, results_count, ROUND(duration_ms, 2) as dur_ms, ROUND(embedding_time_ms, 2) as embed_ms, ROUND(faiss_search_time_ms, 2) as search_ms FROM discovery_metrics ORDER BY timestamp DESC LIMIT 20; ``` #### Discovery Performance Analysis ```sql SELECT COUNT(*) as total_queries, ROUND(AVG(results_count), 2) as avg_results, ROUND(AVG(duration_ms), 2) as avg_duration_ms, ROUND(AVG(embedding_time_ms), 2) as avg_embedding_ms, ROUND(AVG(faiss_search_time_ms), 2) as avg_search_ms FROM discovery_metrics; ``` ### Advanced Queries #### Tool Method Distribution ```sql SELECT method, COUNT(*) as count, COUNT(DISTINCT server_name) as servers_using, ROUND(AVG(duration_ms), 2) as avg_ms FROM tool_metrics WHERE method IS NOT NULL GROUP BY method ORDER BY count DESC; ``` #### Daily Active Clients ```sql SELECT DATE(timestamp) as date, COUNT(DISTINCT client_name) as unique_clients, COUNT(*) as total_calls FROM tool_metrics WHERE client_name IS NOT NULL GROUP BY DATE(timestamp) ORDER BY date DESC; ``` #### Server Performance Comparison ```sql SELECT server_name, COUNT(*) as total_calls, SUM(success) as successful, ROUND(100.0 * SUM(success) / COUNT(*), 2) as success_rate, ROUND(AVG(duration_ms), 2) as avg_duration_ms, ROUND(MIN(duration_ms), 2) as min_ms, ROUND(MAX(duration_ms), 2) as max_ms FROM tool_metrics GROUP BY server_name ORDER BY total_calls DESC; ``` #### Time-Based Performance Analysis ```sql SELECT strftime('%H', timestamp) as hour_of_day, COUNT(*) as requests, ROUND(AVG(duration_ms), 2) as avg_duration_ms, ROUND(100.0 * SUM(success) / COUNT(*), 2) as success_rate FROM tool_metrics GROUP BY hour_of_day ORDER BY hour_of_day; ``` ## OpenTelemetry Metrics The metrics service exports metrics to OpenTelemetry in two formats: ### Prometheus Endpoint Access raw Prometheus metrics: ```bash curl http://localhost:9465/metrics ``` **Available Metrics:** - `mcp_auth_requests_total` - Counter of authentication requests - `mcp_auth_request_duration_seconds` - Histogram of auth request durations - `mcp_tool_executions_total` - Counter of tool executions - `mcp_tool_execution_duration_seconds` - Histogram of tool execution durations - `mcp_tool_discovery_total` - Counter of discovery requests - `mcp_tool_discovery_duration_seconds` - Histogram of discovery durations - `mcp_protocol_latency_seconds` - Histogram of protocol flow latencies - `mcp_health_checks_total` - Counter of health checks - `mcp_health_check_duration_seconds` - Histogram of health check durations ### OTLP Export If configured, metrics are also exported to an OTLP endpoint (e.g., OpenTelemetry Collector). Configuration in `.env`: ```bash OTEL_OTLP_ENDPOINT=http://otel-collector:4318 ``` ## Configuring OpenTelemetry Collector The OpenTelemetry Collector is a vendor-agnostic proxy that can receive, process, and export telemetry data to multiple backends (AWS CloudWatch, Datadog, New Relic, etc.). ### Step 1: Add OTel Collector to Docker Compose Add this service to your `docker-compose.yml`: ```yaml # OpenTelemetry Collector otel-collector: image: otel/opentelemetry-collector-contrib:latest command: ["--config=/etc/otel-collector-config.yaml"] volumes: - ./config/otel-collector-config.yaml:/etc/otel-collector-config.yaml ports: - "4318:4318" # OTLP HTTP receiver - "4317:4317" # OTLP gRPC receiver - "8888:8888" # Prometheus metrics exposed by the collector - "8889:8889" # Prometheus exporter metrics restart: unless-stopped ``` ### Step 2: Create OTel Collector Configuration Create `config/otel-collector-config.yaml`: #### Basic Configuration (Prometheus Export) ```yaml receivers: otlp: protocols: http: endpoint: 0.0.0.0:4318 grpc: endpoint: 0.0.0.0:4317 processors: batch: timeout: 10s send_batch_size: 1024 exporters: prometheus: endpoint: "0.0.0.0:8889" namespace: mcp_gateway logging: loglevel: info service: pipelines: metrics: receivers: [otlp] processors: [batch] exporters: [prometheus, logging] ``` #### Advanced Configuration (Multiple Backends) ```yaml receivers: otlp: protocols: http: endpoint: 0.0.0.0:4318 grpc: endpoint: 0.0.0.0:4317 processors: batch: timeout: 10s send_batch_size: 1024 # Add resource attributes resource: attributes: - key: environment value: production action: insert - key: service.namespace value: mcp-gateway action: insert # Filter metrics if needed filter: metrics: include: match_type: regexp metric_names: - mcp_.* exporters: # Export to Prometheus prometheus: endpoint: "0.0.0.0:8889" namespace: mcp_gateway # Export to AWS CloudWatch awscloudwatch: region: us-east-1 namespace: MCP/Gateway endpoint: https://monitoring.us-east-1.amazonaws.com # Export to Datadog datadog: api: key: ${DATADOG_API_KEY} site: datadoghq.com # Export to New Relic otlphttp/newrelic: endpoint: https://otlp.nr-data.net:4318 headers: api-key: ${NEW_RELIC_API_KEY} # Export to Grafana Cloud otlphttp/grafanacloud: endpoint: ${GRAFANA_CLOUD_OTLP_ENDPOINT} headers: authorization: Basic ${GRAFANA_CLOUD_AUTH} # Export to Honeycomb otlphttp/honeycomb: endpoint: https://api.honeycomb.io headers: x-honeycomb-team: ${HONEYCOMB_API_KEY} # Logging for debugging logging: loglevel: info service: pipelines: metrics: receivers: [otlp] processors: [batch, resource, filter] exporters: [prometheus, awscloudwatch, logging] # Add other exporters as needed: datadog, otlphttp/newrelic, etc. ``` ### Step 3: Update Metrics Service Configuration Update your `.env` file: ```bash # Enable OTLP export to collector OTEL_OTLP_ENDPOINT=http://otel-collector:4318 ``` Update `docker-compose.yml` metrics-service environment: ```yaml metrics-service: environment: - OTEL_OTLP_ENDPOINT=http://otel-collector:4318 # ... other env vars depends_on: - metrics-db - otel-collector # Add dependency ``` ### Step 4: Configure Prometheus to Scrape OTel Collector Update `config/prometheus.yml`: ```yaml global: scrape_interval: 15s evaluation_interval: 15s scrape_configs: # Scrape metrics directly from metrics-service - job_name: 'mcp-metrics-service' static_configs: - targets: ['metrics-service:9465'] # Scrape metrics from OTel Collector - job_name: 'otel-collector' static_configs: - targets: ['otel-collector:8889'] ``` ### Step 5: Deploy and Verify ```bash # Restart services docker compose down docker compose up -d # Check OTel Collector logs docker compose logs -f otel-collector # Verify metrics are being received curl http://localhost:8889/metrics | grep mcp_ # Check Prometheus targets curl http://localhost:9090/api/v1/targets | jq '.data.activeTargets[] | select(.labels.job == "otel-collector")' ``` ### Cloud Provider Specific Configurations #### AWS CloudWatch ```yaml exporters: awscloudwatch: region: us-east-1 namespace: MCP/Gateway dimension_rollup_option: NoDimensionRollup metric_declarations: - dimensions: [[service, metric_type]] metric_name_selectors: - mcp_.* ``` **Required IAM Permissions:** ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "cloudwatch:PutMetricData" ], "Resource": "*" } ] } ``` **Environment Variables:** ```bash AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=your-access-key AWS_SECRET_ACCESS_KEY=your-secret-key ``` #### Datadog ```yaml exporters: datadog: api: key: ${DATADOG_API_KEY} site: datadoghq.com # or datadoghq.eu for EU host_metadata: enabled: true hostname_source: config_or_system ``` **Environment Variables:** ```bash DATADOG_API_KEY=your-datadog-api-key ``` #### Grafana Cloud ```yaml exporters: otlphttp/grafanacloud: endpoint: ${GRAFANA_CLOUD_OTLP_ENDPOINT} headers: authorization: Basic ${GRAFANA_CLOUD_AUTH} ``` **Setup:** 1. Get OTLP endpoint from Grafana Cloud console 2. Create service account and get API key 3. Base64 encode: `echo -n "instance_id:api_key" | base64` **Environment Variables:** ```bash GRAFANA_CLOUD_OTLP_ENDPOINT=https://otlp-gateway-prod-us-central-0.grafana.net/otlp GRAFANA_CLOUD_AUTH=base64_encoded_credentials ``` #### New Relic ```yaml exporters: otlphttp/newrelic: endpoint: https://otlp.nr-data.net:4318 headers: api-key: ${NEW_RELIC_API_KEY} ``` **Environment Variables:** ```bash NEW_RELIC_API_KEY=your-new-relic-license-key ``` ### Troubleshooting OTel Collector #### Check Collector Health ```bash # View collector logs docker compose logs otel-collector # Check internal metrics curl http://localhost:8888/metrics # Verify receivers are active docker compose exec otel-collector wget -qO- http://localhost:13133/ ``` #### Common Issues **Metrics not flowing to backend:** ```bash # Enable debug logging in collector config exporters: logging: loglevel: debug # Check for export errors in logs docker compose logs otel-collector | grep -i error ``` **Connection refused to OTLP endpoint:** ```bash # Verify collector is reachable from metrics-service docker compose exec metrics-service ping otel-collector # Check port is open docker compose exec metrics-service nc -zv otel-collector 4318 ``` **Authentication failures:** ```bash # Verify API keys are set docker compose exec otel-collector env | grep -i key # Test exporter authentication separately ``` ### Best Practices 1. **Use Batch Processor**: Reduces network overhead ```yaml processors: batch: timeout: 10s send_batch_size: 1024 ``` 2. **Add Resource Attributes**: Tag metrics with environment/deployment info ```yaml processors: resource: attributes: - key: environment value: ${ENVIRONMENT} action: insert ``` 3. **Filter Metrics**: Only export what you need ```yaml processors: filter: metrics: include: match_type: regexp metric_names: - mcp_auth_.* - mcp_tool_.* ``` 4. **Enable Health Check**: Monitor collector itself ```yaml extensions: health_check: endpoint: 0.0.0.0:13133 service: extensions: [health_check] ``` 5. **Set Retention Policies**: Configure backend retention based on use case - Real-time alerts: 7-30 days - Compliance/auditing: 1-2 years - General monitoring: 90 days ### Example Complete Setup See `config/otel-collector-config.example.yaml` for a complete configuration template. ## Grafana Dashboards Access Grafana dashboards at: `http://localhost:3000` **Credentials:** - Username: `admin` - Password: The value of `GRAFANA_ADMIN_PASSWORD` from your `.env` file **Important:** You must set a strong, random password for `GRAFANA_ADMIN_PASSWORD` in your `.env` file before starting Grafana. Generate one with: ```bash python3 -c "import secrets; print(secrets.token_urlsafe(24))" ``` For ECS deployments, the Grafana dashboard is available at `https:///grafana/` and the password is configured via `grafana_admin_password` in `terraform.tfvars`. ### Pre-configured Dashboards The MCP Gateway includes pre-configured Grafana dashboards for: 1. **Authentication Metrics** - Success rates by server - Request volume over time - Error code distribution - Average response times 2. **Tool Execution Metrics** - Most used tools - Client distribution - Success rates - Performance trends 3. **Discovery Metrics** - Search query volume - Result counts - Performance breakdown (embedding vs. FAISS search) 4. **System Health** - Overall request volume - Error rates - Performance percentiles (p50, p95, p99) ### Prometheus Queries Access Prometheus at: `http://localhost:9090` **Sample PromQL Queries:** ```promql # Authentication success rate rate(mcp_auth_requests_total{success="true"}[5m]) / rate(mcp_auth_requests_total[5m]) # Average tool execution duration by server rate(mcp_tool_execution_duration_seconds_sum[5m]) / rate(mcp_tool_execution_duration_seconds_count[5m]) # Top 5 most used tools topk(5, sum by (tool_name) (rate(mcp_tool_executions_total[5m]))) # 95th percentile request duration histogram_quantile(0.95, rate(mcp_auth_request_duration_seconds_bucket[5m])) ``` ## Monitoring Best Practices ### Key Metrics to Monitor 1. **Authentication Success Rate**: Should be >95% 2. **Tool Execution Success Rate**: Should be >90% 3. **Average Response Time**: Should be <100ms for auth, <500ms for tools 4. **Error Rate**: Should be <5% 5. **Discovery Query Performance**: Embedding time should be <50ms ### Setting Up Alerts Configure alerts in Grafana or Prometheus for: - Authentication failure rate >10% - Tool execution errors >5% - Response time p95 >1000ms - Discovery query failures ### Data Retention - SQLite database: 90 days (configurable via `METRICS_RETENTION_DAYS`) - Prometheus: 200 hours (configurable in `prometheus.yml`) - Adjust retention based on storage capacity and compliance requirements ## Troubleshooting ### No Metrics Being Collected 1. Check metrics service is running: ```bash docker compose ps metrics-service ``` 2. Verify API keys are configured: ```bash docker compose logs metrics-service | grep "API key" ``` 3. Check middleware is enabled in auth-server logs: ```bash docker compose logs auth-server | grep "metrics" ``` ### Database Connection Issues ```bash # Check database volume docker volume inspect mcp-gateway-registry_metrics-db-data # Check database file permissions docker compose exec metrics-db ls -la /var/lib/sqlite/ # Test database connectivity docker compose exec metrics-db sqlite3 /var/lib/sqlite/metrics.db "SELECT COUNT(*) FROM metrics;" ``` ### OpenTelemetry Export Issues ```bash # Check Prometheus targets curl http://localhost:9090/api/v1/targets # Check metrics-service OTEL configuration docker compose logs metrics-service | grep -i otel ``` ## Schema Reference ### auth_metrics Table | Column | Type | Description | |--------|------|-------------| | id | INTEGER | Primary key | | request_id | TEXT | Unique request identifier | | timestamp | TEXT | ISO 8601 timestamp | | service | TEXT | Service name (e.g., "auth-server") | | duration_ms | REAL | Request duration in milliseconds | | success | BOOLEAN | Whether auth was successful | | method | TEXT | Auth method used | | server | TEXT | MCP server name | | user_hash | TEXT | Hashed user identifier | | error_code | TEXT | Error code if failed | | created_at | TEXT | Record creation time | ### tool_metrics Table | Column | Type | Description | |--------|------|-------------| | id | INTEGER | Primary key | | request_id | TEXT | Unique request identifier | | timestamp | TEXT | ISO 8601 timestamp | | service | TEXT | Service name | | duration_ms | REAL | Execution duration in milliseconds | | tool_name | TEXT | Tool or method name | | server_path | TEXT | Server path | | server_name | TEXT | MCP server name | | success | BOOLEAN | Whether execution succeeded | | error_code | TEXT | Error code if failed | | input_size_bytes | INTEGER | Request payload size | | output_size_bytes | INTEGER | Response payload size | | client_name | TEXT | Client application name | | client_version | TEXT | Client version | | method | TEXT | MCP protocol method | | user_hash | TEXT | Hashed user identifier | | created_at | TEXT | Record creation time | ### discovery_metrics Table | Column | Type | Description | |--------|------|-------------| | id | INTEGER | Primary key | | request_id | TEXT | Unique request identifier | | timestamp | TEXT | ISO 8601 timestamp | | service | TEXT | Service name | | duration_ms | REAL | Total query duration | | query | TEXT | Search query text | | results_count | INTEGER | Number of results returned | | top_k_services | INTEGER | Number of services requested | | top_n_tools | INTEGER | Number of tools requested | | embedding_time_ms | REAL | Time to generate embeddings | | faiss_search_time_ms | REAL | Time for FAISS search | | created_at | TEXT | Record creation time | ## Additional Resources - [Prometheus Documentation](https://prometheus.io/docs/) - [Grafana Documentation](https://grafana.com/docs/) - [OpenTelemetry Documentation](https://opentelemetry.io/docs/) - [SQLite Documentation](https://www.sqlite.org/docs.html) ================================================ FILE: docs/README.md ================================================ # Documentation This directory contains the MkDocs-based documentation for the MCP Gateway & Registry. ## Building Documentation Locally ### Prerequisites ```bash # Using uv (recommended) uv pip install -e ".[docs]" # Or using pip pip install -e ".[docs]" ``` ### Development Server ```bash # Start development server with live reload mkdocs serve # The documentation will be available at http://127.0.0.1:8000 ``` ### Building Static Site ```bash # Build static site mkdocs build # The built site will be in the `site/` directory ``` ## Documentation Structure - `index.md` - Main landing page (generated from README.md) - `complete-setup-guide.md` - Step-by-step setup from scratch - `installation.md` - Complete installation guide - `auth.md` - Authentication and OAuth setup - `cognito.md` - Amazon Cognito configuration - `keycloak-integration.md` - Keycloak integration guide - `scopes.md` - Access control and permissions - `registry_api.md` - API reference - `dynamic-tool-discovery.md` - AI agent tool discovery - `ai-coding-assistants-setup.md` - IDE integration guide - `faq/index.md` - Frequently asked questions ## Deployment The documentation is automatically deployed to GitHub Pages when changes are pushed to the `main` branch via GitHub Actions. ### Manual Deployment ```bash # Deploy to GitHub Pages mkdocs gh-deploy ``` ## Theme and Configuration The documentation uses the [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) theme with: - Light/dark mode toggle - Navigation tabs and sections - Search functionality - Code syntax highlighting - Mermaid diagram support - Git revision dates ## Contributing When adding new documentation: 1. Create markdown files in the appropriate directory 2. Update `mkdocs.yml` navigation structure 3. Use proper markdown formatting and admonitions 4. Include code examples where relevant 5. Test locally with `mkdocs serve` before committing ## Plugins Used - **search** - Full-text search functionality - **git-revision-date-localized** - Shows last update dates - **minify** - Minifies HTML output for production - **pymdown-extensions** - Enhanced markdown features ================================================ FILE: docs/TELEMETRY.md ================================================ # Telemetry Documentation ## Overview The MCP Gateway Registry collects anonymous usage telemetry to understand adoption patterns and improve the product. This document describes what data is collected, how to opt-out, and our privacy commitments. ## What Data is Collected ### Tier 1: Startup Ping (Opt-Out, Default ON) Sent once at startup: | Field | Example | Description | |-------|---------|-------------| | `registry_id` | `c546a650-...` | Registry Card UUID (public, not PII) | | `v` | `1.0.16` | Registry version | | `py` | `3.12` | Python version (major.minor) | | `os` | `linux` | Operating system (linux, darwin, windows) | | `arch` | `x86_64` | CPU architecture | | `cloud` | `aws` | Cloud provider (aws, gcp, azure, unknown) | | `compute` | `ecs` | Compute platform (ecs, eks, kubernetes, docker, ec2, unknown) | | `mode` | `with-gateway` | Deployment mode | | `registry_mode` | `full` | Registry operating mode | | `storage` | `documentdb` | Storage backend (file, documentdb, mongodb-ce) | | `auth` | `keycloak` | Auth provider | | `federation` | `true` | Whether federation is enabled | | `search_queries_total` | `150` | Lifetime semantic search query count | | `search_queries_24h` | `12` | Search queries in the last 24 hours | | `search_queries_1h` | `3` | Search queries in the last hour | | `ts` | `2026-03-18T00:00:00Z` | ISO 8601 timestamp | ### Tier 2: Daily Heartbeat (Opt-Out, Default ON) > **Behavior change (post v1.0.18):** The daily heartbeat was previously opt-in (`MCP_TELEMETRY_OPT_IN=1`). It is now opt-out and sent by default every 24 hours. Since the heartbeat contains only aggregate counts (no PII), this aligns it with the startup ping behavior. Sent at a configurable interval (default: every 24 hours). Includes all Tier 1 fields plus: | Field | Example | Description | |-------|---------|-------------| | `servers_count` | `15` | Number of registered MCP servers | | `agents_count` | `8` | Number of registered A2A agents | | `skills_count` | `23` | Number of registered skills | | `peers_count` | `2` | Number of federation peers | | `search_backend` | `documentdb` | Search backend (faiss or documentdb) | | `embeddings_provider` | `sentence-transformers` | Embeddings provider | | `uptime_hours` | `48` | Hours since server started | ## Request Signing (HMAC) All telemetry requests are signed with HMAC-SHA256 to prevent unauthorized use of the collector endpoint. The registry computes a signature over the JSON request body and sends it in the `X-Telemetry-Signature` HTTP header. The server-side Lambda collector verifies this signature before processing any event. This is not a secret-based authentication mechanism -- the signing key is embedded in the open-source code. Its purpose is to raise the bar against casual abuse (e.g., random `curl` requests to the endpoint). Combined with IP-based rate limiting and strict Pydantic schema validation, this makes endpoint abuse impractical. ### Example HTTP Request A startup event request looks like this: ```http POST /v1/collect HTTP/1.1 Host: m3ijrhd020.execute-api.us-east-1.amazonaws.com Content-Type: application/json X-Telemetry-Signature: 8a3f2b...c9d1e0 {"arch":"x86_64","auth":"keycloak","cloud":"aws","compute":"ecs","event":"startup","federation":true,"mode":"with-gateway","os":"linux","py":"3.12","registry_id":"c546a650-8af9-4721-9efb-7df221b2a0d9","registry_mode":"full","schema_version":"1","search_queries_1h":3,"search_queries_24h":12,"search_queries_total":150,"storage":"documentdb","ts":"2026-03-18T00:00:00+00:00","v":"1.0.16"} ``` A heartbeat event request: ```http POST /v1/collect HTTP/1.1 Host: m3ijrhd020.execute-api.us-east-1.amazonaws.com Content-Type: application/json X-Telemetry-Signature: 5b7e1a...d4f2c3 {"agents_count":8,"cloud":"aws","compute":"ecs","embeddings_provider":"sentence-transformers","event":"heartbeat","peers_count":2,"registry_id":"c546a650-8af9-4721-9efb-7df221b2a0d9","schema_version":"1","search_backend":"documentdb","search_queries_1h":3,"search_queries_24h":12,"search_queries_total":150,"servers_count":15,"skills_count":23,"ts":"2026-03-18T12:00:00+00:00","uptime_hours":48,"v":"1.0.16"} ``` Notes: - JSON body keys are sorted alphabetically (`sort_keys=True`) and compact (`separators=(",",":")`) for deterministic HMAC computation - The `X-Telemetry-Signature` header is the HMAC-SHA256 hex digest of the raw JSON body ## Force Telemetry (Admin API) Admins can trigger telemetry events on demand (bypasses the distributed lock): ```bash # Force heartbeat uv run python api/registry_management.py --registry-url http://localhost --token-file .token-local telemetry-heartbeat # Force startup ping uv run python api/registry_management.py --registry-url http://localhost --token-file .token-local telemetry-startup ``` API endpoints (require admin auth): - `POST /api/registry-management/telemetry/heartbeat` - `POST /api/registry-management/telemetry/startup` ## What is NOT Collected We never collect any personally identifiable information (PII): - ❌ IP addresses, MAC addresses, hostnames - ❌ Server names, URLs, file paths - ❌ User data, credentials, tokens - ❌ Query content, agent card content, skill code - ❌ Any data that could identify a person or organization ## Startup Banner When telemetry is enabled (the default), you will see this banner at startup: ``` ============================================================================== [telemetry] Anonymous usage telemetry is ON (startup ping + daily heartbeat) [telemetry] No PII is collected (no IPs, hostnames, or user data) [telemetry] Endpoint: https://m3ijrhd020.execute-api.us-east-1.amazonaws.com/v1/collect [telemetry] To disable all: set MCP_TELEMETRY_DISABLED=1 [telemetry] Details: https://github.com/agentic-community/mcp-gateway-registry/blob/main/docs/TELEMETRY.md ============================================================================== ``` ## Telemetry Configuration Parameters | Environment Variable | Purpose | Default | |---------------------|---------|---------| | `MCP_TELEMETRY_DISABLED` | Set to `1` to disable all telemetry (startup ping + heartbeat) | _(not set, telemetry ON)_ | | `MCP_TELEMETRY_OPT_OUT` | Set to `1` to disable daily heartbeat only (startup ping still sent) | _(not set, heartbeat ON)_ | | `MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES` | Heartbeat send frequency in minutes | `1440` (24 hours) | | `MCP_TELEMETRY_ENDPOINT` | HTTPS URL for a self-hosted telemetry collector | _(built-in endpoint)_ | | `MCP_TELEMETRY_DEBUG` | Set to `true` to log payloads instead of sending | `false` | ### Docker Compose Add these to your `.env` file in the project root: ```bash # .env MCP_TELEMETRY_DISABLED=1 # Disable all telemetry (startup ping + heartbeat) MCP_TELEMETRY_OPT_OUT=1 # Disable heartbeat only (startup ping still sent) MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES=1440 # Heartbeat interval in minutes (default: 1440 = 24h) MCP_TELEMETRY_ENDPOINT=https://your-collector.example.com/v1/collect # Self-hosted (optional) MCP_TELEMETRY_DEBUG=true # Debug mode (optional) ``` These are automatically picked up by the `docker-compose.yml`, `docker-compose.prebuilt.yml`, and `docker-compose.podman.yml` files. ### ECS (Terraform) Add these to your `terraform.tfvars`: ```hcl # terraform.tfvars mcp_telemetry_disabled = "1" # Disable all telemetry mcp_telemetry_opt_out = "1" # Disable heartbeat only (startup ping still sent) mcp_telemetry_heartbeat_interval_minutes = "1440" # Heartbeat interval in minutes (default: 1440 = 24h) telemetry_debug = "true" # Debug mode (optional) ``` The corresponding Terraform variables are defined in `terraform/aws-ecs/variables.tf`. ### Kubernetes (Helm) Set these in your `values.yaml` or pass with `--set`: ```yaml # values.yaml (standalone chart) app: mcpTelemetryDisabled: true # Disable all telemetry mcpTelemetryOptOut: true # Disable heartbeat only (startup ping still sent) telemetryHeartbeatIntervalMinutes: "1440" # Heartbeat interval in minutes (default: 1440 = 24h) telemetryDebug: true # Debug mode (optional) # -- OR for the stack chart -- # values.yaml (mcp-gateway-registry-stack) registry: app: mcpTelemetryDisabled: true mcpTelemetryOptOut: true telemetryHeartbeatIntervalMinutes: "1440" telemetryDebug: true ``` Or with `helm install`/`helm upgrade`: ```bash helm upgrade my-release charts/registry \ --set app.mcpTelemetryDisabled=true \ --set app.mcpTelemetryOptOut=true ``` These values are injected as environment variables via the `registry-otel-config` ConfigMap. ## How to Opt-Out Set `MCP_TELEMETRY_DISABLED=1` using the method for your deployment (see above). When telemetry is disabled, you'll see this message at startup: ``` [telemetry] Telemetry is disabled. ``` ## How to Opt-Out of Heartbeat Only Both startup ping and daily heartbeat are enabled by default. To disable the heartbeat while keeping the startup ping: Set `MCP_TELEMETRY_OPT_OUT=1` using the method for your deployment (see above). When heartbeat is opted out, you'll see: ``` [telemetry] Heartbeat scheduler not started (opted out or telemetry disabled) ``` ## Debug Mode Set `MCP_TELEMETRY_DEBUG=true` using the method for your deployment (see above). This logs the full JSON payload to stderr instead of sending it to the collector. ## Privacy Commitments 1. **Privacy First**: No PII is ever collected or stored 2. **Conspicuous Disclosure**: Every startup logs a clear message about telemetry 3. **Easy Opt-Out**: Multiple methods to disable telemetry 4. **Fail-Silent**: Telemetry failures never impact registry operation 5. **No Tracking**: No user identification or cross-session tracking 6. **Open Source**: The telemetry code is open source and auditable ## Multi-Replica Deployments In multi-replica deployments (ECS, Kubernetes), telemetry uses MongoDB-based distributed locks to prevent duplicate sends. Only one replica will send telemetry within the configured interval: - **Startup ping**: At most once per 60 seconds - **Heartbeat**: At most once per configured interval (default: 1440 minutes = 24 hours) ## Self-Hosted Telemetry Collector If you want to run your own telemetry collector instead of using the default endpoint, you can deploy the server-side infrastructure from issue #559. ### Why Self-Host? - **Data Sovereignty**: Keep telemetry data in your own AWS account - **Compliance**: Meet specific regulatory requirements - **Custom Analytics**: Run your own queries and dashboards - **Air-Gapped Deployments**: Collect telemetry without external network access ### Quick Start The telemetry collector infrastructure is available in `terraform/telemetry-collector/`: ```bash cd terraform/telemetry-collector # Configure deployment cp terraform.tfvars.example terraform.tfvars vi terraform.tfvars # Set aws_region, deployment_stage, etc. # Deploy infrastructure (~15-20 minutes) terraform init terraform apply # Get your collector URL terraform output collector_url ``` ### Point Registry to Your Collector ```bash # Set custom endpoint export MCP_TELEMETRY_ENDPOINT=https://your-collector-url.execute-api.us-east-1.amazonaws.com/v1/collect # Start registry uv run python -m registry ``` ### Infrastructure Components The self-hosted collector includes: - **API Gateway HTTP API**: HTTPS endpoint (`/v1/collect`) - **Lambda Function**: VPC-enabled, validates events with Pydantic schemas - **DynamoDB**: Privacy-preserving rate limiting (hashed IPs) - **DocumentDB**: MongoDB-compatible storage with 365-day TTL - **Secrets Manager**: Secure credential management - **CloudWatch**: Logs and alarms (production) ### Cost Estimate - **Testing**: ~$85-90/month (db.t3.medium DocumentDB) - **Production**: ~$195-200/month (db.r5.large DocumentDB) See `terraform/telemetry-collector/README.md` for detailed cost breakdown. ### Security Features - **No IP Logging**: Source IPs are hashed (SHA-256) for rate limiting only - **HMAC Signed**: Requests signed with HMAC-SHA256 to reject unauthorized callers - **Rate Limited**: DynamoDB-based per-IP rate limiting (10 requests/minute) - **Schema Validated**: Strict Pydantic validation rejects malformed payloads - **VPC Isolated**: DocumentDB not accessible from internet - **TLS Everywhere**: All connections encrypted - **Always Returns 204**: No information leakage (same response for valid, invalid, or rejected) - **IAM Least Privilege**: Minimal Lambda permissions ### Bastion Host Scripts The bastion host provides scripts for querying and managing telemetry data in DocumentDB. Scripts are located in `terraform/telemetry-collector/bastion-scripts/` and should be copied to the bastion home directory. #### Interactive Shell (connect.sh) Open an interactive mongosh session against DocumentDB: ```bash ~/connect.sh ``` #### Quick Summary (query.sh) Print a summary of telemetry collections (counts, last 5 events, storage backend breakdown): ```bash ~/query.sh ``` #### Export to CSV (telemetry_db.py export) Dump telemetry data to a CSV file: ```bash # Export all collections to registry_metrics.csv python3 ~/telemetry_db.py export # Export to a custom path python3 ~/telemetry_db.py export --output /tmp/metrics.csv # Export only startup events python3 ~/telemetry_db.py export --collection startup_events # Export only heartbeat events python3 ~/telemetry_db.py export --collection heartbeat_events ``` #### Purge Data (telemetry_db.py purge) Delete all telemetry data from DocumentDB (with interactive confirmation): ```bash # Purge all collections (prompts for confirmation) python3 ~/telemetry_db.py purge # Purge only startup events python3 ~/telemetry_db.py purge --collection startup_events # Purge only heartbeat events python3 ~/telemetry_db.py purge --collection heartbeat_events # Skip confirmation prompt python3 ~/telemetry_db.py purge --confirm ``` #### Deploying Scripts to Bastion Copy scripts to the bastion host after initial setup: ```bash BASTION_IP=$(terraform output -raw bastion_public_ip) scp -i ~/.ssh/id_ed25519 \ bastion-scripts/connect.sh \ bastion-scripts/query.sh \ bastion-scripts/telemetry_db.py \ ec2-user@$BASTION_IP:~/ ssh -i ~/.ssh/id_ed25519 ec2-user@$BASTION_IP 'chmod +x ~/connect.sh ~/query.sh' ``` ### Full Documentation See `terraform/telemetry-collector/README.md` for: - Prerequisites and deployment steps - DocumentDB index setup - Testing procedures - Troubleshooting guide - Production deployment (custom domain, alarms) ## Questions? For more information or questions about telemetry: - **GitHub Issue**: https://github.com/agentic-community/mcp-gateway-registry/issues/558 - **Telemetry Source Code**: https://github.com/agentic-community/mcp-gateway-registry/blob/main/registry/core/telemetry.py ================================================ FILE: docs/a2a-agent-management.md ================================================ # A2A Agent Management Guide This guide covers registering, managing, and using A2A agents through the MCP Gateway Registry using the `mcp-gateway-m2m` service account. ## Quick Start ### Service Account: `mcp-gateway-m2m` All agent management operations use the **`mcp-gateway-m2m`** Keycloak M2M service account: ```bash # Register an agent (uses mcp-gateway-m2m token automatically) uv run python cli/agent_mgmt.py register cli/examples/code_reviewer_agent.json # List agents uv run python cli/agent_mgmt.py list # Get agent details uv run python cli/agent_mgmt.py get /code-reviewer # Test agent (verify registry metadata and endpoint accessibility) uv run python cli/agent_mgmt.py test /code-reviewer # Test all agents uv run python cli/agent_mgmt.py test-all # Search agents (semantic search by capability) uv run python cli/agent_mgmt.py search "code review agent" # Update agent uv run python cli/agent_mgmt.py update /code-reviewer cli/examples/code_reviewer_agent.json # Toggle agent enabled/disabled status uv run python cli/agent_mgmt.py toggle /code-reviewer true # Enable uv run python cli/agent_mgmt.py toggle /code-reviewer false # Disable # Delete agent uv run python cli/agent_mgmt.py delete /code-reviewer ``` **Token Details:** - **File:** `.oauth-tokens/ingress.json` (auto-loaded by CLI) - **Generated by:** `./credentials-provider/generate_creds.sh` - **Keycloak Groups:** `mcp-servers-unrestricted`, `a2a-agent-admin` - **Permissions:** Full agent management (register, modify, delete, list) ## Service Account Details ### Account Identity | Property | Value | |----------|-------| | Keycloak Client ID | `mcp-gateway-m2m` | | Service Account User | `service-account-mcp-gateway-m2m` | | Token File | `.oauth-tokens/ingress.json` | | Token Generator | `./credentials-provider/generate_creds.sh` | ### Keycloak Groups (Auto-Assigned) - **`mcp-servers-unrestricted`** - Full access to unrestricted MCP servers - **`a2a-agent-admin`** - Full A2A agent management permissions ### Permissions - ✅ Register agents - ✅ Modify agents - ✅ Delete agents - ✅ List agents - ✅ Manage MCP server groups ## Authentication Flow ``` 1. CLI loads token from .oauth-tokens/ingress.json └─ JWT contains: groups: ["mcp-servers-unrestricted", "a2a-agent-admin"] 2. POST /api/agents/register with Authorization: Bearer 3. Nginx /api/ location intercepts request └─ Calls auth-server /validate endpoint 4. Auth-server validates JWT and maps groups to scopes └─ Returns: X-Scopes: a2a-agent-admin 5. Nginx forwards request to FastAPI with X-Scopes header 6. FastAPI reads X-Scopes and grants permissions └─ Detects a2a-agent-admin → allows agent registration 7. Agent is registered successfully ``` ## Agent Examples ### Register Code Reviewer Agent ```bash uv run python cli/agent_mgmt.py register cli/examples/code_reviewer_agent.json ``` Available examples: - `code_reviewer_agent.json` - Code quality analysis - `test_automation_agent.json` - Test case generation and execution - `data_analysis_agent.json` - Statistical analysis and visualization - `security_analyzer_agent.json` - Vulnerability detection - `documentation_agent.json` - API documentation generation - `devops_deployment_agent.json` - Infrastructure automation ### Create Custom Agent 1. Copy example file: ```bash cp cli/examples/code_reviewer_agent.json cli/examples/my_agent.json ``` 2. Edit JSON with your agent details: ```json { "name": "My Agent", "path": "/my-agent", "description": "What my agent does", "url": "http://my-domain.com/agents/my-agent", "version": "1.0.0", "protocol_version": "1.0", "visibility": "public", "trust_level": "community", "tags": ["custom"], "security_schemes": { "bearer": { "type": "bearer" } } } ``` 3. Register: ```bash uv run python cli/agent_mgmt.py register cli/examples/my_agent.json ``` ### Custom Metadata Agents support optional custom metadata for organization, compliance, and integration tracking. All metadata is fully searchable via semantic search. #### Example Agent with Metadata ```json { "name": "Data Analysis Agent", "path": "/data-analysis", "description": "Advanced data analysis agent", "url": "https://example.com/agents/data-analysis", "version": "3.2.1", "protocol_version": "1.0", "visibility": "public", "trust_level": "verified", "tags": ["analytics", "data-science"], "metadata": { "team": "data-science", "owner": "bob@example.com", "cost_center": "analytics-dept", "version": "3.2.1", "deployment_region": "us-east-1", "jira_ticket": "DATA-456" } } ``` #### Metadata Use Cases **Organization & Team:** ```json { "team": "data-science", "owner": "bob@example.com", "department": "analytics" } ``` **Compliance & Governance:** ```json { "compliance_level": "HIPAA", "data_classification": "phi", "audit_logging": true } ``` **Cost & Project Tracking:** ```json { "cost_center": "analytics-dept", "project_code": "AI-2024-Q1" } ``` **Deployment & Integration:** ```json { "deployment_region": "us-east-1", "environment": "production", "version": "3.2.1", "jira_ticket": "DATA-456" } ``` #### Search by Metadata Once registered, agents with metadata are searchable: ```bash # Find agents by team uv run python cli/agent_mgmt.py search "team:data-science" # Find agents by owner uv run python cli/agent_mgmt.py search "bob@example.com owned agents" # Find agents by cost center uv run python cli/agent_mgmt.py search "cost center analytics" # Find agents in specific region uv run python cli/agent_mgmt.py search "us-east-1 deployed agents" ``` **Key Features:** - **Flexible Schema:** Any JSON-serializable data - **Fully Searchable:** Included in semantic search - **Optional:** Works with or without metadata - **Type-Safe:** Pydantic validation ## Verification ### Check Token Has Correct Groups ```bash python3 << 'EOF' import json, base64 with open('.oauth-tokens/ingress.json') as f: token = json.load(f)['access_token'] payload = token.split('.')[1] payload += '='*(4-len(payload)%4) decoded = json.loads(base64.urlsafe_b64decode(payload)) print("Service Account:", decoded.get('client_id')) print("Groups:", decoded.get('groups')) print("Expected groups: ['mcp-servers-unrestricted', 'a2a-agent-admin']") EOF ``` Expected output: ``` Service Account: mcp-gateway-m2m Groups: ['mcp-servers-unrestricted', 'a2a-agent-admin'] Expected groups: ['mcp-servers-unrestricted', 'a2a-agent-admin'] ``` ### Check Auth Server Sees Groups ```bash TOKEN=$(python3 -c "import json; print(json.load(open('.oauth-tokens/ingress.json'))['access_token'])") curl -s "http://localhost:8888/validate" \ -H "Authorization: Bearer $TOKEN" \ -H "X-Original-URL: http://localhost/agents/register" | \ python3 -m json.tool | grep -E "groups|scopes" ``` Expected output should include: ``` "groups": ["mcp-servers-unrestricted", "a2a-agent-admin"], "scopes": [..., "a2a-agent-admin", ...] ``` ## Troubleshooting ### Issue: "Authentication required" Error **Check 1: Token file exists** ```bash [ -f .oauth-tokens/ingress.json ] && echo "✓ Token exists" || echo "✗ Token missing" ``` **Check 2: Token has groups** ```bash python3 << 'EOF' import json, base64 with open('.oauth-tokens/ingress.json') as f: token = json.load(f)['access_token'] payload = token.split('.')[1] + '='*(4-len(token.split('.')[1])%4) decoded = json.loads(base64.urlsafe_b64decode(payload)) groups = decoded.get('groups', []) print(f"Groups in token: {groups}") if 'a2a-agent-admin' not in groups: print("✗ Missing a2a-agent-admin - try refreshing token:") print(" ./credentials-provider/generate_creds.sh") EOF ``` **Check 3: Auth server sees groups** ```bash TOKEN=$(python3 -c "import json; print(json.load(open('.oauth-tokens/ingress.json'))['access_token'])") curl -s "http://localhost:8888/validate" -H "Authorization: Bearer $TOKEN" | \ python3 -m json.tool | grep groups ``` Should show: `"groups": ["mcp-servers-unrestricted", "a2a-agent-admin"]` **Check 4: Nginx forwards scopes** ```bash curl -v http://localhost/api/agents/register \ -X POST \ -H "Authorization: Bearer $(python3 -c 'import json; print(json.load(open(\".oauth-tokens/ingress.json\"))[\"access_token\"])')" \ 2>&1 | grep -i x-scopes ``` Should include: `x-scopes: a2a-agent-admin` (note: uses port 80 via Nginx, not direct application port) ### Solution: Refresh Token If token is missing groups, regenerate it: ```bash ./credentials-provider/generate_creds.sh ``` Then verify groups reappear: ```bash python3 << 'EOF' import json, base64 with open('.oauth-tokens/ingress.json') as f: token = json.load(f)['access_token'] payload = token.split('.')[1] + '='*(4-len(token.split('.')[1])%4) decoded = json.loads(base64.urlsafe_b64decode(payload)) print("Groups:", decoded.get('groups')) EOF ``` ## JWT Token Structure The `mcp-gateway-m2m` service account's JWT token contains: ```json { "exp": 1761942660, "iat": 1761942360, "iss": "http://localhost:8080/realms/mcp-gateway", "sub": "user-id-uuid", "typ": "Bearer", "azp": "mcp-gateway-m2m", "client_id": "mcp-gateway-m2m", "preferred_username": "service-account-mcp-gateway-m2m", "groups": ["mcp-servers-unrestricted", "a2a-agent-admin"], "scope": "profile email mcp-servers-restricted/execute mcp-servers-restricted/read mcp-servers-unrestricted/read mcp-servers-unrestricted/execute a2a-agent-admin" } ``` Key fields: - **`client_id`**: `mcp-gateway-m2m` - identifies the service account - **`groups`**: List of Keycloak groups the account belongs to - **`scope`**: Space-separated list of OAuth2 scopes for authorization - **`a2a-agent-admin`**: The scope that grants agent management permissions ## Setup (Automatic During Initialization) The `mcp-gateway-m2m` service account is automatically configured during Keycloak initialization: 1. **Keycloak Client Created** - `mcp-gateway-m2m` M2M client registered 2. **Service Account User Created** - `service-account-mcp-gateway-m2m` user created 3. **Groups Assigned** - User assigned to `mcp-servers-unrestricted` and `a2a-agent-admin` groups 4. **Groups Mapper Added** - JWT tokens include groups claim 5. **Auth Scopes Configured** - Groups mapped to OAuth2 scopes in auth_server/scopes.yml 6. **Nginx Protected** - `/api/*` endpoints require JWT authentication No manual configuration needed - everything is automatic! ## Initialization Files These files are involved in the automatic setup: | File | Purpose | |------|---------| | `keycloak/setup/init-keycloak.sh` | Creates groups, assigns M2M to groups, adds groups mapper | | `auth_server/scopes.yml` | Maps groups to scopes | | `docker/nginx_rev_proxy_http_only.conf` | Protects `/api/` endpoints with authentication | | `credentials-provider/generate_creds.sh` | Generates fresh JWT tokens | ## Related Documentation - [Authentication Guide](auth.md) - General authentication and authorization - [CLI Reference](cli.md) - Command-line interface documentation - [Keycloak Integration](keycloak-integration.md) - Keycloak configuration details ## Environment Variables The token generation process uses these environment variables from `.env`: ```bash # Keycloak Configuration KEYCLOAK_URL=http://localhost:8080 KEYCLOAK_REALM=mcp-gateway KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD=SecureKeycloakAdmin123! # M2M Client Credentials (set by init script) KEYCLOAK_M2M_CLIENT_ID=mcp-gateway-m2m KEYCLOAK_M2M_CLIENT_SECRET= ``` ## Common Tasks ### Register Multiple Agents ```bash # Create multiple agent files cp cli/examples/code_reviewer_agent.json cli/examples/reviewer.json cp cli/examples/test_automation_agent.json cli/examples/tester.json # Register each uv run python cli/agent_mgmt.py register cli/examples/reviewer.json uv run python cli/agent_mgmt.py register cli/examples/tester.json # List all uv run python cli/agent_mgmt.py list ``` ### Update Agent Configuration ```bash # Modify the agent JSON file vi cli/examples/my_agent.json # Re-register to update (will overwrite) uv run python cli/agent_mgmt.py register cli/examples/my_agent.json ``` ### Remove Agent ```bash uv run python cli/agent_mgmt.py delete /my-agent ``` ### Test Agent Availability ```bash # Test that agent is accessible uv run python cli/agent_mgmt.py test /code-reviewer # If successful: # ✓ Agent registered # ✓ Endpoint is reachable # ✓ Agent is responding ``` ## FAQ **Q: What if I get "Agent already exists" error?** A: The agent is already registered. Either delete it first or change the `path` in your JSON. **Q: Where are agents stored?** A: In the registry database. Agent metadata is stored at `registry/agents/` during development. **Q: Can I use a different service account?** A: No, `mcp-gateway-m2m` is the configured M2M account for all CLI operations. Create separate accounts only if needed for different purposes. **Q: How often should I refresh the token?** A: Automatically via `./credentials-provider/generate_creds.sh` which is called by the shell startup. Manual refresh only needed if token expires. **Q: What if the token expires?** A: Run `./credentials-provider/generate_creds.sh` to get a fresh token. **Q: Can agents be private?** A: Yes, set `"visibility": "private"` in agent JSON. Only registered users can access private agents. ## Getting Help 1. Check [Troubleshooting](#troubleshooting) section above 2. Review JWT token contents and verify groups are present 3. Check service is running: `docker-compose ps` 4. View logs: `docker logs mcp-gateway-registry-auth-server-1` 5. Verify Keycloak groups in admin UI: http://localhost:8080/admin ## Summary - **Service Account:** `mcp-gateway-m2m` (auto-created during init) - **Token File:** `.oauth-tokens/ingress.json` (auto-loaded by CLI) - **Permissions:** Full agent management (register, modify, delete, list) - **Groups:** `mcp-servers-unrestricted`, `a2a-agent-admin` (auto-assigned) - **Setup:** Fully automatic - no manual configuration needed ================================================ FILE: docs/a2a.md ================================================ # Agent-to-Agent (A2A) Protocol Support The MCP Gateway & Registry now supports **Agent-to-Agent (A2A) communication**, enabling AI agents to securely register themselves and discover other agents within a centralized registry. This creates a self-managed agent ecosystem where agents can autonomously find, connect to, and communicate with other agents while maintaining security and access control features. ## Overview ### What is A2A? Agent-to-Agent (A2A) communication allows autonomous AI agents to: 1. **Self-Register** - Agents register their capabilities, skills, and metadata with the central registry 2. **Discover Other Agents** - Agents can discover and list other agents they have permission to access 3. **Secure Communication** - All agent-to-agent communication is authenticated and authorized via Keycloak 4. **Access Control** - Fine-grained permissions ensure agents only access agents they're authorized for ### Why A2A Matters Instead of having a central orchestrator manage all agent communication: ``` ❌ OLD: Orchestrator ←→ Agent A, Agent B, Agent C (bottleneck, single point of failure, limited scalability) ✅ NEW: Agent A ←→ Registry ←→ Agent B Agent C discovers both via registry (decentralized, scalable, autonomous) ``` A2A enables: - **Autonomous agent networks** - Agents operate independently - **Dynamic discovery** - New agents join without reconfiguration - **Enterprise security** - Keycloak-based access control - **Audit trails** - Complete visibility into agent interactions ## Architecture ### A2A Agent Flow ``` Agent Application (AI Code) ↓ M2M Token (Keycloak Service Account) ┌─────────────────────────────────────┐ │ Agent Registry API (/api/agents) │ │ - POST /api/agents/register │ │ - GET /api/agents │ │ - GET /api/agents/{path} │ │ - PUT /api/agents/{path} │ │ - DELETE /api/agents/{path} │ │ - POST /api/agents/{path}/toggle │ └─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ Agent State Management │ │ - registry/agents/agent_state.json │ │ - registry/agents/{name}.json │ └─────────────────────────────────────┘ ``` ### Three-Tier Access Control The A2A implementation uses **three-tier access control** to ensure agents only access agents they're authorized for: 1. **UI-Scopes** - What agents each group can see/access - `list_agents` - List agents visible to this group - `get_agent` - Get details of specific agents - `publish_agent` - Register new agents - `modify_agent` - Update agent metadata - `delete_agent` - Remove agents 2. **Group Mappings** - Maps Keycloak groups to scope names - `mcp-registry-admin` - Full access to all agents - `registry-users-lob1` - Limited to LOB1 agents - `registry-users-lob2` - Limited to LOB2 agents 3. **Individual Agent Scopes** - Detailed access per group - Specific agents each group can access - Methods each group can call on agents ## Getting Started with A2A ### Quick Start: Register an Agent ```bash # 1. Ensure credentials are generated ./credentials-provider/generate_creds.sh # 2. Register an agent uv run python cli/agent_mgmt.py register cli/examples/code_reviewer_agent.json # 3. Verify registration curl -H "Authorization: Bearer $(jq -r '.access_token' .oauth-tokens/admin-bot-token.json)" \ http://localhost/api/agents | jq . ``` ### Complete Agent Lifecycle ```bash # Register agent uv run python cli/agent_mgmt.py register agent-config.json # List agents (filtered by permissions) uv run python cli/agent_mgmt.py list # Get agent details uv run python cli/agent_mgmt.py get /code-reviewer # Update agent uv run python cli/agent_mgmt.py update /code-reviewer agent-config.json # Disable agent (without deleting) uv run python cli/agent_mgmt.py toggle /code-reviewer # Re-enable agent uv run python cli/agent_mgmt.py toggle /code-reviewer # Delete agent uv run python cli/agent_mgmt.py delete /code-reviewer ``` See [A2A Agent Management](a2a-agent-management.md) for complete CLI guide. ## Agent Configuration ### Agent Metadata Example ```json { "protocol_version": "1.0", "name": "Code Reviewer Agent", "description": "Reviews code for quality and best practices", "path": "/code-reviewer", "url": "https://agent.example.com", "skills": [ { "id": "review-python", "name": "Python Code Review", "description": "Reviews Python code for style and correctness", "parameters": { "code_snippet": {"type": "string"}, "max_issues": {"type": "integer", "default": 10} } } ], "security": ["bearer"], "tags": ["code-review", "qa"], "visibility": "public", "trust_level": "verified", "metadata": { "team": "qa-platform", "owner": "alice@example.com", "cost_center": "engineering", "deployment_region": "us-east-1" } } ``` ### Custom Metadata Agents support optional custom metadata for organization, compliance, and integration purposes. All metadata is fully searchable via semantic search. **Common Use Cases:** ```json { "metadata": { "team": "data-science", "owner": "bob@example.com", "compliance_level": "HIPAA", "cost_center": "analytics-dept", "deployment_region": "us-east-1", "environment": "production", "version": "3.2.1", "jira_ticket": "AI-456" } } ``` **Search by Metadata:** - `"team:data-science agents"` - Find agents by team - `"HIPAA compliant agents"` - Find by compliance level - `"alice@example.com owned"` - Find by owner - `"us-east-1 deployed"` - Find by region **Key Features:** - Flexible JSON schema (any serializable data) - Fully searchable via semantic search - Optional field (backward compatible) - Type-safe validation See [A2A Agent Management Guide](a2a-agent-management.md#custom-metadata) for detailed examples. ## Testing A2A Features ### Agent CRUD Test Script Simple script to test all agent operations: ```bash # Generate fresh credentials ./credentials-provider/generate_creds.sh # Run CRUD tests bash tests/agent_crud_test.sh # With custom token bash tests/agent_crud_test.sh /path/to/token.json # With environment variable TOKEN_FILE=/path/to/token.json bash tests/agent_crud_test.sh ``` Tests all 9 CRUD operations: 1. CREATE - Register new agent 2. READ - Retrieve agent details 3. UPDATE - Modify agent metadata 4. LIST - List all agents 5. TOGGLE - Disable agent 6. TOGGLE - Re-enable agent 7. DELETE - Remove agent 8. VERIFY - Confirm deletion 9. RE-CREATE - Restore agent See [Test Quick Reference](../tests/TEST_QUICK_REFERENCE.md) for details. ### Access Control Testing Test that agents only access agents they're authorized for: ```bash # Generate tokens for all bots ./keycloak/setup/generate-agent-token.sh admin-bot ./keycloak/setup/generate-agent-token.sh lob1-bot ./keycloak/setup/generate-agent-token.sh lob2-bot # Run 14 comprehensive access control tests bash tests/run-lob-bot-tests.sh ``` Tests include: - **MCP Service Access** (Tests 1-6) - Verify service permissions - **Agent Registry API** (Tests 7-14) - Verify agent visibility and access See [LOB Bot Access Control Testing](../tests/lob-bot-access-control-testing.md) for detailed test documentation. ## Implementation Details ### Core Components **CLI Module** (`cli/agent_mgmt.py`) - Agent registration and lifecycle management - CRUD operations on agent metadata - Argument validation and error handling - Structured logging and status reporting **API Routes** (`registry/api/agent_routes.py`) - Implements Agent Registry REST API endpoints - Access control enforcement via scopes - Token validation and authentication - Agent state persistence and management **Data Models** (`registry/models/`) - Agent schema validation - Skill/capability definitions - Security configuration models - State tracking models **Services** (`registry/services/agent_service.py`) - Agent business logic - State file management - Permission checking - Validation ### Key Features - **JWT Token Validation** - 5-minute token TTL with expiration checks - **Base64 Padding** - Proper JWT payload decoding - **HTTP Status Codes** - Correct semantics (200, 201, 204, 400, 403, 404) - **Error Messages** - Comprehensive debugging information - **File-Based Persistence** - Simple, reliable agent state storage - **Keycloak Integration** - Enterprise authentication and authorization ### Token Management All A2A operations use **machine-to-machine (M2M) authentication**: ```bash # Tokens expire in 5 minutes and must be regenerated ./credentials-provider/generate_creds.sh # Generate specific bot tokens for testing ./keycloak/setup/generate-agent-token.sh admin-bot ./keycloak/setup/generate-agent-token.sh lob1-bot ./keycloak/setup/generate-agent-token.sh lob2-bot ``` Token validation includes: - JWT payload decoding with base64 padding - Expiration time checking - Bearer token authentication - Group-based access control ## Use Cases ### Multi-Agent System Coordination Multiple specialized agents register themselves and discover each other: ``` Code Analyzer Agent ──┐ │ Data Processor Agent ─├──→ Agent Registry │ Report Generator Agent└──→ All agents can discover and coordinate ``` ### Team Isolation with A2A Different teams' agents only see their team's agents: ``` LOB1 Agents (Code Reviewer, Test Automation) ↓ Registry (with access control) ↓ LOB1 agents can discover each other, but not LOB2 agents LOB2 Agents (Data Analysis, Security Analyzer) ↓ Registry (with access control) ↓ LOB2 agents can discover each other, but not LOB1 agents ``` ### Autonomous Tool Discovery Agents can discover other agents providing specialized tools: ``` General Agent needs to perform code review ↓ Queries registry for agents with "code-review" capability ↓ Discovers Code Reviewer Agent, requests review ↓ Continues with confidence in code quality ``` ## Documentation - **[A2A Agent Management](a2a-agent-management.md)** - Complete CLI guide and examples - **[Agent CRUD Test](../tests/TEST_QUICK_REFERENCE.md#agent-crud-test)** - Testing CRUD operations - **[LOB Bot Access Control Testing](../tests/lob-bot-access-control-testing.md)** - Testing access control - **[Scopes Configuration](../auth_server/scopes.yml)** - Permission definitions - **[LLM Navigation Guide](llms.txt#section-45)** - For AI systems understanding implementation ## Support For issues or questions: 1. **Review Documentation** - Check [A2A Agent Management](a2a-agent-management.md) 2. **Run Tests** - Verify setup with `bash tests/agent_crud_test.sh` 3. **Check Access Control** - Run `bash tests/run-lob-bot-tests.sh` 4. **Review Logs** - Check `/tmp/*_*.log` for error details 5. **Create Issue** - Include test output and logs --- **Part of the [Agentic Community](https://github.com/agentic-community) - Building the future of AI agent ecosystems.** ================================================ FILE: docs/agent-skills-operational-guide.md ================================================ # Agent Skills Operational Guide ## Demo Video https://github.com/user-attachments/assets/5d1f227a-25f8-480d-9ff9-acba2498844b --- This guide covers registering, managing, and using Agent Skills in MCP Gateway Registry. ## Overview Agent Skills are reusable instruction sets that enhance AI coding assistants with specialized workflows and behaviors. Skills are defined in SKILL.md files hosted on GitHub, GitLab, or Bitbucket, and registered in the MCP Gateway Registry for discovery and access control. ## Quick Start ### Prerequisites - MCP Gateway Registry instance running - Authenticated user account - SKILL.md file hosted on GitHub, GitLab, or Bitbucket ### Step 1: Create a SKILL.md File Create a SKILL.md file in your repository following the [agentskills.io](https://agentskills.io) specification: ```markdown --- name: pdf-processing description: Convert and manipulate PDF documents using various tools --- # PDF Processing Skill This skill helps you work with PDF documents including conversion, extraction, and manipulation. ## When to Use This Skill - Converting documents to PDF format - Extracting text or images from PDFs - Merging or splitting PDF files - Adding watermarks or annotations ## Workflow 1. Identify the PDF operation needed 2. Check for required tools (pdftk, poppler-utils) 3. Execute the appropriate command 4. Verify the output ## Examples ### Convert HTML to PDF ```bash wkhtmltopdf input.html output.pdf ``` ### Extract text from PDF ```bash pdftotext document.pdf output.txt ``` ``` ### Step 2: Register the Skill **Using the UI:** 1. Navigate to the Skills section in the dashboard 2. Click "Register Skill" 3. Enter the SKILL.md URL (e.g., `https://github.com/org/repo/blob/main/skills/pdf-processing/SKILL.md`) 4. Fill in additional details: - Name: Auto-populated from SKILL.md or enter manually - Description: Brief description of the skill - Visibility: Public, Private, or Group - Tags: Add relevant tags for discovery 5. Click "Register" **Using the API:** For API details, see the OpenAPI specification at [api/openapi.json](../api/openapi.json). Use the `registry_management.py` CLI for Python-based commands (see [CLI Commands](#cli-commands) section below). ### Step 3: Verify Registration Check that the skill is registered and healthy using the CLI: ```bash # Get skill details uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-get --path pdf-processing # Check skill health uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-health --path pdf-processing ``` ## Managing Skills ### List All Skills **UI:** Navigate to the Skills section in the dashboard. **CLI:** ```bash uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-list ``` For custom curl commands with query parameters (e.g., `include_disabled`, `tag`), see [api/openapi.json](../api/openapi.json). ### Update a Skill **UI:** Click the edit (pencil) icon on a skill card. **API:** For update endpoints, see the OpenAPI specification at [api/openapi.json](../api/openapi.json). ### Enable/Disable Skills **UI:** Use the toggle switch on the skill card. **CLI:** ```bash # Disable a skill uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-toggle --path pdf-processing --enabled false # Enable a skill uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-toggle --path pdf-processing --enabled true ``` ### Delete a Skill **UI:** Click the delete (trash) icon on a skill card. **CLI:** ```bash uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-delete --path pdf-processing ``` ## Health Monitoring ### Check Skill Health The registry verifies that SKILL.md files are accessible: **UI:** Click the refresh icon on a skill card to check health. **CLI:** ```bash uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-health --path pdf-processing ``` Response example: ```json { "healthy": true, "status_code": 200, "checked_at": "2025-02-07T15:30:00Z" } ``` ### Health Status Indicators | Status | Meaning | |--------|---------| | Healthy (green) | SKILL.md is accessible | | Unhealthy (red) | SKILL.md fetch failed | | Unknown (yellow) | Not yet checked | ## Rating Skills ### Submit a Rating **UI:** Click the star rating widget on a skill card. **CLI:** ```bash uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-rate --path pdf-processing --rating 5 ``` ### View Ratings **UI:** Rating is displayed on the skill card. **CLI:** ```bash uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-rating --path pdf-processing ``` Response example: ```json { "num_stars": 4.5, "rating_details": [ {"user": "alice", "rating": 5}, {"user": "bob", "rating": 4} ] } ``` ## Viewing Skill Content ### View SKILL.md Content **UI:** Click the info (i) icon on a skill card to open the content modal. The modal displays: - YAML frontmatter in a table format - Formatted markdown content - Links to GitHub source - Copy and download buttons **CLI:** ```bash uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-content --path pdf-processing ``` Response example: ```json { "content": "---\nname: pdf-processing\n...", "url": "https://raw.githubusercontent.com/org/repo/main/skills/pdf-processing/SKILL.md" } ``` ## Tool Validation Skills can reference required MCP server tools. Validate tool availability: **UI:** Click the wrench icon on a skill card. **API:** For tool validation endpoints, see [api/openapi.json](../api/openapi.json). Response example: ```json { "all_available": true, "tool_results": [ { "tool_name": "Bash", "server_path": "/servers/claude-tools", "available": true } ], "missing_tools": [] } ``` ## Access Control ### Visibility Levels | Level | Description | |-------|-------------| | Public | Visible to all authenticated users | | Private | Visible only to the owner | | Group | Visible to specified groups | ### Set Visibility For visibility update endpoints, see [api/openapi.json](../api/openapi.json). Visibility options are: - `public` - Visible to all authenticated users - `private` - Visible only to the owner - `group` - Visible to specified groups (requires `allowed_groups` parameter) ## Search and Discovery ### Search Skills **UI:** Use the search bar in the Skills section. **CLI:** ```bash uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-search --query "pdf" ``` For advanced search with multiple filters, see [api/openapi.json](../api/openapi.json). ## Integration with AI Assistants ### Claude Code Integration Skills in Claude Code are stored as SKILL.md files in skill directories. To use a skill from the registry: 1. **Download the skill content** to your local skills directory: ```bash # Global skills directory mkdir -p ~/.claude/skills/pdf-processing curl -H "Authorization: Bearer " \ https://your-registry.com/api/skills/pdf-processing/content \ | jq -r '.content' > ~/.claude/skills/pdf-processing/SKILL.md # Or project-level skills mkdir -p .claude/skills/pdf-processing curl -H "Authorization: Bearer " \ https://your-registry.com/api/skills/pdf-processing/content \ | jq -r '.content' > .claude/skills/pdf-processing/SKILL.md ``` 2. **Invoke the skill** using the slash command (folder name becomes the command): ``` /pdf-processing ``` See [Claude Code Skills Documentation](https://code.claude.com/docs/en/skills) for more details. ### Cursor Integration Cursor uses Agent Skills stored in `.agents/skills/` directories. To use a skill from the registry: 1. **Download the skill content** to your project's skills directory: ```bash mkdir -p .agents/skills/pdf-processing curl -H "Authorization: Bearer " \ https://your-registry.com/api/skills/pdf-processing/content \ | jq -r '.content' > .agents/skills/pdf-processing/SKILL.md ``` 2. **Regenerate AGENTS.md** if using custom rules (required after adding new skills) See [Cursor Agent Skills Documentation](https://cursor.com/docs/context/skills) for more details. ## Troubleshooting ### Skill Registration Fails 1. **Invalid URL**: Ensure the URL points to a valid SKILL.md file 2. **Name conflict**: Skill names must be unique 3. **Invalid name format**: Names must be lowercase alphanumeric with hyphens ### Skill Shows as Unhealthy 1. **Check URL**: Verify the SKILL.md file is accessible in a browser 2. **Repository access**: Ensure the repository is public or accessible 3. **Raw URL**: The registry uses raw URLs; verify raw content is accessible ### Rating Not Saved 1. **Authentication**: Ensure you're authenticated 2. **Valid range**: Ratings must be between 1 and 5 3. **Refresh**: Try refreshing the page after rating ### Content Not Loading 1. **CORS**: The registry proxies content to avoid CORS issues 2. **Health check**: Verify the skill is healthy first 3. **Network**: Check network connectivity to the source ## Best Practices ### Skill Naming - Use lowercase letters and hyphens only - Choose descriptive, specific names - Avoid generic names like "helper" or "utils" ### SKILL.md Content - Include clear trigger conditions - Provide step-by-step workflows - Add practical examples - Document required tools ### Tagging - Use consistent tag conventions - Include category tags (e.g., "documents", "automation") - Add technology tags (e.g., "pdf", "python") ### Visibility - Start with private for testing - Use group visibility for team-specific skills - Make public for community sharing ## CLI Commands The `registry_management.py` CLI provides commands for managing skills from the command line. ### Common Parameters Global parameters must come **before** the subcommand: | Parameter | Description | |-----------|-------------| | `--registry-url` | Registry base URL (default: http://localhost:8000) | | `--token-file` | Path to JSON file containing access token | ### Register Skills from Anthropic Skills Repository Register coding, documentation, and spreadsheet skills from the official [anthropics/skills](https://github.com/anthropics/skills) repository: ```bash # Set common variables REGISTRY_URL="https://your-registry.com" TOKEN_FILE="/path/to/.token" # Register doc-coauthoring skill (collaborative documentation) uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-register \ --name doc-coauthoring \ --url "https://github.com/anthropics/skills/blob/main/skills/doc-coauthoring/SKILL.md" \ --description "Guide users through structured workflow for co-authoring documentation" \ --tags docs,authoring,collaboration \ --visibility public # Register docx skill (Word document handling) uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-register \ --name docx \ --url "https://github.com/anthropics/skills/blob/main/skills/docx/SKILL.md" \ --description "Create and manipulate Microsoft Word documents" \ --tags docs,word,docx,documents \ --visibility public # Register xlsx skill (Excel spreadsheet handling) uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-register \ --name xlsx \ --url "https://github.com/anthropics/skills/blob/main/skills/xlsx/SKILL.md" \ --description "Create and manipulate Excel spreadsheets" \ --tags spreadsheet,excel,xlsx,data \ --visibility public # Register pdf skill (PDF document handling) uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-register \ --name pdf \ --url "https://github.com/anthropics/skills/blob/main/skills/pdf/SKILL.md" \ --description "Create and manipulate PDF documents" \ --tags pdf,documents,conversion \ --visibility public # Register mcp-builder skill (MCP server development) uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-register \ --name mcp-builder \ --url "https://github.com/anthropics/skills/blob/main/skills/mcp-builder/SKILL.md" \ --description "Build MCP servers and tools for AI assistant integrations" \ --tags mcp,coding,development,servers \ --visibility public ``` ### Other Skill CLI Commands ```bash # List all skills uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-list # Get skill details uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-get --path doc-coauthoring # Check skill health uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-health --path doc-coauthoring # Get SKILL.md content uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-content --path doc-coauthoring # Search for skills uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-search --query "document" # Toggle skill enabled/disabled uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-toggle --path doc-coauthoring --enabled false # Rate a skill (1-5 stars) uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-rate --path doc-coauthoring --rating 5 # Get skill rating uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-rating --path doc-coauthoring # Delete a skill uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ skill-delete --path doc-coauthoring ``` ### Token File Format The token file should be a JSON file with the following structure: ```json { "tokens": { "access_token": "eyJ..." } } ``` Or the simpler format: ```json { "access_token": "eyJ..." } ``` ## API Reference For complete API endpoint documentation, see: - **OpenAPI Specification**: [api/openapi.json](../api/openapi.json) - Full API spec for writing custom curl commands - **API Reference**: [API Reference](api-reference.md) - Human-readable endpoint documentation ## Related Documentation - [Agent Skills Architecture](design/agent-skills-architecture.md) - [Authentication](auth.md) - [Federation](federation.md) ================================================ FILE: docs/agent-visibility-and-group-access.md ================================================ # Agent Visibility and Group-Based Access Control This document explains how the MCP Gateway Registry controls who can see and use agents using two layers: **group scope configs** (admin-managed) and **agent-level allowed_groups** (publisher-managed). ## How Group Scopes Work Today An admin creates a group scope that defines exactly which agents a group can access. Group scopes can be created through: - The **UI** (IAM group management page) - The **CLI** (`registry_management.py scope-create`) - The **API** directly (see [openapi.json](https://github.com/agentic-community/mcp-gateway-registry/blob/main/api/openapi.json)) The scope is synced to the identity provider (Keycloak, Entra, Cognito, Okta). ### Narrow Scope Example: public-mcp-users Users in the `public-mcp-users` group can only see the `/flight-booking` agent: ```json { "scope_name": "public-mcp-users", "group_mappings": ["public-mcp-users"], "ui_permissions": { "list_agents": ["/flight-booking"], "get_agent": ["/flight-booking"] } } ``` This is a **narrow scope**: the admin explicitly lists which agents the group can access. ### Broad Scope Example: registry-admins Admin users can see all agents: ```json { "_id": "registry-admins", "group_mappings": ["registry-admins"], "ui_permissions": { "list_agents": ["all"], "get_agent": ["all"], "publish_agent": ["all"], "modify_agent": ["all"], "delete_agent": ["all"] } } ``` This is a **broad scope**: `"list_agents": ["all"]` means the group can see every agent in the registry. ## The Problem with Broad Scopes Broad scopes are convenient for large teams. An admin might configure an `engineering` group with `"list_agents": ["all"]` so engineers can discover and use any agent without filing a request each time. But what happens when someone publishes a sensitive agent? Say the HR team publishes a `/salary-calculator` agent. With a broad scope, every engineer can see it. The HR team lead does not want that, but they cannot change the group scope config because that requires an admin. This is where `allowed_groups` comes in. ## What allowed_groups Does When registering or editing an agent, the publisher can set `visibility: "group-restricted"` and specify `allowed_groups`. This acts as a second filter **on top of** the IAM group scope. The two layers work as an AND: 1. **IAM scope check**: Is the agent in the user's `accessible_agents` list (from their group scope config)? 2. **allowed_groups check**: If the agent is `group-restricted`, do the user's JWT groups intersect with the agent's `allowed_groups`? A user must pass **both** checks to see the agent. ## Concrete Scenario ### Setup An enterprise has three groups configured in the identity provider: | Group | Scope Type | Agent Access | |-------|-----------|--------------| | `engineering` | Broad | `"list_agents": ["all"]` | | `hr-team` | Broad | `"list_agents": ["all"]` | | `public-mcp-users` | Narrow | `"list_agents": ["/flight-booking"]` | The registry has three agents: | Agent | Visibility | allowed_groups | |-------|-----------|----------------| | `/flight-booking` | `public` | `[]` | | `/code-reviewer` | `public` | `[]` | | `/salary-calculator` | `group-restricted` | `["hr-team"]` | ### Who Sees What **Alice (in `engineering` group):** - `/flight-booking`: IAM scope = `["all"]`, so passes. Visibility = `public`, no group check. **Sees it.** - `/code-reviewer`: Same logic. **Sees it.** - `/salary-calculator`: IAM scope = `["all"]`, so passes. But visibility = `group-restricted` and Alice's groups (`engineering`) do not intersect with `allowed_groups` (`hr-team`). **Does NOT see it.** **Bob (in `hr-team` group):** - `/flight-booking`: IAM scope = `["all"]`, passes. Visibility = `public`. **Sees it.** - `/code-reviewer`: Same. **Sees it.** - `/salary-calculator`: IAM scope = `["all"]`, passes. Visibility = `group-restricted` and Bob's groups (`hr-team`) intersect with `allowed_groups` (`hr-team`). **Sees it.** **Carol (in `public-mcp-users` group):** - `/flight-booking`: IAM scope = `["/flight-booking"]`, passes. Visibility = `public`. **Sees it.** - `/code-reviewer`: IAM scope = `["/flight-booking"]`, does NOT include `/code-reviewer`. **Does NOT see it.** (Filtered at IAM layer, `allowed_groups` is never checked.) - `/salary-calculator`: IAM scope does NOT include `/salary-calculator`. **Does NOT see it.** ### The Key Takeaway - For **narrow-scoped groups** like `public-mcp-users`, the IAM scope already controls per-agent access. The `allowed_groups` field has no effect because the IAM layer filters first. - For **broad-scoped groups** like `engineering`, the IAM scope grants access to everything. The `allowed_groups` field is the publisher's mechanism to restrict visibility within that broad grant, without needing to ask an admin to create a narrower scope. ## When to Use allowed_groups | Your group scope config | Use allowed_groups? | Why | |------------------------|---------------------|-----| | Narrow (`["/agent-a", "/agent-b"]`) | No benefit | IAM already controls per-agent access | | Broad (`["all"]`) | Yes, for sensitive agents | Lets the publisher restrict who sees their agent | | Mix of narrow and broad groups | Yes, for agents that broad groups should not all see | Narrows access for broad groups while narrow groups are unaffected (filtered at IAM layer first) | ## Visibility Modes | Visibility | IAM Check | allowed_groups Check | Who Can See | |------------|-----------|---------------------|-------------| | `public` | Must have IAM scope | No | All users with IAM access | | `group-restricted` | Must have IAM scope | Must be in allowed_groups | Users with IAM access AND in allowed groups | | `private` | Must have IAM scope | No | Only the agent owner | | `unlisted` | Must have IAM scope | No | Users with the direct URL | ## API Examples ### Register a Group-Restricted Agent The HR team lead publishes a salary calculator that only `hr-team` can see: ```bash curl -s -X POST "https://your-registry/api/agents/register" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Salary Calculator", "path": "/salary-calculator", "version": "1.0.0", "url": "https://example.com/salary-calculator", "supportedProtocol": "a2a", "description": "Calculate salary projections and tax estimates", "visibility": "group-restricted", "allowedGroups": ["hr-team"], "skills": [ { "id": "calculate-salary", "name": "Calculate Salary", "description": "Calculate salary projections", "tags": ["hr", "finance"], "inputSchema": {} } ] }' ``` ### Register a Public Agent (No Group Restriction) A general-purpose agent visible to anyone with IAM access: ```bash curl -s -X POST "https://your-registry/api/agents/register" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Flight Booking", "path": "/flight-booking", "version": "1.0.0", "url": "https://example.com/flight-booking", "supportedProtocol": "a2a", "description": "Book flights for business travel", "visibility": "public", "skills": [ { "id": "book-flight", "name": "Book Flight", "description": "Search and book flights", "tags": ["travel"], "inputSchema": {} } ] }' ``` ### Update allowed_groups Expand access to include the `finance-team`: ```bash curl -s -X PUT "https://your-registry/api/agents/salary-calculator" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Salary Calculator", "path": "/salary-calculator", "version": "1.0.0", "url": "https://example.com/salary-calculator", "supportedProtocol": "a2a", "description": "Calculate salary projections and tax estimates", "visibility": "group-restricted", "allowedGroups": ["hr-team", "finance-team"], "skills": [ { "id": "calculate-salary", "name": "Calculate Salary", "description": "Calculate salary projections", "tags": ["hr", "finance"], "inputSchema": {} } ] }' ``` ### List Agents Filtered by allowed_groups ```bash # Show only agents shared with hr-team curl -s "https://your-registry/api/agents?allowed_groups=hr-team" \ -H "Authorization: Bearer $TOKEN" | jq . # Show agents shared with either hr-team or finance-team curl -s "https://your-registry/api/agents?allowed_groups=hr-team,finance-team" \ -H "Authorization: Bearer $TOKEN" | jq . ``` ### Group Scope Config for a Broad-Access Team To create an `engineering` group with broad agent access (where `allowed_groups` becomes useful): ```json { "scope_name": "engineering", "description": "Engineering team with broad agent access", "group_mappings": ["engineering"], "ui_permissions": { "list_agents": ["all"], "get_agent": ["all"], "list_service": ["all"] }, "create_in_idp": true } ``` Upload via CLI: ```bash python api/registry_management.py --registry-url https://your-registry \ --token-file .token-admin import-group --file engineering.json ``` Or via curl: ```bash curl -s -X POST "https://your-registry/api/servers/groups/import" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d @engineering.json ``` With this config, all engineers see every `public` agent but cannot see `group-restricted` agents unless their group is in the agent's `allowed_groups`. ================================================ FILE: docs/agentcore-auto-registration-prerequisites.md ================================================ # AgentCore Auto-Registration Prerequisites This guide covers the setup required before using the AgentCore auto-registration CLI (`python -m cli.agentcore sync`). The prerequisites depend on the **authorizer type** configured on each AgentCore Gateway. | Authorizer Type | What You Need | |-----------------|---------------| | `CUSTOM_JWT` | OAuth2 M2M client credentials from your identity provider (Cognito, Auth0, Okta, etc.) | | `AWS_IAM` | AWS credentials with appropriate IAM permissions | | `NONE` | No setup required | > The auto-registration CLI discovers the authorizer type from each gateway automatically. You only need to prepare credentials for the authorizer types your gateways use. --- ## IAM Permissions for Discovery Regardless of gateway authorizer type, the CLI needs AWS credentials with permissions to call the Bedrock AgentCore control-plane APIs for resource discovery. ### Required IAM Policy Attach the following policy to the IAM user or role running the CLI: ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "AgentCoreDiscovery", "Effect": "Allow", "Action": [ "bedrock-agent:ListAgentGateways", "bedrock-agent:GetAgentGateway", "bedrock-agent:ListAgentRuntimes", "bedrock-agent:GetAgentRuntime", "bedrock-agent:ListTargets", "sts:GetCallerIdentity" ], "Resource": "*" } ] } ``` - `bedrock-agent:ListAgentGateways` / `GetAgentGateway` — discover gateways and their details - `bedrock-agent:ListAgentRuntimes` / `GetAgentRuntime` — discover runtimes and their protocol configuration - `bedrock-agent:ListTargets` — enumerate targets behind each gateway - `sts:GetCallerIdentity` — verify AWS credentials are valid (also used for `AWS_IAM` authorizer verification) ### AWS Credential Setup The CLI uses the standard boto3 credential chain. Configure credentials using any of these methods: **Option A: Environment variables** ```bash export AWS_ACCESS_KEY_ID=AKIA... export AWS_SECRET_ACCESS_KEY=... export AWS_REGION=us-east-1 ``` **Option B: AWS CLI profile** ```bash aws configure --profile agentcore-sync export AWS_PROFILE=agentcore-sync export AWS_REGION=us-east-1 ``` **Option C: IAM role (EC2 / ECS / Lambda)** If running on an AWS compute resource, attach the IAM policy above to the instance role or task role. No explicit credential configuration is needed. --- ## CUSTOM_JWT Authorizer — OAuth2 M2M Client Setup Gateways with `CUSTOM_JWT` authorizer require OAuth2 machine-to-machine (M2M) client credentials. The CLI uses these credentials to generate egress tokens for authenticating with the gateway. You need to create an M2M client in your OAuth2 provider and note the **Client ID**, **Client Secret**, and **OAuth2 domain URL**. ### Amazon Cognito 1. Open the [Amazon Cognito console](https://console.aws.amazon.com/cognito/) and select the User Pool associated with your AgentCore Gateway. 2. Navigate to **App integration** → **App clients** and create a new app client: - App type: **Confidential client** - App client name: e.g., `agentcore-sync-m2m` - Generate a client secret: **Yes** - Authentication flows: **Client credentials** (`ALLOW_CUSTOM_AUTH` is not needed) 3. Under **Hosted UI**, configure the allowed OAuth scopes for the client. Use the scope defined by your AgentCore Gateway's resource server (e.g., `default-m2m-resource-server-XXXXXXXX/read`). 4. Note the following values: - **Client ID**: shown on the app client page - **Client Secret**: click "Show client secret" - **OAuth2 domain**: `https://.auth..amazoncognito.com` 5. Set the environment variable: ```bash export OAUTH_DOMAIN="https://.auth..amazoncognito.com" ``` ### Auth0 1. Log in to the [Auth0 Dashboard](https://manage.auth0.com/) and navigate to **Applications** → **Applications**. 2. Click **Create Application**: - Name: e.g., `agentcore-sync-m2m` - Application type: **Machine to Machine** 3. Authorize the application for the API (audience) that your AgentCore Gateway uses. Select the required scopes. 4. Note the following values from the **Settings** tab: - **Client ID** - **Client Secret** - **Domain**: e.g., `your-tenant.auth0.com` 5. Set the environment variable: ```bash export OAUTH_DOMAIN="https://your-tenant.auth0.com" ``` ### Okta 1. Log in to the [Okta Admin Console](https://developer.okta.com/) and navigate to **Applications** → **Applications**. 2. Click **Create App Integration**: - Sign-in method: **API Services** (client credentials) - App integration name: e.g., `agentcore-sync-m2m` 3. On the app's **General** tab, note: - **Client ID** - **Client Secret** 4. Under **Okta API Scopes**, grant the scopes required by your AgentCore Gateway. 5. Set the environment variable using your Okta domain: ```bash export OAUTH_DOMAIN="https://your-org.okta.com" ``` ### Providing Credentials to the CLI You can provide OAuth2 credentials in two ways: **Option A: Environment variables (recommended for CI/CD)** ```bash # Gateway 1: CUSTOM_JWT (requires OAuth2 credentials) export AGENTCORE_CLIENT_ID_1="your-client-id" export AGENTCORE_CLIENT_SECRET_1="your-client-secret" export AGENTCORE_GATEWAY_ARN_1="arn:aws:bedrock:us-east-1:123456789012:gateway/gw-abc123" export AGENTCORE_SERVER_NAME_1="my-oauth-gateway" export AGENTCORE_AUTHORIZER_TYPE_1="CUSTOM_JWT" # Gateway 2: AWS_IAM (no OAuth2 credentials needed) export AGENTCORE_GATEWAY_ARN_2="arn:aws:bedrock:us-east-1:123456789012:gateway/gw-def456" export AGENTCORE_SERVER_NAME_2="my-iam-gateway" export AGENTCORE_AUTHORIZER_TYPE_2="AWS_IAM" # Gateway 3: NONE (no credentials needed) export AGENTCORE_GATEWAY_ARN_3="arn:aws:bedrock:us-east-1:123456789012:gateway/gw-ghi789" export AGENTCORE_SERVER_NAME_3="my-public-gateway" export AGENTCORE_AUTHORIZER_TYPE_3="NONE" ``` > The `AGENTCORE_AUTHORIZER_TYPE_{N}` variable is optional — the CLI auto-detects the authorizer type from the gateway. Set it explicitly only if you want to override the detected type. **Option B: Interactive prompt** If no environment variables are set, the CLI will prompt for credentials during `sync`: ``` OAuth2 credentials needed for gateway: arn:aws:bedrock:us-east-1:123456789012:gateway/gw-abc123 (Press Enter to skip) Client ID: Client Secret: ``` The Client Secret is entered securely (not echoed to the terminal). --- ## AWS_IAM Authorizer Gateways with `AWS_IAM` authorizer use the standard AWS credential chain for authentication (SigV4 signing). No OAuth2 client setup is needed. ### What You Need 1. AWS credentials configured (see [AWS Credential Setup](#aws-credential-setup) above). 2. The `sts:GetCallerIdentity` permission (included in the discovery policy above). The CLI verifies your AWS credentials by calling `sts:GetCallerIdentity` during the sync process. If verification succeeds, the gateway is registered without any OAuth2 credential collection or token generation. --- ## NONE Authorizer Gateways with `NONE` authorizer require **no setup**. The CLI registers these gateways without collecting credentials or generating tokens. --- ## Cross-Account Scanning To scan AgentCore resources in other AWS accounts, you need an IAM role in each target account that the CLI can assume. ### Target Account Role Setup In each target account, create an IAM role (default name: `AgentCoreSyncRole`) with: 1. **Trust policy** — allows the caller's account to assume the role: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::CALLER_ACCOUNT_ID:root" }, "Action": "sts:AssumeRole", "Condition": {} } ] } ``` Replace `CALLER_ACCOUNT_ID` with the AWS account ID where the CLI runs. You can restrict the principal to a specific IAM user or role instead of `root` for tighter security. 2. **Permissions policy** — the same AgentCore discovery policy from [IAM Permissions for Discovery](#iam-permissions-for-discovery): ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "AgentCoreDiscovery", "Effect": "Allow", "Action": [ "bedrock-agent:ListAgentGateways", "bedrock-agent:GetAgentGateway", "bedrock-agent:ListAgentRuntimes", "bedrock-agent:GetAgentRuntime", "bedrock-agent:ListTargets", "sts:GetCallerIdentity" ], "Resource": "*" } ] } ``` ### Caller Account Permissions The IAM user or role running the CLI also needs permission to assume the role in each target account: ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "AssumeAgentCoreSyncRole", "Effect": "Allow", "Action": "sts:AssumeRole", "Resource": [ "arn:aws:iam::111111111111:role/AgentCoreSyncRole", "arn:aws:iam::222222222222:role/AgentCoreSyncRole" ] } ] } ``` Replace the account IDs and role name with your actual values. ### Quick Setup (AWS CLI) ```bash # In each target account, create the role: aws iam create-role \ --role-name AgentCoreSyncRole \ --assume-role-policy-document file://trust-policy.json aws iam put-role-policy \ --role-name AgentCoreSyncRole \ --policy-name AgentCoreDiscovery \ --policy-document file://discovery-policy.json ``` --- ## Verification Checklist Before running `python -m cli.agentcore sync`, verify: - [ ] AWS credentials are configured and can call `sts:GetCallerIdentity` - [ ] The IAM policy includes all required `bedrock-agent:*` permissions - [ ] For `CUSTOM_JWT` gateways: OAuth2 M2M client is created and `OAUTH_DOMAIN` is set - [ ] For `AWS_IAM` gateways: AWS credentials are available in the environment - [ ] The MCP Gateway Registry is running and accessible at the configured `REGISTRY_URL` - [ ] A valid registry auth token exists at the configured `--token-file` path (default: `.oauth-tokens/ingress.json`). Generate it with: `python credentials-provider/oauth/ingress_oauth.py` - [ ] For cross-account scanning: `AgentCoreSyncRole` (or custom role) exists in each target account - [ ] For cross-account scanning: The caller has `sts:AssumeRole` permission for each target role ## Next Steps - [Auto-Registration CLI Usage](agentcore.md#auto-registration) — CLI commands, environment variables, and troubleshooting - [AgentCore Gateway Integration Guide](agentcore.md) — Manual gateway registration walkthrough ================================================ FILE: docs/agentcore.md ================================================ # Registering Amazon Bedrock AgentCore Assets This guide covers how to register AgentCore Gateways and Agent Runtimes in the MCP Gateway Registry. There are two approaches: bulk auto-registration (scan an entire AWS account) or per-server manual registration (one resource at a time). ## Two Ways to Register AgentCore Assets | Approach | Best For | Token Management | |----------|----------|-----------------| | **Method 1: Bulk Scanner** | Discovering and registering all resources in an AWS account at once | Automated via `token_refresher.py` | | **Method 2: Per-Server Registration** | Registering individual gateways or agents manually (same as any other MCP server/agent) | Manual token generation | --- ## Method 1: Bulk Scanner (Auto-Registration) The AgentCore scanner CLI automates the discovery and registration of all AgentCore Gateways and Agent Runtimes in your AWS account. Instead of manually creating JSON configuration files for each resource, the CLI scans your account, builds registrations, and writes a token refresh manifest -- all in one command. A separate token refresher process then keeps egress tokens up to date. The scanner and token refresher work with any OIDC-compliant identity provider -- Cognito, Auth0, Okta, Entra ID, Keycloak, or any custom provider. The IdP is auto-detected from the OIDC discovery URL in each gateway's configuration. > **Prerequisites:** Before using auto-registration, complete the setup steps in the [Auto-Registration Prerequisites Guide](agentcore-auto-registration-prerequisites.md). ### Step 1: Scan and Register ```bash # Discover resources without registering (preview) uv run python -m cli.agentcore sync --dry-run # Register all gateways and runtimes uv run python -m cli.agentcore sync # Overwrite existing registrations (update metadata if changed) uv run python -m cli.agentcore sync --overwrite # List discovered resources without registering uv run python -m cli.agentcore list ``` The sync command: 1. Discovers all READY gateways and runtimes via AWS Bedrock AgentCore API 2. Registers each gateway as an MCP Server and each runtime as an MCP Server (protocol=MCP) or A2A Agent (protocol=HTTP/A2A) 3. Writes `token_refresh_manifest.json` listing all CUSTOM_JWT gateways that need token refresh > **Note:** Agents imported from runtimes are registered with an empty skills array. To add skills after import, use the agent edit dialog in the UI or the `PUT /api/agents/{path}` API endpoint. ### Step 2: Configure Client Secrets (for CUSTOM_JWT Gateways) CUSTOM_JWT gateways need OAuth2 client secrets to generate egress tokens. Add them to your `.env` file: ```bash # Per-client secret (highest priority) -- use the client_id from allowed_clients OAUTH_CLIENT_SECRET_49ujl0b9ser72gnp6q1ph9v6vs=your-secret-here # Or vendor-level secrets (shared across all gateways for that IdP) AUTH0_CLIENT_SECRET=your-auth0-secret OKTA_CLIENT_SECRET=your-okta-secret ENTRA_CLIENT_SECRET=your-entra-secret KEYCLOAK_CLIENT_SECRET=your-keycloak-secret ``` **Cognito gateways need no configuration** -- the token refresher auto-retrieves client secrets via the AWS API (`describe_user_pool_client`). Secret resolution priority: 1. Per-client env var: `OAUTH_CLIENT_SECRET_` 2. Cognito auto-retrieval via AWS API (Cognito only) 3. Vendor-specific env var: `AUTH0_CLIENT_SECRET`, etc. ### Step 3: Run Token Refresher The token refresher reads the manifest, resolves secrets, fetches OAuth2 tokens, PATCHes them into the registry, and triggers a security rescan for each updated server (enabled by default, requires admin privileges on the registry token): ```bash # One-time refresh uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token # Continuous mode (sidecar -- refreshes every 45 minutes) uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token \ --loop --interval 2700 ``` Or set up a cron job: ```bash # Refresh every 45 minutes (tokens typically expire in 60 min) */45 * * * * cd /app && uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token \ >> /var/log/token-refresher.log 2>&1 ``` ### Scanner CLI Reference #### Sync -- Discover and Register ```bash # Basic sync uv run python -m cli.agentcore sync # Dry-run preview uv run python -m cli.agentcore sync --dry-run # Overwrite existing registrations uv run python -m cli.agentcore sync --overwrite # Register only gateways (skip runtimes) uv run python -m cli.agentcore sync --gateways-only # Register only runtimes (skip gateways) uv run python -m cli.agentcore sync --runtimes-only # Also register individual mcpServer gateway targets as separate MCP Servers uv run python -m cli.agentcore sync --include-mcp-targets # Set visibility for registered resources uv run python -m cli.agentcore sync --visibility public # JSON output for CI/CD pipelines uv run python -m cli.agentcore sync --output json # Specify region and registry URL uv run python -m cli.agentcore sync --region us-west-2 --registry-url https://registry.example.com # Custom token file and timeout uv run python -m cli.agentcore sync --token-file .token --timeout 60 # Enable debug logging uv run python -m cli.agentcore sync --debug ``` #### List -- Discover and Display ```bash # List all discovered resources uv run python -m cli.agentcore list # List only gateways uv run python -m cli.agentcore list --gateways-only # List only runtimes uv run python -m cli.agentcore list --runtimes-only # JSON output uv run python -m cli.agentcore list --output json # Specify region uv run python -m cli.agentcore list --region eu-west-1 ``` #### Token Refresher ```bash # One-time refresh uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token # With per-client env vars OAUTH_CLIENT_SECRET_49ujl0b9ser72gnp6q1ph9v6vs=secret \ uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token # With vendor-level env vars AUTH0_CLIENT_SECRET=xxx OKTA_CLIENT_SECRET=yyy \ uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token # Continuous mode (sidecar) uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token \ --loop --interval 2700 # Enable debug logging uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --token-file .token \ --debug ``` ### Scanner CLI Arguments | Argument | Subcommand | Default | Description | |----------|------------|---------|-------------| | `--region` | sync, list | `AWS_REGION` env or `us-east-1` | AWS region to scan | | `--registry-url` | sync, list | `REGISTRY_URL` env or `http://localhost` | Registry base URL | | `--token-file` | sync, list | `REGISTRY_TOKEN_FILE` env or `.token` | Path to registry auth token file | | `--timeout` | sync, list | `30` | AWS API call timeout in seconds | | `--gateways-only` | sync, list | `false` | Only process gateways | | `--runtimes-only` | sync, list | `false` | Only process runtimes | | `--output` | sync, list | `text` | Output format: `text` or `json` | | `--accounts` | sync, list | `AGENTCORE_ACCOUNTS` env or empty | Comma-separated AWS account IDs for cross-account scanning | | `--assume-role-name` | sync, list | `AGENTCORE_ASSUME_ROLE_NAME` env or `AgentCoreSyncRole` | IAM role name to assume in each target account | | `--debug` | sync, list | `false` | Enable DEBUG logging | | `--dry-run` | sync | `false` | Preview without registering | | `--overwrite` | sync | `false` | Overwrite existing registrations | | `--visibility` | sync | `internal` | Registration visibility: `public`, `internal`, `group-restricted` | | `--include-mcp-targets` | sync | `false` | Register mcpServer gateway targets as separate MCP Servers | | `--manifest` | sync | `token_refresh_manifest.json` | Output path for token refresh manifest | ### Token Refresher Arguments | Argument | Default | Description | |----------|---------|-------------| | `--manifest` | `token_refresh_manifest.json` | Path to manifest file | | `--registry-url` | `REGISTRY_URL` env or `http://localhost` | Registry base URL | | `--token-file` | `REGISTRY_TOKEN_FILE` env or `.token` | Registry auth token file | | `--loop` | `false` | Run continuously | | `--interval` | `2700` (45 min) | Refresh interval in seconds | | `--scan` / `--no-scan` | `--scan` (enabled) | Trigger security rescan after each credential update. Requires admin privileges on the registry token. Use `--no-scan` to disable. | | `--debug` | `false` | Enable DEBUG logging | ### Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `AWS_REGION` | `us-east-1` | AWS region to scan | | `REGISTRY_URL` | `http://localhost` | MCP Gateway Registry URL | | `REGISTRY_TOKEN_FILE` | `.token` | Path to registry auth token | | `OAUTH_CLIENT_SECRET_` | -- | Per-client OAuth2 secret (highest priority) | | `AUTH0_CLIENT_SECRET` | -- | Client secret for Auth0 gateways | | `OKTA_CLIENT_SECRET` | -- | Client secret for Okta gateways | | `ENTRA_CLIENT_SECRET` | -- | Client secret for Entra gateways | | `KEYCLOAK_CLIENT_SECRET` | -- | Client secret for Keycloak gateways | | `AGENTCORE_ACCOUNTS` | -- | Comma-separated AWS account IDs for cross-account scanning | | `AGENTCORE_ASSUME_ROLE_NAME` | `AgentCoreSyncRole` | IAM role name to assume in each target account | ### Cross-Account Scanning The CLI can scan multiple AWS accounts in a single run. It assumes an IAM role in each target account to discover and register resources. ```bash # Scan two accounts uv run python -m cli.agentcore sync --accounts 111111111111,222222222222 # Scan with a custom role name uv run python -m cli.agentcore sync --accounts 111111111111,222222222222 --assume-role-name MyCrossAccountRole # List resources across accounts uv run python -m cli.agentcore list --accounts 111111111111,222222222222 # Or use environment variables export AGENTCORE_ACCOUNTS=111111111111,222222222222 export AGENTCORE_ASSUME_ROLE_NAME=AgentCoreSyncRole uv run python -m cli.agentcore sync ``` How it works: 1. The CLI parses the `--accounts` flag (or `AGENTCORE_ACCOUNTS` env var) into a list of account IDs. 2. For each account, it calls `sts:AssumeRole` on `arn:aws:iam::{account_id}:role/{role_name}` to obtain temporary credentials. 3. A boto3 session is created with those temporary credentials and passed to the scanner and registration builder. 4. Discovery and registration proceed as normal, scoped to each account's resources. 5. If `--accounts` is not provided, the CLI scans only the current account (default behavior). If `AssumeRole` fails for any account, the CLI stops and reports the error. Each target account needs an IAM role that trusts the caller's account and has AgentCore discovery permissions. See the [Cross-Account IAM Prerequisites](agentcore-auto-registration-prerequisites.md#cross-account-scanning) for setup details. ### Troubleshooting Auto-Registration #### `AccessDeniedException` during discovery The IAM user or role lacks required permissions. Attach the discovery policy from the [prerequisites guide](agentcore-auto-registration-prerequisites.md#iam-permissions-for-discovery). #### "Already registered - skipping (use --overwrite)" The resource is already registered in the registry. Use `--overwrite` to update the existing registration with current metadata. #### Token refresher returns HTTP 500 If the token refresher logs `HTTP 500 from nginx -- registry token may be expired`, regenerate the registry auth token and retry: ```bash # Regenerate the registry ingress token python credentials-provider/oauth/ingress_oauth.py # Retry token refresh uv run python -m cli.agentcore.token_refresher --manifest token_refresh_manifest.json --token-file .token ``` #### Token file not found The registry auth token file (default: `.token`) does not exist. Generate it with: ```bash python credentials-provider/oauth/ingress_oauth.py ``` #### Dry-run shows resources but sync registers nothing In `--dry-run` mode, the CLI performs discovery but does not register. Remove the `--dry-run` flag to perform actual registration. #### Timeout errors on AWS API calls Increase the timeout with `--timeout 60` (or higher). The default is 30 seconds. --- ## Method 2: Per-Server Manual Registration For registering individual AgentCore gateways or agents one at a time, use the same registration process as any other MCP server or agent in the registry. This is no different from how you register any MCP server or A2A agent -- you create a JSON configuration file and use the service management CLI. This approach is useful when: - You want to register a single gateway without scanning the entire account - You need custom configuration (specific tool lists, descriptions, tags) - You are integrating a specific AgentCore sample (e.g., Customer Support Assistant) ### How It Works 1. **Create a JSON config file** describing the gateway or agent (path, proxy URL, auth scheme, tags, tool list) 2. **Register with the CLI**: `./cli/service_mgmt.sh add gateway-config.json` 3. **Provide a JWT token** when calling the gateway -- the IdP does not matter, just provide a valid bearer token at call time via `--token-file` 4. **Refresh the token** when it expires -- how you obtain the token is up to you (curl, SDK, script) The identity provider is irrelevant for manual registration. The registry uses passthrough authentication for `auth_provider: "bedrock-agentcore"` -- it forwards the bearer token to the AgentCore gateway, which validates it against whatever IdP is configured. ### Example JSON Configuration ```json { "server_name": "customer-support-assistant", "description": "Amazon Bedrock AgentCore Gateway for customer support operations", "path": "/customer-support-assistant", "proxy_pass_url": "https://.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp/", "auth_provider": "bedrock-agentcore", "auth_scheme": "bearer", "supported_transports": ["streamable-http"], "tags": ["bedrock", "agentcore", "customer-support"], "headers": [ { "Authorization": "Bearer $CUSTOMER_SUPPORT_AUTH_TOKEN" } ], "num_tools": 2, "is_python": false, "tool_list": [ { "name": "LambdaUsingSDK___check_warranty_status", "parsed_description": { "main": "Check the warranty status of a product using its serial number" }, "schema": { "type": "object", "properties": { "serial_number": {"type": "string", "description": "Product serial number"} }, "required": ["serial_number"] } } ] } ``` **Key Configuration Parameters:** | Parameter | Description | |-----------|-------------| | `path` | URL path where this service is accessible through the registry | | `proxy_pass_url` | Backend AgentCore Gateway URL. Replace `` with your actual Gateway ID | | `auth_provider` | Set to `bedrock-agentcore` for passthrough authentication -- the registry forwards the bearer token without validating it | | `tags` | Searchable tags used by `intelligent_tool_finder` for hybrid search (semantic + tag-based) | | `tool_list` | Tool definitions with names, descriptions, and JSON schemas. Enables the registry to catalog tools for dynamic discovery by AI agents | ### Register and Call ```bash # Register the gateway ./cli/service_mgmt.sh add gateway-config.json # Call a tool through the registry (provide a valid JWT from any IdP) uv run cli/mcp_client.py \ --url http://localhost/customer-support-assistant/mcp \ --token-file .cognito_access_token \ call --tool LambdaUsingSDK___check_warranty_status \ --args '{"serial_number":"MNO33333333"}' ``` ### When to Choose Each Method | | Method 1: Bulk Scanner | Method 2: Manual Registration | |---|---|---| | **Discovery** | Automatic (scans AWS account) | Manual (you provide the config) | | **Token refresh** | Automated (`token_refresher.py`) | Manual (you manage token lifecycle) | | **Customization** | Standard metadata from AWS API; skills must be added manually after import | Full control (tool lists, descriptions, tags, skills) | | **Scale** | All gateways/runtimes at once | One resource at a time | | **IdP** | Auto-detected from discovery URL | Any -- just provide a valid JWT | --- ## Troubleshooting ### 404 Not Found Error Verify: 1. Service is registered: `uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call --tool list_services --args '{}'` 2. Path matches (use trailing slash for bedrock-agentcore services) 3. Health status is healthy in the UI ### 401 Authentication Error 1. Refresh your egress access token (regenerate from your IdP) 2. Verify token file path is correct 3. Check token has not expired (TTL varies by IdP) ### Service Not Showing as Healthy 1. Verify AgentCore gateway is accessible from the registry container 2. Check network connectivity 3. Review registry logs: `docker logs mcp-gateway-registry-registry-1` ================================================ FILE: docs/ai-coding-assistants-setup.md ================================================ # AI Coding Assistants Setup Guide Complete guide for integrating the MCP Gateway & Registry with popular AI development tools. ## Overview The MCP Gateway automatically generates configuration files for various AI coding assistants, enabling seamless access to enterprise-curated MCP servers with proper authentication and governance. ## Prerequisites - MCP Gateway & Registry deployed and running - Authentication credentials generated via `./credentials-provider/generate_creds.sh` - Access to the AI coding assistant of your choice ## Supported AI Development Tools ### VS Code MCP Extension Microsoft's popular editor with native MCP support. **Setup:** ```bash # Copy generated configuration cp .oauth-tokens/vscode-mcp.json ~/.vscode/settings.json # Alternative: Merge with existing settings cat .oauth-tokens/vscode-mcp.json >> ~/.vscode/settings.json ``` **Configuration Format:** ```json { "mcp": { "servers": { "mcpgw": { "url": "https://your-gateway.com/mcpgw/mcp", "headers": { "Authorization": "Bearer eyJ...", "X-User-Pool-Id": "us-east-1_vm1115QSU", "X-Client-Id": "5v2rav1v93...", "X-Region": "us-east-1" }, "transport": "streamable-http" } } } } ``` ### Roo Code Plugin - Enterprise Showcase Roo Code demonstrates the power of enterprise governance for AI development tools. **Setup:** ```bash # Copy Roo Code configuration cp .oauth-tokens/mcp.json ~/.vscode/mcp_settings.json ``` **Alternative Setup Options:** ```bash # Option 1: Direct copy (recommended) cp .oauth-tokens/mcp.json ~/.vscode/mcp_settings.json # Option 2: Create symbolic link for automatic updates ln -sf "$(pwd)/.oauth-tokens/mcp.json" ~/.vscode/mcp_settings.json ``` **Enterprise Use Case:**
![Roo Code MCP Configuration](img/roo.png) **Enterprise Tool Catalog** - Curated MCP servers approved by IT - Consistent across all developer environments - Centralized authentication and governance - Real-time health monitoring ![Roo Code Agent in Action](img/roo_agent.png) **AI Assistant in Action** - Natural language tool discovery - Secure execution of enterprise tools - Complete audit trail for compliance - Seamless developer experience
**Key Enterprise Benefits:** **Centralized Control** - IT teams manage approved MCP servers across all development environments - Consistent tool availability regardless of developer setup - Rapid deployment of new tools to entire organization **Secure Authentication** - All tool access routes through enterprise identity systems (Amazon Cognito) - No individual API key management required - Automatic token refresh and rotation via [Token Refresh Service](token-refresh-service.md) **Usage Analytics & Compliance** - Track which developers use which tools and when - Generate compliance reports for audit requirements - Monitor tool adoption and usage patterns across teams **Developer Productivity** - Zero configuration required for approved tools - Instant access to new enterprise tools as they're approved - Same experience across VS Code, Cursor, Claude Code, and other assistants ### Claude Code Anthropic's coding assistant with standardized MCP configurations. **Setup:** ```bash # Claude Code uses similar JSON format cp .oauth-tokens/vscode-mcp.json ~/.claude-code/mcp-config.json ``` **Features:** - Natural language interaction with MCP tools - Context-aware tool suggestions - Integrated code generation and tool execution ### Cursor AI-first code editor with advanced MCP integration. **Setup:** ```bash # Cursor configuration (similar to VS Code) cp .oauth-tokens/vscode-mcp.json ~/.cursor/mcp-settings.json ``` **Advanced Features:** - Multi-file context for tool operations - Predictive tool suggestions based on code context - Integrated diff view for tool-generated changes ### Cline (formerly Claude Dev) Autonomous coding agent compatible with VS Code. **Setup:** ```bash # Cline uses VS Code-style configuration cp .oauth-tokens/vscode-mcp.json ~/.vscode/settings.json ``` **Autonomous Capabilities:** - Goal-directed tool usage - Multi-step task execution - Error handling and retry logic ### Custom MCP Clients For custom applications or other MCP clients: **Use Raw Authentication:** ```bash # Access authentication details directly cat .oauth-tokens/ingress.json ``` **Example Integration:** ```python import json import mcp from mcp.client.sse import sse_client # Load authentication from generated file with open('.oauth-tokens/ingress.json') as f: auth = json.load(f) headers = { 'Authorization': f'Bearer {auth["access_token"]}', 'X-User-Pool-Id': auth['user_pool_id'], 'X-Client-Id': auth['client_id'], 'X-Region': auth['region'] } # Connect to MCP server async with sse_client('https://gateway.com/mcpgw/sse', headers=headers) as (read, write): async with mcp.ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() ``` ## Configuration Management ### Automatic Token Refresh The MCP Gateway includes an [Automated Token Refresh Service](token-refresh-service.md) that provides continuous token management: ```bash # Start the token refresh service (runs in background) ./start_token_refresher.sh # Service automatically: # - Monitors token expiration (1-hour buffer by default) # - Refreshes tokens before they expire # - Updates all MCP client configurations # - Generates fresh configs for all AI assistants ``` **Key Benefits:** - **Zero Downtime**: Tokens refresh automatically before expiration - **Continuous Operation**: AI assistants never lose access due to expired tokens - **Multiple Client Support**: Updates configurations for VS Code, Roo Code, Claude Code, etc. - **Background Operation**: Runs as a service with comprehensive logging ### Manual Configuration Updates If you need to manually regenerate configurations: ```bash # Regenerate all configurations ./credentials-provider/generate_creds.sh # Copy updated configurations to AI assistants ./scripts/update-ai-assistants.sh # Custom script you can create ``` **For AI assistants using symbolic links** (recommended setup), configuration updates are automatic since they point to the live `.oauth-tokens/` files. ### Environment-Specific Configurations **Development Environment:** ```bash # Generate development configurations ENVIRONMENT=dev ./credentials-provider/generate_creds.sh cp .oauth-tokens/dev-* ~/.vscode/ ``` **Production Environment:** ```bash # Generate production configurations ENVIRONMENT=prod ./credentials-provider/generate_creds.sh cp .oauth-tokens/prod-* ~/.vscode/ ``` ## Troubleshooting ### Authentication Issues **Token Expired:** *If using Token Refresh Service (recommended):* ```bash # Check if token refresh service is running ps aux | grep token_refresher # Restart token refresh service if needed ./start_token_refresher.sh # Check service logs tail -f token_refresher.log ``` *Manual token refresh:* ```bash # Regenerate credentials ./credentials-provider/generate_creds.sh # Update AI assistant configurations ``` **Permission Denied:** ```bash # Check user permissions in Cognito aws cognito-idp admin-list-groups-for-user \ --user-pool-id YOUR_POOL_ID \ --username YOUR_USERNAME # Verify scope configuration cat auth_server/scopes.yml ``` ### Configuration Issues **Tools Not Appearing:** ```bash # Verify MCP server health curl -H "Authorization: Bearer TOKEN" \ https://your-gateway.com/server-name/sse # Check AI assistant logs tail -f ~/.vscode/logs/mcp.log ``` **Connection Failures:** ```bash # Test gateway connectivity ./tests/mcp_cmds.sh ping # Verify SSL certificates (if using HTTPS) openssl s_client -connect your-gateway.com:443 ``` ## Best Practices ### Security 1. **Credential Storage** - Store generated configurations in secure locations - Use environment-specific credentials - Regularly rotate authentication tokens 2. **Access Control** - Follow principle of least privilege - Regularly review user permissions - Monitor tool usage for anomalies 3. **Network Security** - Use HTTPS in production environments - Restrict network access to authorized IP ranges - Monitor for unauthorized access attempts ### Development Workflow 1. **Team Onboarding** ```bash # Create onboarding script #!/bin/bash ./credentials-provider/generate_creds.sh cp .oauth-tokens/vscode-mcp.json ~/.vscode/settings.json echo "MCP Gateway configured successfully!" ``` 2. **Tool Discovery** - Use natural language queries: "find tools for database operations" - Explore available tools through web interface - Share useful tool combinations with team 3. **Automation** ```bash # Automate configuration updates crontab -e # Add: 0 9 * * * /path/to/update-mcp-config.sh ``` ## Enterprise Deployment Considerations ### Scale Considerations - **Large Teams (100+ developers)**: Consider load balancing and caching - **Global Teams**: Deploy regional gateways for reduced latency - **High Security**: Use private networking and enhanced monitoring ### Compliance & Governance - **Audit Requirements**: Enable comprehensive logging - **Data Residency**: Deploy in compliant regions - **Access Reviews**: Implement periodic permission audits ### Cost Optimization - **Resource Management**: Monitor gateway resource usage - **Tool Usage**: Analyze tool usage patterns for optimization - **License Management**: Track per-developer tool usage ## Backend Server Authentication When MCP servers require their own authentication (API keys, bearer tokens, etc.), the MCP Gateway Registry provides automatic configuration generation that includes both: 1. **Gateway Authentication** - The `X-Authorization` header for authenticating with the MCP Gateway 2. **Backend Server Authentication** - The server's own auth header (`Authorization`, custom API key headers, etc.) ### Supported Authentication Schemes The registry supports three backend authentication schemes: | Scheme | Description | Example Header | |--------|-------------|----------------| | `none` | No backend authentication required | N/A | | `bearer` | Bearer token authentication | `Authorization: Bearer ` | | `api_key` | API key with custom header | `CONTEXT7_API_KEY: ` or `X-API-Key: ` | ### Example Configurations #### Example 1: API Key Authentication (Context7) **Server Details:** - **Display Name**: Context7 - **Auth Scheme**: `api_key` - **Auth Header**: `CONTEXT7_API_KEY` - **Credential**: API key provided during registration **Generated MCP Configuration (VS Code):** ```json { "servers": { "context7": { "type": "http", "url": "https://mcpgateway.ddns.net/context7/mcp", "headers": { "X-Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "CONTEXT7_API_KEY": "[YOUR_API_KEY]" } } } } ``` **Generated MCP Configuration (Roo Code/Cline):** ```json { "mcpServers": { "context7": { "type": "streamable-http", "url": "https://mcpgateway.ddns.net/context7/mcp", "disabled": false, "headers": { "X-Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "CONTEXT7_API_KEY": "[YOUR_API_KEY]" } } } } ``` #### Example 2: Bearer Token Authentication (Cloudflare) **Server Details:** - **Display Name**: Cloudflare API - **Auth Scheme**: `bearer` - **Auth Header**: `Authorization` - **Credential**: Bearer token provided during registration **Generated MCP Configuration (VS Code):** ```json { "servers": { "cloudflare-api": { "type": "http", "url": "https://mcpgateway.ddns.net/cloudflare-api/mcp", "headers": { "X-Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "Authorization": "Bearer [YOUR_SERVER_AUTH_TOKEN]" } } } } ``` ### How It Works 1. **Gateway Authentication Header** (`X-Authorization`): - Authenticates the AI coding assistant with the MCP Gateway - Automatically generated when you open the MCP Configuration modal - Validates user identity and permissions 2. **Backend Server Authentication Header** (e.g., `Authorization`, `CONTEXT7_API_KEY`): - Authenticates the MCP Gateway with the backend MCP server - The credential is encrypted and stored in the registry during server registration - Automatically decrypted and included in health checks and tool fetching 3. **Automatic Configuration Generation**: - The Registry UI automatically detects the server's auth scheme - Both headers are included in the generated configuration - Works with all supported AI coding assistants (VS Code, Cursor, Cline, Roo Code, Claude Code) ### UI Workflow 1. **Open Server Card** in the Registry dashboard 2. **Click "Get MCP Config"** button 3. **Select Your IDE** (VS Code, Cursor, Cline, Roo Code, or Claude Code) 4. **Copy Configuration** - The generated config includes both gateway and backend auth headers 5. **Paste into your IDE's MCP settings file** **Screenshot Example:** ![MCP Configuration Modal showing dual authentication headers](img/mcp-config-dual-auth.png) ### Registry-Only Mode In registry-only deployment mode (catalog mode), only the backend server authentication is included: ```json { "mcpServers": { "context7": { "type": "streamable-http", "url": "https://context7-direct-endpoint.com/mcp", "disabled": false, "headers": { "CONTEXT7_API_KEY": "[YOUR_API_KEY]" } } } } ``` See [Registry Deployment Modes](registry-deployment-modes.md) for more details on deployment configurations. ## Support & Resources - [Configuration Reference](configuration.md) - Complete configuration options - [Authentication Guide](auth.md) - Identity provider setup and server credential management - [Server Registration](auth.md#server-authentication-credentials) - How to register servers with auth credentials - [Troubleshooting Guide](troubleshooting.md) - Common issues and solutions - [API Reference](registry_api.md) - Programmatic management - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - Community support ================================================ FILE: docs/ai-registry-tools.md ================================================ # AI Registry Tools An MCP server that provides AI agents with tools to discover and query MCP servers, tools, agents, and skills registered in the MCP Gateway Registry. See the [What's New section](../README.md#whats-new) in the main README for the latest updates and features. ## What This Server Does **AI Registry Tools** gives your AI coding assistants the ability to: - **Search for MCP tools** using natural language queries - **List all available MCP servers** in the registry - **Discover AI agents** that can help with specific tasks - **Find skills** (like Claude Code skills) available in the ecosystem - **Check registry health** and statistics This server is **automatically registered** in the MCP Gateway Registry and ready to use. ## Adding to Your AI Agent To use AI Registry Tools with your coding assistant (Claude Code, Roo Code, Cline, etc.): 1. **Navigate to the registry web UI** at your registry URL 2. **Find the AI Registry Tools server card** in the servers list 3. **Click the gear icon** on the server card 4. **Follow the configuration instructions** to add it to your AI agent The gear icon will provide ready-to-use configuration snippets for popular AI coding assistants. ## Available Tools ### intelligent_tool_finder Search for MCP tools using natural language semantic search. **Parameters:** - `query` (string, required) - Natural language description of what you want to do - `top_n` (integer, optional) - Number of results to return (default: 5, max: 100) **Returns:** ```typescript { results: Array<{ tool_name: string; // Name of the tool server_name: string; // Server providing the tool description: string | null; // Tool description score: number | null; // Relevance score (0-1) path: string | null; // Server path }>; query: string; // Your search query total_results: number; // Number of results found status: "success" | "failed"; } ``` **Example Query:** "find tools to help me work with databases" --- ### list_services List all MCP servers registered in the gateway. **Parameters:** None **Returns:** ```typescript { services: Array<{ server_name: string | null; // Display name of the server path: string; // URL path (e.g., '/weather-api') description: string | null; // Server description enabled: boolean; // Whether server is active tags: string[]; // Server tags tool_count: number | null; // Number of tools provided }>; total_count: number; // Total servers enabled_count: number; // Number of enabled servers status: "success" | "failed"; } ``` --- ### list_agents List all AI agents registered in the gateway. **Parameters:** None **Returns:** ```typescript { agents: Array<{ name: string | null; // Agent name description: string | null; // Agent description tags: string[]; // Agent tags created_at: string | null; // ISO timestamp }>; total_count: number; // Total agents status: "success" | "failed"; } ``` --- ### list_skills List all skills (Claude Code skills, etc.) registered in the gateway. **Parameters:** None **Returns:** ```typescript { skills: Array<{ path: string; // Skill path name: string | null; // Skill name description: string | null; // Skill description tags: string[]; // Skill tags created_at: string | null; // ISO timestamp }>; total_count: number; // Total skills status: "success" | "failed"; } ``` --- ### healthcheck Get registry health status and statistics. **Parameters:** None **Returns:** ```typescript { // Dynamic fields from registry health endpoint // May include: total_servers, enabled_servers, // total_tools, uptime, version, etc. [key: string]: any; status: "success" | "failed"; } ``` ## Use Cases ### For AI Coding Assistants **Discover new capabilities:** ``` You: "What tools are available for working with AWS?" AI: *calls intelligent_tool_finder(query="AWS tools")* AI: "I found 12 AWS-related tools including aws-kb for documentation, aws-bedrock for AI models, and cloudformation for infrastructure..." ``` **Check what's available:** ``` You: "Show me all MCP servers in the registry" AI: *calls list_services()* AI: "There are 47 MCP servers registered, including weather-api, github-mcp, slack-tools, and more..." ``` **Find specialized agents:** ``` You: "Are there any agents that can help with travel planning?" AI: *calls list_agents()* AI: "Yes, there's a travel-assistant-agent that can help with flight bookings, hotel searches, and itinerary planning." ``` ### For Development Workflows - **Tool discovery during development** - Find the right MCP tool before building custom solutions - **Registry exploration** - Understand what's available in your organization's MCP ecosystem - **Integration planning** - Identify which servers and tools to integrate into your projects - **Capability mapping** - Map business requirements to available MCP tools ## Authentication All tools require bearer token authentication. The authentication is handled automatically when you configure the server through your AI agent's settings. **How authentication works:** 1. Your AI agent includes an `Authorization: Bearer ` header with each request 2. AI Registry Tools forwards this token to the registry API 3. The registry validates your token and returns the requested data **If you see authentication errors:** - Verify your token is valid and not expired - Check that your token has appropriate permissions in the registry - Contact your registry administrator if issues persist ## Technical Architecture ``` AI Agent → AI Registry Tools → MCP Gateway Registry (MCP Protocol) (HTTP/JSON API) Bearer Token Token Forwarding ``` **Design principles:** - **Lightweight** - Minimal dependencies, fast startup - **Stateless** - No session management, horizontally scalable - **Pass-through authentication** - Tokens forwarded to registry - **Protocol adapter** - Translates MCP tool calls to HTTP API requests ## Configuration AI Registry Tools is configured via environment variables: | Variable | Default | Description | |----------|---------|-------------| | `REGISTRY_BASE_URL` | `http://localhost` | Registry API endpoint | | `HOST` | `127.0.0.1` | Bind host (use `0.0.0.0` for Docker/K8s) | | `PORT` | `8003` | Server port | For Docker/Kubernetes deployments, the registry automatically configures these variables. ## Support - **Documentation**: See the MCP Gateway Registry docs - **Issues**: Report issues in the main registry repository - **Configuration help**: Use the gear icon on the server card for setup guidance --- **Server Status**: Auto-registered and ready to use **Protocol**: Model Context Protocol (MCP) **Transport**: Streamable HTTP **Authentication**: Bearer token (forwarded to registry) ================================================ FILE: docs/anthropic-registry-import.md ================================================ # Importing Servers from Anthropic MCP Registry This guide explains how to import MCP servers from [Anthropic's official MCP Registry](https://registry.modelcontextprotocol.io/) into your MCP Gateway. ## Overview The Anthropic MCP Registry is an open, collaboratively governed directory of Model Context Protocol (MCP) servers. It is maintained by Anthropic in partnership with GitHub and the wider community through an open-source contribution model. This registry provides a curated catalog of publicly available and community-contributed MCP servers. Its API enables MCP clients and gateways to discover and import server configurations automatically, simplifying integration and discovery workflows for developers. The import functionality allows you to quickly add these servers to your gateway without manual configuration. ## Prerequisites - MCP Gateway up and running - Access to the registry container or CLI tools - Environment variables configured in `.env` file (for authenticated servers) > **Note**: The Anthropic API version is defined in `registry/constants.py` as `ANTHROPIC_API_VERSION` for easy version management. ## Quick Start ### Import a Single Server ```bash cd /home/ubuntu/repos/mcp-gateway-registry ./cli/import_from_anthropic_registry.sh ai.smithery/smithery-ai-github ``` ### Import Multiple Servers from a List Create or edit `cli/import_server_list.txt`: ```text # Popular MCP Servers ai.smithery/smithery-ai-github io.github.jgador/websharp ai.smithery/Hint-Services-obsidian-github-mcp ``` Then import all servers in the list: ```bash ./cli/import_from_anthropic_registry.sh --import-list cli/import_server_list.txt ``` ## Import Script Features ### Automatic Environment Variable Substitution The import script automatically: - Loads environment variables from `.env` file - Substitutes authentication header placeholders with actual values - Stores the final configuration with real credentials in JSON files **Example:** ```json // Before substitution (from Anthropic registry): { "headers": [ { "Authorization": "Bearer {smithery_api_key}" } ] } // After import (stored in gateway): { "headers": [ { "Authorization": "Bearer 3899299d-b7a2-471d-a185-200b9e9adcb2" } ] } ``` ### Server Name Transformation Server names from the Anthropic registry are automatically transformed to work with the gateway: - Slashes (`/`) are replaced with hyphens (`-`) - Example: `ai.smithery/github` becomes `ai.smithery-github` - The path is set to `/ai.smithery-github` ### Automatic Configuration The import script automatically configures: - **Server name** and **description** from registry - **Proxy URL** to the remote server - **Authentication type** (oauth, api-key, or none) - **Authentication provider** (Keycloak for oauth servers) - **Transport type** (streamable-http) - **Tags** for discovery and organization - **Headers** with substituted credentials ## Command Reference ### Basic Usage ```bash ./cli/import_from_anthropic_registry.sh [OPTIONS] [SERVER_NAME] ``` ### Options - `--import-list ` - Import servers from a file (one server name per line) - `--dry-run` - Show what would be imported without actually importing - `--gateway-url ` - Override gateway URL (default: http://localhost) - `--base-port ` - Override base port for local servers (default: 8100) ### Examples **Import with dry run:** ```bash ./cli/import_from_anthropic_registry.sh --dry-run ai.smithery/smithery-ai-github ``` **Import from custom list:** ```bash ./cli/import_from_anthropic_registry.sh --import-list my-servers.txt ``` **Import to remote gateway:** ```bash GATEWAY_URL="https://mcpgateway.example.com" ./cli/import_from_anthropic_registry.sh ai.smithery/smithery-ai-github ``` ## Server List File Format Create a text file with one server name per line: ```text # Lines starting with # are comments # Empty lines are ignored # GitHub API access ai.smithery/smithery-ai-github # Web search and article extraction io.github.jgador/websharp # Obsidian vault integration ai.smithery/Hint-Services-obsidian-github-mcp ``` ## Authentication Setup ### For Servers Requiring Authentication 1. **Get API Keys**: Obtain API keys from the service provider - Smithery servers: Visit [smithery.ai](https://smithery.ai) - Other services: Check their documentation 2. **Add to .env file**: ```bash # Smithery API Key SMITHERY_API_KEY=your-api-key-here # Other service keys OTHER_SERVICE_API_KEY=your-other-key ``` 3. **Import servers**: The script automatically substitutes the keys ### Supported Authentication Types The import script recognizes and configures: - **OAuth/Bearer tokens**: `Authorization: Bearer {api_key}` - **API keys**: `X-API-Key: {api_key}` or `API-Key: {api_key}` - **Custom headers**: Other authentication header formats ## Finding Servers to Import ### Browse Anthropic's MCP Registry Visit [registry.modelcontextprotocol.io](https://registry.modelcontextprotocol.io/) to: - Browse available servers - View server capabilities and tools - Check authentication requirements - Read documentation ### List Servers via API ```bash # List all available servers curl https://registry.modelcontextprotocol.io/v0.1/servers | jq '.servers[] | .name' # Get details for a specific server curl https://registry.modelcontextprotocol.io/v0.1/servers/ai.smithery%2Fsmithery-ai-github/versions/latest | jq '.' ``` ### Test Server Before Importing Use the test script to verify server details: ```bash ./cli/test_anthropic_api.py ai.smithery/smithery-ai-github ``` ## Verifying Imported Servers ### Check Server Status After importing, verify the server was registered: ```bash # Via CLI ./cli/service_mgmt.sh list # Via API curl http://localhost/mcpgw/mcp -X POST \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_TOKEN" \ -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}' ``` ### View Server in UI Navigate to the gateway UI to see imported servers: - http://localhost/ ### Check Health Status The health check service automatically monitors imported servers: ```bash docker compose logs registry | grep -i "health" ``` ## Troubleshooting ### Import Fails with Authentication Error **Problem**: Server requires authentication but key is missing **Solution**: 1. Check if the server requires an API key 2. Add the key to your `.env` file with the correct name 3. Re-run the import ### Server Shows as Unhealthy **Problem**: Imported server shows unhealthy in health checks **Possible causes**: - Invalid or expired API key - Network connectivity issues - Server is temporarily down **Check logs**: ```bash docker compose logs registry --tail 100 | grep -i "server-name" ``` ### Environment Variable Not Substituted **Problem**: Server JSON still shows `${VAR_NAME}` instead of actual value **Solution**: 1. Ensure the variable is defined in `.env` 2. Variable names are case-sensitive 3. Re-run the import after updating `.env` ### Server Name Conflicts **Problem**: Server already exists with same path **Solution**: ```bash # Delete existing server ./cli/service_mgmt.sh delete /server-path "server-name" # Re-import ./cli/import_from_anthropic_registry.sh server-name ``` ## Advanced Usage ### Custom Transformation To customize how servers are imported, edit `cli/anthropic_transformer.py`: - Modify tag generation - Change path formatting - Adjust authentication handling - Add custom metadata ### Batch Import with Filtering ```bash # Import only servers matching a pattern curl -s https://registry.modelcontextprotocol.io/v0.1/servers | \ jq -r '.servers[] | select(.name | contains("smithery")) | .name' > smithery-servers.txt ./cli/import_from_anthropic_registry.sh --import-list smithery-servers.txt ``` ### Automated Imports Add to cron or systemd timer for automatic updates: ```bash # Daily import of curated server list 0 2 * * * cd /path/to/repo && ./cli/import_from_anthropic_registry.sh --import-list cli/import_server_list.txt ``` ## Best Practices 1. **Curate your server list**: Only import servers you need and trust 2. **Review before importing**: Use `--dry-run` to preview changes 3. **Secure API keys**: Never commit `.env` to version control 4. **Monitor health**: Regularly check imported server health status 5. **Update regularly**: Re-import servers to get latest configurations 6. **Test thoroughly**: Verify each server works after importing ## Related Documentation - [Anthropic MCP Registry API](anthropic_registry_api.md) - [Service Management](service-management.md) - [Authentication Setup](../README.md#authentication) - [Health Monitoring](OBSERVABILITY.md) ## Support For issues or questions: - GitHub Issues: [mcp-gateway-registry/issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - Anthropic Registry: [modelcontextprotocol.io](https://modelcontextprotocol.io/) ================================================ FILE: docs/anthropic_registry_api.md ================================================ # Anthropic MCP Registry API Documentation The MCP Gateway Registry implements the server listing and related APIs from the [Anthropic MCP Registry REST API](https://raw.githubusercontent.com/modelcontextprotocol/registry/refs/heads/main/docs/reference/api/openapi.yaml) specification (currently v0.1). Additional API endpoints will be added in future releases. > **Note**: The Anthropic API version is defined in `registry/constants.py` as `ANTHROPIC_API_VERSION` for easy version management. ## Overview This API provides programmatic access to the MCP server registry using standard REST endpoints with JWT authentication. The API respects user permissions - users only see servers they have access to based on their configured privileges. ## Authentication The API uses JWT Bearer token authentication. You need to obtain a JWT token from the Keycloak authentication provider first. ### Generate JWT Token via UI (Admin Users) 1. **Login to the Registry Web Interface** - Navigate to your registry instance at `https://your-registry-domain/` or `http://localhost:7860/` - Login with your admin credentials 2. **Access Token Management** - After logging in, you should see the main dashboard - As an admin user, you have access to generate JWT tokens 3. **Generate JWT Token** - Click the "Generate JWT Token" button or navigate to the token generation page - The system will store your JWT tokens in files like `.oauth-tokens/mcp-registry-api-tokens-YYYY-MM-DD.json` - **Note**: Tokens have a short lifetime (typically 5-15 minutes) for security ### Token File Format The token file typically contains: ```json { "tokens": { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": null, "token_type": "bearer", "expires_in": 300 }, "keycloak_url": "http://localhost:8080", "realm": "mcp-gateway", "client_id": "mcp-gateway-m2m" } ``` **Note**: Refresh tokens are not provided for security reasons. If your token expires, generate a new one from the UI or ask your administrator to increase the access token timeout in Keycloak (Realm Settings → Tokens → Access Token Lifespan). ## API Endpoints All endpoints are prefixed with the API version (currently `/v0.1`, defined in `registry/constants.py`) and require authentication via Bearer token. ### 1. List Servers **Endpoint:** `GET /v0.1/servers` Lists all MCP servers that the authenticated user has access to. **Parameters:** - `cursor` (optional): Pagination cursor from previous response - `limit` (optional): Maximum number of items (1-1000, default: 100) **Response:** ```json { "servers": [ { "name": "io.mcpgateway/fininfo", "description": "Financial information and market data", "version": "1.0.0", "vendor": "MCP Gateway" } ], "nextCursor": "eyJpZCI6ImF0bGFzc2lhbiJ9" } ``` ### 2. Get Server Versions **Endpoint:** `GET /v0.1/servers/{server_name}/versions` Lists all available versions for a specific server. **Parameters:** - `server_name`: URL-encoded server name (e.g., `io.mcpgateway%2Ffininfo`) **Response:** ```json { "versions": [ { "version": "1.0.0", "description": "Latest stable version", "publishedAt": "2024-10-13T00:00:00Z" } ] } ``` ### 3. Get Server Version Details **Endpoint:** `GET /v0.1/servers/{server_name}/versions/{version}` Gets detailed information about a specific server version. **Parameters:** - `server_name`: URL-encoded server name - `version`: Version identifier or "latest" **Response:** ```json { "name": "io.mcpgateway/fininfo", "version": "1.0.0", "description": "Financial information and market data", "vendor": "MCP Gateway", "sourceUrl": "https://github.com/mcpgateway/mcp-gateway-registry", "configuration": { "mcpVersion": "2024-11-05", "capabilities": { "tools": {}, "resources": {} } } } ``` ## Using curl You can test the API directly using curl: ```bash # First, extract the access token from your token file ACCESS_TOKEN=$(cat /path/to/your/token-file.json | jq -r '.tokens.access_token') # List all servers you have access to curl -X GET "http://localhost/v0.1/servers?limit=10" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" # Get versions for a specific server curl -X GET "http://localhost/v0.1/servers/io.mcpgateway%2Ffininfo/versions" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" # Get details for a specific server version curl -X GET "http://localhost/v0.1/servers/io.mcpgateway%2Ffininfo/versions/latest" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" ``` **Note**: Server names with slashes must be URL-encoded (e.g., `io.mcpgateway/fininfo` becomes `io.mcpgateway%2Ffininfo`). ## Using the Test Script A complete test script is provided at `cli/test_anthropic_api.py` that demonstrates how to interact with the API programmatically. ### Basic Usage ```bash # Run all tests with a token file uv run python cli/test_anthropic_api.py --token-file /path/to/your/token-file.json # Test specific endpoint uv run python cli/test_anthropic_api.py \ --token-file /path/to/your/token-file.json \ --test list-servers \ --limit 10 # Get details for a specific server uv run python cli/test_anthropic_api.py \ --token-file /path/to/your/token-file.json \ --test get-server \ --server-name io.mcpgateway/fininfo ``` ### Additional Options ```bash # Use with different registry instance uv run python cli/test_anthropic_api.py \ --token-file tokens.json \ --base-url https://mcpgateway.ddns.net # Enable debug logging uv run python cli/test_anthropic_api.py \ --token-file tokens.json \ --debug ``` ### Command Line Options The test script supports the following options: | Option | Description | Default | |--------|-------------|---------| | `--token-file` | Path to JWT token file (required) | - | | `--base-url` | Registry API base URL | `http://localhost` | | `--test` | Which test to run (all, list-servers, get-versions, get-server) | `all` | | `--server-name` | Server name for specific tests | - | | `--limit` | Number of servers to list | `5` | | `--debug` | Enable debug logging | `false` | ## Example Python Code Here's a minimal example of how to build your own client (you would obviously write your own code adapted to your needs): ```python import requests import json from typing import Dict, Any, Optional class MCPRegistryClient: def __init__(self, base_url: str, access_token: str): self.base_url = base_url self.headers = { "Authorization": f"Bearer {access_token}", "Content-Type": "application/json" } def list_servers(self, limit: int = 100, cursor: Optional[str] = None) -> Dict[str, Any]: """List all available MCP servers.""" params = {"limit": limit} if cursor: params["cursor"] = cursor response = requests.get( f"{self.base_url}/v0.1/servers", headers=self.headers, params=params ) response.raise_for_status() return response.json() def get_server_versions(self, server_name: str) -> Dict[str, Any]: """Get all versions for a specific server.""" encoded_name = server_name.replace("/", "%2F") response = requests.get( f"{self.base_url}/v0.1/servers/{encoded_name}/versions", headers=self.headers ) response.raise_for_status() return response.json() def get_server_details(self, server_name: str, version: str = "latest") -> Dict[str, Any]: """Get detailed information about a server version.""" encoded_name = server_name.replace("/", "%2F") response = requests.get( f"{self.base_url}/v0.1/servers/{encoded_name}/versions/{version}", headers=self.headers ) response.raise_for_status() return response.json() # Usage example def main(): # Load token from file with open('/path/to/your/token-file.json', 'r') as f: token_data = json.load(f) access_token = token_data["tokens"]["access_token"] # Create client client = MCPRegistryClient("http://localhost", access_token) # List servers servers = client.list_servers(limit=10) print(f"Found {len(servers['servers'])} servers") # Get details for a specific server if servers["servers"]: server_name = servers["servers"][0]["name"] details = client.get_server_details(server_name) print(f"Server details: {json.dumps(details, indent=2)}") if __name__ == "__main__": main() ``` ## Token Lifetime Management Tokens have a short lifetime (typically 5-15 minutes) for security. When your token expires: 1. **Generate a new token** from the UI (recommended approach) 2. **Or ask your administrator** to increase the access token timeout in Keycloak: - Navigate to: **Keycloak Admin Console → Realm Settings → Tokens → Access Token Lifespan** - Increase the value as needed for your automation or extended use cases This approach is more secure than using refresh tokens and provides better audit trails. ## Error Handling The API returns standard HTTP status codes: - `200 OK`: Success - `401 Unauthorized`: Invalid or expired token - `403 Forbidden`: Insufficient permissions - `404 Not Found`: Server or version not found - `500 Internal Server Error`: Server error Error responses follow this format: ```json { "error": { "code": "UNAUTHORIZED", "message": "Invalid or expired token" } } ``` ## Rate Limiting The API may implement rate limiting. Check response headers for rate limit information: - `X-RateLimit-Limit`: Maximum requests per time window - `X-RateLimit-Remaining`: Remaining requests in current window - `X-RateLimit-Reset`: When the rate limit window resets ## Security Considerations 1. **Token Storage**: Store JWT tokens securely and never commit them to version control 2. **Token Expiry**: Generate new tokens when needed or configure longer lifetimes in Keycloak 3. **HTTPS**: Always use HTTPS in production environments 4. **Access Control**: Tokens respect user permissions - users only see servers they have access to ## Support For issues with the Anthropic Registry API implementation: 1. **Official Anthropic Registry API Specification**: [View the interactive API documentation](https://elements-demo.stoplight.io/?spec=https://raw.githubusercontent.com/modelcontextprotocol/registry/refs/heads/main/docs/reference/api/openapi.yaml) - This is the official Anthropic MCP Registry REST API specification that this implementation follows 2. Review the [authentication guide](./auth.md) for authentication setup 3. Examine the test script at `cli/test_anthropic_api.py` for working examples 4. Check server logs for detailed error information The API is fully compatible with Anthropic's MCP Registry specification, so any client built for the official registry should work with this implementation. ================================================ FILE: docs/api-reference.md ================================================ # MCP Gateway Registry - Complete API Reference This document provides a comprehensive overview of all 49 API endpoints available in the MCP Gateway Registry, organized by category with authentication requirements, request/response specifications, and OpenAPI documentation links. ## Table of Contents 1. [API Categories](#api-categories) 2. [Authentication Schemes](#authentication-schemes) 3. [A2A Agent Management APIs](#a2a-agent-management-apis) 4. [Anthropic MCP Registry API v0](#anthropic-mcp-registry-api-v0) 5. [Internal Server Management APIs](#internal-server-management-apis) 6. [JWT Server Management API](#jwt-server-management-api) 7. [Authentication & Login APIs](#authentication--login-apis) 8. [Health Monitoring APIs](#health-monitoring-apis) 9. [Discovery & Well-Known Endpoints](#discovery--well-known-endpoints) 10. [Utility Endpoints](#utility-endpoints) 11. [Response Codes & Error Handling](#response-codes--error-handling) 12. [OpenAPI Specifications](#openapi-specifications) --- ## API Categories | Category | Count | Auth Method | Purpose | |----------|-------|-------------|---------| | A2A Agent Management | 8 | JWT Bearer Token | Agent registration, discovery, and management | | Anthropic Registry API v0 (Servers) | 3 | JWT Bearer Token | Standard MCP server discovery via Anthropic API spec | | Internal Server Management (UI) | 10 | Session Cookie | Dashboard and service management | | Internal Server Management (Admin) | 12 | HTTP Basic Auth | Administrative operations and group management | | JWT Server Management | 11 | JWT Bearer Token | Programmatic server registration, auth credentials, and management | | Authentication & Login | 7 | OAuth2 + Session | User authentication and provider management | | Health Monitoring | 3 | Session Cookie / None | Real-time health updates and statistics | | Discovery | 1 | None (Public) | Public MCP server discovery | | Utility | 2 | Session Cookie / Public | Current user info and service health | | **TOTAL** | **46** | **Multiple** | **Full registry functionality** | --- ## Authentication Schemes ### 1. JWT Bearer Token (Nginx-Proxied Auth) **Used by:** A2A Agent APIs, Anthropic Registry API v0 **How it works:** - Client sends JWT token in `Authorization: Bearer ` header - Nginx validates token via `/validate` endpoint against auth-server - Auth-server validates token against Keycloak - Token scopes determine user permissions **Token Sources:** - Keycloak M2M service account (`mcp-gateway-m2m`) - User tokens generated via `/api/tokens/generate` **Example:** ```bash curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ..." \ http://localhost/v0.1/agents ``` --- ### 2. Session Cookie (Enhanced Auth) **Used by:** UI Server Management, Health Monitoring (WebSocket), Auth status endpoints **How it works:** - User logs in via OAuth2 (Keycloak) - Auth-server sets `mcp_gateway_session` cookie - Browser automatically includes cookie in subsequent requests - Registry validates cookie against auth-server **Example:** ```bash curl -b "mcp_gateway_session=" \ http://localhost/api/servers ``` --- ### 3. Public (No Authentication) **Used by:** Discovery endpoints, login page, OAuth2 providers list **Endpoints:** - `GET /.well-known/mcp-servers` - `GET /api/auth/login` - `GET /api/auth/providers` - `GET /health` --- ## A2A Agent Management APIs **File:** `registry/api/agent_routes.py` **Route Prefix:** `/api` **Authentication:** JWT Bearer Token (nginx_proxied_auth) ### 1. Register Agent **Endpoint:** `POST /api/agents/register` **Purpose:** Register a new A2A agent in the registry **Authentication:** Requires `publish_agent` scope **Request Body:** ```json { "name": "string", "description": "string", "path": "/agent-name", "url": "https://example.com/agent", "version": "1.0.0", "provider": "anthropic|custom|other", "security_schemes": { "scheme_name": { "type": "bearer|api_key|oauth2|etc", "description": "string" } }, "skills": [ { "name": "skill_name", "description": "string", "input_schema": {} } ], "tags": "string, comma, separated", "visibility": "public|private|internal", "license": "MIT|Apache-2.0|etc" } ``` **Response:** `201 Created` ```json { "message": "Agent registered successfully", "agent": { "name": "string", "path": "/agent-name", "url": "https://example.com/agent", "num_skills": 5, "registered_at": "2025-11-01T04:53:56.228791+00:00", "is_enabled": false } } ``` **Error Codes:** - `409 Conflict` - Agent path already exists - `422 Unprocessable Entity` - Validation error (invalid JSON, missing fields) - `403 Forbidden` - User lacks `publish_agent` permission --- ### 2. List Agents **Endpoint:** `GET /api/agents` **Purpose:** List all agents, optionally filtered **Authentication:** Optional (results filtered by user permissions) **Query Parameters:** - `query` (optional, string) - Search query string - `enabled_only` (optional, boolean, default: false) - Show only enabled agents - `visibility` (optional, string) - Filter by visibility level **Response:** `200 OK` ```json { "agents": [ { "name": "string", "path": "/agent-name", "description": "string", "is_enabled": true, "total_count": 5 } ] } ``` --- ### 3. Get Single Agent **Endpoint:** `GET /api/agents/{path:path}` **Purpose:** Get a single agent by path **Authentication:** JWT Bearer Token required **Path Parameter:** - `path` - Agent path (e.g., `/code-reviewer`) **Response:** `200 OK` ```json { "name": "Code Reviewer Agent", "path": "/code-reviewer", "description": "string", "url": "https://example.com/agents/code-reviewer", "version": "1.0.0", "skills": [ { "name": "review_code", "description": "string" } ], "is_enabled": true } ``` **Error Codes:** - `404 Not Found` - Agent doesn't exist - `403 Forbidden` - User not authorized --- ### 4. Update Agent **Endpoint:** `PUT /api/agents/{path:path}` **Purpose:** Update an existing agent **Authentication:** Requires `modify_service` permission and ownership **Path Parameter:** - `path` - Agent path **Request Body:** Same as registration request **Response:** `200 OK` with updated agent card **Error Codes:** - `404 Not Found` - Agent doesn't exist - `403 Forbidden` - User lacks modify permission - `422 Unprocessable Entity` - Validation error --- ### 5. Delete Agent **Endpoint:** `DELETE /api/agents/{path:path}` **Purpose:** Delete an agent from registry **Authentication:** Requires admin permission or agent ownership **Path Parameter:** - `path` - Agent path **Response:** `204 No Content` **Error Codes:** - `404 Not Found` - Agent doesn't exist - `403 Forbidden` - User lacks delete permission --- ### 6. Toggle Agent Status **Endpoint:** `POST /api/agents/{path:path}/toggle` **Purpose:** Enable or disable an agent **Authentication:** Requires `toggle_service` permission **Path Parameter:** - `path` - Agent path **Query Parameter:** - `enabled` (boolean) - True to enable, false to disable **Response:** `200 OK` ```json { "path": "/agent-name", "is_enabled": true, "message": "Agent enabled successfully" } ``` **Error Codes:** - `404 Not Found` - Agent doesn't exist - `403 Forbidden` - User lacks toggle permission --- ### 7. Discover Agents by Skills **Endpoint:** `POST /api/agents/discover` **Purpose:** Find agents that match required skills **Authentication:** Optional **Request Body:** ```json { "skills": ["skill1", "skill2"], "tags": ["optional", "filters"] } ``` **Query Parameter:** - `max_results` (optional, integer, default: 10, max: 100) **Response:** `200 OK` ```json { "agents": [ { "path": "/agent-name", "name": "string", "relevance_score": 0.95, "matching_skills": ["skill1"] } ] } ``` **Error Codes:** - `400 Bad Request` - No skills provided --- ### 8. Discover Agents Semantically **Endpoint:** `POST /api/agents/discover/semantic` **Purpose:** Find agents using NLP semantic search (FAISS vector search) **Authentication:** Optional **Query Parameters:** - `query` (required, string) - Natural language query (e.g., "Find agents that can analyze code") - `max_results` (optional, integer, default: 10, max: 100) **Response:** `200 OK` ```json { "agents": [ { "path": "/code-reviewer", "name": "Code Reviewer Agent", "relevance_score": 0.92, "description": "Analyzes code for issues..." } ] } ``` **Error Codes:** - `400 Bad Request` - Empty query - `500 Internal Server Error` - Search error --- ## Anthropic MCP Registry API v0 This section implements the official [Anthropic MCP Registry API specification](https://github.com/modelcontextprotocol/registry) for standard server discovery and agent discovery using the same API patterns. ### MCP Servers (v0) **File:** `registry/api/registry_routes.py` **Route Prefix:** `/v0` (from `REGISTRY_CONSTANTS.ANTHROPIC_API_VERSION`) **Authentication:** JWT Bearer Token #### 1. List MCP Servers **Endpoint:** `GET /v0/servers` **Purpose:** List all MCP servers with cursor-based pagination **Query Parameters:** - `cursor` (optional, string) - Pagination cursor from previous response - `limit` (optional, integer, default: 100, max: 1000) - Max items per page **Response:** `200 OK` ```json { "servers": [ { "id": "io.mcpgateway/example-server", "name": "Example Server", "description": "string", "homepage": "https://example.com", "resources": [ { "uri": "example://resource", "mimeType": "text/plain" } ] } ], "_meta": { "pagination": { "hasMore": false, "nextCursor": null } } } ``` --- #### 2. List Server Versions **Endpoint:** `GET /v0/servers/{serverName:path}/versions` **Purpose:** List all versions for a specific server **Path Parameter:** - `serverName` - URL-encoded reverse-DNS name (e.g., `io.mcpgateway%2Fexample-server`) **Response:** `200 OK` with versions array (currently one version per server) **Error Codes:** - `404 Not Found` - Server not found or user lacks access --- #### 3. Get Server Version Details **Endpoint:** `GET /v0/servers/{serverName:path}/versions/{version}` **Purpose:** Get detailed information about a specific server version **Path Parameters:** - `serverName` - URL-encoded server name - `version` - Version string or `latest` **Response:** `200 OK` with complete server details including tools **Error Codes:** - `404 Not Found` - Server/version not found or user lacks access --- ## Internal Server Management APIs ### UI Management Endpoints **File:** `registry/api/server_routes.py` **Route Prefix:** `/api` **Authentication:** Session Cookie (enhanced_auth) #### 1. Dashboard/Root **Endpoint:** `GET /api/` **Purpose:** Main dashboard showing services based on user permissions **Query Parameters:** - `query` (optional, string) - Search services **Response:** HTML page with filtered service list --- #### 2. Get Servers JSON **Endpoint:** `GET /api/servers` **Purpose:** Get servers data as JSON for React frontend **Query Parameters:** - `query` (optional, string) **Response:** `200 OK` ```json { "servers": [ { "path": "/example", "name": "Example Server", "description": "string", "is_enabled": true, "health_status": "healthy" } ] } ``` --- #### 3. Toggle Service **Endpoint:** `POST /api/toggle/{service_path:path}` **Purpose:** Enable/disable a service **Authentication:** Requires `toggle_service` UI permission **Form Parameters:** - `enabled` (boolean) **Response:** `200 OK` with new status **Error Codes:** - `404 Not Found` - Service doesn't exist - `403 Forbidden` - User lacks toggle permission - `500 Internal Server Error` - Toggle operation failed --- #### 4. Register Service (UI) **Endpoint:** `POST /api/register` **Purpose:** Register new service via dashboard **Authentication:** Requires `register_service` UI permission **Form Parameters:** - `name`, `description`, `path`, `proxy_pass_url`, `tags`, `num_tools`, `num_stars`, `is_python`, `license` **Response:** `201 Created` **Error Codes:** - `400 Bad Request` - Service already exists - `403 Forbidden` - User lacks register permission --- #### 5. Edit Service Form **Endpoint:** `GET /api/edit/{service_path:path}` **Purpose:** Show edit form for service **Authentication:** Requires `modify_service` UI permission **Response:** HTML edit form --- #### 6. Update Service **Endpoint:** `POST /api/edit/{service_path:path}` **Purpose:** Handle service edit submission **Authentication:** Requires `modify_service` UI permission **Form Parameters:** Same as register **Response:** `303 See Other` (redirect to home) --- #### 7. Token Generation Page **Endpoint:** `GET /api/tokens` **Purpose:** Show JWT token generation form **Response:** HTML form --- #### 8. Get Server Details **Endpoint:** `GET /api/server_details/{service_path:path}` **Purpose:** Get detailed server info by path or all servers **Path Parameter:** - `service_path` - Service path or `all` **Response:** `200 OK` with server details --- #### 9. Get Service Tools **Endpoint:** `GET /api/tools/{service_path:path}` **Purpose:** Get tools list for service **Path Parameter:** - `service_path` - Service path or `all` **Response:** `200 OK` ```json { "tools": [ { "name": "tool_name", "description": "string", "inputSchema": {} } ] } ``` **Error Codes:** - `404 Not Found` - Service not found - `400 Bad Request` - Service disabled - `403 Forbidden` - User lacks access --- #### 10. Refresh Service **Endpoint:** `POST /api/refresh/{service_path:path}` **Purpose:** Refresh service health and tools **Authentication:** Requires `health_check_service` permission **Response:** `200 OK` with refresh status --- ### Internal Admin Endpoints **Authentication:** HTTP Basic Auth (admin credentials) #### 11. Internal Register Service **Endpoint:** `POST /api/internal/register` **Purpose:** Internal service registration for mcpgw-server **Form Parameters:** All registration parameters + `overwrite`, `auth_provider`, `auth_type`, `supported_transports`, `headers`, `tool_list_json` **Response:** `201 Created` or `409 Conflict` **Features:** Auto-enables services, updates scopes.yml --- #### 12. Internal Remove Service **Endpoint:** `POST /api/internal/remove` **Form Parameters:** `service_path` **Response:** `200 OK` or `404/500` error --- #### 13. Internal Toggle Service **Endpoint:** `POST /api/internal/toggle` **Form Parameters:** `service_path` **Response:** `200 OK` with new state --- #### 14. Internal Healthcheck **Endpoint:** `POST /api/internal/healthcheck` **Response:** Health status for all servers --- #### 15. Add Server to Groups **Endpoint:** `POST /api/internal/add-to-groups` **Form Parameters:** - `server_name` - Server name - `group_names` - Comma-separated group names **Response:** `200 OK` with result --- #### 16. Remove Server from Groups **Endpoint:** `POST /api/internal/remove-from-groups` **Form Parameters:** Same as add-to-groups **Response:** `200 OK` --- #### 17. Internal List Services **Endpoint:** `GET /api/internal/list` **Response:** `200 OK` with all services and health status --- #### 18. Create Group **Endpoint:** `POST /api/internal/create-group` **Form Parameters:** - `group_name` - `description` (optional) - `create_in_idp` (optional) **Response:** `200 OK` --- #### 19. Delete Group **Endpoint:** `POST /api/internal/delete-group` **Form Parameters:** - `group_name` - `delete_from_idp` (optional) - `force` (optional) **Response:** `200 OK` **Note:** Prevents deletion of system groups --- #### 20. List Groups **Endpoint:** `GET /api/internal/list-groups` **Query Parameters:** - `include_keycloak` (default: true) - `include_scopes` (default: true) **Response:** `200 OK` with synchronized groups info --- #### 21. Generate JWT Token **Endpoint:** `POST /api/tokens/generate` **Purpose:** Generate JWT token for authenticated user **Request Body:** ```json { "requested_scopes": ["optional", "scopes"], "expires_in_hours": 8, "description": "Token description" } ``` **Response:** `200 OK` ```json { "access_token": "string", "token_type": "Bearer", "expires_in": 28800, "refresh_token": "string (if enabled)", "scope": "space separated scopes" } ``` --- #### 22. Admin Get Keycloak Token **Endpoint:** `GET /api/admin/tokens` **Purpose:** Admin-only endpoint to retrieve M2M tokens **Authentication:** Admin users only **Response:** `200 OK` with access token **Error Codes:** - `403 Forbidden` - Non-admin user - `500 Internal Server Error` - Configuration error --- ## JWT Server Management API Modern JWT-authenticated endpoints for programmatic server management. These are the external API equivalents of the internal UI endpoints. **File:** `registry/api/server_routes.py` **Route Prefix:** `/api` **Authentication:** JWT Bearer Token (nginx_proxied_auth) #### 1. Register Server **Endpoint:** `POST /api/servers/register` **Purpose:** Register an MCP server with optional backend authentication credentials **Request body (form data):** - `name` (required): Service name - `description` (required): Service description - `path` (required): Service path (e.g., `/myservice`) - `proxy_pass_url` (required): Backend URL (e.g., `http://localhost:8000`) - `tags` (optional): Comma-separated tags - `auth_scheme` (optional): Backend auth scheme -- `none` (default), `bearer`, or `api_key` - `auth_credential` (optional): Plaintext credential (encrypted before storage) - `auth_header_name` (optional): Custom header name (default: `Authorization` for bearer, `X-API-Key` for api_key) - `tool_list_json` (optional): JSON array of MCP tool definitions (for manual tool registration) - `supported_transports` (optional): JSON array of transports - `headers` (optional): JSON object of custom headers - `mcp_endpoint` (optional): Custom MCP endpoint URL - `sse_endpoint` (optional): Custom SSE endpoint URL - `version` (optional): Server version (e.g., `v1.0.0`) - `status` (optional): Lifecycle status (`active`, `deprecated`, `draft`, `beta`) - `provider_organization` (optional): Provider organization name - `provider_url` (optional): Provider URL **Response:** `201 Created` **Error Codes:** - `400 Bad Request` - Invalid input data - `401 Unauthorized` - Missing or invalid JWT token - `409 Conflict` - Server already exists with same version - `500 Internal Server Error` - Server error **Example:** ```bash # Register a server behind Bearer token auth curl -X POST https://registry.example.com/api/servers/register \ -H "Authorization: Bearer $JWT_TOKEN" \ -F "name=My Protected Server" \ -F "description=An MCP server behind Bearer auth" \ -F "path=/my-protected-server" \ -F "proxy_pass_url=http://my-server:8000" \ -F "auth_scheme=bearer" \ -F "auth_credential=backend-server-token" ``` --- #### 2. Update Server **Endpoint:** `PUT /api/servers/{server_path:path}` **Purpose:** Update an existing server's details **Path Parameter:** - `server_path` - Server path (e.g., `/my-server`) **Request body (form data):** Same fields as register **Response:** `200 OK` with updated server details **Error Codes:** - `404 Not Found` - Server not found --- #### 3. Update Auth Credential **Endpoint:** `PATCH /api/servers/{server_path:path}/auth-credential` **Purpose:** Update or rotate the authentication credential for a registered server without re-registering **Path Parameter:** - `server_path` - Server path (e.g., `/my-server`) **Request body (JSON):** - `auth_scheme` (required): `none`, `bearer`, or `api_key` - `auth_credential` (optional): New credential. Required if auth_scheme is not `none`. - `auth_header_name` (optional): Custom header name. Default: `X-API-Key` for api_key. **Response:** `200 OK` **Error Codes:** - `400 Bad Request` - Invalid auth_scheme or missing credential - `404 Not Found` - Server not found **Example:** ```bash # Rotate a Bearer token curl -X PATCH https://registry.example.com/api/servers/my-server/auth-credential \ -H "Authorization: Bearer $JWT_TOKEN" \ -H "Content-Type: application/json" \ -d '{"auth_scheme": "bearer", "auth_credential": "new-token"}' ``` --- #### 4. Delete Server **Endpoint:** `DELETE /api/servers/{server_path:path}` **Purpose:** Remove a registered server **Path Parameter:** - `server_path` - Server path **Response:** `200 OK` **Error Codes:** - `404 Not Found` - Server not found **Example:** ```bash curl -X DELETE https://registry.example.com/api/servers/my-server \ -H "Authorization: Bearer $JWT_TOKEN" ``` --- #### 5. Toggle Server **Endpoint:** `POST /api/servers/toggle` **Purpose:** Enable or disable a server **Request body (form data):** - `path` (required): Service path - `new_state` (required): `true` (enabled) or `false` (disabled) **Response:** `200 OK` with updated status --- #### 6. Get Health Status **Endpoint:** `GET /api/servers/health` **Purpose:** Get health status for all registered servers **Response:** `200 OK` with health data for all servers --- #### 7. Get Server Rating **Endpoint:** `GET /api/servers/{server_path:path}/rating` **Purpose:** Get the rating for a server **Response:** `200 OK` with rating data --- #### 8. Submit Server Rating **Endpoint:** `POST /api/servers/{server_path:path}/rating` **Purpose:** Submit a rating for a server **Request body (JSON):** - `rating` (required): Rating value (1-5) - `comment` (optional): Review comment **Response:** `201 Created` --- #### 9. List Server Versions **Endpoint:** `GET /api/servers/{server_path:path}/versions` **Purpose:** List all versions for a server **Response:** `200 OK` with versions array --- #### 10. Group Management **Add to groups:** `POST /api/servers/groups/add` **Remove from groups:** `POST /api/servers/groups/remove` **Request body (form data):** - `server_name` (required): Service name - `group_names` (required): Comma-separated group names **Example:** ```bash curl -X POST https://registry.example.com/api/servers/groups/add \ -H "Authorization: Bearer $JWT_TOKEN" \ -F "server_name=myservice" \ -F "group_names=admin,developers" ``` --- #### 11. Get Single Server **Endpoint:** `GET /api/servers/{path:path}` **Purpose:** Get detailed information about a single MCP server by path. Mirrors the `GET /api/agents/{path}` endpoint pattern. **Path Parameter:** - `path` - Server path (e.g., `/my-server`) **Response:** `200 OK` with server details including tools, versions, health status **Notes:** - `proxy_pass_url` is stripped for non-admin users in with-gateway deployment mode - In registry-only deployment mode, `proxy_pass_url` is included for all users (needed to connect directly) - Credentials are never included in the response **Error Codes:** - `403 Forbidden` - User lacks access to this server - `404 Not Found` - Server not found at the given path **Example:** ```bash curl -X GET https://registry.example.com/api/servers/my-server \ -H "Authorization: Bearer $JWT_TOKEN" ``` --- ## Authentication & Login APIs **File:** `registry/auth/routes.py` **Route Prefix:** `/api/auth` ### 1. Login Form **Endpoint:** `GET /api/auth/login` **Purpose:** Show login form with OAuth2 providers **Query Parameters:** - `error` (optional) - Error message **Response:** HTML login form --- ### 2. OAuth2 Redirect **Endpoint:** `GET /api/auth/auth/{provider}` **Purpose:** Redirect to auth server for OAuth2 login **Path Parameter:** - `provider` - OAuth2 provider (e.g., `keycloak`, `cognito`) **Response:** `302 Redirect` to auth server --- ### 3. OAuth2 Callback **Endpoint:** `GET /api/auth/auth/callback` **Purpose:** Handle OAuth2 callback **Query Parameters:** - `error` (optional) - `details` (optional) **Response:** `302 Redirect` to home or login with error --- ### 4. Login Submit (Form) **Endpoint:** `POST /api/auth/login` **Purpose:** Handle login form submission **Form Parameters:** - `username` - `password` **Response:** `302 Redirect` to home on success, `401` on failure --- ### 5. Logout (GET) **Endpoint:** `GET /api/auth/logout` **Purpose:** Handle logout via GET **Response:** `302 Redirect` to login (clears session) --- ### 6. Logout (POST) **Endpoint:** `POST /api/auth/logout` **Purpose:** Handle logout via POST **Response:** `302 Redirect` to login (clears session) --- ### 7. OAuth2 Providers List **Endpoint:** `GET /api/auth/providers` **Purpose:** Get available OAuth2 providers **Authentication:** None (public) **Response:** `200 OK` ```json { "providers": [ { "name": "keycloak", "display_name": "Keycloak", "icon": "keycloak" } ] } ``` --- ## Health Monitoring APIs **File:** `registry/health/routes.py` **Route Prefix:** `/api/health` ### 1. Health Status WebSocket **Endpoint:** `WebSocket /api/health/ws/health_status` **Purpose:** Real-time health status updates via WebSocket **Authentication:** Session cookie required **Messages:** Periodic health status broadcasts **Features:** - Authenticated connections only - Ping/pong keep-alive - Graceful disconnect handling --- ### 2. Health Status HTTP **Endpoint:** `GET /api/health/ws/health_status` **Purpose:** Get health status via HTTP (WebSocket fallback) **Authentication:** None **Response:** `200 OK` with health status JSON --- ### 3. WebSocket Statistics **Endpoint:** `GET /api/health/ws/stats` **Purpose:** Get WebSocket performance statistics **Response:** `200 OK` ```json { "active_connections": 5, "total_messages_sent": 1234, "uptime_seconds": 86400 } ``` --- ## Discovery & Well-Known Endpoints **File:** `registry/api/wellknown_routes.py` **Route Prefix:** `/.well-known` **Authentication:** None (public) ### MCP Servers Discovery **Endpoint:** `GET /.well-known/mcp-servers` **Purpose:** Public MCP server discovery for client tools **Response:** `200 OK` ```json { "servers": [ { "id": "io.mcpgateway/example", "name": "Example Server", "description": "string", "mcp": { "transport": "streamable-http", "url": "https://gateway.example.com/example/" } } ], "_meta": { "registry": "MCP Gateway Registry", "updated_at": "2025-11-01T04:53:56Z" } } ``` **Features:** - Server filtering by enabled status - Authentication info included - Tools preview - Public cache headers with configurable TTL --- ## Utility Endpoints ### 1. Current User Info **Endpoint:** `GET /api/auth/me` **Purpose:** Get current user information for React auth context **Authentication:** Session cookie (enhanced_auth) **Response:** `200 OK` ```json { "username": "admin", "email": "admin@example.com", "auth_method": "oauth2", "provider": "keycloak", "scopes": ["mcp-registry-admin"], "groups": ["mcp-registry-admin", "mcp-servers-unrestricted"], "is_admin": true } ``` --- ### 2. Health Check **Endpoint:** `GET /health` **Purpose:** Simple health check for load balancers **Authentication:** None (public) **Response:** `200 OK` ```json { "status": "healthy", "service": "mcp-gateway-registry" } ``` --- ## Response Codes & Error Handling ### Success Responses | Code | Meaning | Use Case | |------|---------|----------| | `200 OK` | Successful GET/POST | Data retrieval, updates | | `201 Created` | Resource created | Agent/server registration | | `204 No Content` | Successful deletion | DELETE operations | | `303 See Other` | Redirect after form | Form submissions (POST) | ### Client Error Responses | Code | Meaning | Example | |------|---------|---------| | `400 Bad Request` | Invalid input | Missing required fields, invalid JSON | | `401 Unauthorized` | Authentication failed | Missing/invalid JWT token | | `403 Forbidden` | Permission denied | User lacks required scope | | `404 Not Found` | Resource doesn't exist | Agent/server not found | | `409 Conflict` | Resource conflict | Agent path already registered | | `422 Unprocessable Entity` | Validation error | Invalid field values | ### Server Error Responses | Code | Meaning | Example | |------|---------|---------| | `500 Internal Server Error` | Server error | Exception during processing | | `502 Bad Gateway` | Upstream error | Auth server unreachable | | `503 Service Unavailable` | Service down | Database unavailable | ### Error Response Format ```json { "detail": "Human-readable error message", "error_code": "optional_error_code", "request_id": "unique_request_identifier" } ``` --- ## OpenAPI Specifications ### Access OpenAPI Specifications FastAPI automatically generates OpenAPI (Swagger) specifications: **Available Endpoints:** - **OpenAPI JSON:** `GET /openapi.json` - **Swagger UI:** `GET /docs` - **ReDoc:** `GET /redoc` **Local Access:** ```bash curl http://localhost:7860/openapi.json ``` **Browser Access:** - Swagger UI: http://localhost:7860/docs - ReDoc: http://localhost:7860/redoc ### Generate Spec Files To download and save OpenAPI specs: ```bash # Get full OpenAPI spec as JSON curl -s http://localhost:7860/openapi.json > openapi.json # Filter for specific tags curl -s http://localhost:7860/openapi.json | \ jq '.paths | keys[] | select(contains("/agents"))' > agents-endpoints.json # Generate Swagger YAML (requires conversion) curl -s http://localhost:7860/openapi.json | \ python3 -c "import sys, json, yaml; print(yaml.dump(json.load(sys.stdin)))" > openapi.yaml ``` ### Using Generated Specs 1. **Code Generation:** ```bash # Generate Python client openapi-generator-cli generate -i openapi.json -g python -o ./python-client # Generate JavaScript client openapi-generator-cli generate -i openapi.json -g javascript -o ./js-client ``` 2. **API Documentation:** Import into Postman, Insomnia, or other API tools 3. **Validation:** Use `openapi-spec-validator` to validate the spec --- ## Summary Table | Category | Endpoints | Auth | Purpose | |----------|-----------|------|---------| | A2A Agents | 8 | JWT Bearer | Agent lifecycle management | | Anthropic v0 (Servers) | 3 | JWT Bearer | Standard server discovery | | Anthropic v0 (Agents) | 3 | JWT Bearer | Standard agent discovery | | UI Management | 10 | Session Cookie | Dashboard operations | | Admin Operations | 12 | HTTP Basic Auth | Administrative tasks | | Authentication | 7 | OAuth2/Session | User login/logout | | Health Monitoring | 3 | Session/None | Real-time status | | Discovery | 1 | None | Public server discovery | | Utility | 2 | Session/None | Helper endpoints | | **TOTAL** | **49** | **Multiple** | **Full system coverage** | --- ## Quick Reference by Use Case ### I want to register an agent - **Endpoint:** `POST /api/agents/register` - **Auth:** JWT Bearer Token with `publish_agent` scope - **Documentation:** See [A2A Agent Management APIs > Register Agent](#1-register-agent) ### I want to discover agents by capability - **Endpoint:** `POST /api/agents/discover/semantic` - **Auth:** Optional - **Query:** Natural language query - **Documentation:** See [A2A Agent Management APIs > Discover Agents Semantically](#8-discover-agents-semantically) ### I want to list all servers (Anthropic API format) - **Endpoint:** `GET /v0/servers` - **Auth:** JWT Bearer Token - **Documentation:** See [Anthropic MCP Registry API v0 > List MCP Servers](#1-list-mcp-servers) ### I want to generate a JWT token - **Endpoint:** `POST /api/tokens/generate` - **Auth:** Session Cookie - **Documentation:** See [Internal Server Management APIs > Generate JWT Token](#21-generate-jwt-token) ### I want to find servers I have access to - **Endpoint:** `GET /api/servers` - **Auth:** Session Cookie - **Documentation:** See [Internal Server Management APIs > Get Servers JSON](#2-get-servers-json) --- ## Version History | Date | Version | Changes | |------|---------|---------| | 2025-11-01 | 1.0 | Initial API reference documentation, 49 endpoints cataloged | ================================================ FILE: docs/audit-logging.md ================================================ # Audit Logging MCP Gateway Registry provides comprehensive audit logging for compliance, security monitoring, and operational visibility. All API requests and MCP server access events are logged to MongoDB/DocumentDB with automatic retention management. ![Audit Log Viewer](img/audit-log.png) ## Overview Audit logging captures two types of events: 1. **Registry API Access** - All REST API requests to the Registry (`/api/*`, `/v0.1/*`) 2. **MCP Server Access** - All MCP protocol requests proxied through the Gateway Sensitive data such as authentication tokens, session cookies, and passwords are never logged. Credentials are masked to show only the last 6 characters as a hint for debugging. ## Security and Privacy ### Data That Is NOT Logged The following sensitive data is explicitly excluded from audit logs: - **Authentication tokens** (Bearer tokens, JWT tokens) - **Session cookies** (Cookie header values) - **Passwords** (form fields, query parameters) - **API keys** (full values) - **Refresh tokens** - **Authorization header values** ### Data Masking When credential hints are logged for debugging purposes, they are automatically masked: - Full token: `eyJhbGciOiJSUzI1NiIsInR5...` becomes `***zI1Ni` - Tokens shorter than 6 characters become `***` Query parameters with sensitive names (token, password, key, secret, api_key, etc.) are automatically masked. ## Event Schemas ### Registry API Access Event Logged for every REST API request to the Registry. ```json { "timestamp": "2026-02-06T10:30:00.000Z", "log_type": "registry_api_access", "version": "1.0", "request_id": "abc123-def456-...", "correlation_id": null, "identity": { "username": "john.doe@example.com", "auth_method": "oauth2", "provider": "keycloak", "groups": ["mcp-registry-admin", "developers"], "scopes": ["registry-admins"], "is_admin": true, "credential_type": "session_cookie", "credential_hint": "***abc123" }, "request": { "method": "POST", "path": "/api/servers", "query_params": {}, "client_ip": "192.168.1.100", "forwarded_for": "10.0.0.1", "user_agent": "Mozilla/5.0...", "content_length": 1024 }, "response": { "status_code": 201, "duration_ms": 45.32, "content_length": 512 }, "action": { "operation": "create", "resource_type": "server", "resource_id": "my-mcp-server", "description": "Create new MCP server" }, "authorization": { "decision": "ALLOW", "required_permission": "servers:write", "evaluated_scopes": ["registry-admins"] } } ``` ### MCP Server Access Event Logged for every MCP protocol request proxied through the Gateway. ```json { "timestamp": "2026-02-06T10:30:00.000Z", "log_type": "mcp_server_access", "version": "1.0", "request_id": "xyz789-...", "correlation_id": null, "identity": { "username": "ai-agent@example.com", "auth_method": "jwt_bearer", "provider": "keycloak", "groups": [], "scopes": ["mcp-server-cloudflare-docs"], "is_admin": false, "credential_type": "bearer_token", "credential_hint": "***def456" }, "mcp_server": { "name": "cloudflare-docs", "path": "/cloudflare-docs", "version": "1.0.0", "proxy_target": "http://internal-mcp-server:8080/mcp" }, "mcp_request": { "method": "tools/call", "tool_name": "search_docs", "resource_uri": null, "mcp_session_id": "session-123", "transport": "streamable-http", "jsonrpc_id": "1" }, "mcp_response": { "status": "success", "duration_ms": 123.45, "error_code": null, "error_message": null } } ``` ## Data Fields Reference ### Identity Fields | Field | Description | |-------|-------------| | `username` | Username or identifier of the requester | | `auth_method` | Authentication method: `oauth2`, `traditional`, `jwt_bearer`, `anonymous` | | `provider` | Identity provider: `cognito`, `entra_id`, `keycloak` | | `groups` | Groups the user belongs to | | `scopes` | OAuth scopes granted to the user | | `is_admin` | Whether the user has admin privileges | | `credential_type` | Type of credential: `session_cookie`, `bearer_token`, `none` | | `credential_hint` | Masked hint of the credential (last 6 chars only) | ### Action Fields (Registry API only) | Field | Description | |-------|-------------| | `operation` | Operation type: `create`, `read`, `update`, `delete`, `list`, `toggle`, `rate`, `login`, `logout`, `search` | | `resource_type` | Resource type: `server`, `agent`, `auth`, `federation`, `health`, `search` | | `resource_id` | Identifier of the resource being acted upon | | `description` | Human-readable description of the action | ### MCP Request Fields (MCP Access only) | Field | Description | |-------|-------------| | `method` | JSON-RPC method name: `tools/call`, `tools/list`, `resources/read`, `resources/list`, etc. | | `tool_name` | Name of the tool being called (for `tools/call` method) | | `resource_uri` | URI of the resource being accessed (for `resources/read` method) | | `mcp_session_id` | MCP session identifier | | `transport` | Transport protocol: `streamable-http`, `sse`, `stdio` | | `jsonrpc_id` | JSON-RPC request ID | ## Data Retention Audit logs are automatically expired using MongoDB/DocumentDB TTL (Time-To-Live) indexes. ### Default Retention - **Default retention period**: 7 days - **TTL index field**: `timestamp` ### Configuring Retention Set the `AUDIT_LOG_MONGODB_TTL_DAYS` environment variable to customize retention: ```bash # Keep logs for 30 days export AUDIT_LOG_MONGODB_TTL_DAYS=30 # Keep logs for 90 days (compliance requirement) export AUDIT_LOG_MONGODB_TTL_DAYS=90 ``` The TTL index is created when running the DocumentDB initialization script: ```bash ./scripts/init-documentdb.sh ``` ### Important Notes - TTL indexes run approximately once per minute in MongoDB/DocumentDB - Documents may persist slightly longer than the TTL value - Changing the TTL requires dropping and recreating the index with `--recreate` flag - For compliance requirements, consider also streaming logs to a long-term archive ## Storage ### MongoDB Collection Audit events are stored in the `audit_events_{namespace}` collection with the following indexes: | Index | Purpose | |-------|---------| | `request_id` (unique) | Fast lookup by request ID | | `identity.username` + `timestamp` | Query by user over time range | | `action.operation` + `timestamp` | Query by operation type over time range | | `action.resource_type` + `timestamp` | Query by resource type over time range | | `timestamp` (TTL) | Automatic expiration after configured days | ### Storage Sizing Typical event sizes: - Registry API event: ~1-2 KB - MCP Server Access event: ~1-2 KB Estimated storage (without compression): - 1,000 requests/day for 7 days: ~14 MB - 10,000 requests/day for 30 days: ~600 MB - 100,000 requests/day for 90 days: ~18 GB ## Viewing Audit Logs ### Admin UI Administrators can view audit logs in the Registry UI: 1. Navigate to **Settings** > **Audit** > **Audit Logs** 2. Select log stream: **Registry API** or **MCP Access** 3. Apply filters (time range, username, operation, status) 4. Click any row to view full event details 5. Export filtered results as JSONL or CSV ### API Access Query audit events programmatically: ```bash # Get recent Registry API events curl -H "Authorization: Bearer $TOKEN" \ "https://registry.example.com/api/audit/events?stream=registry_api&limit=50" # Get MCP access events for a specific user curl -H "Authorization: Bearer $TOKEN" \ "https://registry.example.com/api/audit/events?stream=mcp_access&username=john.doe" # Export events as JSONL curl -H "Authorization: Bearer $TOKEN" \ "https://registry.example.com/api/audit/export?stream=registry_api&format=jsonl" ``` ### MongoDB/DocumentDB Direct Query ```javascript // Find all events for a user in the last 24 hours db.audit_events_default.find({ "identity.username": "john.doe@example.com", "timestamp": { $gte: new Date(Date.now() - 24*60*60*1000) } }).sort({ timestamp: -1 }) // Count events by operation type db.audit_events_default.aggregate([ { $match: { log_type: "registry_api_access" } }, { $group: { _id: "$action.operation", count: { $sum: 1 } } }, { $sort: { count: -1 } } ]) // Find failed MCP requests db.audit_events_default.find({ "log_type": "mcp_server_access", "mcp_response.status": "error" }) ``` ## Configuration ### Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `AUDIT_LOG_ENABLED` | `true` | Enable/disable audit logging | | `AUDIT_LOG_MONGODB_TTL_DAYS` | `7` | Log retention period in days | ### Non-Blocking Design Audit logging is designed to never impact request processing: - Logging happens asynchronously after the response is sent - Failures in audit logging are logged as warnings but don't fail requests - High-volume scenarios use batched writes (if enabled) ## Compliance Considerations ### SOC 2 / ISO 27001 Audit logs support compliance requirements by capturing: - **Who**: User identity with auth method and provider - **What**: Operation performed with resource details - **When**: Precise UTC timestamp - **Where**: Client IP and forwarded-for headers - **Outcome**: Success/failure status with error details ### GDPR - User identifiers (usernames) are logged for accountability - No PII beyond usernames is captured - Logs can be exported and deleted per data subject requests - TTL-based retention supports data minimization ### Additional Recommendations For production compliance deployments: 1. Stream audit logs to a SIEM (Splunk, Datadog, etc.) for long-term retention 2. Set up alerts for suspicious patterns (failed auths, privilege escalation) 3. Regularly review admin actions in the audit log 4. Document your retention policy and ensure TTL matches it ## Troubleshooting ### Logs Not Appearing 1. Verify audit logging is enabled: `AUDIT_LOG_ENABLED=true` 2. Check MongoDB connection: ensure the Registry can write to the database 3. Look for warnings in Registry logs: `grep "audit" registry.log` ### TTL Not Working 1. Verify the TTL index exists: `db.audit_events_default.getIndexes()` 2. Note that MongoDB TTL runs approximately every 60 seconds 3. Documents may persist up to 60 seconds beyond their expiration time ### Missing Events 1. Check if the request completed (cancelled requests may not be logged) 2. Verify the log stream filter matches the event type 3. For MCP access, ensure the path is not an API path (starts with `/api/`) ================================================ FILE: docs/auth-mgmt.md ================================================ # Authentication and User Management Guide This guide describes how to manage groups, users, and M2M (machine-to-machine) service accounts in the MCP Gateway Registry, and how to generate JWT tokens for authentication. > **SECURITY WARNING** > > The examples in this document use placeholder credentials for demonstration purposes only. > **NEVER use these example values in production.** > > Always generate unique, secure credentials and store them in: > - AWS Secrets Manager (production) > - Environment variables (development) > - `.env` files (local only, never commit) ## Table of Contents 1. [Overview](#overview) 2. [Bootstrap State](#bootstrap-state) 3. [Creating Groups](#creating-groups) 4. [Creating Human Users](#creating-human-users) 5. [Creating M2M Service Accounts](#creating-m2m-service-accounts) 6. [Generating JWT Tokens](#generating-jwt-tokens) - [For Human Users (via UI)](#for-human-users-via-ui) - [For M2M Accounts (via generate_creds.sh)](#for-m2m-accounts-via-generate_credssh) 7. [Provider-Specific Notes](#provider-specific-notes) --- ## Overview The MCP Gateway Registry supports two identity providers: - **Keycloak** - Self-hosted identity provider with full automation support - **Microsoft Entra ID** - Enterprise Azure AD integration Both providers use the same CLI interface (`registry_management.py`) for user and group management, with minor differences in configuration. --- ## Bootstrap State When the system is first deployed, it is bootstrapped with **minimal configuration**: ### Initial Bootstrap (Both Providers) | Component | Description | |-----------|-------------| | **registry-admins** group | Administrative group with full registry access | | **Admin user** | Initial administrator account | | **Admin scopes** | `registry-admins` scope mapped to the admin group | ### Keycloak Bootstrap For Keycloak deployments, the `init-keycloak.sh` script automatically creates: - The `mcp-gateway` realm - `mcp-gateway-web` client (for web UI) - `mcp-gateway-m2m` client (for M2M authentication) - Initial admin user and `registry-admins` group ### Entra ID Bootstrap For Entra ID deployments: - The `registry-admins` group **must be created manually** in Azure Portal - The Group Object ID is required when running the DocumentDB initialization script: ```bash ./terraform/aws-ecs/scripts/run-documentdb-init.sh --entra-group-id "your-group-object-id" ``` See [Entra ID Setup Guide](./entra-id-setup.md) for detailed Entra ID configuration instructions. **All additional groups, users, and M2M accounts must be created as described below.** --- ## Creating Groups Groups control access to MCP servers and registry resources. Users and M2M accounts are assigned to groups to receive their permissions. ### Prerequisites You need an admin token to create groups. You can obtain one by: - **UI Method**: Log in to the registry web UI and click the **"Get JWT Token"** button in the top-left sidebar. Save the token to `api/.token`. - **M2M Method**: Create an M2M account with admin permissions and generate a token using `generate_creds.sh` (see [Generating JWT Tokens](#generating-jwt-tokens)). ### Create a Group Definition File Create a JSON file defining the group (e.g., `my-group.json`): ```json { "scope_name": "public-mcp-users", "description": "Users with access to public MCP servers", "servers": [ { "server_name": "currenttime", "tools": ["get_current_time"], "access_level": "execute" }, { "server_name": "mcpgw", "tools": ["*"], "access_level": "execute" } ], "create_in_idp": true } ``` **Key fields:** | Field | Required | Description | |-------|----------|-------------| | `scope_name` | Yes | Unique identifier for the group/scope | | `description` | Yes | Human-readable description | | `servers` | Yes | List of server access configurations | | `create_in_idp` | No | If `true`, creates the group in the identity provider (Keycloak/Entra ID) | ### Import the Group ```bash uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://registry.us-east-1.example.com \ import-group --file my-group.json ``` ### Example Group Definitions See the [cli/examples/](../cli/examples/) directory for sample group definitions: - [public-mcp-users.json](../cli/examples/public-mcp-users.json) - Public access group with access to context7, cloudflare-docs servers and flight-booking agent - `currenttime-users.json` - Access to currenttime server only ### Bootstrap Admin Scope The `registry-admins` scope is automatically loaded during database initialization from [scripts/registry-admins.json](../scripts/registry-admins.json). This file defines full administrative access: ```json { "_id": "registry-admins", "group_mappings": ["registry-admins"], "server_access": [ { "server": "*", "methods": ["all"], "tools": ["all"] } ] } ``` This is loaded by the database initialization scripts: - **Local (MongoDB CE)**: `docker compose up mongodb-init` runs `scripts/init-mongodb-ce.py` - **Production (DocumentDB)**: `./terraform/aws-ecs/scripts/run-documentdb-init.sh` runs `scripts/init-documentdb-indexes.py` - **Entra ID**: `./terraform/aws-ecs/scripts/run-documentdb-init.sh --entra-group-id "your-group-object-id"` For Entra ID, the `--entra-group-id` parameter adds the Entra ID Group Object ID to the `group_mappings` array so that members of that Azure AD group receive admin permissions. --- ## Creating Human Users Human users can log in via the web UI using OAuth2 authentication (Keycloak or Entra ID). ### Create a Human User ```bash uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://registry.us-east-1.example.com \ user-create-human \ --username jsmith \ --email jsmith@example.com \ --first-name John \ --last-name Smith \ --groups public-mcp-users \ --password "SecurePassword123!" ``` **Parameters:** | Parameter | Required | Description | |-----------|----------|-------------| | `--username` | Yes | Unique username for the user | | `--email` | Yes | Email address | | `--first-name` | Yes | First name | | `--last-name` | Yes | Last name | | `--groups` | Yes | Comma-separated list of groups to assign | | `--password` | Yes | Initial password (user should change on first login) | ### Multiple Groups To assign a user to multiple groups: ```bash uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://registry.us-east-1.example.com \ user-create-human \ --username analyst \ --email analyst@example.com \ --first-name Data \ --last-name Analyst \ --groups "public-mcp-users,analytics-team" \ --password "SecurePassword123!" ``` --- ## Creating M2M Service Accounts M2M (machine-to-machine) accounts are used for programmatic API access by AI coding assistants, agents, and automated systems. ### Create an M2M Service Account ```bash uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://registry.us-east-1.example.com \ user-create-m2m \ --name my-ai-agent \ --groups public-mcp-users \ --description "AI coding assistant service account" ``` **Output:** ``` Client ID: my-ai-agent Client Secret: sqFaOkF8un1tAfKXjlgm2xjGQBfLlNS3 Groups: public-mcp-users IMPORTANT: Save the client secret securely - it cannot be retrieved later. ``` **Parameters:** | Parameter | Required | Description | |-----------|----------|-------------| | `--name` | Yes | Unique name for the M2M account (becomes the Client ID) | | `--groups` | Yes | Comma-separated list of groups to assign | | `--description` | No | Description of the service account's purpose | ### Save the Credentials **The client secret is only displayed once.** Save it immediately to a secure location: ```bash # Create an agent configuration file for use with generate_creds.sh cat > .oauth-tokens/agent-my-ai-agent.json << 'EOF' { "client_id": "my-ai-agent", "client_secret": "sqFaOkF8un1tAfKXjlgm2xjGQBfLlNS3", "keycloak_url": "https://kc.us-east-1.example.com", "keycloak_realm": "mcp-gateway", "auth_provider": "keycloak" } EOF ``` For Entra ID, add the identity to `.oauth-tokens/entra-identities.json`: ```json [ { "identity_name": "my-ai-agent", "tenant_id": "your-tenant-id", "client_id": "client-id-from-output", "client_secret": "client-secret-from-output", "scope": "api://your-app-client-id/.default" } ] ``` --- ## Generating JWT Tokens ### For Human Users (via UI) Human users generate JWT tokens through the MCP Gateway Registry web interface: 1. **Log in** to the registry at `https://registry.us-east-1.example.com` 2. Click the **"Get JWT Token"** button in the top-left sidebar 3. **Copy the generated token** These self-signed tokens: - Are signed with HS256 using the server's `SECRET_KEY` - Include the user's groups and scopes - Can be used for programmatic API access - Have a configurable expiration time **Using the token:** ```bash # Save to a token file echo '{"access_token": "eyJhbGciOi..."}' > api/.token # Use with registry_management.py uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://registry.us-east-1.example.com \ list ``` ### For M2M Accounts (via generate_creds.sh) M2M accounts generate tokens using the OAuth2 client credentials flow via the `generate_creds.sh` script. #### Step 1: Configure the Agent Create an agent configuration file in `.oauth-tokens/`: **For Keycloak:** ```json { "client_id": "my-ai-agent", "client_secret": "sqFaOkF8un1tAfKXjlgm2xjGQBfLlNS3", "keycloak_url": "https://kc.us-east-1.example.com", "keycloak_realm": "mcp-gateway", "auth_provider": "keycloak" } ``` Save as `.oauth-tokens/agent-my-ai-agent.json` **For Entra ID:** Edit `.oauth-tokens/entra-identities.json`: ```json [ { "identity_name": "my-ai-agent", "tenant_id": "6e6ee81b-6bf3-495d-a7fc-d363a551f765", "client_id": "your-client-id", "client_secret": "your-client-secret", "scope": "api://1bd17ba1-aad3-447f-be0b-26f8f9ee859f/.default" } ] ``` #### Step 2: Generate the Token **For Keycloak:** ```bash ./credentials-provider/generate_creds.sh \ -a keycloak \ -k https://kc.us-east-1.example.com ``` **For Entra ID:** ```bash ./credentials-provider/generate_creds.sh \ -a entra \ -i .oauth-tokens/entra-identities.json ``` #### Step 3: Use the Generated Token The script saves tokens to `.oauth-tokens/agent--token.json`: ```bash # List servers using the generated token uv run python api/registry_management.py \ --token-file .oauth-tokens/agent-my-ai-agent-token.json \ --registry-url https://registry.us-east-1.example.com \ list ``` ### generate_creds.sh Options ``` ./credentials-provider/generate_creds.sh [OPTIONS] OPTIONS: --auth-provider, -a PROVIDER Auth provider: 'keycloak' or 'entra' (required) --keycloak-url, -k URL Keycloak server URL (required for keycloak) --keycloak-realm, -r REALM Keycloak realm name (default: mcp-gateway) --entra-tenant-id TENANT_ID Entra tenant ID --entra-client-id CLIENT_ID Entra client ID --entra-client-secret SECRET Entra client secret --entra-login-url URL Entra login base URL (default: https://login.microsoftonline.com) --identities-file, -i FILE Custom path to identities JSON file (for entra) --verbose, -v Enable verbose debug logging --help, -h Show help message EXAMPLES: # Keycloak ./generate_creds.sh -a keycloak -k https://kc.example.com # Entra ID with identities file ./generate_creds.sh -a entra -i .oauth-tokens/entra-identities.json # Keycloak with verbose output ./generate_creds.sh -a keycloak -k https://kc.example.com -v ``` ### Manual Token Generation (curl) You can also generate tokens directly using curl: **Keycloak:** ```bash curl -s -X POST "https://kc.us-east-1.example.com/realms/mcp-gateway/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "client_id=my-ai-agent" \ -d "client_secret=sqFaOkF8un1tAfKXjlgm2xjGQBfLlNS3" \ -d "grant_type=client_credentials" ``` **Entra ID:** ```bash curl -s -X POST "https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "client_id={M2M_CLIENT_ID}" \ -d "client_secret={M2M_CLIENT_SECRET}" \ -d "scope=api://{APP_CLIENT_ID}/.default" \ -d "grant_type=client_credentials" ``` --- ## Provider-Specific Notes ### Keycloak - Groups are identified by **name** (e.g., `registry-admins`) - M2M accounts are created as Keycloak clients with service accounts - Token lifetime is configurable in Keycloak realm settings (default: 5 minutes) - Supports automatic group and user creation via API ### Entra ID - Groups are identified by **Object ID** (UUID, e.g., `16c7e67e-e8ae-498c-ba2e-0593c0159e43`) - M2M accounts are Azure App Registrations with client credentials - Token lifetime is typically 1 hour - Groups must be created manually in Azure Portal before use - Group Object IDs are required for scope mappings in `scopes.yml` ### Token Comparison | Aspect | Human User Token | M2M Token | |--------|------------------|-----------| | **Generation** | UI "Get JWT Token" button | `generate_creds.sh` or curl | | **Algorithm** | HS256 (self-signed) | RS256 (IdP-signed) | | **Validation** | Server SECRET_KEY | JWKS from IdP | | **Use Case** | Interactive/programmatic access | Automated systems, AI agents | | **Refresh** | Generate new via UI | Use client credentials flow | --- ## See Also - [Entra ID Setup Guide](./entra-id-setup.md) - Complete Entra ID configuration - [Complete Setup Guide](./complete-setup-guide.md) - Initial system setup - [Terraform AWS ECS README](../terraform/aws-ecs/README.md) - Production deployment ================================================ FILE: docs/auth.md ================================================ # Authentication and Authorization The MCP Gateway Registry provides authentication and authorization using industry-standard OAuth 2.0 flows with fine-grained access control. ## Overview The authentication system supports three distinct identity scenarios: 1. **Human Users** - Interactive users accessing the Registry UI via browser 2. **Programmatic Access** - Self-signed JWT tokens for CLI tools and AI coding assistants 3. **Workload Identity (M2M)** - Service accounts for AI agents and automated systems ## Related Documentation ### Design Documents For architectural details and design decisions: - [Authentication Design](design/authentication-design.md) - Detailed auth flows for human users, programmatic access, and M2M workloads - [Multi-Provider IdP Support](design/idp-provider-support.md) - Architecture for supporting multiple identity providers (Keycloak, Entra ID) ### Configuration Guides For setup and configuration: - [Scopes Management](scopes-mgmt.md) - Scope configuration file format and fine-grained access control - [Authentication Management](auth-mgmt.md) - Managing users, groups, and scopes via CLI - [Microsoft Entra ID Setup](entra-id-setup.md) - Entra ID-specific setup and configuration - [Complete Setup Guide](complete-setup-guide.md) - End-to-end deployment instructions --- ## Authentication Architecture ### Identity Types The system distinguishes between three types of identities, each with different authentication flows: | Identity Type | Use Case | Auth Method | Token Signing | Lifetime | |--------------|----------|-------------|---------------|----------| | Human Users | Browser UI | OAuth2 Authorization Code | RS256 (IdP) | Session-based | | Programmatic | CLI, AI assistants | Self-signed JWT | HS256 (SECRET_KEY) | 8 hours | | M2M Workloads | AI agents, automation | OAuth2 Client Credentials | RS256 (IdP) | 1 hour | ### Supported Identity Providers The registry supports multiple identity providers through a pluggable architecture: - **Keycloak** - Open-source identity management - **Microsoft Entra ID** - Enterprise Azure AD integration Provider selection is controlled by the `AUTH_PROVIDER` environment variable: ```bash AUTH_PROVIDER=keycloak # Use Keycloak (default) AUTH_PROVIDER=entra # Use Microsoft Entra ID ``` --- ## High-Level Authentication Flow ```mermaid sequenceDiagram participant User as User/Developer participant Agent as AI Agent participant Auth as Keycloak/Entra ID
(Identity Provider) participant Gateway as NGINX Gateway participant AuthServer as Auth Server participant Registry as Registry API Note over User,Registry: Human User Flow (Browser) User->>Gateway: 1. Access Registry UI Gateway->>Auth: 2. Redirect to IdP login Auth->>User: 3. Login page User->>Auth: 4. Authenticate Auth->>Gateway: 5. Authorization code Gateway->>AuthServer: 6. Exchange code for tokens AuthServer->>Auth: 7. Validate & get user info AuthServer->>User: 8. Set session cookie User->>Registry: 9. Access API with session Note over User,Registry: Programmatic Access (JWT Token) User->>AuthServer: 10. Request JWT token (via UI) AuthServer->>User: 11. Self-signed JWT (HS256) User->>Agent: 12. Configure agent with token Agent->>Gateway: 13. API request with Bearer token Gateway->>AuthServer: 14. Validate token (/validate) AuthServer->>Gateway: 15. User context + permissions Gateway->>Registry: 16. Proxied request ``` --- ## Authorization Model ### Scope-Based Access Control Authorization is based on **scopes** that define: 1. **Server Access** - Which MCP servers and methods users can access 2. **Agent Actions** - Which agent operations users can perform 3. **UI Permissions** - Which UI features are available ### Group-to-Scope Mapping User permissions are determined by mapping IdP groups to scopes stored in MongoDB/DocumentDB: ```mermaid flowchart LR subgraph IdP["Identity Provider"] KC["Keycloak Group
registry-admins"] EA["Entra ID Group
4c46ec66-a4f7-..."] end subgraph DB["MongoDB/DocumentDB"] SM["Scope: registry-admins
group_mappings:
- registry-admins
- 4c46ec66-a4f7-..."] end subgraph Perms["Permissions"] SA["server_access:
server: *
methods: [all]
tools: [all]"] UI["ui_permissions:
list_agents: [all]
publish_agent: [all]
..."] end KC --> SM EA --> SM SM --> SA SM --> UI ``` ### Scope Configuration Scopes are defined in JSON files and loaded into MongoDB. See [Scopes Management](scopes-mgmt.md) for the complete file format. **Example: Admin Scope** ```json { "_id": "registry-admins", "group_mappings": ["registry-admins", "4c46ec66-a4f7-4b62-9095-b7958662f4b6"], "server_access": [ {"server": "*", "methods": ["all"], "tools": ["all"]} ], "ui_permissions": { "list_agents": ["all"], "publish_agent": ["all"], "list_service": ["all"], "toggle_service": ["all"] } } ``` **Example: Limited User Scope** ```json { "_id": "public-mcp-users", "group_mappings": ["public-mcp-users", "5f605d68-06bc-4208-b992-bb378eee12c5"], "server_access": [ {"server": "context7", "methods": ["initialize", "tools/list", "tools/call"], "tools": ["*"]} ], "ui_permissions": { "list_service": ["all"], "list_agents": ["/flight-booking"], "get_agent": ["/flight-booking"] } } ``` --- ## Token Validation Flow All API requests are validated by the auth server through NGINX's `auth_request` directive: ```mermaid sequenceDiagram participant Client as CLI/Agent participant NGINX as NGINX Gateway participant Auth as Auth Server participant IdP as Identity Provider participant API as Registry API Client->>NGINX: 1. API Request
Authorization: Bearer NGINX->>Auth: 2. auth_request /validate alt Self-Signed Token (iss: mcp-auth-server) Auth->>Auth: 3a. Validate with SECRET_KEY (HS256) else IdP Token (iss: Keycloak/Entra) Auth->>IdP: 3b. Fetch JWKS IdP-->>Auth: Public keys Auth->>Auth: 3c. Validate signature (RS256) end Auth->>Auth: 4. Extract groups from token Auth->>Auth: 5. Map groups to scopes Auth->>Auth: 6. Check server/tool access alt Access Granted Auth-->>NGINX: 7a. 200 OK + X-User headers NGINX->>API: 8. Proxy request API-->>Client: 9. Response else Access Denied Auth-->>NGINX: 7b. 403 Forbidden NGINX-->>Client: 403 Forbidden end ``` --- ## Key Security Layers ### Layer 1: Gateway Authentication The NGINX gateway validates all incoming requests: - Extracts JWT from `Authorization` header - Calls auth server `/validate` endpoint - Sets user context headers for downstream services ### Layer 2: Token Validation The auth server supports multiple token types: | Token Type | Issuer | Algorithm | Validation Method | |------------|--------|-----------|-------------------| | Self-signed | `mcp-auth-server` | HS256 | SECRET_KEY | | Keycloak | `{keycloak_url}/realms/{realm}` | RS256 | JWKS endpoint | | Entra ID | `https://sts.windows.net/{tenant}/` | RS256 | JWKS endpoint | ### Layer 3: Scope-Based Authorization After token validation, the auth server: 1. Extracts `groups` claim from token 2. Queries MongoDB for matching scopes (via `group_mappings`) 3. Validates requested server/method/tool against `server_access` rules 4. Returns user context with permissions --- ## Permission Types ### MCP Server Permissions Control access to MCP servers and their tools: | Permission | Description | |------------|-------------| | `server` | Server name or `*` for all | | `methods` | Allowed MCP methods (initialize, tools/list, tools/call, etc.) | | `tools` | Allowed tool names or `*` for all | ### Agent Permissions Control operations on A2A agents: | Permission | Description | |------------|-------------| | `list_agents` | View agents in listings | | `get_agent` | View agent details | | `publish_agent` | Register new agents | | `modify_agent` | Update existing agents | | `delete_agent` | Remove agents | ### UI Permissions Control access to UI features: | Permission | Description | |------------|-------------| | `list_service` | View MCP servers in dashboard | | `register_service` | Register new MCP servers | | `health_check_service` | Run health checks | | `toggle_service` | Enable/disable servers | | `modify_service` | Edit server configurations | --- ## Entra ID Group Mapping When using Microsoft Entra ID, group identifiers are Object IDs (GUIDs), not names: ```json { "group_mappings": [ "public-mcp-users", "5f605d68-06bc-4208-b992-bb378eee12c5" ] } ``` This allows the same scope to work with both Keycloak (group names) and Entra ID (Object IDs). **Finding Entra ID Group Object IDs:** 1. Azure Portal > Azure Active Directory > Groups 2. Select the group 3. Copy the "Object ID" from the Overview page --- ## Session Management ### Human User Sessions Browser sessions use signed cookies: - Created after successful OAuth2 login - Contains: username, groups, provider, scopes - Validated using `SECRET_KEY` (HS256) - Default expiry: 8 hours (configurable) ### Programmatic Tokens Self-signed JWT tokens for CLI/API access: - Generated via "Get JWT Token" in UI - Contains: username, groups, scopes, permissions - Signed with `SECRET_KEY` (HS256) - Default expiry: 8 hours ### M2M Tokens Service account tokens from IdP: - Obtained via OAuth2 Client Credentials flow - Signed by IdP (RS256) - Default expiry: 1 hour - Must be refreshed periodically --- ## Security Best Practices 1. **Use HTTPS** - All production deployments should use TLS 2. **Rotate Secrets** - Regularly rotate SECRET_KEY and client secrets 3. **Least Privilege** - Assign minimal required permissions to users/agents 4. **Audit Logging** - Monitor authentication events and access patterns 5. **Token Expiry** - Use short-lived tokens and implement refresh flows --- ## Troubleshooting ### Common Issues **Token validation fails:** - Check token issuer matches expected provider - Verify JWKS endpoint is accessible - Ensure SECRET_KEY matches between auth server instances **Permission denied:** - Verify user's groups in IdP - Check group_mappings in scope configuration - Ensure scope includes required server/method access **Group not recognized:** - For Entra ID: Use Object ID, not group name - Verify group exists in group_mappings array - Reload scopes after configuration changes ### Debug Endpoints ```bash # Check user context curl -H "Authorization: Bearer $TOKEN" \ https://registry.example.com/api/debug/user-context # List available scopes curl -H "Authorization: Bearer $TOKEN" \ https://registry.example.com/api/scopes ``` --- ## Server Authentication Credentials The MCP Gateway Registry supports backend server authentication, allowing MCP servers that require their own authentication (API keys, bearer tokens, etc.) to be registered with encrypted credentials. ### Overview When an MCP server requires authentication, you can provide the credentials during registration. The registry: 1. **Encrypts** the credential using Fernet symmetric encryption 2. **Stores** the encrypted credential in MongoDB/DocumentDB 3. **Automatically decrypts** and uses the credential for: - Health checks - Tool discovery and fetching - MCP client connections ### Supported Authentication Schemes | Scheme | Description | Example Use Case | |--------|-------------|------------------| | `none` | No authentication required | Public MCP servers | | `bearer` | Bearer token in `Authorization` header | OAuth2-protected services | | `api_key` | API key with custom header name | Services requiring API keys (e.g., `X-API-Key`, `CONTEXT7_API_KEY`) | ### Credential Encryption All credentials are encrypted before storage using the Fernet encryption scheme: - **Algorithm**: Fernet (symmetric encryption based on AES-128-CBC) - **Key**: Derived from `ENCRYPTION_KEY` environment variable - **Storage**: Encrypted credential stored as `auth_credential_encrypted` in MongoDB - **Decryption**: Automatic during health checks and MCP client initialization **Configuration** (`.env` file): ```bash # Encryption key for server credentials (base64-encoded Fernet key) ENCRYPTION_KEY=your-base64-encoded-fernet-key-here # Generate a new key with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" ``` ### Registering Servers with Authentication #### Method 1: Registry UI 1. Navigate to **Register Server** in the Registry UI 2. Fill in server details (name, path, proxy URL, etc.) 3. Select **Authentication Scheme**: - `none` - No authentication - `bearer` - Bearer token - `api_key` - API key 4. If `bearer` or `api_key`: - Enter the **credential** (API key or bearer token) - For `api_key`: Specify the **header name** (e.g., `CONTEXT7_API_KEY`, `X-API-Key`) 5. Click **Register** ![Server Registration with Authentication Scheme](img/auth-scheme.gif) The credential is automatically encrypted and stored securely. #### Method 2: REST API **Register server with bearer token:** ```bash curl -X POST https://registry.example.com/api/servers/register \ -H "Authorization: Bearer $TOKEN" \ -F "server_name=My Protected Server" \ -F "path=/my-server" \ -F "proxy_pass_url=http://backend:8000/" \ -F "auth_scheme=bearer" \ -F "auth_credential=my-bearer-token-value" \ -F "description=A server requiring bearer auth" ``` **Register server with API key:** ```bash curl -X POST https://registry.example.com/api/servers/register \ -H "Authorization: Bearer $TOKEN" \ -F "server_name=Context7" \ -F "path=/context7" \ -F "proxy_pass_url=http://context7:8000/" \ -F "auth_scheme=api_key" \ -F "auth_credential=ctx7sk-6dd75bd4-80ef-486e-99ef-b5493df4e578" \ -F "auth_header_name=CONTEXT7_API_KEY" \ -F "description=Context7 LLM context service" ``` **Response:** ```json { "message": "Server registered successfully", "path": "/context7", "server_name": "Context7", "auth_scheme": "api_key", "auth_header_name": "CONTEXT7_API_KEY", "auth_credential_encrypted": true } ``` #### Method 3: CLI Tool (`registry_management.py`) **Register server with credentials:** ```bash # Set up authentication export REGISTRY_URL=https://registry.example.com export REGISTRY_TOKEN=$(cat .token) # Register with API key python3 api/registry_management.py \ --registry-url $REGISTRY_URL \ --token $REGISTRY_TOKEN \ server-register \ --name "Context7" \ --path "/context7" \ --proxy-pass-url "http://context7:8000/" \ --auth-scheme api_key \ --auth-credential "ctx7sk-6dd75bd4-80ef-486e-99ef-b5493df4e578" \ --auth-header-name "CONTEXT7_API_KEY" \ --description "Context7 LLM context service" # Register with bearer token python3 api/registry_management.py \ --registry-url $REGISTRY_URL \ --token $REGISTRY_TOKEN \ server-register \ --name "Cloudflare API" \ --path "/cloudflare-api" \ --proxy-pass-url "http://cloudflare-mcp:8000/" \ --auth-scheme bearer \ --auth-credential "my-cloudflare-bearer-token" \ --description "Cloudflare MCP Server" ``` ### Updating Server Credentials Credentials can be updated without re-registering the entire server. #### Method 1: REST API **Update credential endpoint:** ```bash curl -X PUT https://registry.example.com/api/servers/context7/credentials \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "auth_scheme": "api_key", "auth_credential": "new-api-key-value", "auth_header_name": "CONTEXT7_API_KEY" }' ``` **Switch to bearer token:** ```bash curl -X PUT https://registry.example.com/api/servers/my-server/credentials \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "auth_scheme": "bearer", "auth_credential": "new-bearer-token" }' ``` **Remove authentication:** ```bash curl -X PUT https://registry.example.com/api/servers/my-server/credentials \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "auth_scheme": "none" }' ``` **Response:** ```json { "message": "Auth credentials updated successfully", "path": "/context7", "auth_scheme": "api_key", "auth_header_name": "CONTEXT7_API_KEY" } ``` #### Method 2: CLI Tool **Update server credential:** ```bash python3 api/registry_management.py \ --registry-url $REGISTRY_URL \ --token $REGISTRY_TOKEN \ server-update-credential \ --path "/context7" \ --auth-scheme api_key \ --credential "new-api-key-value" \ --auth-header-name "CONTEXT7_API_KEY" ``` **Update to bearer token:** ```bash python3 api/registry_management.py \ --registry-url $REGISTRY_URL \ --token $REGISTRY_TOKEN \ server-update-credential \ --path "/cloudflare-api" \ --auth-scheme bearer \ --credential "new-bearer-token-value" ``` **Remove authentication:** ```bash python3 api/registry_management.py \ --registry-url $REGISTRY_URL \ --token $REGISTRY_TOKEN \ server-update-credential \ --path "/my-server" \ --auth-scheme none ``` **Output:** ``` Successfully updated credentials for server '/context7' New auth scheme: api_key Auth header name: CONTEXT7_API_KEY ``` ### How Credentials Are Used #### 1. Health Checks When the health check service performs periodic checks, it: 1. Retrieves the server's `auth_credential_encrypted` from MongoDB 2. Decrypts the credential using the `ENCRYPTION_KEY` 3. Includes the appropriate header in the MCP initialize request: - Bearer: `Authorization: Bearer ` - API Key: `: ` **Example health check with auth:** ```python # Health check service automatically decrypts and uses credentials headers = {} if server.auth_scheme == "bearer": headers["Authorization"] = f"Bearer {decrypt_credential(server.auth_credential_encrypted)}" elif server.auth_scheme == "api_key": header_name = server.auth_header_name or "X-API-Key" headers[header_name] = decrypt_credential(server.auth_credential_encrypted) # MCP initialize request with auth headers response = await mcp_client.initialize(url=server.proxy_pass_url, headers=headers) ``` #### 2. Tool Discovery When fetching tools from a server: 1. Registry decrypts the credential 2. Includes auth headers in the MCP `tools/list` request 3. Stores the fetched tools in the database #### 3. MCP Client Connections When AI coding assistants connect to a server through the gateway: 1. User provides gateway auth token (`X-Authorization` header) 2. Gateway validates user permissions 3. Gateway retrieves and decrypts server credential 4. Gateway proxies the request with the server's auth header **Example MCP client configuration:** ```json { "mcpServers": { "context7": { "type": "streamable-http", "url": "https://mcpgateway.ddns.net/context7/mcp", "disabled": false, "headers": { "X-Authorization": "Bearer ", "CONTEXT7_API_KEY": "" } } } } ``` ### Security Considerations 1. **Encryption at Rest**: - All credentials are encrypted in MongoDB using Fernet - Never store plaintext credentials in the database 2. **Key Management**: - Store `ENCRYPTION_KEY` securely (AWS Secrets Manager, Vault, etc.) - Never commit encryption keys to version control - Rotate encryption keys periodically 3. **Access Control**: - Only users with `register_service` or `modify_service` permissions can set/update credentials - Credentials are never returned in API responses (only `auth_credential_encrypted` flag) 4. **Audit Logging**: - All credential updates are logged with username and timestamp - Review audit logs regularly for unauthorized changes ### Best Practices 1. **Use Environment-Specific Credentials**: - Development: Use test credentials with limited access - Production: Use production credentials with full access 2. **Rotate Credentials Regularly**: - Use the credential update API/CLI to rotate without downtime - Update credentials before they expire 3. **Monitor Health Checks**: - Watch for "auth-expired" health status - Set up alerts for authentication failures 4. **Document Custom Headers**: - For `api_key` auth, document the required header name - Ensure consistency across environments ### Troubleshooting **Credential Update Fails:** ```bash # Verify server exists curl -H "Authorization: Bearer $TOKEN" \ https://registry.example.com/api/servers # Check auth scheme is valid # Valid values: none, bearer, api_key ``` **Health Check Shows "auth-expired":** ```bash # Update the credential python3 api/registry_management.py \ server-update-credential \ --path "/my-server" \ --auth-scheme bearer \ --credential "new-valid-token" # Force immediate health check curl -X POST -H "Authorization: Bearer $TOKEN" \ https://registry.example.com/api/servers/my-server/refresh ``` **MCP Client Connection Fails:** - Verify both gateway auth (`X-Authorization`) and server auth headers are present - Check credential hasn't expired - Ensure `auth_header_name` matches server's expectation --- ## Additional Resources - [Authentication Design](design/authentication-design.md) - Detailed auth flow diagrams - [IdP Provider Support](design/idp-provider-support.md) - Provider architecture - [Scopes Management](scopes-mgmt.md) - Scope file format reference - [Auth Management](auth-mgmt.md) - CLI operations guide - [AI Coding Assistants Setup](ai-coding-assistants-setup.md) - Complete setup with backend auth examples ================================================ FILE: docs/auth0-m2m-setup.md ================================================ # Auth0 M2M Client Management This guide explains how to manage Auth0 Machine-to-Machine (M2M) client applications and their group mappings in the MCP Gateway Registry. ## Overview Auth0 M2M tokens do not include groups in the JWT payload (similar to Okta). The MCP Gateway Registry solves this by: 1. **Syncing M2M clients** from Auth0 Management API to MongoDB 2. **Storing group mappings** in the `idp_m2m_clients` collection 3. **Enriching tokens** with groups during authentication This enables group-based authorization for Auth0 M2M clients without modifying Auth0 configuration. ## Architecture ### Collections **`auth0_m2m_clients`** (Auth0-specific): - Stores Auth0 M2M application metadata - Synced via Auth0 Management API - Used for listing and managing Auth0 clients **`idp_m2m_clients`** (Provider-agnostic): - Generic collection for all IdP providers (Keycloak, Okta, Entra, Auth0) - Used by auth-server for groups enrichment - Schema: `{client_id, name, groups, provider, enabled, ...}` ### Flow ``` ┌─────────────────┐ │ Auth0 M2M App │ │ (no groups) │ └────────┬────────┘ │ 1. M2M Token (JWT) │ - iss: https://domain.auth0.com/ │ - sub: client_id@clients │ - aud: https://domain.auth0.com/api/v2/ │ - groups: [] (empty) │ v ┌─────────────────────────┐ │ Auth Server │ │ (validate_token) │ └────────┬────────────────┘ │ 2. Groups enrichment │ - Query: db.idp_m2m_clients.find_one({client_id}) │ - Return: ["registry-admins"] │ v ┌─────────────────────────┐ │ Authorization │ │ (with groups) │ └─────────────────────────┘ ``` ## Prerequisites ### 1. Auth0 M2M Application Create a Machine-to-Machine application in Auth0: 1. Navigate to **Applications** > **Applications** > **Create Application** 2. Select **Machine to Machine Applications** 3. Name it (e.g., "MCP Gateway M2M Sync") 4. Authorize for **Auth0 Management API** 5. Grant required scopes: - `read:clients` - Read client applications - `read:client_grants` - Read client grants (optional) ### 2. Environment Variables Configure the following in `.env`: ```bash # Auth0 M2M credentials for Management API access AUTH0_DOMAIN=dev-abc123.us.auth0.com AUTH0_M2M_CLIENT_ID=your_m2m_client_id AUTH0_M2M_CLIENT_SECRET=your_m2m_client_secret ``` These credentials are used by the sync service to query the Auth0 Management API. ## API Endpoints ### Sync M2M Clients Fetch all M2M applications from Auth0 and store in MongoDB. **Request:** ```http POST /api/iam/auth0/m2m/sync Authorization: Bearer Content-Type: application/json { "force_full_sync": false } ``` **Response:** ```json { "synced_count": 3, "added_count": 2, "updated_count": 1, "removed_count": 0, "errors": [] } ``` ### List M2M Clients Get all synced Auth0 M2M clients. **Request:** ```http GET /api/iam/auth0/m2m/clients Authorization: Bearer ``` **Response:** ```json [ { "client_id": "KhZMijfKUcl2TEJqZzrzVJb8rmwk6Qcd", "name": "MCP Gateway M2M", "description": "M2M client for registry access", "groups": ["registry-admins"], "enabled": true, "provider": "auth0", "created_at": "2026-03-29T00:00:00Z", "updated_at": "2026-03-29T00:00:00Z" } ] ``` ### Get Client Groups Get groups for a specific M2M client. **Request:** ```http GET /api/iam/auth0/m2m/clients/{client_id}/groups Authorization: Bearer ``` **Response:** ```json ["registry-admins", "public-mcp-users"] ``` ### Update Client Groups Update groups for an M2M client (admin only). **Request:** ```http PATCH /api/iam/auth0/m2m/clients/{client_id}/groups Authorization: Bearer Content-Type: application/json { "groups": ["registry-admins", "developers"] } ``` **Response:** ```json { "client_id": "KhZMijfKUcl2TEJqZzrzVJb8rmwk6Qcd", "groups": ["registry-admins", "developers"], "message": "Groups updated successfully" } ``` ## Usage ### 1. Initial Sync After configuring Auth0 credentials, perform an initial sync: ```bash curl -X POST https://registry.example.com/api/iam/auth0/m2m/sync \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"force_full_sync": true}' ``` This will: 1. Fetch all M2M applications from Auth0 2. Store them in `auth0_m2m_clients` collection 3. Write to `idp_m2m_clients` collection for groups enrichment ### 2. Assign Groups Update groups for M2M clients: ```bash curl -X PATCH https://registry.example.com/api/iam/auth0/m2m/clients/{client_id}/groups \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"groups": ["registry-admins"]}' ``` ### 3. Verify Groups Enrichment Test an M2M token: ```bash # Get M2M token from Auth0 TOKEN=$(curl -X POST https://dev-abc123.us.auth0.com/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id=${CLIENT_ID}" \ -d "client_secret=${CLIENT_SECRET}" \ -d "audience=https://dev-abc123.us.auth0.com/api/v2/" \ | jq -r '.access_token') # Use token to access registry curl https://registry.example.com/api/servers \ -H "Authorization: Bearer $TOKEN" ``` The auth-server will: 1. Validate the JWT signature 2. Detect empty groups claim 3. Query `idp_m2m_clients` for the client ID 4. Enrich with groups from database 5. Apply group-based authorization ## Default Groups You can configure default groups for specific client IDs in `registry/services/auth0_m2m_sync.py`: ```python DEFAULT_CLIENT_GROUPS = { "KhZMijfKUcl2TEJqZzrzVJb8rmwk6Qcd": ["registry-admins"], "another_client_id": ["public-mcp-users"], } ``` These groups are assigned during sync and can be overridden via the API. ## Troubleshooting ### Sync Returns Empty List **Problem:** No M2M clients found **Solutions:** 1. Verify Auth0 has M2M applications (app_type: "non_interactive") 2. Check Management API credentials have `read:clients` scope 3. Review logs: `docker logs mcp-gateway-registry 2>&1 | grep "Auth0 M2M"` ### Token Has No Groups **Problem:** M2M token works but has no authorization **Solutions:** 1. Verify client is synced: `GET /api/iam/auth0/m2m/clients` 2. Check `idp_m2m_clients` collection in MongoDB: ```javascript db.idp_m2m_clients.find({ client_id: "your_client_id" }) ``` 3. Assign groups via API: `PATCH /api/iam/auth0/m2m/clients/{id}/groups` 4. Check auth-server logs for groups enrichment messages ### Permission Denied **Problem:** 403 Forbidden despite having correct groups **Solutions:** 1. Verify groups are mapped to scopes in `group_to_scope_mappings` collection 2. Check auth-server includes "auth0" in provider list (line 1557 in server.py) 3. Ensure `AUTH_PROVIDER=auth0` in environment variables ## MongoDB Queries ### Check M2M Client ```javascript db.idp_m2m_clients.find({ provider: "auth0", client_id: "your_client_id" }).pretty() ``` ### Update Groups Manually ```javascript db.idp_m2m_clients.updateOne( { client_id: "your_client_id" }, { $set: { groups: ["registry-admins"], updated_at: new Date() } } ) ``` ### List All Auth0 M2M Clients ```javascript db.idp_m2m_clients.find({ provider: "auth0" }).pretty() ``` ## Related Documentation - [Auth0 Management API](https://auth0.com/docs/api/management/v2) - [Auth0 M2M Applications](https://auth0.com/docs/get-started/applications/application-types#machine-to-machine-applications) - [Groups Enrichment](../auth_server/mongodb_groups_enrichment.py) - [Okta M2M Setup](okta-setup.md) - Similar pattern for Okta ================================================ FILE: docs/auth0.md ================================================ # Auth0 Integration for MCP Gateway Registry > **⚠️ IMPORTANT DISCLAIMER** > > This documentation is a **reference guide based on our testing and development experience**, not an official Auth0 configuration manual. Auth0's interface, features, and best practices evolve over time. > > **Always consult the [official Auth0 documentation](https://auth0.com/docs) for:** > - Current UI layouts and navigation paths > - Latest security recommendations > - Production-grade configuration guidance > - Detailed API references > > **Purpose of this guide:** > - Document the specific configuration steps we used during development > - Provide a working reference for MCP Gateway Registry integration > - Share lessons learned and troubleshooting tips > > If you encounter differences between this guide and your Auth0 console, refer to Auth0's official documentation as the authoritative source. This document provides instructions for integrating Auth0 as the authentication provider for the MCP Gateway Registry, including user management and group-based authorization. ## Overview The MCP Gateway Registry supports Auth0 as an OAuth2/OIDC identity provider. Users authenticate via Auth0 and receive JWT tokens for programmatic access to gateway APIs (CLI tools, coding assistants, etc.). **Key Concepts:** - **Users**: People who log in to the registry - **Roles**: Auth0's term for groups (e.g., `registry-admins`, `registry-users`) - **Groups**: The MCP Gateway converts Auth0 roles → groups for authorization - **M2M (Machine-to-Machine)**: Service accounts for CLI tools and scripts (no human login) ## Architecture ### Authentication Flow ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Browser │ │ Registry │ │ Auth Server │ │ Auth0 │ │ (User) │ │ Frontend │ │ │ │ Tenant │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ 1. Click Login │ │ │ │──────────────────>│ │ │ │ │ │ │ │ 2. Redirect to Auth Server │ │ │<──────────────────│ │ │ │ │ │ │ │ 3. /oauth2/login/auth0 │ │ │──────────────────────────────────────>│ │ │ │ │ │ │ 4. Redirect to Auth0 /authorize endpoint │ │<─────────────────────────────────────────────────────────>│ │ │ │ │ │ 5. User authenticates with Auth0 │ │ │<─────────────────────────────────────────────────────────>│ │ │ │ │ │ 6. Redirect with auth code │ │ │──────────────────────────────────────>│ │ │ │ │ │ │ │ 7. Exchange code │ │ │ │ for tokens │ │ │ │ │──────────────────>│ │ │ │<──────────────────│ │ │ │ (ID token + │ │ │ │ access token) │ │ │ │ │ │ 8. Set session cookie + redirect │ │ │<──────────────────────────────────────│ │ │ │ │ │ │ 9. Access Registry with session │ │ │──────────────────>│ │ │ │ │ │ │ ``` ### Group Extraction User groups are extracted from the Auth0 ID token using a **custom namespaced claim**. Auth0 does not include group memberships in tokens by default -- you must configure an Auth0 Action (or legacy Rule) to add them. **Claim lookup order:** 1. Custom namespaced claim (default: `https://mcp-gateway/groups`) 2. Fallback: `permissions` claim from Auth0 RBAC If neither claim contains data, the user will have an empty groups list and no permissions. --- ## Complete Setup Guide ### Prerequisites - Auth0 account (free tier works fine for testing) - MCP Gateway Registry deployed and accessible via HTTPS - Access to modify nginx configuration and environment variables ### Step 1: Create an Auth0 Application 1. **Log in to Auth0 Dashboard** at https://manage.auth0.com/ 2. Navigate to **Applications > Applications** (left sidebar) 3. Click **Create Application** 4. Configure the application: - **Name**: `AI Registry` (or your preferred name) - **Application Type**: Select **Regular Web Application** - Click **Create** 5. **Copy your credentials** from the Settings tab: - **Domain**: e.g., `dev-abc123xyz.us.auth0.com` (without `https://`) - **Client ID**: Long alphanumeric string - **Client Secret**: Click the eye icon to reveal and copy **Important:** Keep these credentials secure. You'll need them for environment configuration. ### Step 2: Configure Application URLs Scroll down to **Application URIs** section and configure: **Allowed Callback URLs:** ``` https://your-registry-domain.com/oauth2/callback/auth0 ``` **Allowed Logout URLs:** ``` https://your-registry-domain.com ``` **Allowed Web Origins:** ``` https://your-registry-domain.com ``` **Example for local testing:** ``` http://localhost/oauth2/callback/auth0 http://localhost ``` Click **Save Changes** at the bottom. ### Step 3: Create an Auth0 Action (Required for Groups) Auth0 Actions are custom code that runs during authentication to add groups to tokens. 1. Navigate to **Actions > Triggers** (left sidebar) 2. Click on the **post-login** trigger box 3. You'll see the Login Flow diagram with Start → Complete 4. Click **Create Action** (bottom right) 5. Configure the Action: - **Name**: `Add Groups to Tokens` - **Trigger**: `Login / Post Login` (already selected) - **Runtime**: `Node 18` (or latest) - Click **Create** 6. **Paste this code** in the editor: ```javascript exports.onExecutePostLogin = async (event, api) => { const namespace = "https://mcp-gateway/"; // Add user's roles as groups if (event.authorization && event.authorization.roles) { api.idToken.setCustomClaim(namespace + "groups", event.authorization.roles); api.accessToken.setCustomClaim(namespace + "groups", event.authorization.roles); } // Fallback to permissions if no roles if (event.authorization && event.authorization.permissions) { if (!event.authorization.roles || event.authorization.roles.length === 0) { api.idToken.setCustomClaim(namespace + "groups", event.authorization.permissions); api.accessToken.setCustomClaim(namespace + "groups", event.authorization.permissions); } } // Optional: Add organization info if using Auth0 Organizations if (event.organization) { api.idToken.setCustomClaim(namespace + "org_id", event.organization.id); api.idToken.setCustomClaim(namespace + "org_name", event.organization.name); } }; ``` 7. Click **Deploy** (top-right corner) 8. Go back to the Post Login flow (click the back arrow) 9. **Add the Action to the flow**: - On the right panel, click the **Custom** tab - Find your "Add Groups to Tokens" Action - **Drag and drop** it between "Start" and "Complete" in the flow diagram 10. Click **Apply** (top-right) **Note:** The namespace `https://mcp-gateway/` must match your `AUTH0_GROUPS_CLAIM` environment variable. ### Step 4: Create Roles (Groups) Auth0 uses "Roles" for authorization. The MCP Gateway maps these to "groups". 1. Navigate to **User Management > Roles** (left sidebar) 2. Click **Create Role** 3. Create the administrator role: - **Name**: `registry-admins` - **Description**: `Registry administrators with full access` - Click **Create** 4. **Optional:** Create additional roles as needed: - `registry-users` - Regular users - `registry-viewers` - Read-only access - `developers` - Developer access **Important:** Role names must match the groups configured in your `scopes.yml` file. ### Step 5: Create Users 1. Navigate to **User Management > Users** (left sidebar) 2. Click **Create User** 3. Fill in user details: - **Email**: User's email address - **Password**: Set a strong password (or send password reset email) - **Connection**: `Username-Password-Authentication` (default database) 4. Click **Create** **Repeat** for additional users. ### Step 6: Assign Roles to Users 1. Go to **User Management > Users** 2. Click on a user you just created 3. Go to the **Roles** tab 4. Click **Assign Roles** 5. Select `registry-admins` (or other roles) 6. Click **Assign** **Verification:** The user should now have roles listed in their profile. ### Step 7: Configure Environment Variables #### Option A: Update Existing .env File Edit your `.env` file and update these variables: ```bash # Authentication Provider AUTH_PROVIDER=auth0 # Auth0 Configuration AUTH0_DOMAIN=dev-abc123xyz.us.auth0.com AUTH0_CLIENT_ID=your-client-id-here AUTH0_CLIENT_SECRET=your-client-secret-here AUTH0_GROUPS_CLAIM=https://mcp-gateway/groups AUTH0_ENABLED=true # Disable other providers KEYCLOAK_ENABLED=false ENTRA_ENABLED=false COGNITO_ENABLED=false ``` #### Option B: Create Provider-Specific Files For easy switching between providers: ```bash # Backup current configuration cp .env .env.keycloak # Create Auth0 configuration cp .env .env.auth0 # Edit .env.auth0 with Auth0 credentials (as shown above) # Activate Auth0 cp .env.auth0 .env ``` #### Complete Environment Variables | Variable | Required | Description | Example | |----------|----------|-------------|---------| | `AUTH_PROVIDER` | Yes | Set to `auth0` | `auth0` | | `AUTH0_DOMAIN` | Yes | Auth0 tenant domain (no https://) | `dev-abc123xyz.us.auth0.com` | | `AUTH0_CLIENT_ID` | Yes | Application client ID | `eYNHy8GXBHH1s60Po9J0SLGcsLGsNPoA` | | `AUTH0_CLIENT_SECRET` | Yes | Application client secret | `q-9A_nlgypKAOfwLmTvv0k...` | | `AUTH0_GROUPS_CLAIM` | No | Custom claim name for groups | `https://mcp-gateway/groups` (default) | | `AUTH0_ENABLED` | Yes | Enable Auth0 provider | `true` | | `AUTH0_AUDIENCE` | No | API identifier (M2M only) | `https://api.example.com` | | `AUTH0_M2M_CLIENT_ID` | No | M2M client ID (M2M only) | `xyz789...` | | `AUTH0_M2M_CLIENT_SECRET` | No | M2M client secret (M2M only) | `abc456...` | ### Step 8: Verify Nginx Configuration The nginx reverse proxy needs Auth0 route configuration. Check that these location blocks exist in your nginx config file: **File:** `docker/nginx_rev_proxy_http_and_https.conf` ```nginx # OAuth2 Auth0 callback endpoint location /oauth2/callback/auth0 { proxy_pass http://auth-server:8888/oauth2/callback/auth0; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; proxy_pass_request_headers on; proxy_pass_request_body on; } # OAuth2 Auth0 login endpoint location /oauth2/login/auth0 { proxy_pass http://auth-server:8888/oauth2/login/auth0; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $real_scheme; proxy_pass_request_headers on; } ``` **If these blocks are missing**, add them to both the HTTP (port 8080) and HTTPS (port 8443) server blocks. Place them after the Google OAuth endpoints and before the Keycloak section. ### Step 9: Restart Services Restart the registry and auth-server containers to apply the new configuration: ```bash # If using docker-compose docker-compose restart registry auth-server # Or rebuild and restart all services docker-compose down docker-compose up -d ``` Wait for services to become healthy: ```bash docker-compose ps ``` ### Step 10: Test Authentication 1. **Open the registry** in your browser: `https://your-registry-domain.com` 2. **Click "Login"** or navigate to the login page 3. **Select "Auth0"** from the provider list 4. You should be **redirected to Auth0 login page** 5. **Enter credentials** for the user you created 6. After successful login, you should be **redirected back to the registry** 7. **Verify your session**: - Check that your username appears in the UI - Admin users should see admin panels/options **Check logs if login fails:** ```bash # Auth server logs docker-compose logs --tail=50 auth-server # Registry logs docker-compose logs --tail=50 registry ``` --- ## Machine-to-Machine (M2M) Authentication M2M authentication allows non-human clients (CLI tools, scripts, cron jobs) to authenticate and access the registry API programmatically using OAuth2 client credentials flow. ### When to Use M2M - CLI tools that need to access the registry without browser login - Automated scripts (CI/CD pipelines) - Service-to-service authentication - Cron jobs that sync or update registry data - Federation between registry instances ### How M2M Authentication Works 1. An M2M application requests an access token from Auth0 using client credentials 2. Auth0 validates the credentials and returns a JWT access token 3. The client sends the token in the `Authorization: Bearer` header to the registry API 4. The registry validates the token against Auth0's JWKS endpoint 5. The registry looks up the client's groups from MongoDB (since M2M tokens do not contain group claims from Auth0 Actions) **Important:** M2M tokens do NOT go through Auth0's Post Login Actions, so group claims like `https://mcp-gateway/groups` are not included in the JWT. The registry resolves groups by looking up the client ID in the `idp_m2m_clients` MongoDB collection. You must sync M2M clients and assign groups via the registry's IAM API. ### M2M Setup #### Step 1: Identify the API Audience The `AUTH0_AUDIENCE` is the identifier of the API your M2M client will request tokens for. For the MCP Gateway Registry, you can use: - **Auth0 Management API** (default): `https://your-tenant.auth0.com/api/v2/` - **Custom API**: Create your own API identifier (see "Create a Custom API" below) The audience value must match exactly between: - The `AUTH0_AUDIENCE` environment variable in your registry deployment - The `audience` parameter in your token request #### Step 2: Create an M2M Application in Auth0 1. Log in to the **Auth0 Dashboard** (https://manage.auth0.com) 2. Navigate to **Applications > Applications** in the left sidebar 3. Click **+ Create Application** (top right) 4. Configure the application: - **Name**: Give it a descriptive name (e.g., `Registry CLI Client`, `CI/CD Pipeline`) - **Application Type**: Select **Machine to Machine Applications** 5. Click **Create** 6. On the next screen, you will be asked to authorize the application for an API: - Select the API matching your `AUTH0_AUDIENCE` (e.g., **Auth0 Management API**) - Select the required scopes/permissions (e.g., `read:clients` for basic access) - Click **Authorize** 7. You will be taken to the application's **Settings** tab 8. Copy the **Client ID** and **Client Secret** -- you will need these to generate tokens #### Step 3: Authorize the M2M Application for the API This is a critical step that is often missed. Each M2M application must be explicitly authorized to request tokens for a specific API. **If you skipped authorization during creation, or need to authorize for a different API:** 1. Navigate to **Applications > APIs** in the left sidebar 2. Click on the API you want to authorize against (e.g., **Auth0 Management API**) 3. Click the **Machine to Machine Applications** tab (also called **Application Access**) 4. You will see a list of all M2M applications in your tenant 5. Find your application in the list 6. **Toggle the switch ON** next to the application name to authorize it 7. After toggling ON, a permissions dropdown appears 8. Select the scopes/permissions the application needs: - For basic registry API access: `read:clients` is sufficient - For management operations: add `read:users`, `read:roles`, etc. 9. Click **Update** to save **If the toggle is OFF**, the M2M application will receive an `access_denied` error when requesting tokens for that API audience. #### Step 4: (Optional) Create a Custom API If you prefer a dedicated API for registry access instead of using the Auth0 Management API: 1. Navigate to **Applications > APIs** in the left sidebar 2. Click **+ Create API** (top right) 3. Configure the API: - **Name**: `MCP Registry API` - **Identifier**: `https://api.your-domain.com` (this becomes your `AUTH0_AUDIENCE`) - **Signing Algorithm**: `RS256` 4. Click **Create** 5. Go to the **Machine to Machine Applications** tab 6. Authorize your M2M applications as described in Step 3 #### Step 5: Configure Environment Variables Add the following to your `.env` file: ```bash # Auth0 domain (no https:// prefix) AUTH0_DOMAIN=your-tenant.us.auth0.com # API audience - must match the API identifier in Auth0 # Use Management API URL or your custom API identifier AUTH0_AUDIENCE=https://your-tenant.us.auth0.com/api/v2/ # M2M client credentials (for the registry's own Management API access) AUTH0_M2M_CLIENT_ID=your-m2m-client-id AUTH0_M2M_CLIENT_SECRET=your-m2m-client-secret ``` For Terraform deployments, set in `terraform.tfvars`: ```hcl auth0_audience = "https://your-tenant.us.auth0.com/api/v2/" auth0_m2m_client_id = "your-m2m-client-id" auth0_m2m_client_secret = "your-m2m-client-secret" ``` #### Step 6: Generate an M2M Token **Option A: Using the helper script (recommended)** ```bash python3 credentials-provider/auth0/get_m2m_token.py \ --auth0-domain your-tenant.us.auth0.com \ --client-id YOUR_CLIENT_ID \ --client-secret YOUR_CLIENT_SECRET \ --audience "https://your-tenant.us.auth0.com/api/v2/" \ --output-file /tmp/m2m_token.json ``` **Option B: Using curl** ```bash curl --request POST \ --url https://your-tenant.us.auth0.com/oauth/token \ --header 'content-type: application/json' \ --data '{ "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", "audience": "https://your-tenant.us.auth0.com/api/v2/", "grant_type": "client_credentials" }' ``` The response contains an `access_token` field with your JWT bearer token. #### Step 7: Test M2M Token with the Registry API ```bash # Using the registry management CLI tool python3 api/registry_management.py \ --registry-url https://your-registry-domain.com \ --token-file /tmp/m2m_token.json \ --action list-servers # Or using curl directly TOKEN=$(cat /tmp/m2m_token.json | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") curl -H "Authorization: Bearer $TOKEN" \ https://your-registry-domain.com/api/servers ``` #### Step 8: Assign Groups to M2M Clients Since M2M tokens do not include group claims from Auth0 Actions, you must manage groups for M2M clients through the registry's IAM API: 1. **Sync M2M clients** from Auth0 to the registry database: ```bash curl -X POST \ -H "Authorization: Bearer $ADMIN_TOKEN" \ https://your-registry-domain.com/api/iam/auth0/m2m/sync ``` 2. **List synced M2M clients:** ```bash curl -H "Authorization: Bearer $ADMIN_TOKEN" \ https://your-registry-domain.com/api/iam/auth0/m2m/clients ``` 3. **Assign groups to an M2M client:** ```bash curl -X PATCH \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"groups": ["registry-admins", "registry-users"]}' \ https://your-registry-domain.com/api/iam/auth0/m2m/clients/CLIENT_ID/groups ``` The registry will use these stored groups when validating API requests from M2M clients. ### M2M Troubleshooting #### "access_denied" Error When Requesting Token **Cause:** The M2M application is not authorized for the requested API audience. **Fix:** 1. Go to **Applications > APIs** in Auth0 Dashboard 2. Click on the API matching your audience 3. Click the **Machine to Machine Applications** tab 4. Find your application and **toggle the switch ON** 5. Select the required scopes and click **Update** #### "Audience doesn't match" Error from Registry **Cause:** The `AUTH0_AUDIENCE` in your `.env` does not match the audience in the token. **Fix:** 1. Check what audience is in your token: `echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool | grep aud` 2. Set `AUTH0_AUDIENCE` in `.env` to match exactly 3. Restart the registry: `docker-compose restart registry auth-server` #### M2M Client Has No Permissions (403 Forbidden) **Cause:** The M2M client has no groups assigned in the registry database. **Fix:** 1. Sync M2M clients: `POST /api/iam/auth0/m2m/sync` 2. Assign groups: `PATCH /api/iam/auth0/m2m/clients/{client_id}/groups` 3. Verify groups: `GET /api/iam/auth0/m2m/clients/{client_id}/groups` --- ## User and Role Management ### Creating Additional Roles 1. Go to **User Management > Roles** 2. Click **Create Role** 3. Enter role name (e.g., `developers`, `viewers`) 4. Click **Create** 5. **Map roles to MCP Gateway groups** in your `scopes.yml` file ### Assigning Roles in Bulk 1. Go to **User Management > Roles** 2. Click on a role (e.g., `registry-users`) 3. Go to the **Users** tab 4. Click **Add Users** 5. Search and select multiple users 6. Click **Assign** ### Removing Roles from Users 1. Go to **User Management > Users** 2. Click on a user 3. Go to the **Roles** tab 4. Click the `...` menu next to a role 5. Click **Remove** ### Creating Users Programmatically Use the Auth0 Management API to automate user creation: ```bash # Get Management API token curl --request POST \ --url https://your-tenant.auth0.com/oauth/token \ --header 'content-type: application/json' \ --data '{ "client_id": "your-management-api-client-id", "client_secret": "your-management-api-client-secret", "audience": "https://your-tenant.auth0.com/api/v2/", "grant_type": "client_credentials" }' # Create a user curl --request POST \ --url https://your-tenant.auth0.com/api/v2/users \ --header 'authorization: Bearer ' \ --header 'content-type: application/json' \ --data '{ "email": "newuser@example.com", "password": "SecurePassword123!", "connection": "Username-Password-Authentication" }' ``` ### IAM Management (Settings > IAM > Groups/Users) The MCP Gateway Registry provides a web UI for managing users and roles via **Settings > IAM**. This requires Auth0 Management API access. **Important:** This is separate from M2M authentication for registry API access. The Management API allows the registry to: - List users and roles - Create/delete users - Assign roles to users - Manage role (group) definitions #### Option 1: M2M Application for Management API (Recommended) Create a dedicated M2M application with Management API permissions: 1. **Navigate to Applications > Applications** in Auth0 Dashboard 2. Click **Create Application** 3. Configure: - **Name**: `Registry Management Client` - **Application Type**: Select **Machine to Machine Applications** 4. **Select API**: Choose **Auth0 Management API** (this is pre-created by Auth0) 5. Click **Authorize** 6. **Grant Permissions**: Select the following scopes: - `read:users` - `update:users` - `create:users` - `delete:users` - `read:roles` - `update:roles` - `create:roles` - `delete:roles` - `read:users_app_metadata` - `update:users_app_metadata` 7. Click **Authorize** to confirm 8. Copy the **Client ID** and **Client Secret** **Add to .env file:** ```bash AUTH0_M2M_CLIENT_ID=your-management-client-id AUTH0_M2M_CLIENT_SECRET=your-management-client-secret ``` #### Option 2: Static Management API Token Alternatively, use a static token (less secure, expires): 1. Go to **Applications > APIs > Auth0 Management API** 2. Click **API Explorer** tab 3. Click **Create & Authorize Test Application** 4. Copy the generated token **Add to .env file:** ```bash AUTH0_MANAGEMENT_API_TOKEN=your-static-token ``` **⚠️ Warning:** Static tokens expire after 24 hours by default. M2M credentials (Option 1) are recommended for production. #### Testing IAM Management After configuring Management API access: 1. Restart the registry: `docker-compose restart registry auth-server` 2. Open the web UI: `https://your-registry-domain.com` 3. Navigate to **Settings > IAM > Groups** 4. You should see your Auth0 roles listed (e.g., `registry-admins`, `registry-users`) 5. Navigate to **Settings > IAM > Users** 6. You should see all Auth0 users with their role assignments **Troubleshooting:** - If you see an empty list or errors, check auth server logs: `docker-compose logs auth-server | grep Management` - Verify M2M credentials are correct: `grep AUTH0_M2M .env` - Ensure Management API permissions are granted in Auth0 Dashboard --- ## Group-to-Scope Mapping The MCP Gateway uses a `scopes.yml` file to map Auth0 roles to registry permissions. ### Example scopes.yml Configuration ```yaml group_mappings: registry-admins: - admin:* - servers:* - agents:* - scopes:manage registry-users: - servers:read - servers:write - agents:read - tools:* registry-viewers: - servers:read - agents:read - tools:read ``` **Location:** This file should be in your registry configuration directory and loaded at startup. --- ## Troubleshooting ### Empty Page or No Redirect to Auth0 **Symptom:** Clicking login shows an empty page at `/oauth2/login/auth0` **Causes:** 1. Missing nginx configuration for Auth0 routes 2. Auth server not receiving the request **Solution:** 1. Verify nginx has Auth0 location blocks (Step 8) 2. Restart the registry container: `docker-compose restart registry` 3. Check nginx error logs: `docker-compose exec registry cat /var/log/nginx/error.log` ### Users Have No Groups After Login **Symptom:** User logs in successfully but has no permissions **Causes:** 1. Auth0 Action not deployed or not in the flow 2. User has no roles assigned 3. `AUTH0_GROUPS_CLAIM` mismatch **Solution:** 1. Go to **Actions > Triggers > Post Login** and verify the Action is in the flow 2. Check user has roles: **User Management > Users > [User] > Roles tab** 3. Verify environment variable: `grep AUTH0_GROUPS_CLAIM .env` 4. Check auth server logs: `docker-compose logs auth-server | grep "Auth0 ID token claims"` ### Callback URL Mismatch Error **Symptom:** Auth0 shows "Callback URL mismatch" error after login **Solution:** 1. Go to Auth0 Dashboard > Applications > Your App > Settings 2. Verify **Allowed Callback URLs** exactly matches: ``` https://your-domain.com/oauth2/callback/auth0 ``` 3. Click **Save Changes** 4. Try logging in again ### Token Validation Errors **Symptom:** "Invalid token" or "Token validation failed" errors **Causes:** 1. `AUTH0_DOMAIN` has `https://` prefix 2. Wrong Client ID or Client Secret 3. Token expired **Solution:** 1. Verify domain has no protocol: ```bash # Correct AUTH0_DOMAIN=dev-abc123xyz.us.auth0.com # Wrong AUTH0_DOMAIN=https://dev-abc123xyz.us.auth0.com ``` 2. Verify credentials match Auth0 Dashboard 3. Check auth server logs for specific error messages ### M2M Token Failures **Symptom:** M2M authentication returns 401 or 403 **Causes:** 1. M2M application not authorized for the API 2. Wrong audience parameter 3. Missing API in Auth0 **Solution:** 1. Go to Auth0 Dashboard > Applications > APIs > [Your API] 2. Go to the **Machine to Machine Applications** tab 3. Ensure your M2M app is listed and **Authorized** 4. Verify `AUTH0_AUDIENCE` matches the API **Identifier** exactly ### CORS Errors **Symptom:** Browser console shows CORS errors **Solution:** 1. Verify **Allowed Web Origins** in Auth0 includes your domain 2. Check nginx is setting correct CORS headers 3. Ensure `AUTH_SERVER_EXTERNAL_URL` matches your public domain --- ## Security Best Practices ### 1. Use Strong Secrets - Generate strong, random client secrets (Auth0 does this automatically) - Never commit secrets to version control - Rotate secrets periodically ### 2. Restrict Callback URLs Only add legitimate callback URLs to Auth0: ``` # Good - specific domains https://registry.example.com/oauth2/callback/auth0 https://registry-staging.example.com/oauth2/callback/auth0 # Bad - wildcards allow any subdomain https://*.example.com/oauth2/callback/auth0 ``` ### 3. Enable Multi-Factor Authentication (MFA) 1. Go to **Security > Multi-factor Auth** in Auth0 Dashboard 2. Enable **One-time Password** or **SMS** 3. Configure policies (e.g., require MFA for admins) ### 4. Monitor Login Activity 1. Go to **Monitoring > Logs** in Auth0 Dashboard 2. Review failed login attempts 3. Set up alerts for suspicious activity ### 5. Implement Principle of Least Privilege - Create specific roles with minimal permissions - Don't assign `registry-admins` to regular users - Regularly audit user roles --- ## Additional Resources - **Auth0 Documentation**: https://auth0.com/docs - **Auth0 Actions**: https://auth0.com/docs/customize/actions - **Auth0 Roles & Permissions**: https://auth0.com/docs/manage-users/access-control - **MCP Gateway Registry Docs**: https://github.com/agentic-community/mcp-gateway-registry/docs --- ## Summary Checklist Use this checklist to verify your Auth0 integration is complete: - [ ] Auth0 Application created (Regular Web Application) - [ ] Domain, Client ID, and Client Secret copied - [ ] Allowed Callback URLs configured - [ ] Allowed Logout URLs configured - [ ] Allowed Web Origins configured - [ ] Auth0 Action created and deployed - [ ] Action added to Post Login flow - [ ] Roles created (e.g., `registry-admins`) - [ ] Users created in Auth0 - [ ] Roles assigned to users - [ ] Environment variables configured in `.env` - [ ] Nginx configuration includes Auth0 routes - [ ] Services restarted to apply configuration - [ ] Login tested successfully - [ ] User groups appear correctly in registry - [ ] Admin permissions verified (if applicable) - [ ] M2M application created (Machine to Machine type) - [ ] M2M application authorized for the correct API audience - [ ] `AUTH0_AUDIENCE` configured in `.env` and deployment configs - [ ] M2M token generation tested successfully - [ ] M2M clients synced to registry database - [ ] Groups assigned to M2M clients in registry Once all items are checked, your Auth0 integration is complete! ================================================ FILE: docs/aws-agent-registry-federation.md ================================================ # AWS Agent Registry Federation This guide covers how to federate MCP servers, A2A agents, and agent skills from [Amazon Bedrock AgentCore](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/registry.html) registries into the MCP Gateway Registry. Once federated, AgentCore records appear alongside locally registered assets and can be discovered, searched, and invoked through the gateway. [Demo Video](https://app.vidcast.io/share/6d2e0a43-4a68-477e-b5b9-2b3e2aa59f83?playerMode=vidcast) ## Overview AWS Agent Registry Federation connects MCP Gateway Registry to one or more Amazon Bedrock AgentCore registries. The gateway periodically syncs records from each configured registry, transforming AgentCore descriptors (MCP, A2A, CUSTOM, AGENT_SKILLS) into native MCP Gateway assets. Cross-account and cross-region access is supported via IAM role assumption. ### What Gets Synced | AgentCore Descriptor Type | MCP Gateway Asset Type | Stored In | |---------------------------|----------------------|-----------| | MCP | MCP Server | `mcp_servers` collection | | A2A | A2A Agent | `mcp_agents` collection | | CUSTOM | A2A Agent | `mcp_agents` collection | | AGENT_SKILLS | Agent Skill | `agent_skills` collection | ### Key Capabilities - **Multi-registry**: Add multiple AgentCore registries (same or different AWS accounts/regions) - **Cross-account**: Assume an IAM role in another account to read its registry - **Selective sync**: Choose which descriptor types to sync per registry - **Status filtering**: Sync only APPROVED, PENDING, or REJECTED records - **Cascade cleanup**: Removing a registry automatically deregisters all its synced assets - **Startup sync**: Optionally sync records when the gateway starts ## Prerequisites - MCP Gateway Registry up and running (Docker Compose or ECS) - AWS credentials with Amazon Bedrock AgentCore permissions (see [IAM Setup](#iam-setup)) - At least one AgentCore registry with published records ## Step 1: Enable AWS Agent Registry Federation ### Option A: Environment Variable (Recommended for ECS/Terraform) Set the environment variable in your `.env` file or ECS task definition: ```bash AWS_REGISTRY_FEDERATION_ENABLED=true ``` This overrides the `aws_registry.enabled` flag in the federation config on every startup. For Terraform deployments, set in `terraform.tfvars`: ```hcl aws_registry_federation_enabled = true ``` ### Option B: API Enable via the federation config API: ```bash curl -X PUT https://your-registry.com/api/federation/config/default \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{ "aws_registry": { "enabled": true, "aws_region": "us-east-1", "sync_on_startup": true } }' ``` ### Option C: Settings UI Navigate to **Settings > Federation > External Registries**. The AWS Agent Registry card will show "Enabled" or "Disabled" based on the current configuration. ![External Registries page showing AWS Agent Registry enabled with Anthropic and ASOR sources](img/aws-agent-reg-federation-1.png) ## Step 2: Add a Registry ### Using the UI 1. Navigate to **Settings > Federation > External Registries** 2. On the **AWS Agent Registry** card, click the **+** (Add) button 3. In the Add AWS Agent Registry modal, enter the **Registry ID** (ARN or plain ID) - If you paste a full ARN (`arn:aws:bedrock-agentcore:us-east-1:123456789012:registry/rXXXXXXXX`), the **AWS Region** and **AWS Account ID** fields auto-populate from the ARN 4. (Optional) Fill in additional fields: - **AWS Account ID**: Auto-populated from ARN, or enter manually - **AWS Region**: Auto-populated from ARN, or enter manually (leave empty to use global region) - **Assume Role ARN**: Only needed if adding a registry from a different AWS account - **Descriptor Types**: Select which types to sync (MCP, A2A, CUSTOM, AGENT_SKILLS) - **Sync Status Filter**: Choose APPROVED (default), PENDING, or REJECTED 5. Click **Add** ![Add AWS Agent Registry modal with ARN auto-populating region and account ID](img/aws-agent-reg-federation-2.png) ### Using the API ```bash curl -X POST https://your-registry.com/api/federation/config/default/aws_registry/registries \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{ "registry_id": "arn:aws:bedrock-agentcore:us-east-1:123456789012:registry/rCu9kFIgrbNOpEsF", "aws_account_id": "123456789012", "aws_region": "us-east-1", "descriptor_types": ["MCP", "A2A", "CUSTOM", "AGENT_SKILLS"], "sync_status_filter": "APPROVED" }' ``` ### Using the CLI ```bash # Save a federation config with AWS Agent Registry enabled uv run python registry_management.py federation-save \ --config cli/examples/federation-config-agentcore-example.json # Or get existing config, edit, and save back uv run python registry_management.py federation-get --json > federation-config.json # Edit federation-config.json to add registry entries under aws_registry.registries uv run python registry_management.py federation-save --config federation-config.json ``` ## Step 3: Sync Records ### Manual Sync (UI) After adding, the registry entry appears on the card showing the ARN, account ID, status filter, and descriptor type tags. ![Registry entry added showing ARN, tags for MCP, A2A, CUSTOM, AGENT_SKILLS](img/aws-agent-reg-federation-3.png) Click the **Sync** button on the AWS Agent Registry card to trigger an immediate sync of all configured registries. ### Manual Sync (API) ```bash # Sync only AWS Agent Registry source curl -X POST https://your-registry.com/api/federation/sync?source=aws_registry \ -H "Authorization: Bearer " # Sync all federation sources curl -X POST https://your-registry.com/api/federation/sync \ -H "Authorization: Bearer " ``` ### Manual Sync (CLI) ```bash # Sync only AWS Agent Registry source uv run python registry_management.py federation-sync --source aws_registry # Sync all federation sources uv run python registry_management.py federation-sync ``` ### Automatic Sync on Startup Set `sync_on_startup: true` in the federation config (via API or UI) to sync automatically when the gateway starts. ## Step 4: Verify Synced Assets After syncing, the card shows the sync result with a breakdown of synced items and a toast notification confirming the count. ![Sync completed showing 6 items: 2 Servers, 3 Agents, 1 Skill](img/aws-agent-reg-federation-4.png) Federated assets appear in the main registry views under the **External Registries** tab: - **MCP Servers**: Synced servers appear with `source: agentcore` and an `agentcore` tag - **A2A Agents**: Synced agents appear with `agentcore` tag and metadata containing the source registry ID - **Agent Skills**: Synced skills appear with `agentcore` tag and serve inline content ![External Registries tab showing synced servers, agents, and skills from AgentCore](img/aws-agent-reg-federation-5.png) ## Removing a Registry ### Using the UI 1. On the AWS Agent Registry card, click the **X** (remove) button next to the registry entry ![Registry entry with X remove button](img/aws-agent-reg-federation-6.png) 2. Confirm the deletion in the modal dialog. The modal warns that all servers, agents, and skills synced from this source will also be deregistered. ![Confirm removal modal with cascade cleanup warning](img/aws-agent-reg-federation-7.png) ### Using the API ```bash # URL-encode the registry ID (ARNs contain colons and slashes) curl -X DELETE "https://your-registry.com/api/federation/config/default/aws_registry/registries/arn%3Aaws%3Abedrock-agentcore%3Aus-east-1%3A123456789012%3Aregistry%2FrCu9kFIgrbNOpEsF" \ -H "Authorization: Bearer " ``` ### Cascade Cleanup When a registry is removed, all assets that were synced from it are automatically deregistered: - MCP servers with `source: agentcore` and matching `metadata.agentcore_registry_id` - A2A agents with matching `metadata.agentcore_registry_id` or `agentcore` tag + path prefix - Agent skills with matching metadata or `agentcore` tag + path prefix The API response includes counts of deregistered assets: ```json { "message": "Registry removed and 3 server(s), 2 agent(s), 1 skill(s) deregistered", "deregistered": { "servers": ["/agentcore-my-server"], "agents": ["/agents/agentcore-my-agent-1", "/agents/agentcore-my-agent-2"], "skills": ["/skills/agentcore-my-skill"] } } ``` ## Cross-Account Federation To sync from a registry in a different AWS account: 1. In the remote account, create an IAM role with AgentCore read permissions and a trust policy allowing your gateway's task role to assume it 2. Tag the role with `Purpose: agentcore-federation` (required by the STS condition policy) 3. When adding the registry, provide the **Assume Role ARN** field ```bash curl -X POST https://your-registry.com/api/federation/config/default/aws_registry/registries \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{ "registry_id": "arn:aws:bedrock-agentcore:us-west-2:999888777666:registry/rRemoteReg", "aws_account_id": "999888777666", "aws_region": "us-west-2", "assume_role_arn": "arn:aws:iam::999888777666:role/AgentCoreFederationReadOnly" }' ``` ## IAM Setup ### ECS Deployment (Terraform) When `aws_registry_federation_enabled = true` in `terraform.tfvars`, Terraform automatically creates and attaches a `bedrock_agentcore_access` IAM policy to the registry ECS task role with: - `bedrock-agentcore:*` -- Full access to AgentCore APIs - `sts:AssumeRole` -- For cross-account federation (scoped to roles tagged `Purpose: agentcore-federation`) ### Docker Compose / Local For local deployments, ensure the AWS credentials available to the container have the following permissions: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "bedrock-agentcore:ListRegistries", "bedrock-agentcore:ListRegistryRecords", "bedrock-agentcore:GetRegistryRecord" ], "Resource": "*" } ] } ``` ## Configuration Reference ### Environment Variables | Variable | Description | Default | |----------|-------------|---------| | `AWS_REGISTRY_FEDERATION_ENABLED` | Override `aws_registry.enabled` in federation config | (not set) | ### Federation Config Fields (`aws_registry` section) | Field | Type | Description | Default | |-------|------|-------------|---------| | `enabled` | bool | Enable/disable AWS Agent Registry federation | `false` | | `aws_region` | string | Default AWS region for AgentCore API calls | `us-east-1` | | `sync_on_startup` | bool | Sync records when the gateway starts | `false` | | `sync_interval_minutes` | int | Interval between automatic syncs | `60` | | `sync_timeout_seconds` | int | Timeout for sync operations | `300` | | `max_concurrent_fetches` | int | Max parallel registry fetches | `5` | | `registries` | list | List of registry configurations (see below) | `[]` | ### Per-Registry Config Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `registry_id` | string | Yes | Registry ID or full ARN | | `aws_account_id` | string | No | AWS account ID (auto-extracted from ARN) | | `aws_region` | string | No | Override region for this registry | | `assume_role_arn` | string | No | IAM role ARN for cross-account access | | `descriptor_types` | list[string] | No | Types to sync: MCP, A2A, CUSTOM, AGENT_SKILLS | | `sync_status_filter` | string | No | Record status to sync: APPROVED, PENDING, REJECTED | ## Troubleshooting ### AWS Agent Registry shows "Disabled" - Verify `AWS_REGISTRY_FEDERATION_ENABLED=true` is set in the environment - Check the registry container logs for `AWS_REGISTRY_FEDERATION_ENABLED=true (from env var)` - If using ECS, verify the env var is present in the task definition ### Sync returns no records - Verify IAM permissions (see [IAM Setup](#iam-setup)) - Check that the registry has records with the configured `sync_status_filter` (default: APPROVED) - Check container logs for API errors: `Failed to sync AgentCore server` ### Cross-account sync fails - Verify the remote IAM role trust policy allows your task role to assume it - Verify the role is tagged with `Purpose: agentcore-federation` - Check that `assume_role_arn` is set correctly on the registry entry ### Assets not cleaned up after registry removal - Older records synced before metadata tracking may only have tag-based matching - Check container logs for `Deregistered X server(s), Y agent(s), Z skill(s)` - Manually remove orphaned assets if needed via the MCP Servers or Agents UI ================================================ FILE: docs/cli.md ================================================ # MCP Client CLI Guide This guide documents how to interact with MCP servers and manage A2A agents using the command-line interface. ## Table of Contents - [Overview](#overview) - [A2A Agent Management](#a2a-agent-management) - [MCP Client Authentication](#mcp-client-authentication) - [Basic Commands](#basic-commands) - [Server Management Commands](#server-management-commands) - [Tool Discovery](#tool-discovery) - [Direct Server Access](#direct-server-access) ## Overview Two CLI tools are available: 1. **`agent_mgmt.py`** - A2A agent management (register, modify, delete, list) 2. **`mcp_client.py`** - MCP server interaction (list tools, call tools, etc.) ## A2A Agent Management For complete A2A agent management documentation, see: [A2A Agent Management Guide](a2a-agent-management.md) Quick start with the `mcp-gateway-m2m` service account: ```bash # Register an agent uv run python cli/agent_mgmt.py register cli/examples/code_reviewer_agent.json # List all agents uv run python cli/agent_mgmt.py list # Test agent uv run python cli/agent_mgmt.py test /code-reviewer ``` ## MCP Client Authentication The client supports two authentication methods: ### 1. M2M (Machine-to-Machine) Authentication with `mcp-gateway-m2m` The primary M2M account `mcp-gateway-m2m` is auto-configured. Set environment variables: ```bash export CLIENT_ID=mcp-gateway-m2m export CLIENT_SECRET= export KEYCLOAK_URL=http://localhost:8080 export KEYCLOAK_REALM=mcp-gateway ``` Or use the auto-generated token from `mcp-gateway-m2m`: ```bash source <(python3 -c "import json; d=json.load(open('.oauth-tokens/ingress.json')); print('TOKEN=' + d['access_token'])") ``` ### 2. Ingress Token Authentication The client will automatically load ingress tokens from `.oauth-tokens/ingress.json` if M2M credentials are not available. This token comes from the `mcp-gateway-m2m` service account. ## Basic Commands ### Test Connectivity (Ping) ```bash # Ping the default gateway uv run cli/mcp_client.py ping # Ping a specific endpoint uv run cli/mcp_client.py --url http://localhost/currenttime/mcp ping ``` ### List Available Tools ```bash # List tools from the default gateway uv run cli/mcp_client.py list # List tools from a specific server uv run cli/mcp_client.py --url http://localhost/currenttime/mcp list ``` ## Server Management Commands ### List All Registered Services ```bash uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool list_services \ --args '{}' ``` Returns a dictionary containing: - `services`: List of service information with details like name, path, status - `total_count`: Total number of registered services ### Register a New Service ```bash uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool register_service \ --args '{ "server_name": "Minimal Server", "path": "/minimal-server", "proxy_pass_url": "http://minimal-server:8000", "description": "A minimal MCP server example", "tags": ["example", "minimal"], "num_tools": 2, "num_stars": 0, "is_python": true, "license": "MIT" }' ``` **Register from a JSON file:** ```bash # Register a service using configuration from a JSON file uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool register_service \ --args "$(cat cli/examples/server-config.json)" ``` **Required parameters:** - `server_name`: Display name for the server - `path`: Unique URL path prefix (must start with '/') - `proxy_pass_url`: Internal URL where the MCP server is running **Optional parameters:** - `description`: Description of the server (default: "") - `tags`: List of tags for categorization (default: null) - `num_tools`: Number of tools provided (default: 0) - `num_stars`: Star rating for the server (default: 0) - `is_python`: Whether implemented in Python (default: false) - `license`: License information (default: "N/A") ### Remove a Service ```bash uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool remove_service \ --args '{"service_path": "/my-service"}' ``` **Example:** ```bash # Remove minimal-server uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool remove_service \ --args '{"service_path": "/minimal-server"}' ``` ### Toggle Service State (Enable/Disable) ```bash uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool toggle_service \ --args '{"service_path": "/my-service"}' ``` ### Health Check Get health status for all registered servers: ```bash uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool healthcheck \ --args '{}' ``` ## Tool Discovery ### Find Tools Using Natural Language Use the intelligent tool finder to discover tools based on natural language queries: ```bash # Find tools for getting current time uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool intelligent_tool_finder \ --args '{"natural_language_query": "get current time in New York", "top_n_tools": 3}' # Find tools by tags only uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool intelligent_tool_finder \ --args '{"tags": ["time", "timezone"], "top_n_tools": 5}' # Combine natural language and tags uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool intelligent_tool_finder \ --args '{ "natural_language_query": "get current time", "tags": ["time"], "top_k_services": 3, "top_n_tools": 5 }' ``` **Parameters:** - `natural_language_query`: Natural language description (optional if tags provided) - `tags`: List of tags to filter by (optional) - `top_k_services`: Number of top services to consider (default: 3) - `top_n_tools`: Number of best tools to return (default: 1) ## Direct Server Access ### Call Tools on Specific Servers #### Current Time Service ```bash # Get current time in a specific timezone uv run cli/mcp_client.py --url http://localhost/currenttime/mcp call \ --tool current_time_by_timezone \ --args '{"tz_name": "America/New_York"}' # Use default timezone (America/New_York) uv run cli/mcp_client.py --url http://localhost/currenttime/mcp call \ --tool current_time_by_timezone \ --args '{}' ``` ## Command Structure ### General Format ```bash uv run cli/mcp_client.py [--url URL] COMMAND [--tool TOOL_NAME] [--args JSON_ARGS] ``` ### Parameters - `--url`: Gateway or server URL (default: `http://localhost/mcpgw/mcp`) - `command`: One of `ping`, `list`, or `call` - `--tool`: Tool name (required for `call` command) - `--args`: Tool arguments as JSON string (for `call` command) ## Examples Summary ### Quick Server Management ```bash # List all services uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call --tool list_services --args '{}' # Register a new service uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool register_service \ --args '{"server_name": "Minimal Server", "path": "/minimal-server", "proxy_pass_url": "http://minimal-server:8000"}' # Remove a service uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool remove_service \ --args '{"service_path": "/minimal-server"}' # Toggle service state uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool toggle_service \ --args '{"service_path": "/minimal-server"}' # Health check all services uv run cli/mcp_client.py --url http://localhost/mcpgw/mcp call --tool healthcheck --args '{}' ``` ### Tool Discovery and Invocation ```bash # Find relevant tools uv run cli/mcp_client.py call --tool intelligent_tool_finder \ --args '{"natural_language_query": "get current time"}' # Call a specific tool directly uv run cli/mcp_client.py --url http://localhost/currenttime/mcp call \ --tool current_time_by_timezone \ --args '{"tz_name": "Europe/London"}' ``` ## Troubleshooting ### Common Issues 1. **HTTP 403: Access forbidden** - Check if your token has the required permissions - Verify the scopes.yml configuration includes the tool you're trying to access 2. **HTTP 405: Method Not Allowed** - Ensure the server path is correct - Verify the server is registered and running 3. **Token Expired** - Refresh your authentication token - For ingress tokens: Run the token refresh script - For M2M: Re-authenticate with credentials 4. **Connection Refused** - Check if the target server is running - Verify the proxy_pass_url in the service registration ## Notes - All service paths must start with '/' - Tool arguments must be valid JSON - The gateway URL defaults to `http://localhost/mcpgw/mcp` - Direct server access bypasses the gateway and connects directly to the service ================================================ FILE: docs/cognito.md ================================================ # Amazon Cognito Setup Guide for MCP Gateway Registry This comprehensive guide covers setting up Amazon Cognito for both user identity and agent identity authentication modes with the MCP Gateway Registry system. ## Table of Contents 1. [Overview](#overview) 2. [Amazon Cognito Setup](#amazon-cognito-setup) 3. [Agent Uses User Identity Mode](#agent-uses-user-identity-mode) 4. [Agent Uses Its Own Identity Mode](#agent-uses-its-own-identity-mode) 5. [Environment Configuration Examples](#environment-configuration-examples) 6. [Testing and Troubleshooting](#testing-and-troubleshooting) ## Overview The MCP Gateway Registry supports two distinct authentication modes: - **Agent Uses User Identity Mode**: Agents act on behalf of users using OAuth 2.0 PKCE flow with session cookies - **Agent Uses Its Own Identity Mode**: Agents have their own identity using Machine-to-Machine (M2M) authentication with JWT tokens Both modes integrate with Amazon Cognito as the Identity Provider (IdP) and use the same scope-based authorization system defined in [`auth_server/scopes.yml`](../auth_server/scopes.yml). ## Amazon Cognito Setup This section covers setting up Amazon Cognito for two distinct authentication modes used by the MCP Gateway Registry system. ### User Group Setup (For Users and Agents Using User Identity) This setup is for users who will authenticate through the web interface and for agents that act on behalf of users using their identity and permissions. #### Step 1: Create User Pool 1. **Navigate to Amazon Cognito Console** - Go to [Amazon Cognito Console](https://console.aws.amazon.com/cognito/) - Select your desired AWS region (e.g., `us-east-1`) - Click the **"Create User pool"** button 2. **Configure Application Type** - Select **"Traditional Web App"** for application type - Name your application **"MCP Gateway"** 3. **Configure Sign-in Options** - Under "Options for sign-in identifiers", select: - **Email** - **Username** - **Phone number** 4. **Set Required Attributes** - Under "Required attributes for sign-up", select: - **Email** (required) 5. **Create User Directory** - Click on **"Create User Directory"** - Once created, click on **"Go to overview"** (typically on the bottom right corner of the page) #### Step 2: Configure App Client for Users 1. **Access App Clients** - Click on **"App Clients"** in the left navigation - Click on **"MCP Gateway"** from the App Client list 2. **Copy Client Credentials** - Copy and paste the **Client ID** and **Client Secret** - Note them separately - you'll need them later for `.env` files for the MCP Gateway and agent. 3. **Configure Login Pages** - Click on **"Login Pages"** and then **"Edit"** 4. **Set Callback URLs** - For the allowed callback URLs, add the following 4 URLs: - `http://localhost:9090/callback` - for creating a session cookie for auth flow where the agent uses a user's identity - `http://localhost/oauth2/callback/cognito` - for testing without an https endpoint and cert - `http://localhost:8888/oauth2/callback/cognito` - for local development and testing with frontend - `https://your_mcp_gateway_domain_name/oauth2/callback/cognito` - for https with SSL cert (replace mcpgateway.ddns.net with your_secure_domain) 5. **Configure OpenID Connect Scopes** - From OpenID Connect Scopes section: - **Email**, **openid**, **phone** would already be there - **Remove** phone - **Add** profile - **Add** aws.cognito.signin.user.admin #### Step 3: Create Users and Groups 1. **Create a User** - Click on **"Users"** in the main menu - Create a new user with the following settings: - Select **email** as identifier - **Don't send invitation** - Provide **username** and **email address** - Mark **email address as verified** (check the checkbox) - Choose a desired **username** and set a **password** 2. **Create Admin Group** - Create a group called **"mcp-registry-admin"** - Leave everything as default 3. **Add User to Group** - Once the group is created, click on the **group name** - Click on **"Add user to group"** - Add the user you created in the previous step to this group ### Machine-to-Machine (M2M) Setup (For Agents Using Their Own Identity) This setup is for agents that have their own identity and authenticate using client credentials flow without user interaction. #### Step 1: Create M2M App Client 1. **Create Machine-to-Machine App Client** - In your user pool, go to "App integration" tab - Click **"Create app client"** - **App type**: Select "Machine to Machine" - **App client name**: Enter `Agent` (or `My AI Assistant` or any name that reflects what the agent will do) - **Client secret**: Select "Generate a client secret" - **Copy and save** the **Client ID** and **Client Secret** - you'll need these for the [`agents/.env.agent`](../agents/.env.agent) file #### Step 2: Create Resource Server and Custom Scopes 1. **Navigate to Domain Settings** - In the sidebar, click on **"Branding"** - Under Branding, click on **"Domain"** 2. **Create Resource Server** - Click **"Create resource server"** - **Name**: `mcp-servers-unrestricted` - **Identifier**: `mcp-servers-unrestricted` (use the same name as identifier) 3. **Add Custom Scopes** - Add two custom scopes: - `read`: "Read access to all MCP servers" - `execute`: "Execute access to all MCP servers" - This group gives your agent access to all MCP servers and tools accessible via the MCP Gateway - See [`auth_server/scopes.yml`](../auth_server/scopes.yml) file for more details on scope configuration #### Step 3: Assign Scopes to Agent App Client 1. **Configure Agent Client Scopes** - Go back to **"App Clients"** - Select your **Agent** app client - Click on **"Login Pages"** → **"Edit"** 2. **Select Custom Scopes** - Under "Custom scopes" section, select: - `mcp-servers-unrestricted/read` - `mcp-servers-unrestricted/execute` - Click **"Save changes"** ## Agent Uses User Identity Mode This mode enables agents to act on behalf of users, using their Cognito identity and group memberships for authorization. ### Configuration Steps #### 1. Cognito User Pool Configuration Ensure your Cognito User Pool is configured with: - **PKCE-enabled app client** (public client without secret) - **Hosted UI enabled** with appropriate callback URLs - **User groups** mapped to MCP scopes via [`scopes.yml`](../auth_server/scopes.yml) #### 2. OAuth 2.0 PKCE Flow Setup The PKCE (Proof Key for Code Exchange) flow is implemented in [`agents/cli_user_auth.py`](../agents/cli_user_auth.py): ```mermaid sequenceDiagram participant User participant CLI as CLI Auth Tool participant Browser participant Cognito as Amazon Cognito participant Agent as MCP Agent User->>CLI: Run cli_user_auth.py CLI->>CLI: Generate PKCE verifier/challenge CLI->>Browser: Open Cognito hosted UI Browser->>Cognito: User login Cognito->>Browser: Authorization code Browser->>CLI: Callback with code CLI->>Cognito: Exchange code for tokens Cognito->>CLI: Access token + user info CLI->>CLI: Create session cookie CLI->>User: Save cookie to ~/.mcp/session_cookie User->>Agent: Run agent with --use-session-cookie Agent->>Agent: Read session cookie Agent->>Gateway: MCP requests with cookie header ``` #### 3. Session Cookie Authentication The session cookie contains: - **Username**: Cognito username - **Groups**: User's Cognito group memberships - **Expiration**: 8-hour validity (configurable) - **Signature**: Signed with `SECRET_KEY` for security #### 4. Required Environment Variables Create `.env.user` file in the `agents/` directory: ```bash # Cognito Configuration COGNITO_USER_POOL_ID=us-east-1_XXXXXXXXX COGNITO_CLIENT_ID=your-public-client-id COGNITO_CLIENT_SECRET=your-client-secret SECRET_KEY=your-secret-key-matching-registry # Optional: Custom domain COGNITO_DOMAIN=your-custom-domain # AWS Region AWS_REGION=us-east-1 # Registry URL (for callback configuration) REGISTRY_URL=http://localhost:7860 ``` #### 5. CLI Authentication Tool Usage Run the CLI authentication tool to obtain a session cookie: ```bash # Navigate to agents directory cd agents/ # Run CLI authentication python cli_user_auth.py # This will: # 1. Open your browser to Cognito hosted UI # 2. After login, capture the authorization code # 3. Exchange code for user information # 4. Create and save session cookie to ~/.mcp/session_cookie ``` #### 6. Agent Usage with Session Cookie ```bash # Use agent with session cookie authentication python agent.py \ --use-session-cookie \ --message "What time is it in Tokyo?" \ --mcp-registry-url http://localhost/mcpgw/sse ``` ## Agent Uses Its Own Identity Mode This mode enables agents to have their own identity using Machine-to-Machine (M2M) authentication. ### Configuration Steps #### 1. Machine-to-Machine Authentication Setup M2M authentication uses the OAuth 2.0 Client Credentials flow: ```mermaid sequenceDiagram participant Agent as MCP Agent participant Cognito as Amazon Cognito participant Gateway as MCP Gateway Agent->>Cognito: Client credentials request Note over Agent,Cognito: client_id + client_secret + scopes Cognito->>Agent: JWT access token Agent->>Gateway: MCP requests with JWT token Gateway->>Cognito: Validate JWT token Cognito->>Gateway: Token valid + scopes Gateway->>Agent: MCP response ``` #### 2. Client Credentials Flow Configuration The M2M flow is implemented in [`auth_server/cognito_utils.py`](../auth_server/cognito_utils.py): 1. **Token Request**: Agent requests token using client credentials 2. **JWT Token**: Cognito issues JWT token with embedded scopes 3. **Token Validation**: Auth server validates JWT signature and claims 4. **Scope Enforcement**: Access granted based on token scopes #### 3. JWT Token Handling JWT tokens contain: - **Issuer**: Cognito User Pool issuer URL - **Client ID**: M2M app client identifier - **Scopes**: Granted scopes for MCP server access - **Expiration**: Token validity period (typically 1 hour) #### 4. Required Environment Variables Create `.env.agent` file in the `agents/` directory: ```bash # Cognito M2M Configuration COGNITO_CLIENT_ID=your-confidential-client-id COGNITO_CLIENT_SECRET=your-client-secret COGNITO_USER_POOL_ID=us-east-1_XXXXXXXXX # AWS Region AWS_REGION=us-east-1 # MCP Registry URL MCP_REGISTRY_URL=http://localhost/mcpgw/sse ``` #### 5. Agent Usage with M2M Authentication ```bash # Use agent with M2M authentication (default mode) python agent.py \ --message "What time is it in Tokyo?" \ --mcp-registry-url http://localhost/mcpgw/sse ``` ### Common Configuration Pitfalls and Solutions #### 1. Callback URL Mismatch **Problem**: `redirect_uri_mismatch` error during OAuth flow **Solution**: Ensure all 4 callback URLs are present in your Cognito configuration: - `http://localhost:9090/callback` - for creating a session cookie for auth flow where the agent uses a user's identity - `http://localhost/oauth2/callback/cognito` - for testing without an https endpoint and cert - `http://localhost:8888/oauth2/callback/cognito` - for local development and testing with frontend - `https://mcpgateway.ddns.net/oauth2/callback/cognito` - for https with SSL cert (replace mcpgateway.ddns.net with your_secure_domain) #### 2. Secret Key Mismatch **Problem**: Session cookie validation fails **Solution**: Ensure `SECRET_KEY` in `.env.user` matches the registry's `SECRET_KEY` in `.env` in the project root directory: ```bash # Generate a new secret key python -c 'import secrets; print(secrets.token_hex(32))' # Use the same key in both .env and registry configuration ``` #### 3. Scope Configuration Issues **Problem**: Access denied errors despite valid authentication **Solution**: Verify scope mappings in [`scopes.yml`](../auth_server/scopes.yml): - Check group mappings match Cognito groups - Ensure server/tool permissions are correctly defined - Verify M2M client has required custom scopes #### 4. JWT Token Validation Errors **Problem**: M2M authentication fails with token validation errors **Solution**: Check the following: - Client ID and secret are correct - User Pool ID format is correct (e.g., `us-east-1_ABC123DEF`) - AWS region matches User Pool region - Custom scopes are properly configured in resource server ## Testing and Troubleshooting ### How to Verify Cognito Configuration #### 1. Test User Authentication Flow ```bash # Test CLI authentication cd agents/ python cli_user_auth.py # Expected output: # - Browser opens to Cognito hosted UI # - After login, callback succeeds # - Session cookie saved to ~/.mcp/session_cookie ``` #### 2. Test M2M Authentication Flow ```bash # Test M2M token generation cd auth_server/ python -c " from cognito_utils import generate_token import os from dotenv import load_dotenv load_dotenv('../agents/.env.agent') token = generate_token( os.environ['COGNITO_CLIENT_ID'], os.environ['COGNITO_CLIENT_SECRET'], os.environ['COGNITO_USER_POOL_ID'], os.environ['AWS_REGION'] ) print('Token generated successfully:', token[:50] + '...') " ``` #### 3. Test Agent Authentication ```bash # Test user identity mode python agent.py --use-session-cookie --message "test message" # Test agent identity mode python agent.py --message "test message" ``` ### Common Authentication Errors and Solutions #### Error: `Invalid redirect URI` **Cause**: Callback URL not registered in Cognito app client **Solution**: 1. Go to Cognito console → App integration → App clients 2. Edit your app client 3. Add the correct callback URL to "Allowed callback URLs" #### Error: `Session cookie has expired` **Cause**: Session cookie is older than 8 hours **Solution**: ```bash # Re-authenticate to get fresh session cookie python cli_user_auth.py ``` #### Error: `Access denied for server/tool` **Cause**: User/agent lacks required scopes for the requested resource **Solution**: 1. Check user's group membership in Cognito 2. Verify group mappings in [`scopes.yml`](../auth_server/scopes.yml) 3. For M2M, check client's assigned scopes in Cognito #### Error: `JWT token validation failed` **Cause**: Token signature validation or claims validation failed **Solution**: 1. Verify client credentials are correct 2. Check User Pool ID format and region 3. Ensure token hasn't expired 4. Verify JWKS endpoint is accessible ### Testing Both Authentication Modes #### User Identity Mode Test ```bash # 1. Authenticate user python cli_user_auth.py # 2. Test with session cookie python agent.py \ --use-session-cookie \ --message "What MCP servers are available?" \ --mcp-registry-url http://localhost/mcpgw/sse # Expected: Agent uses user's permissions based on Cognito groups ``` #### Agent Identity Mode Test ```bash # Test with M2M authentication cd agents python agent.py \ --message "What MCP tools are available?" \ --mcp-registry-url http://localhost/mcpgw/sse # Expected: Agent uses its own permissions based on assigned scopes ``` ### Debugging Authentication Flows #### Enable Debug Logging ```bash # Run agent with debug logging python agent.py --message "test" --mcp-registry-url http://localhost/mcpgw/sse ``` #### Check Auth Server Logs ```bash # View auth server logs for validation details docker-compose logs -f auth-server # Look for: # - Token validation attempts # - Scope mapping results # - Access control decisions ``` #### Verify Scope Mappings ```bash # Test scope mapping logic cd auth_server/ python -c " import yaml from server import map_cognito_groups_to_scopes # Load scopes config with open('scopes.yml', 'r') as f: config = yaml.safe_load(f) # Test group mapping groups = ['mcp-registry-user'] scopes = map_cognito_groups_to_scopes(groups) print(f'Groups {groups} mapped to scopes: {scopes}') " ``` ## Related Documentation - [Main Authentication Guide](auth.md) - Overview of the authentication architecture - [Scopes Configuration](../auth_server/scopes.yml) - Detailed scope and permission definitions - [Environment Template](../.env.template) - Complete environment configuration template - [Agent Implementation](../agents/agent.py) - Reference agent implementation - [CLI Authentication Tool](../agents/cli_user_auth.py) - User authentication utility ## Support and Troubleshooting For additional support: 1. **Check Logs**: Review auth server and agent logs for detailed error messages 2. **Verify Configuration**: Ensure all environment variables are correctly set 3. **Test Components**: Use the testing procedures above to isolate issues 4. **Review Scopes**: Verify scope mappings match your intended access control This guide provides comprehensive coverage of Amazon Cognito setup for both authentication modes. Follow the step-by-step instructions and use the troubleshooting section to resolve common issues. ## Saving Client Credentials to Agent Environment Files After completing the Cognito setup and obtaining your client ID and secret, you need to configure the agent environment files to use these credentials. ### Step 1: Copy Template to Environment File Navigate to the `agents/` directory and copy the template file: ```bash cd agents/ cp .env.template .env.user ``` ### Step 2: Configure Client Credentials Edit the [`agents/.env.user`](../agents/.env.user) file with your Cognito credentials obtained from the [User Group Setup](#user-group-setup-for-users-and-agents-using-user-identity) section: ```bash # Cognito Authentication Configuration # Copy this file to .env and fill in your actual values # Cognito App Client ID (from Step 2 of User Group Setup) COGNITO_CLIENT_ID=your_actual_cognito_client_id_here # Cognito App Client Secret (from Step 2 of User Group Setup) COGNITO_CLIENT_SECRET=your_actual_cognito_client_secret_here # Cognito User Pool ID (from Step 1 of User Group Setup) COGNITO_USER_POOL_ID=your_actual_cognito_user_pool_id_here # AWS Region for Cognito AWS_REGION=us-east-1 # Cognito Domain (without https:// prefix, just the domain name) # Example: mcp-gateway or your-custom-domain # COGNITO_DOMAIN= # Secret key for session cookie signing (must match registry SECRET_KEY), string of hex characters # To generate: python -c 'import secrets; print(secrets.token_hex(32))' SECRET_KEY=your-secret-key-here # Either http://localhost:8000 or the HTTPS URL of your deployed MCP Gateway REGISTRY_URL=your_registry_url_here ``` ### Step 3: Replace Placeholder Values Replace the following placeholder values with your actual Cognito configuration: 1. **COGNITO_CLIENT_ID**: The Client ID copied from Step 2 of the [User Group Setup](#step-2-configure-app-client-for-users) 2. **COGNITO_CLIENT_SECRET**: The Client Secret copied from Step 2 of the [User Group Setup](#step-2-configure-app-client-for-users) 3. **COGNITO_USER_POOL_ID**: Your User Pool ID from Step 1 of the [User Group Setup](#step-1-create-user-pool) 4. **AWS_REGION**: The AWS region where your Cognito User Pool is located (e.g., `us-east-1`) 5. **SECRET_KEY**: Generate a secure secret key using: `python -c 'import secrets; print(secrets.token_hex(32))'` 6. **REGISTRY_URL**: Your MCP Gateway URL (e.g., `http://localhost:7860` for local development) ### Step 4: Verify Configuration After saving the file, verify your configuration by testing the authentication flow: ```bash # Test user authentication python cli_user_auth.py # Test agent with session cookie python agent.py --use-session-cookie --message "test authentication" ``` ### Important Notes - **Security**: Keep your `.env.user` file secure and never commit it to version control - **Secret Key Matching**: Ensure the `SECRET_KEY` in `agents/.env.user` matches the `SECRET_KEY` in your main registry `.env` file - **Multiple Agents**: If you have multiple agent instances, each can use the same `.env.user` file or have separate configuration files - **Environment Separation**: Use different `.env.user` files for different environments (development, staging, production) This completes the client credential configuration for your MCP Gateway agents using Amazon Cognito authentication. ================================================ FILE: docs/complete-setup-guide.md ================================================ # Complete Setup Guide: MCP Gateway & Registry from Scratch This guide provides a comprehensive, step-by-step walkthrough for setting up the MCP Gateway & Registry on a fresh AWS EC2 instance. Perfect for first-time users who want to get the system running from zero. > **SECURITY WARNING** > > The examples in this document use placeholder credentials for demonstration purposes only. > **NEVER use these example values in production.** > > Always generate unique, secure credentials and store them in: > - AWS Secrets Manager (production) > - Environment variables (development) > - `.env` files (local only, never commit) ## Table of Contents 1. [AWS EC2 Instance Setup](#1-aws-ec2-instance-setup) 2. [Initial System Configuration](#2-initial-system-configuration) 3. [Installing Prerequisites](#3-installing-prerequisites) 4. [Cloning and Configuring the Project](#4-cloning-and-configuring-the-project) 5. [Setting Up Keycloak Identity Provider](#5-setting-up-keycloak-identity-provider) 6. [Starting the MCP Gateway Services](#6-starting-the-mcp-gateway-services) 7. [Storage Backend Setup](#7-storage-backend-setup-optional) - [MongoDB CE Setup (Recommended)](#mongodb-ce-setup-recommended-for-local-development) 8. [Verification and Testing](#8-verification-and-testing) 9. [Configuring AI Agents and Coding Assistants](#9-configuring-ai-agents-and-coding-assistants) 10. [Troubleshooting](#10-troubleshooting) 11. [Next Steps](#11-next-steps) --- ## 1. AWS EC2 Instance Setup ### Launch EC2 Instance 1. **Log into AWS Console** and navigate to EC2 2. **Click "Launch Instance"** and configure: - **Name**: `mcp-gateway-server` - **AMI**: Ubuntu Server 24.04 LTS (or latest Ubuntu LTS) - **Instance Type**: `t3.2xlarge` (8 vCPU, 32GB RAM) - **Key Pair**: Create new or select existing SSH key - **Storage**: 100GB gp3 SSD 3. **Network Settings**: - VPC: Default or your custom VPC - Subnet: Public subnet with auto-assign public IP - **Security Group**: Create new with following rules: ``` Inbound Rules: - SSH (22): Your IP address - HTTP (80): 0.0.0.0/0 (or restrict as needed) - HTTPS (443): 0.0.0.0/0 (or restrict as needed) - Custom TCP (7860): 0.0.0.0/0 (Registry UI) - Custom TCP (8080): 0.0.0.0/0 (Keycloak Admin) - Custom TCP (8000): 0.0.0.0/0 (Auth Server) ``` 4. **Launch the instance** and wait for it to be running ### Connect to Your Instance ```bash # From your local terminal ssh -i your-key.pem ubuntu@your-instance-public-ip # Example: ssh -i ~/.ssh/mcp-gateway-key.pem ubuntu@ec2-54-123-456-789.compute-1.amazonaws.com ``` --- ## 2. Initial System Configuration Once connected to your EC2 instance: ```bash # Update system packages sudo apt-get update && sudo apt-get upgrade -y # Set timezone (optional but recommended) sudo timedatectl set-timezone America/New_York # Change to your timezone # Create a working directory mkdir -p ~/workspace cd ~/workspace ``` --- ## 3. Installing Prerequisites ### Install Docker and Docker Compose ```bash # Install Docker sudo apt-get install -y apt-transport-https ca-certificates curl software-properties-common # Add Docker's official GPG key curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg # Add Docker repository (for Ubuntu 24.04 Noble and later) echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list # Update package list sudo apt-get update # Install Docker Engine and CLI sudo apt-get install -y docker-ce docker-ce-cli containerd.io # Add user to docker group sudo usermod -aG docker $USER # Apply the group change immediately for current shell newgrp docker # Verify Docker works without sudo docker --version # Expected output: Docker version 27.x.x or higher # Test Docker permissions (MUST work without sudo) docker run hello-world # Should show "Hello from Docker!" message # Install Docker Compose V2 Plugin (REQUIRED) sudo apt-get install -y docker-compose-plugin # Verify Docker Compose V2 installation docker compose version # Expected output: Docker Compose version v2.x.x or higher # Note: The build_and_run.sh script requires Docker Compose V2 (docker compose) # Do NOT use the old standalone docker-compose v1 ``` ### Install Node.js and npm ```bash # Install Node.js 20.x (LTS) curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejs # Verify installations node --version # Should show v20.x.x npm --version # Should show 10.x.x ``` ### Install Python and UV (Python Package Manager) ```bash # Install Python 3.14 sudo apt-get install -y python3.14 python3.14-venv python3-pip # Install UV package manager curl -LsSf https://astral.sh/uv/install.sh | sh # Add UV to PATH echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc source ~/.bashrc # Verify UV installation uv --version # Expected output: uv 0.x.x ``` ### Install Additional Tools ```bash # Install Git (should already be installed, but just in case) sudo apt-get install -y git # Install jq for JSON processing sudo apt-get install -y jq # Install curl and wget sudo apt-get install -y curl wget # Install net-tools for network debugging sudo apt-get install -y net-tools ``` --- ## 4. Cloning and Configuring the Project ### Clone the Repository ```bash cd ~/workspace git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry # Verify you're in the right directory ls -la # You should see files like docker-compose.yml, .env.example, README.md, etc. ``` ### Setup Python Virtual Environment ```bash # Create and activate Python virtual environment uv sync source .venv/bin/activate # Verify the virtual environment is active which python # Should show: /home/ubuntu/workspace/mcp-gateway-registry/.venv/bin/python ``` ### Initial Environment Configuration ```bash # Copy the example environment file cp .env.example .env # Generate a secure SECRET_KEY and set it in the .env file SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(64))") # Replace SECRET_KEY whether it's commented (#) or not sed -i "s/^#*\s*SECRET_KEY=.*/SECRET_KEY=$SECRET_KEY/" .env # Verify the SECRET_KEY was set correctly echo "Generated SECRET_KEY: $SECRET_KEY" # Open the file for editing nano .env ``` The SECRET_KEY has been automatically generated and added to your `.env` file. This key is essential for session security between the auth-server and registry services. For now, make these additional essential changes in the `.env` file: ```bash # Set authentication provider to Keycloak AUTH_PROVIDER=keycloak #Do not change # Set a secure admin password (change this!) # This is used for Keycloak API authentication during setup KEYCLOAK_ADMIN_PASSWORD=YourSecureAdminPassword123! # change me # CRITICAL: Set INITIAL_ADMIN_PASSWORD to the SAME VALUE as KEYCLOAK_ADMIN_PASSWORD # This is used to set the password for the initial admin user in the realm # THESE MUST MATCH - see Step 5 for details INITIAL_ADMIN_PASSWORD=YourSecureAdminPassword123! # change me # Set Keycloak database password (change this!) KEYCLOAK_DB_PASSWORD=SecureKeycloakDB123! # change me # Leave other Keycloak settings as default for now KEYCLOAK_URL=http://localhost:8080 KEYCLOAK_REALM=mcp-gateway KEYCLOAK_CLIENT_ID=mcp-gateway-client # Session Cookie Security Configuration # CRITICAL: These settings must match your deployment environment # For LOCAL DEVELOPMENT (accessing via http://localhost): SESSION_COOKIE_SECURE=false # MUST be false for HTTP access # For PRODUCTION with HTTPS (accessing via https://your-domain.com): # SESSION_COOKIE_SECURE=true # Uncomment and set to true # Cookie domain (leave empty for most deployments) SESSION_COOKIE_DOMAIN= # Empty = cookie scoped to exact host only # Save and exit (Ctrl+X, then Y, then Enter) ``` **Important**: - Remember the passwords you set here - you'll need to use the same ones in Step 5! - **CRITICAL**: `KEYCLOAK_ADMIN_PASSWORD` and `INITIAL_ADMIN_PASSWORD` MUST be set to the same value. See Step 5 for details about why this is important. - **SESSION_COOKIE_SECURE**: For local development (HTTP), this MUST be `false`. Setting it to `true` will cause login to fail because cookies with `secure=true` are only sent over HTTPS connections. - For production deployments with HTTPS, change `SESSION_COOKIE_SECURE=true` before starting services. ### Download Required Embeddings Model The MCP Gateway requires a sentence-transformers model for intelligent tool discovery. Download it to the shared models directory: ```bash # Download the embeddings model (this may take a few minutes) hf download sentence-transformers/all-MiniLM-L6-v2 --local-dir ${HOME}/mcp-gateway/models/all-MiniLM-L6-v2 # Verify the model was downloaded ls -la ${HOME}/mcp-gateway/models/all-MiniLM-L6-v2/ # You should see model files like model.safetensors, config.json, etc. ``` **Note**: This command automatically creates the necessary directory structure and downloads all required model files (~90MB). --- ## 5. Setting Up Keycloak Identity Provider Keycloak provides authentication with support for both human users and AI agents. ### Set Keycloak Passwords **Important**: These environment variables will override the values in your `.env` file. Use the SAME passwords you configured in Step 4! ```bash # Use the SAME passwords you set in the .env file in Step 4! # Replace these with your actual passwords from Step 4 export KEYCLOAK_ADMIN_PASSWORD="YourSecureAdminPassword123!" export KEYCLOAK_DB_PASSWORD="SecureKeycloakDB123!" # Verify they're set correctly echo "Admin Password: $KEYCLOAK_ADMIN_PASSWORD" echo "DB Password: $KEYCLOAK_DB_PASSWORD" ``` **Critical**: These passwords MUST match what you set in the `.env` file in Step 4. If they don't match, Keycloak initialization will fail! ### Important: Admin Password Configuration When you set up Keycloak, you need to configure TWO admin password variables in your `.env` file: 1. **`KEYCLOAK_ADMIN_PASSWORD`** - Used to authenticate with the Keycloak admin API during initialization 2. **`INITIAL_ADMIN_PASSWORD`** - Used to set the password for the initial admin user created in the mcp-gateway realm **These MUST be set to the SAME VALUE** for proper Keycloak initialization: ```bash # In your .env file (Step 4), set these to the SAME password: KEYCLOAK_ADMIN_PASSWORD=YourSecureAdminPassword123! INITIAL_ADMIN_PASSWORD=YourSecureAdminPassword123! # MUST match KEYCLOAK_ADMIN_PASSWORD ``` If these passwords don't match: - The Keycloak admin user will be created with `INITIAL_ADMIN_PASSWORD` - But API authentication during setup uses `KEYCLOAK_ADMIN_PASSWORD` - This mismatch will cause authentication failures during realm initialization **Best Practice**: Use the same secure password for both variables during setup. ### Start Keycloak and PostgreSQL First, ensure Docker is installed by following the [Installing Prerequisites](#3-installing-prerequisites) section. **Fresh Install Recommended**: If you've previously run the stack with different credentials, you should remove the old database volume to avoid password mismatch errors: ```bash # Remove any existing keycloak database volume (skip if this is a fresh install) docker compose down keycloak keycloak-db docker volume rm mcp-gateway-registry_keycloak_db_data 2>/dev/null || true ``` ```bash # Start only the database and Keycloak services first docker compose up -d keycloak-db keycloak # Check if services are starting docker compose ps # Monitor logs to see when Keycloak is ready docker compose logs -f keycloak # Wait for message: "Keycloak 25.x.x started in xxxms" # Press Ctrl+C to exit logs when you see this message ``` **Important**: Wait at least 2-3 minutes for Keycloak to fully initialize before proceeding. **Note about Health Status**: The Keycloak container may show as "unhealthy" in `docker ps` output when running in development mode. This is normal and won't affect functionality. You can verify Keycloak is working by running: ```bash curl http://localhost:8080/realms/master # Should return JSON with realm information ``` ### Disable SSL Requirement for Master Realm ```bash # Note: KEYCLOAK_ADMIN defaults to "admin" - ensure KEYCLOAK_ADMIN_PASSWORD is set export KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}" ADMIN_TOKEN=$(curl -s -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=${KEYCLOAK_ADMIN}" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli" | \ jq -r '.access_token') && \ curl -X PUT "http://localhost:8080/admin/realms/master" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"sslRequired": "none"}' ``` ### Initialize Keycloak Configuration **Important**: This is a two-step process. The initialization script creates the realm and clients but does NOT save the credentials to files. ```bash # Make the setup script executable chmod +x keycloak/setup/init-keycloak.sh # Step 1: Run the Keycloak initialization ./keycloak/setup/init-keycloak.sh # Expected output: # ✓ Waiting for Keycloak to be ready... # ✓ Keycloak is ready! # ✓ Logged in to Keycloak # ✓ Created realm: mcp-gateway # ✓ Created clients: mcp-gateway-web and mcp-gateway-m2m # ... more success messages ... # ✓ Client secrets generated! # # IMPORTANT: The script will tell you to run get-all-client-credentials.sh # to retrieve and save the credentials. This is the next required step! # Step 2: Disable SSL for Application Realm # Note: KEYCLOAK_ADMIN defaults to "admin" - ensure KEYCLOAK_ADMIN_PASSWORD is set export KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}" ADMIN_TOKEN=$(curl -s -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=${KEYCLOAK_ADMIN}" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli" | \ jq -r '.access_token') && \ curl -X PUT "http://localhost:8080/admin/realms/mcp-gateway" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"sslRequired": "none"}' # Step 3: Retrieve and save all client credentials (REQUIRED) chmod +x keycloak/setup/get-all-client-credentials.sh ./keycloak/setup/get-all-client-credentials.sh # This will: # - Connect to Keycloak and retrieve all client secrets # - Save credentials to .oauth-tokens/keycloak-client-secrets.txt # - Create individual JSON files: .oauth-tokens/.json # - Create individual env files: .oauth-tokens/.env # - Display a summary of all saved credentials # Expected output: # ✓ Admin token obtained # ✓ Found and saved: mcp-gateway-web # ✓ Found and saved: mcp-gateway-m2m # Files created in: .oauth-tokens/ ``` ### Set Up Users and Service Accounts After initializing Keycloak, run the bootstrap script to create default users and M2M service accounts for testing and management: ```bash # Make the bootstrap script executable chmod +x ./cli/bootstrap_user_and_m2m_setup.sh # Run the bootstrap script ./cli/bootstrap_user_and_m2m_setup.sh ``` This script creates: - **3 Keycloak groups**: `registry-users-lob1`, `registry-users-lob2`, `registry-admins` - **6 users for different roles**: - **LOB1 users**: `lob1-bot` (M2M service account) and `lob1-user` (human user) - **LOB2 users**: `lob2-bot` (M2M service account) and `lob2-user` (human user) - **Admin users**: `admin-bot` (M2M service account) and `admin-user` (human user) All credentials are automatically generated and saved to the `.oauth-tokens/` directory. User passwords default to the `INITIAL_USER_PASSWORD` value from your `.env` file. **Next steps**: - Review the generated credentials in `.oauth-tokens/` - Configure appropriate access scopes in your `scopes.yml` file - Use these credentials for testing M2M client flows and human user authentication - Log in to the dashboard with human user accounts to verify access ### Create Your First AI Agent Account ```bash # Make the agent setup script executable chmod +x keycloak/setup/setup-agent-service-account.sh # Create a test agent with full access ./keycloak/setup/setup-agent-service-account.sh \ --agent-id test-agent \ --group mcp-servers-unrestricted # Create an agent for AI coding assistants (VS Code, cursor, etc.) ./keycloak/setup/setup-agent-service-account.sh \ --agent-id ai-coding-assistant \ --group mcp-servers-unrestricted # Create an agent with restricted access for registry operations ./keycloak/setup/setup-agent-service-account.sh \ --agent-id registry-operator \ --group mcp-servers-restricted # Note: The script does not display the credentials at the end. # Your Client ID is: agent-test-agent-m2m # Retrieve and save ALL client credentials (recommended): ./keycloak/setup/get-all-client-credentials.sh # This will: # - Retrieve credentials for ALL clients in the realm # - Save all credentials to .oauth-tokens/keycloak-client-secrets.txt # - Create individual JSON files: .oauth-tokens/.json # - Create individual env files: .oauth-tokens/.env # - Display a summary of all credentials saved # Or to get just one specific client: ./keycloak/setup/get-agent-credentials.sh agent-test-agent-m2m ``` **Important**: Save the Client ID and Client Secret shown in the output. You'll need these to authenticate your AI agents. ### Update .env File with Client Secrets **Critical Step**: After running `get-all-client-credentials.sh`, you MUST update your `.env` file with the retrieved client secrets: ```bash # View the retrieved client secrets cat .oauth-tokens/keycloak-client-secrets.txt # You'll see output like: # KEYCLOAK_CLIENT_ID=mcp-gateway-web # KEYCLOAK_CLIENT_SECRET=JyJzW00JeUBaCmH9Z5xtYDhE2MsGqOSv # # KEYCLOAK_M2M_CLIENT_ID=mcp-gateway-m2m # KEYCLOAK_M2M_CLIENT_SECRET=iCjPsMLLmet124K8b7FCfcEcRJ9bx4Oo # Update your .env file with these exact secret values nano .env # Find and update these lines with the actual secret values from above: # KEYCLOAK_CLIENT_SECRET=JyJzW00JeUBaCmH9Z5xtYDhE2MsGqOSv # KEYCLOAK_M2M_CLIENT_SECRET=iCjPsMLLmet124K8b7FCfcEcRJ9bx4Oo # Save and exit (Ctrl+X, then Y, then Enter) ``` **Note**: These secrets are auto-generated by Keycloak and are different each time you run `init-keycloak.sh`. Always use the latest values from `.oauth-tokens/keycloak-client-secrets.txt`. ### Generate Access Tokens for All Keycloak Users and Agents Generate access tokens for all configured agents and users: ```bash # Generate access tokens for all agents ./credentials-provider/keycloak/get_m2m_token.py --all-agents ``` This will create access token files (both `.json` and `.env` formats) for all Keycloak service accounts in the `.oauth-tokens/` directory. **Note**: If you want tokens to last longer than the default 5 minutes, see [Configure Token Lifetime](#configure-token-lifetime) before generating tokens. ### Verify Keycloak is Running Open a web browser and navigate to: ``` http://localhost:8080 ``` You should see the Keycloak login page. You can log in with: - Username: `admin` - Password: The `KEYCLOAK_ADMIN_PASSWORD` you set earlier --- ## 6. Starting the MCP Gateway Services ### Build and Start All Services **Important**: After starting services, you MUST complete [Section 7: Storage Backend Setup](#7-storage-backend-setup-optional) before using JWT token generation from the UI. The MongoDB initialization loads required scopes that enable JWT token creation. ```bash # Return to project directory cd ~/workspace/mcp-gateway-registry # Activate the virtual environment if not already active source .venv/bin/activate # Make the build script executable chmod +x build_and_run.sh # Build frontend and start all services using the build script ./build_and_run.sh # This script will: # - Check for Node.js and npm installation # - Build the React frontend in the frontend/ directory # - Create necessary local directories # - Build Docker images # - Start all services with docker-compose # After the script completes, check all services are running docker-compose ps # Expected output should show all services as "Up": # - keycloak-db # - keycloak # - auth-server # - registry # - nginx # - Various MCP servers (mcp-weather, mcp-time, etc.) ``` ### Monitor Service Logs ```bash # View all logs docker-compose logs -f # Or view specific service logs docker-compose logs -f auth-server docker-compose logs -f registry docker-compose logs -f nginx # Press Ctrl+C to exit log viewing ``` ### Wait for Services to Initialize ```bash # Check if registry is ready curl http://localhost:7860/health # Expected output: # {"status":"healthy","timestamp":"..."} ``` --- ## 7. Storage Backend Setup The MCP Gateway Registry supports multiple storage backends for production and development use. **DEPRECATION WARNING**: The file-based storage backend is deprecated and will be removed in a future release. MongoDB CE is now the recommended approach for local development. **Storage Backend Options:** - **MongoDB CE**: Recommended for local development (see below) - **DocumentDB**: Used automatically in production (AWS ECS/EKS deployments) - **File-based**: Deprecated - will be removed in future releases ### MongoDB CE Setup (Recommended for Local Development) **Note**: This section is for local Docker Compose installations using MongoDB Community Edition 8.2. For AWS ECS deployments, DocumentDB is used and initialized automatically. MongoDB CE provides a production-like environment for local development with replica set support and application-level vector search capabilities. **Why use MongoDB CE (Recommended):** - Production-like environment for local development - Testing production workflows locally - Multi-instance development environments - Feature development requiring database operations - Compatibility with DocumentDB for seamless cloud migration **Setup MongoDB CE:** ```bash # 1. Set storage backend in .env echo "STORAGE_BACKEND=mongodb-ce" >> .env echo "DOCUMENTDB_HOST=mongodb" >> .env echo "DOCUMENTDB_PORT=27017" >> .env echo "DOCUMENTDB_DATABASE=mcp_registry" >> .env echo "DOCUMENTDB_NAMESPACE=default" >> .env echo "DOCUMENTDB_USE_TLS=false" >> .env # 2. Start MongoDB container docker compose up -d mongodb # 3. Wait for MongoDB to be ready (about 30 seconds for replica set initialization) sleep 30 # 4. Initialize collections and indexes docker compose up mongodb-init # 5. Verify MongoDB setup docker exec mcp-mongodb mongosh --eval "use mcp_registry; show collections" # Expected output should show: # - mcp_servers_default # - mcp_agents_default # - mcp_scopes_default # - mcp_embeddings_1536_default # - mcp_security_scans_default # - mcp_federation_config_default # 6. Restart auth-server and registry to load scopes and use MongoDB backend docker compose restart auth-server registry ``` **Important**: The auth-server must be restarted after mongodb-init to load the JWT token scopes from MongoDB. Without this step, JWT token generation from the UI will fail with "no scopes configured" error. **MongoDB CE Features:** - Replica set configuration for production-like testing - Automatic collection and index management - Application-level vector search for semantic queries - Multi-namespace support for tenant isolation - Compatible with DocumentDB API for seamless cloud migration For detailed MongoDB CE architecture and configuration options, see [Storage Architecture Documentation](design/storage-architecture-mongodb-documentdb.md). --- ## 8. Verification and Testing ### Test the Registry Web Interface 1. Open your web browser and navigate to: ```bash # On macOS: open http://localhost:7860 # On Linux (install xdg-utils if the xdg-open command is not available): # sudo apt install xdg-utils xdg-open http://localhost:7860 # Or simply open http://localhost:7860 in your browser ``` 2. You should see the MCP Gateway Registry login page 3. Click "Login with Keycloak" and use these test credentials: - Username: `admin` - Password: The `KEYCLOAK_ADMIN_PASSWORD` you set ### Test with Python MCP Client ```bash # Navigate to project root directory cd ~/workspace/mcp-gateway-registry # Activate the virtual environment if not already active source .venv/bin/activate # Source the agent credentials from the saved file source .oauth-tokens/agent-test-agent-m2m.env # Option 2: Or manually set the environment variables # export CLIENT_ID="agent-test-agent-m2m" # export CLIENT_SECRET="" # export KEYCLOAK_URL="http://localhost:8080" # export KEYCLOAK_REALM="mcp-gateway" # Test basic connectivity uv run python cli/mcp_client.py ping # Expected output: # ✓ M2M authentication successful # Session established: 277bf44c7d474d9b9674e7cc8a5122c8 # { # "jsonrpc": "2.0", # "id": 2, # "result": {} # } # List available tools uv run python cli/mcp_client.py list # Expected: List of available MCP tools # Test calling a simple tool to get current time # Note: current_time_by_timezone is on the 'currenttime' server, not 'mcpgw' uv run python cli/mcp_client.py --url http://localhost/currenttime/mcp call --tool current_time_by_timezone --args '{"tz_name":"America/New_York"}' # Expected: Current time in JSON format # Alternative: Use intelligent_tool_finder on mcpgw to find and call tools dynamically uv run python cli/mcp_client.py call --tool intelligent_tool_finder --args '{"natural_language_query":"get current time in New York"}' # This will automatically find and route to the correct server ``` ### Refreshing Credentials If your access tokens have expired or you need to regenerate credentials, you can use the credential generation script: ```bash # Navigate to project root directory cd ~/workspace/mcp-gateway-registry # Regenerate all credentials ./credentials-provider/generate_creds.sh ``` **Note**: You may see errors related to "egress token" during credential generation. These errors can be safely ignored as they refer to external identity providers (IdPs) that are not yet configured. The local Keycloak credentials will be generated successfully. ### Test Intelligent Agent Demo ```bash # Use the intelligent tool finder to discover tools with natural language uv run python cli/mcp_client.py call --tool intelligent_tool_finder --args '{"natural_language_query":"What is the current time?"}' # Expected: Tool discovery results with time-related tools # You can also run a full agent with the comprehensive agent script # Note: Use --mcp-registry-url to point to your local gateway uv run python agents/agent.py --agent-name agent-test-agent-m2m --mcp-registry-url http://localhost/mcpgw/mcp --prompt "What's the current time in New York?" # Expected: Natural language response with current time ``` --- ### Accessing the Web UI Before configuring AI agents, you'll want to access the MCP Gateway web interface to verify everything is working and test the Keycloak login flow.
Remote Access Options (click to expand) The method to access the web UI depends on where you're running the MCP Gateway: #### Option A: Local Machine (Linux/macOS) If you're running on your local machine, simply open a browser and navigate to: - **Registry UI**: http://localhost:7860 - **Keycloak Admin**: http://localhost:8080 No additional setup required - you're already on localhost. #### Option B: AWS EC2 with Port Forwarding If you're running on EC2 and want to access from your local machine via SSH port forwarding: ```bash # From your local machine, create SSH tunnels ssh -i your-key.pem -L 7860:localhost:7860 -L 8080:localhost:8080 -L 8888:localhost:8888 -L 80:localhost:80 ubuntu@your-ec2-ip # Then access in your local browser: # - Registry UI: http://localhost:7860 # - Keycloak Admin: http://localhost:8080 ``` #### Option C: AWS EC2 with Remote Desktop (GUI Access) If you prefer a full desktop environment on your EC2 instance: ```bash # Update system sudo apt update && sudo apt upgrade -y # Install XFCE desktop environment (lightweight) sudo apt install -y xfce4 xfce4-goodies # Install XRDP server sudo apt install -y xrdp # Configure XRDP to use XFCE echo "xfce4-session" > ~/.xsession # Start and enable XRDP service sudo systemctl enable xrdp sudo systemctl start xrdp # Set password for ubuntu user sudo passwd ubuntu # Install Firefox browser for testing sudo apt install -y firefox ``` **AWS Security Group**: Add inbound rule for port 3389 (RDP) from your IP. **Connect from Windows**: Use Remote Desktop Connection (mstsc.exe) with: - Computer: `your-ec2-public-ip:3389` - Username: `ubuntu` - Password: The password you set above **Connect from macOS**: Use Microsoft Remote Desktop app from the App Store. Once connected via remote desktop, open Firefox and navigate to http://localhost:7860 to access the Registry UI.
--- ## 9. Configuring AI Agents and Coding Assistants ### Configure OAuth Credentials Before generating tokens, you need to configure your OAuth credentials. Follow the [Configuration Reference](configuration.md) for detailed parameter documentation. ```bash cd ~/workspace/mcp-gateway-registry # Configure OAuth credentials for external services (if needed) cp credentials-provider/oauth/.env.example credentials-provider/oauth/.env # Edit credentials-provider/oauth/.env with your provider credentials # Configure AgentCore credentials (if using Amazon Bedrock AgentCore) cp credentials-provider/agentcore-auth/.env.example credentials-provider/agentcore-auth/.env # Edit credentials-provider/agentcore-auth/.env with your AgentCore credentials ``` ### Generate Authentication Tokens and MCP Configurations ```bash # Generate all authentication tokens and MCP configurations ./credentials-provider/generate_creds.sh # This script will: # 1. Generate Keycloak agent tokens for ingress authentication # 2. Generate external provider tokens for egress authentication (if configured) # 3. Generate AgentCore tokens (if configured) # 4. Create MCP configuration files for AI coding assistants # 5. Add no-auth services to the configurations ``` ### Start Automatic Token Refresh Service For production use, start the token refresh service to automatically maintain valid tokens. See the [Authentication Guide](auth.md) for detailed information about token lifecycle management. ```bash # Start the background token refresh service ./start_token_refresher.sh # Monitor the token refresh process tail -f token_refresher.log ``` **Example Token Refresh Output:** ``` 2025-09-17 03:09:43,391,p455210,{token_refresher.py:370},INFO,Successfully refreshed OAuth token: agent-test-agent-m2m-token.json 2025-09-17 03:09:43,391,p455210,{token_refresher.py:898},INFO,Token successfully updated at: /home/ubuntu/repos/mcp-gateway-registry/.oauth-tokens/agent-test-agent-m2m-token.json 2025-09-17 03:09:43,631,p455210,{token_refresher.py:341},INFO,Refreshing OAuth token for provider: keycloak 2025-09-17 03:09:43,778,p455210,{token_refresher.py:903},INFO,Refresh cycle complete: 8/8 tokens refreshed successfully 2025-09-17 03:09:43,778,p455210,{token_refresher.py:907},INFO,Regenerating MCP configuration files after token refresh... 2025-09-17 03:09:43,781,p455210,{token_refresher.py:490},INFO,MCP configuration files regenerated successfully ``` ### Generated Token Files and Configurations After running `generate_creds.sh`, check the `.oauth-tokens/` directory for generated files: ```bash # List all generated token files and configurations ls -la .oauth-tokens/ ``` **Key Files Generated:** - **Agent Tokens**: `agent-*-m2m-token.json` and `agent-*-m2m.env` files for each Keycloak agent - **External Service Tokens**: `*-egress.json` files for external providers (GitHub, etc.) - **AI Coding Assistant Configurations**: - `mcp.json` - Configuration for Claude Code/Roocode format - `vscode_mcp.json` - Configuration for VS Code format - **Raw Token Files**: `ingress.json`, individual service token files **Example AI Coding Assistant Configuration (mcp.json):** ```json { "mcpServers": { "mcpgw": { "type": "streamable-http", "url": "https://mcpgateway.ddns.net/mcpgw/mcp", "headers": { "X-Authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "X-Client-Id": "agent-ai-coding-assistant-m2m", "X-Keycloak-Realm": "mcp-gateway", "X-Keycloak-URL": "http://localhost:8080" }, "disabled": false, "alwaysAllow": [] } } } ``` ### Configure VS Code / Cursor / Claude Code For VS Code or similar editors, you'll need to: 1. Copy the configuration to your local machine: ```bash # From your local machine (not the EC2 instance) scp -i your-key.pem ubuntu@your-instance-ip:~/workspace/mcp-gateway-registry/.oauth-tokens/mcp.json ~/ ``` 2. Add to your editor's MCP settings: - VS Code: Add to `.vscode/settings.json` - Cursor: Add to cursor settings - Claude Code: Add to claude settings ### Create a Python Test Agent ```bash cd ~/workspace/mcp-gateway-registry/agents # Create a test configuration cat > agent_config.json <", "gateway_url": "http://localhost:8000" } EOF # Install Python dependencies uv venv source .venv/bin/activate uv pip install -r requirements.txt # Run the test agent uv run python agent.py --config agent_config.json ``` --- ## 10. Troubleshooting ### Common Issues and Solutions #### Services Won't Start ```bash # Check Docker daemon sudo systemctl status docker # Restart Docker if needed sudo systemctl restart docker # Check for port conflicts sudo netstat -tlnp | grep -E ':(80|443|7860|8080|8000)' # Stop conflicting services if found sudo systemctl stop apache2 # If Apache is running ``` #### Keycloak Initialization Fails ```bash # Check Keycloak logs docker-compose logs keycloak | tail -50 # Restart Keycloak docker-compose restart keycloak # Wait 2-3 minutes and retry initialization ./keycloak/setup/init-keycloak.sh ``` **Password Mismatch Issue**: If you see authentication failures during initialization: 1. Verify that `KEYCLOAK_ADMIN_PASSWORD` and `INITIAL_ADMIN_PASSWORD` are set to the SAME VALUE in your `.env` file 2. If they don't match, fix them: ```bash # Edit your .env file and ensure these match: nano .env # KEYCLOAK_ADMIN_PASSWORD=your-password # INITIAL_ADMIN_PASSWORD=your-password (MUST be identical) ``` 3. Restart Keycloak and try initialization again: ```bash docker-compose restart keycloak # Wait 2-3 minutes, then: ./keycloak/setup/init-keycloak.sh ``` #### Login Redirects Back to Login Page **Most Common Cause**: Incorrect `SESSION_COOKIE_SECURE` setting **Symptoms**: - You enter username/password - Page redirects back to login page without error message - No session cookie is stored in browser **Solution**: 1. Check your `.env` file: ```bash grep SESSION_COOKIE_SECURE .env ``` 2. **For localhost (HTTP) access**: ```bash # MUST be false SESSION_COOKIE_SECURE=false ``` 3. **For HTTPS access**: ```bash # MUST be true SESSION_COOKIE_SECURE=true ``` 4. **Verify in browser dev tools**: - Open browser dev tools (F12) - Go to Application → Cookies → Your domain - Check if `mcp_gateway_session` cookie exists - For HTTP: `Secure` flag should be UNCHECKED - For HTTPS: `Secure` flag should be CHECKED 5. **After fixing, rebuild and restart**: ```bash docker compose down docker compose build --no-cache auth-server registry docker compose up -d ``` **Why this happens**: Cookies with `secure=true` are ONLY sent over HTTPS connections. If you access via HTTP (like `http://localhost:7860`), the browser will reject the cookie and login will fail. #### Authentication Issues ```bash # Verify Keycloak is accessible curl http://localhost:8080/realms/mcp-gateway # Check auth server logs docker-compose logs auth-server | tail -50 # Regenerate agent credentials ./keycloak/setup/setup-agent-service-account.sh \ --agent-id new-test-agent \ --group mcp-servers-unrestricted ``` #### Login Redirects Back to Login Page This usually indicates a session cookie issue between auth-server and registry: ```bash # Check for SECRET_KEY mismatch docker-compose logs auth-server | grep "SECRET_KEY" docker-compose logs registry | grep -E "(session|cookie|Invalid)" # If you see "No SECRET_KEY environment variable found", regenerate and restart: SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(64))") sed -i "s/SECRET_KEY=.*/SECRET_KEY=$SECRET_KEY/" .env # Recreate containers to pick up new SECRET_KEY docker-compose stop auth-server registry docker-compose rm -f auth-server registry docker-compose up -d auth-server registry # Test login again - should work now ``` #### Configure Token Lifetime By default, Keycloak generates tokens with a 5-minute (300 seconds) lifetime. To change this for longer-lived tokens: **Method 1: Via Keycloak Admin Console** 1. Go to `http://localhost:8080/admin` (or your Keycloak URL) 2. Login with admin credentials 3. Select the `mcp-gateway` realm 4. Go to **Realm Settings** → **Tokens** → **Access Token Lifespan** 5. Change from `5 Minutes` to desired value (e.g., `1 Hour`) 6. Click **Save** **Method 2: Via Keycloak Admin API** ```bash # Get admin token ADMIN_TOKEN=$(curl -s -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=password&client_id=admin-cli&username=admin&password=your-keycloak-admin-password" | \ jq -r '.access_token') # Update access token lifespan to 1 hour (3600 seconds) # Note: By default, Keycloak access tokens expire after 5 minutes # Only increase this timeout if it's consistent with your organization's security policy curl -X PUT "http://localhost:8080/admin/realms/mcp-gateway" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"accessTokenLifespan": 3600}' # Verify the change curl -X GET "http://localhost:8080/admin/realms/mcp-gateway" \ -H "Authorization: Bearer $ADMIN_TOKEN" | jq '.accessTokenLifespan' ``` **Note**: New tokens generated after this change will use the updated lifetime. Existing tokens retain their original expiration time. #### OAuth2 Callback Failed If you see "oauth2_callback_failed" error: ```bash # Check Keycloak external URL configuration docker-compose exec -T auth-server env | grep KEYCLOAK_EXTERNAL_URL # Should show: KEYCLOAK_EXTERNAL_URL=http://localhost:8080 # If missing, add to .env file: echo "KEYCLOAK_EXTERNAL_URL=http://localhost:8080" >> .env docker-compose restart auth-server # Check auth-server can reach Keycloak internally docker-compose exec auth-server curl -f http://keycloak:8080/health/ready ``` #### Registry Not Loading ```bash # Check registry logs docker-compose logs registry | tail -50 # Rebuild registry frontend cd ~/workspace/mcp-gateway-registry/registry npm install npm run build cd .. docker-compose restart registry ``` ### View Real-time Logs ```bash # All services docker-compose logs -f # Specific service docker-compose logs -f # Last 100 lines docker-compose logs --tail=100 ``` ### Stopping Services ```bash # Graceful shutdown (keeps data) docker-compose down # Complete cleanup (removes all data) docker-compose down -v # Just stop services (to restart later) docker-compose stop ``` ### Reset Everything If you need to start over completely: ```bash # Stop all services and remove volumes docker-compose down -v # Remove all Docker images (optional) docker system prune -a # Start fresh docker-compose up -d keycloak-db keycloak # Then follow setup steps again from Step 5 ``` --- ## 11. Custom HTTPS Domain Configuration If you're running this setup with a custom HTTPS domain (e.g., `https://mcpgateway.mycorp.com`) instead of localhost, you'll need to update the following parameters in your `.env` file: ### Parameters to Update for Custom HTTPS Domain ```bash # Update these parameters in your .env file: # 1. Registry URL - Replace with your custom domain REGISTRY_URL=https://mcpgateway.mycorp.com # 2. Auth Server External URL - Replace with your custom domain AUTH_SERVER_EXTERNAL_URL=https://mcpgateway.mycorp.com # 3. Keycloak External URL - Replace with your custom domain KEYCLOAK_EXTERNAL_URL=https://mcpgateway.mycorp.com # 4. Keycloak Admin URL - Replace with your custom domain KEYCLOAK_ADMIN_URL=https://mcpgateway.mycorp.com ``` ### Parameters to KEEP UNCHANGED These parameters should remain as localhost/Docker network addresses for internal communication: ```bash # DO NOT CHANGE - These are for internal Docker network communication: AUTH_SERVER_URL=http://auth-server:8888 KEYCLOAK_URL=http://keycloak:8080 ``` ### Additional Considerations for Custom Domains 1. **SSL/TLS Certificates**: Ensure you have valid SSL certificates for your domain 2. **Firewall Rules**: Update security groups/firewall rules for your custom domain 3. **DNS Configuration**: Ensure your domain points to your server's public IP address ### Testing Custom Domain Setup After updating your `.env` file with custom domain values: ```bash # Restart services to pick up new configuration docker-compose restart auth-server registry # Test the custom domain curl -f https://mcpgateway.mycorp.com/health # Test Keycloak access curl -f https://mcpgateway.mycorp.com/realms/mcp-gateway ``` --- ## 12. Next Steps ### Secure Your Installation 1. **Update Security Groups**: Restrict IP access to only necessary addresses 2. **Enable HTTPS**: Set up SSL certificates for production use 3. **Change Default Passwords**: Update all default passwords in production 4. **Set up Monitoring**: Configure CloudWatch or similar monitoring ### Add More MCP Servers 1. Check available MCP servers: ```bash ls ~/workspace/mcp-gateway-registry/registry/servers/ ``` 2. Edit `docker-compose.yml` to enable additional servers 3. Restart services: ```bash docker-compose up -d ``` ### Configure Production Settings 1. **Domain Name**: Set up a domain name and update configurations 2. **Load Balancer**: Add an Application Load Balancer for redundancy and load distribution 3. **Backup Strategy**: Implement regular backups of PostgreSQL database 4. **Scaling**: Consider EKS deployment for auto-scaling capabilities ### Explore Advanced Features - **Fine-grained Access Control**: Configure `scopes.yml` for detailed permissions - **Custom MCP Servers**: Add your own MCP server implementations - **OAuth Integration**: Connect with external services (GitHub, Google, etc.) - **Monitoring Dashboard**: Set up Grafana for metrics visualization ### Documentation Resources - [Authentication Guide](auth.md) - Deep dive into authentication options - [Keycloak Advanced Configuration](keycloak-integration.md) - Enterprise features - [API Reference](registry_api.md) - Programmatic registry management - [Dynamic Tool Discovery](dynamic-tool-discovery.md) - AI agent capabilities - [AWS ECS Deployment](../terraform/aws-ecs/README.md) - Deployment best practices ### Getting Help - **GitHub Issues**: https://github.com/agentic-community/mcp-gateway-registry/issues - **Discussions**: https://github.com/agentic-community/mcp-gateway-registry/discussions - **Documentation**: Check the `/docs` folder for detailed guides --- ## Container Publishing for Production Deployment For production environments or to contribute pre-built images, you can publish the containers to Docker Hub and GitHub Container Registry. ### Publishing Script Overview The `scripts/publish_containers.sh` script automates building and publishing all 6 container components: - `registry` - Main registry service with nginx and web UI - `auth-server` - Authentication service - `currenttime-server` - Current time MCP server - `realserverfaketools-server` - Example tools MCP server - `fininfo-server` - Financial information MCP server - `mcpgw-server` - MCP Gateway proxy server ### Publishing Commands **Test build locally (no push):** ```bash ./scripts/publish_containers.sh --local ``` **Publish to Docker Hub:** ```bash ./scripts/publish_containers.sh --dockerhub ``` **Publish to GitHub Container Registry:** ```bash ./scripts/publish_containers.sh --ghcr ``` **Publish to both registries:** ```bash ./scripts/publish_containers.sh --dockerhub --ghcr ``` **Build specific component:** ```bash ./scripts/publish_containers.sh --dockerhub --component registry ``` ### Required Environment Variables Add these to your `.env` file for publishing: ```bash # Container Registry Credentials DOCKERHUB_USERNAME=aarora79 DOCKERHUB_TOKEN=your_docker_hub_token GITHUB_TOKEN=your_github_token # Organization names for publishing DOCKERHUB_ORG=mcpgateway GITHUB_ORG=agentic-community ``` ### Generated Image Names **Docker Hub (Organization Account):** - `mcpgateway/registry:latest` - `mcpgateway/auth-server:latest` - `mcpgateway/currenttime-server:latest` - `mcpgateway/realserverfaketools-server:latest` - `mcpgateway/fininfo-server:latest` - `mcpgateway/mcpgw-server:latest` **GitHub Container Registry:** - `ghcr.io/agentic-community/mcp-registry:latest` - `ghcr.io/agentic-community/mcp-auth-server:latest` - `ghcr.io/agentic-community/mcp-currenttime-server:latest` - `ghcr.io/agentic-community/mcp-realserverfaketools-server:latest` - `ghcr.io/agentic-community/mcp-fininfo-server:latest` - `ghcr.io/agentic-community/mcp-mcpgw-server:latest` ### Using Pre-built Images Once published, anyone can use the pre-built images with: ```bash # Use the pre-built deployment option ./build_and_run.sh --prebuilt ``` This deployment method: - Skips the build process entirely - Pulls pre-built images from container registries - Starts services in under 2 minutes - Requires no Node.js or build dependencies --- ## Summary You now have a fully functional MCP Gateway & Registry running on your AWS EC2 instance! The system is ready to: - Authenticate AI agents and human users through Keycloak - Provide centralized access to MCP servers - Enable dynamic tool discovery for AI assistants - Offer a web-based registry for managing configurations Remember to: - Save all generated credentials securely - Monitor service logs regularly - Keep the system updated with latest releases - Follow security best practices for production use Congratulations on completing the setup! Your enterprise MCP gateway is now operational and ready to serve both AI agents and development teams. ================================================ FILE: docs/configuration.md ================================================ # Configuration Reference This document provides a comprehensive reference for all configuration files in the MCP Gateway Registry project. Each configuration file serves a specific purpose in the authentication and operation of the system. ## Configuration Files Overview | File | Purpose | Type | Location | Example File | User Modification | |------|---------|------|----------|--------------|-------------------| | [`.env`](#main-environment-configuration) | Main project environment variables | Environment | Project root | `.env.example` | **Yes** - Required | | [`.env` (OAuth)](#oauth-environment-configuration) | OAuth provider credentials | Environment | `credentials-provider/oauth/` | `.env.example` | **Yes** - Required | | [`.env` (AgentCore)](#agentcore-environment-configuration) | AgentCore authentication config | Environment | `credentials-provider/agentcore-auth/` | `.env.example` | **Optional** - Only if using AgentCore | | [`oauth2_providers.yml`](#oauth2-providers-configuration) | OAuth2 provider definitions | YAML | `auth_server/` | - | **No** - Pre-configured | | [`oauth_providers.yaml`](#oauth-providers-mapping) | Provider-specific OAuth configurations | YAML | `credentials-provider/oauth/` | - | **No** - Pre-configured | | [`docker-compose.yml`](#docker-compose-configuration) | Container orchestration | YAML | Project root | - | **Rarely** - Only for custom deployments | --- ## Main Environment Configuration **File:** `.env` (Project root) **Purpose:** Core project settings, registry URLs, and primary authentication credentials. ### Authentication Provider Selection The MCP Gateway Registry supports multiple authentication providers. Choose one by setting the `AUTH_PROVIDER` environment variable: - **`keycloak`**: Open-source identity and access management with individual agent audit trails - **`cognito`**: Amazon managed authentication service Based on your selection, configure the corresponding provider-specific variables below. ### Core Variables | Variable | Description | Example | Required | |----------|-------------|---------|----------| | `REGISTRY_URL` | Public URL of the MCP Gateway Registry | `https://mcpgateway.ddns.net` | ✅ | | `AUTH_PROVIDER` | Authentication provider (`cognito` or `keycloak`) | `keycloak` | ✅ | | `AWS_REGION` | AWS region for services | `us-east-1` | ✅ | ### Deployment Mode Configuration Controls how the registry operates and which UI tabs are visible. | Variable | Description | Values | Default | |----------|-------------|--------|---------| | `DEPLOYMENT_MODE` | How registry integrates with the gateway | `with-gateway`, `registry-only` | `with-gateway` | | `REGISTRY_MODE` | Which feature categories are enabled | `full`, `mcp-servers-only`, `agents-only`, `skills-only` | `full` | **Deployment Mode Options:** - **`with-gateway`**: Full integration with nginx reverse proxy. Nginx config is regenerated when servers are registered or deleted. - **`registry-only`**: Registry operates as a catalog/discovery service only. Nginx config is not updated on server changes. **Registry Mode Options:** - **`full`**: All features enabled (MCP servers, agents, skills, federation) - **`mcp-servers-only`**: Only MCP server and virtual server features enabled - **`agents-only`**: Only A2A agent features enabled - **`skills-only`**: Only skills features enabled **Note:** `with-gateway` + `skills-only` is an invalid combination and auto-corrects to `registry-only` + `skills-only` at startup. ### Tab Visibility Overrides These variables allow hiding specific UI tabs independently of `REGISTRY_MODE`. The visibility formula is: ``` tab_visible = REGISTRY_MODE enables the feature AND SHOW_*_TAB is true ``` | Variable | Description | Default | |----------|-------------|---------| | `SHOW_SERVERS_TAB` | Show the MCP Servers tab in the UI | `true` | | `SHOW_VIRTUAL_SERVERS_TAB` | Show the Virtual MCP Servers tab in the UI | `true` | | `SHOW_SKILLS_TAB` | Show the Skills tab in the UI | `true` | | `SHOW_AGENTS_TAB` | Show the Agents tab in the UI | `true` | **Precedence Matrix:** | Scenario | Result | |----------|--------| | `REGISTRY_MODE=full` + `SHOW_AGENTS_TAB=true` | Agents tab shown | | `REGISTRY_MODE=full` + `SHOW_AGENTS_TAB=false` | Agents tab hidden (backend APIs still work) | | `REGISTRY_MODE=mcp-servers-only` + `SHOW_AGENTS_TAB=true` | Agents tab hidden (mode blocks it) | | `REGISTRY_MODE=mcp-servers-only` + `SHOW_AGENTS_TAB=false` | Agents tab hidden | **Important:** - Setting `SHOW_*_TAB=false` only hides the UI tab. Backend APIs remain fully functional. - If a `SHOW_*_TAB` is set to `true` but `REGISTRY_MODE` does not enable that feature, a warning is logged at startup. - All defaults are `true` for backward compatibility. - These settings are visible in the **Settings > System Config** page under the "Deployment Mode" group. ### Keycloak Configuration (if AUTH_PROVIDER=keycloak) | Variable | Description | Example | Required | |----------|-------------|---------|----------| | `KEYCLOAK_URL` | Keycloak server URL (internal/Docker network) | `http://keycloak:8080` | ✅ | | `KEYCLOAK_EXTERNAL_URL` | Keycloak server URL (external/browser access) | `https://mcpgateway.ddns.net` (production)
`http://localhost:8080` (local development) | ✅ | | `KEYCLOAK_ADMIN_URL` | Keycloak admin URL (for setup scripts) | `http://localhost:8080` | ✅ | | `KEYCLOAK_REALM` | Keycloak realm name | `mcp-gateway` | ✅ | | `KEYCLOAK_ADMIN` | Keycloak admin username | `admin` | ✅ | | `KEYCLOAK_ADMIN_PASSWORD` | Keycloak admin password | `SecureKeycloakAdmin123!` | ✅ | | `KEYCLOAK_DB_PASSWORD` | Keycloak database password | `SecureKeycloakDB123!` | ✅ | | `KEYCLOAK_CLIENT_ID` | Keycloak web client ID (see note below) | `mcp-gateway-web` | ✅ | | `KEYCLOAK_CLIENT_SECRET` | Keycloak web client secret (auto-generated) | `0tiBtgQFcaBiwHXIxDws...` | ✅ | | `KEYCLOAK_M2M_CLIENT_ID` | Keycloak M2M client ID (see note below) | `mcp-gateway-m2m` | ✅ | | `KEYCLOAK_M2M_CLIENT_SECRET` | Keycloak M2M client secret (auto-generated) | `ZJqbsamnQs79hbUbkJLB...` | ✅ | | `KEYCLOAK_ENABLED` | Enable Keycloak in OAuth2 providers | `true` | ✅ | | `INITIAL_ADMIN_PASSWORD` | Initial admin user password | `changeme` | For setup | | `INITIAL_USER_PASSWORD` | Initial test user password | `testpass` | For setup | **Note: Getting Keycloak Client IDs and Secrets** The client IDs and secrets are automatically generated when you run the Keycloak initialization script: ```bash cd keycloak/setup ./init-keycloak.sh ``` The script will: 1. Create the clients with the IDs you specify (`mcp-gateway-web` and `mcp-gateway-m2m`) 2. Generate secure random secrets for each client 3. Display the generated secrets at the end of the script output 4. Save them to a file for your reference **To retrieve existing client secrets from a running Keycloak instance:** ```bash # Method 1: Use the helper script (Recommended) cd keycloak/setup export KEYCLOAK_ADMIN_PASSWORD="your-admin-password" ./get-all-client-credentials.sh # This will display the secrets and save them to .oauth-tokens/keycloak-client-secrets.txt # Method 2: Using Keycloak Admin Console (Web UI) # 1. Navigate to https://your-keycloak-url/admin # 2. Login with admin credentials # 3. Select your realm (mcp-gateway) # 4. Go to Clients → Select your client # 5. Go to Credentials tab # 6. Copy the Secret value # Method 3: Check the original initialization output # The init-keycloak.sh script saves secrets to keycloak-client-secrets.txt cat keycloak/setup/keycloak-client-secrets.txt ``` ### Amazon Cognito Configuration (if AUTH_PROVIDER=cognito) | Variable | Description | Example | Required | |----------|-------------|---------|----------| | `COGNITO_USER_POOL_ID` | Amazon Cognito User Pool ID | `us-east-1_vm1115QSU` | ✅ | | `COGNITO_CLIENT_ID` | Amazon Cognito App Client ID | `3aju04s66t...` | ✅ | | `COGNITO_CLIENT_SECRET` | Amazon Cognito App Client Secret | `85ps32t55df39hm61k966fqjurj...` | ✅ | | `COGNITO_DOMAIN` | Cognito domain (optional) | `auto` | Optional | ### Session Cookie Security Configuration **CRITICAL:** These settings control how session cookies are transmitted and shared. Incorrect configuration will cause login failures. | Variable | Description | Example | Required | Default | |----------|-------------|---------|----------|---------| | `SESSION_COOKIE_SECURE` | Enable HTTPS-only cookie transmission | `false` (localhost)
`true` (production) | ✅ | `false` | | `SESSION_COOKIE_DOMAIN` | Cookie domain for cross-subdomain sharing | `""` (single domain)
`.example.com` (cross-subdomain) | ❌ | Empty | #### SESSION_COOKIE_SECURE - Critical for Your Environment **YOU MUST SET THIS CORRECTLY OR LOGIN WILL FAIL:** **For Local Development (localhost via HTTP):** ```bash SESSION_COOKIE_SECURE=false # MUST be false ``` - Localhost runs over HTTP (not HTTPS) - Cookies with `secure=true` are ONLY sent over HTTPS - Setting this to `true` on localhost = **login will fail** **For Production with HTTPS:** ```bash SESSION_COOKIE_SECURE=true # MUST be true ``` - Production deployments use HTTPS - Cookies must have `secure=true` to prevent session hijacking - Setting this to `false` in production = **security vulnerability** ❌ #### SESSION_COOKIE_DOMAIN - When to Set This **Most deployments should leave this EMPTY** (default behavior = safest): ```bash SESSION_COOKIE_DOMAIN= # Empty string or unset ``` **Only set this if you need cross-subdomain authentication:** | Deployment Type | Example Domains | SESSION_COOKIE_DOMAIN | |----------------|-----------------|----------------------| | **Single domain** | `mcpgateway.ddns.net` | `""` (empty) | | **Cross-subdomain** | `auth.example.com`
`registry.example.com` | `.example.com` | | **Multi-level domains** | `registry.region-1.corp.company.internal` | `.corp.company.internal` | **Important Security Notes:** - Empty domain = cookie scoped to exact host only (safest) - Set domain only when you control ALL subdomains - Never set to public suffixes (`.com`, `.net`, `.ddns.net`) - Domain must start with a dot (`.example.com`) **See Also:** [Cookie Security Design Documentation](design/cookie-security-design.md) for detailed security analysis and deployment scenarios. ### Optional Variables | Variable | Description | Example | Default | |----------|-------------|---------|---------| | `AUTH_SERVER_URL` | Internal auth server URL | `http://auth-server:8888` | - | | `AUTH_SERVER_EXTERNAL_URL` | External auth server URL | `https://mcpgateway.ddns.net` | - | | `SECRET_KEY` | Application secret key | Auto-generated if not provided | Auto-generated | | `SRE_GATEWAY_AUTH_TOKEN` | SRE Gateway auth token | Auto-populated from credentials | - | | `ANTHROPIC_API_KEY` | Anthropic API key for Claude models | `sk-ant-api03-...` | For AI functionality | ### GitHub Private Repository Access Enable authenticated access to SKILL.md files hosted in private GitHub repositories. Two authentication methods are supported: Personal Access Token (simple) or GitHub App (recommended for organizations). If both are configured, GitHub App takes priority. #### Environment Variables | Variable | Description | Example | Default | |----------|-------------|---------|---------| | `GITHUB_PAT` | Personal Access Token with `repo` scope (or fine-grained PAT with `contents: read`) | `ghp_your_token_here` | Empty (disabled) | | `GITHUB_APP_ID` | GitHub App ID | `123456` | Empty | | `GITHUB_APP_INSTALLATION_ID` | GitHub App Installation ID | `78901234` | Empty | | `GITHUB_APP_PRIVATE_KEY` | GitHub App private key in PEM format (newlines as `\n`) | `-----BEGIN RSA PRIVATE KEY-----\n...` | Empty | | `GITHUB_EXTRA_HOSTS` | Comma-separated extra GitHub hosts for auth header injection | `github.mycompany.com,raw.github.mycompany.com` | Empty | | `GITHUB_API_BASE_URL` | GitHub API base URL (for GHES token exchange) | `https://github.mycompany.com/api/v3` | `https://api.github.com` | **Security:** Auth headers are only sent to `github.com`, `raw.githubusercontent.com`, and hosts explicitly listed in `GITHUB_EXTRA_HOSTS`. #### Terraform/ECS Configuration ```hcl # Option 1: Personal Access Token # github_pat = "ghp_your_token_here" # Option 2: GitHub App authentication # github_app_id = "123456" # github_app_installation_id = "78901234" # github_app_private_key = "-----BEGIN RSA PRIVATE KEY-----\\n...\\n-----END RSA PRIVATE KEY-----" # GitHub Enterprise Server support # github_extra_hosts = "github.mycompany.com,raw.github.mycompany.com" # github_api_base_url = "https://github.mycompany.com/api/v3" ``` #### Helm Configuration ```yaml app: # Option 1: PAT (plain value or Kubernetes secret) githubPat: "" githubPatExistingSecret: "" # K8s secret name githubPatExistingSecretKey: "GITHUB_PAT" # Option 2: GitHub App githubAppId: "" githubAppInstallationId: "" githubAppPrivateKey: "" githubAppPrivateKeyExistingSecret: "" # K8s secret name githubAppPrivateKeyExistingSecretKey: "GITHUB_APP_PRIVATE_KEY" # GitHub Enterprise Server githubExtraHosts: "" githubApiBaseUrl: "https://api.github.com" ``` For Helm deployments, use `ExistingSecret` fields to inject credentials from Kubernetes secrets rather than plain values. ### Storage Backend Configuration The MCP Gateway Registry supports three storage backends for servers, agents, and scopes management. | Variable | Description | Values | Default | |----------|-------------|--------|---------| | `STORAGE_BACKEND` | Storage backend for registry data | `file`, `mongodb-ce`, or `documentdb` | `file` | > **⚠️ DEPRECATION WARNING:** File-based storage is deprecated and will be removed in a future release. MongoDB CE is now the recommended backend for local development and testing. **Backend Options:** #### File Backend (Deprecated) - **Status**: **DEPRECATED** - Will be removed in a future release - **Migration Path**: Switch to MongoDB CE for local development or DocumentDB for production - **Pros**: Simple, no external dependencies, human-readable JSON files - **Cons**: Limited concurrent writes, no distributed access, FAISS-based vector search, **deprecated** ```bash STORAGE_BACKEND=file # DEPRECATED - Use mongodb-ce instead ``` **Data stored in:** - Servers: `~/mcp-gateway/servers/*.json` - Agents: `~/mcp-gateway/agents/*.json` - Security scans: `~/mcp-gateway/security_scans/*.json` #### MongoDB CE Backend (Recommended for Local Development) - **Status**: **RECOMMENDED** for all local development and testing - **Best for**: Local development, feature development, testing, CI/CD pipelines - **Pros**: Docker-based, no cloud dependencies, replica set support, application-level vector search, production-like environment - **Cons**: Limited to ~10,000 documents, O(n) vector search performance (acceptable for development) ```bash STORAGE_BACKEND=mongodb-ce DOCUMENTDB_HOST=mongodb # Docker service name DOCUMENTDB_PORT=27017 DOCUMENTDB_DATABASE=mcp_registry DOCUMENTDB_NAMESPACE=default DOCUMENTDB_USE_TLS=false # No TLS for local dev ``` **MongoDB Collections Created:** - `mcp_servers_{namespace}` - Server definitions - `mcp_agents_{namespace}` - A2A agent cards - `mcp_scopes_{namespace}` - Authorization scopes - `mcp_embeddings_1536_{namespace}` - Vector embeddings (1536 dimensions) - `mcp_security_scans_{namespace}` - Security scan results - `mcp_federation_config_{namespace}` - Federation configuration **First-Time MongoDB CE Setup:** ```bash # 1. Start MongoDB container docker-compose up -d mongodb sleep 5 # 2. Initialize collections and indexes docker-compose up mongodb-init # 3. Verify setup docker exec mcp-mongodb mongosh --eval "use mcp_registry; show collections" # 4. Switch backend and restart export STORAGE_BACKEND=mongodb-ce docker-compose restart registry ``` #### DocumentDB Backend (Production, Recommended) - **Best for**: Production deployments, high concurrency, large-scale systems - **Pros**: Native HNSW vector search, distributed storage, AWS-managed, clustering support - **Cons**: Requires AWS infrastructure, uses AWS pricing ```bash STORAGE_BACKEND=documentdb DOCUMENTDB_HOST=cluster.docdb.amazonaws.com DOCUMENTDB_PORT=27017 DOCUMENTDB_DATABASE=mcp_registry DOCUMENTDB_NAMESPACE=production DOCUMENTDB_USERNAME=admin DOCUMENTDB_PASSWORD= DOCUMENTDB_USE_TLS=true DOCUMENTDB_TLS_CA_FILE=global-bundle.pem DOCUMENTDB_REPLICA_SET=rs0 ``` **DocumentDB Collections Created:** Same as MongoDB CE (above), but with native HNSW vector indexes for sub-100ms semantic search. **First-Time DocumentDB Setup:** ```bash # 1. Deploy DocumentDB cluster via Terraform cd terraform/aws-ecs terraform apply # 2. Collections and indexes are created automatically on first application startup # 3. Verify setup (from bastion host or EC2 with access) mongosh --host \ --username admin \ --password \ --tls \ --tlsCAFile global-bundle.pem \ --eval "use mcp_registry; show collections" ``` **Important Notes:** - MongoDB CE uses application-level vector search (Python cosine similarity) - DocumentDB uses native HNSW vector indexes for production performance - Both backends use the same repository code (`DocumentDBServerRepository`, etc.) - Scopes are stored in MongoDB (collection `mcp_scopes_{namespace}`) and managed via the API **Switching Between Backends:** You can switch between backends at any time by changing `STORAGE_BACKEND`: ```bash # Switch to file backend export STORAGE_BACKEND=file docker-compose restart registry # Switch to MongoDB CE backend export STORAGE_BACKEND=mongodb-ce docker-compose restart registry # Switch to DocumentDB backend export STORAGE_BACKEND=documentdb docker-compose restart registry ``` **For AWS ECS Deployments:** See [terraform/aws-ecs/README.md](../terraform/aws-ecs/README.md) for automated Terraform deployment with DocumentDB. **For Detailed Architecture:** See [Storage Architecture: MongoDB CE & AWS DocumentDB](design/storage-architecture-mongodb-documentdb.md) for comprehensive implementation details. ### Container Registry Configuration (Optional - for CI/CD and local builds) | Variable | Description | Example | Required | |----------|-------------|---------|----------| | `DOCKERHUB_USERNAME` | Docker Hub username for publishing containers | `your_dockerhub_username` | **Optional** | | `DOCKERHUB_TOKEN` | Docker Hub access token | `your_dockerhub_access_token` | **Optional** | | `GITHUB_USERNAME` | GitHub username for GHCR publishing | `your_github_username` | **Optional** | | `GITHUB_TOKEN` | GitHub Personal Access Token with packages:write scope | `ghp_your_token_here` | **Optional** | | `DOCKERHUB_ORG` | Docker Hub organization name (leave empty for personal account) | `mcpgateway` or empty | **Optional** | | `GITHUB_ORG` | GitHub organization name (leave empty for personal account) | `agentic-community` or empty | **Optional** | **Note: Container Registry Credentials (Completely Optional)** These credentials are **entirely optional** and only needed if you want to: - **Publish container images**: Automatically via GitHub Actions or manually via scripts - **Contribute pre-built containers**: For easier deployment by other users **What happens if these are not configured:** - ✅ **The MCP Gateway Registry will work perfectly** - all core functionality remains intact - ✅ **GitHub Actions will succeed** - builds will complete successfully, just without publishing to Docker Hub - ✅ **Local development is unaffected** - no scripts will fail or produce errors - ✅ **Only container publishing is skipped** - everything else continues normally **When you might want to configure these:** - **Contributing to the project**: Publishing official container images - **Custom deployments**: Creating your own container registry for internal use - **Development workflow**: Testing container builds locally **How to obtain credentials (only if needed):** - **Docker Hub**: Get access token from [Docker Hub Security Settings](https://hub.docker.com/settings/security) - **GitHub Container Registry**: Generate Personal Access Token with `packages:write` scope from [GitHub Token Settings](https://github.com/settings/tokens) **Setup instructions (only if publishing containers):** - **In GitHub Actions**: Add `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` as repository secrets - **For local builds**: Add credentials to your `.env` file and use `scripts/publish_containers.sh` - **GITHUB_TOKEN**: Automatically provided in GitHub Actions, manually generated for local use **Organization vs Personal Account Publishing:** - **Personal Account** (Free): Leave `DOCKERHUB_ORG` and `GITHUB_ORG` empty - Images published as: `username/image-name` - Example: `aarora79/registry:latest` - **Organization Account** (Paid for Docker Hub): Set organization names - Images published as: `organization/image-name` - Example: `mcpgateway/registry:latest` --- ### Federation Configuration #### WORKDAY_TOKEN_URL (Optional) Configuration for Workday ASOR (Agent Service Orchestrator) federation integration. | Variable | Description | Example | Required | |----------|-------------|---------|----------| | `WORKDAY_TOKEN_URL` | Workday OAuth2 token endpoint URL | `https://services.wd101.myworkday.com/ccx/oauth2/production_instance/token` | **Optional** | **Required only if using Workday ASOR federation** - **Default**: `https://your-tenant.workday.com/ccx/oauth2/your_instance/token` (placeholder) - **Format**: `https://.workday.com/ccx/oauth2//token` - **Example**: `https://services.wd101.myworkday.com/ccx/oauth2/production_instance/token` - **Security**: Must use HTTPS in production environments - **Behavior**: If not configured with a valid URL, ASOR federation will be automatically disabled with a warning logged **Getting your Workday token URL:** Replace the placeholder values with your actual Workday tenant identifiers: - ``: Your Workday tenant domain (e.g., `services.wd101.myworkday.com`) - ``: Your Workday instance name (e.g., `production_instance`, `sandbox_instance`) **Configuration example:** ```bash # For production Workday instance WORKDAY_TOKEN_URL=https://services.wd101.myworkday.com/ccx/oauth2/production_instance/token # For sandbox/testing instance WORKDAY_TOKEN_URL=https://services.wd101.myworkday.com/ccx/oauth2/sandbox_instance/token ``` **Troubleshooting:** - If ASOR federation is not working, check the registry logs for warnings about WORKDAY_TOKEN_URL - Ensure the URL uses HTTPS (HTTP will fail in production) - Verify your Workday tenant and instance names are correct - Contact your Workday administrator if you're unsure about your instance configuration --- ## Keycloak Setup and Configuration When using Keycloak as your authentication provider, the system provides comprehensive setup scripts and configuration options: ### Initial Setup Run the Keycloak initialization script to set up the realm, clients, and groups: ```bash cd keycloak/setup ./init-keycloak.sh ``` This script will: 1. Create the `mcp-gateway` realm 2. Set up web and M2M clients with proper configurations 3. Create necessary groups (`mcp-servers-unrestricted`, `mcp-servers-restricted`) 4. Configure group mappers for JWT token claims 5. Create initial admin and test users ### Service Account Management For individual AI agent audit trails, create service accounts: ```bash # Create individual agent service account ./setup-agent-service-account.sh --agent-id sre-agent --group mcp-servers-unrestricted # Create shared M2M service account ./setup-m2m-service-account.sh ``` ### Token Generation Generate tokens for Keycloak authentication: ```bash # Generate M2M token for ingress uv run python credentials-provider/token_refresher.py # Generate agent-specific token uv run python credentials-provider/token_refresher.py --agent-id sre-agent ``` For detailed Keycloak integration documentation, see [Keycloak Integration Guide](keycloak-integration.md). --- ## OAuth Environment Configuration **File:** `credentials-provider/oauth/.env` **Purpose:** OAuth provider credentials for ingress and egress authentication flows. ### Ingress Authentication #### For Keycloak (if AUTH_PROVIDER=keycloak) | Variable | Description | Example | Required | |----------|-------------|---------|----------| | `KEYCLOAK_URL` | Keycloak server URL | `https://mcpgateway.ddns.net` | ✅ | | `KEYCLOAK_REALM` | Keycloak realm | `mcp-gateway` | ✅ | | `KEYCLOAK_M2M_CLIENT_ID` | M2M client ID | `mcp-gateway-m2m` | ✅ | | `KEYCLOAK_M2M_CLIENT_SECRET` | M2M client secret | `ZJqbsamnQs79hbUbkJLB...` | ✅ | #### For Cognito (if AUTH_PROVIDER=cognito) | Variable | Description | Example | Required | |----------|-------------|---------|----------| | `INGRESS_OAUTH_USER_POOL_ID` | Cognito User Pool for ingress auth | `us-east-1_vm1115QSU` | ✅ | | `INGRESS_OAUTH_CLIENT_ID` | Cognito client ID for ingress | `5v2rav1v93...` | ✅ | | `INGRESS_OAUTH_CLIENT_SECRET` | Cognito client secret for ingress | `1i888fnolv6k5sa1b8s5k839pdm...` | ✅ | ### Egress Authentication (Optional) Support for multiple OAuth provider configurations using numbered suffixes (`_1`, `_2`, `_3`, etc.): | Variable Pattern | Description | Example | Required | |------------------|-------------|---------|----------| | `EGRESS_OAUTH_CLIENT_ID_N` | OAuth client ID for provider N | `cNYWTFwyZB...` | For each provider | | `EGRESS_OAUTH_CLIENT_SECRET_N` | OAuth client secret for provider N | `ATOAubT-N-lAzpT05RDFq9dxcVr...` | For each provider | | `EGRESS_OAUTH_REDIRECT_URI_N` | OAuth redirect URI for provider N | `http://localhost:8080/callback` | For each provider | | `EGRESS_OAUTH_SCOPE_N` | OAuth scopes for provider N | Uses provider defaults if not set | Optional | | `EGRESS_PROVIDER_NAME_N` | Provider name (google, github, etc.) | `google` | For each provider | | `EGRESS_MCP_SERVER_NAME_N` | MCP server name for provider N | `google` | For each provider | ### Supported Providers - **Google**: Gmail, Drive, Calendar services - **GitHub**: Repository and issue management - **Microsoft**: Office 365, Teams integration - **Bedrock AgentCore**: AWS AgentCore services --- ## AgentCore Environment Configuration **File:** `credentials-provider/agentcore-auth/.env` **Purpose:** Amazon Bedrock AgentCore authentication configuration with support for multiple gateways. ### Shared Configuration | Variable | Description | Example | Required | |----------|-------------|---------|----------| | `COGNITO_DOMAIN` | AgentCore Cognito domain URL | `https://your-cognito-domain.auth.region.amazoncognito.com` | ✅ | | `COGNITO_USER_POOL_ID` | Cognito User Pool ID | `region_your_pool_id` | ✅ | ### Gateway-Specific Configurations Support for multiple gateways using numbered suffixes (`_1`, `_2`, `_3`, etc., up to `_100`). Each configuration set requires all four parameters: | Variable Pattern | Description | Example | Required | |------------------|-------------|---------|----------| | `AGENTCORE_CLIENT_ID_N` | AgentCore Cognito client ID for gateway N | `your_client_id_here` | ✅ | | `AGENTCORE_CLIENT_SECRET_N` | AgentCore Cognito client secret for gateway N | `your_client_secret_here` | ✅ | | `AGENTCORE_GATEWAY_ARN_N` | Amazon Bedrock AgentCore Gateway ARN for gateway N | `arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/my-gateway-1` | ✅ | | `AGENTCORE_SERVER_NAME_N` | MCP server name for AgentCore gateway N | `my-gateway-1` | ✅ | **Example Configuration:** ```bash # Configuration Set 1 AGENTCORE_CLIENT_ID_1=your_client_id_here AGENTCORE_CLIENT_SECRET_1=your_client_secret_here AGENTCORE_GATEWAY_ARN_1=arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/my-gateway-1 AGENTCORE_SERVER_NAME_1=my-gateway-1 # Configuration Set 2 AGENTCORE_CLIENT_ID_2=your_client_id_here AGENTCORE_CLIENT_SECRET_2=your_client_secret_here AGENTCORE_GATEWAY_ARN_2=arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/my-gateway-2 AGENTCORE_SERVER_NAME_2=my-gateway-2 ``` --- ## OAuth2 Providers Configuration **File:** `auth_server/oauth2_providers.yml` **Purpose:** OAuth2 provider definitions for web-based authentication flows. ### Keycloak Provider Configuration When using Keycloak as the authentication provider, the following configuration is used: | Field | Description | Required | Example | |-------|-------------|----------|---------| | `display_name` | Human-readable name | ✅ | `"Keycloak"` | | `client_id` | OAuth client ID | ✅ | `"${KEYCLOAK_CLIENT_ID}"` | | `client_secret` | OAuth client secret | ✅ | `"${KEYCLOAK_CLIENT_SECRET}"` | | `auth_url` | Authorization endpoint | ✅ | `"${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth"` | | `token_url` | Token endpoint | ✅ | `"${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token"` | | `user_info_url` | User info endpoint | ✅ | `"${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/userinfo"` | | `logout_url` | Logout endpoint | ✅ | `"${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/logout"` | | `scopes` | OAuth scopes | ✅ | `["openid", "email", "profile"]` | | `groups_claim` | JWT claim for groups | ✅ | `"groups"` | | `enabled` | Provider enabled | ✅ | `true` | ### General Provider Configuration Fields | Field | Description | Required | Example | |-------|-------------|----------|---------| | `display_name` | Human-readable provider name | ✅ | `"Amazon Cognito"` | | `client_id` | OAuth client ID (can use env vars) | ✅ | `"${COGNITO_CLIENT_ID}"` | | `client_secret` | OAuth client secret (can use env vars) | ✅ | `"${COGNITO_CLIENT_SECRET}"` | | `auth_url` | Authorization endpoint URL | ✅ | `"https://domain.auth.region.amazoncognito.com/oauth2/authorize"` | | `token_url` | Token endpoint URL | ✅ | `"https://domain.auth.region.amazoncognito.com/oauth2/token"` | | `user_info_url` | User info endpoint URL | ✅ | `"https://domain.auth.region.amazoncognito.com/oauth2/userInfo"` | | `logout_url` | Logout endpoint URL | ✅ | `"https://domain.auth.region.amazoncognito.com/logout"` | | `scopes` | OAuth scopes array | ✅ | `["openid", "email", "profile"]` | | `response_type` | OAuth response type | ✅ | `"code"` | | `grant_type` | OAuth grant type | ✅ | `"authorization_code"` | | `username_claim` | JWT claim for username | ✅ | `"email"` | | `groups_claim` | JWT claim for groups | ❌ | `"cognito:groups"` | | `email_claim` | JWT claim for email | ✅ | `"email"` | | `name_claim` | JWT claim for name | ✅ | `"name"` | | `enabled` | Whether provider is enabled | ✅ | `true` | ### Supported Providers - **Keycloak**: Open-source identity and access management - **Amazon Cognito**: Amazon managed authentication service - **GitHub**: Repository and development services (planned) - **Google**: Google Workspace and consumer services (planned) --- ## OAuth Providers Mapping **File:** `credentials-provider/oauth/oauth_providers.yaml` **Purpose:** Provider-specific OAuth endpoint configurations and metadata. ### Provider Fields | Field | Description | Example | |-------|-------------|---------| | `auth_url` | OAuth authorization URL | `https://accounts.google.com/o/oauth2/v2/auth` | | `token_url` | OAuth token exchange URL | `https://oauth2.googleapis.com/token` | | `scopes` | Default OAuth scopes | `["https://www.googleapis.com/auth/drive.readonly"]` | | `client_credentials_supported` | Whether provider supports client credentials flow | `false` | --- ## Docker Compose Configuration **File:** `docker-compose.yml` **Purpose:** Container orchestration for development and deployment. ### Services - **registry**: Main MCP Gateway Registry service - **auth-server**: OAuth2 authentication server - **frontend**: Web interface (React application) ### Key Configuration - Environment variable injection from `.env` files - Port mappings for local development - Volume mounts for persistent data - Health checks and restart policies --- ## Configuration Security ### Best Practices 1. **Never commit real credentials** to version control 2. **Use environment variables** for sensitive data 3. **Rotate credentials regularly** especially for production 4. **Limit scope permissions** to minimum required access 5. **Monitor credential usage** through logging and audit trails ### File Permissions - `.env` files should have `600` permissions (readable only by owner) - Configuration directories should have `700` permissions - Generated token files are automatically secured with `600` permissions --- ## Troubleshooting ### Common Issues 1. **Login redirects back to login page** - **Most Common Cause:** `SESSION_COOKIE_SECURE=true` but accessing via HTTP - **Solution for localhost:** Set `SESSION_COOKIE_SECURE=false` in `.env` - **Solution for production:** Ensure HTTPS is properly configured - **Check:** Browser dev tools → Application → Cookies (cookie should be present) - **Check:** Server logs for `Auth server setting session cookie: secure=...` 2. **Missing environment variables**: Check that all required variables are set in the appropriate `.env` files 3. **Invalid credentials**: Verify OAuth client IDs and secrets with providers 4. **Network connectivity**: Ensure firewall rules allow OAuth callback URLs 5. **Token expiration**: Use the credential refresh scripts to update expired tokens 6. **Scope mismatches**: Verify requested OAuth scopes match provider configurations 7. **Session cookie not being sent by browser** - Check cookie domain matches your hostname - Verify `SESSION_COOKIE_DOMAIN` is empty for single-domain deployments - Check browser third-party cookie settings - Inspect cookie attributes in browser dev tools ### Validation Commands ```bash # Validate OAuth configuration cd credentials-provider ./generate_creds.sh --verbose # Test MCP gateway connectivity cd tests ./tests/mcp_cmds.sh ping # Check configuration files python -c "import yaml; yaml.safe_load(open('file.yml'))" # YAML validation ``` ### Log Files - **OAuth flows**: `.oauth-tokens/` directory contains generated tokens and logs - **Registry operations**: Check `registry.log` for service-level issues - **Authentication**: Check `auth.log` for OAuth and FGAC issues --- ## Viewing Configuration via UI Administrators can view and export the current system configuration through the web interface. ### Accessing the Configuration Viewer 1. Navigate to **Settings** from the main dashboard 2. Select **System Config** > **Configuration** from the sidebar ![System Configuration Viewer](img/system-config.png) ### Features The Configuration Viewer provides: - **Grouped View**: Configuration parameters organized into categories: - Deployment Mode (includes tab visibility overrides) - Storage Backend - Authentication - Embeddings / Vector Search - Health Checks - WebSocket Settings - Security Scanning (MCP Servers) - Security Scanning (Agents) - Audit Logging - Federation - Well-Known Discovery - **Search**: Filter configuration parameters by name or value - **Expand/Collapse**: View all groups or focus on specific categories - **Sensitive Value Masking**: Passwords, API keys, and secrets are automatically masked - **Statistics**: Quick overview showing total, enabled, disabled, and issue counts ### Export Options Click the **Export** button to download configuration in multiple formats: | Format | Description | Use Case | |--------|-------------|----------| | ENV | Shell environment variables | Docker/shell deployment | | JSON | Structured JSON format | Programmatic access | | TFVARS | Terraform variables | Infrastructure as Code | | YAML | YAML format | Kubernetes ConfigMaps | **Note**: Sensitive values are masked by default in exports. Use `include_sensitive=true` with caution. --- ## Configuration API The registry provides REST API endpoints for programmatic configuration access. ### GET /api/config Returns basic configuration information (public endpoint). ```bash curl -X GET "https://your-registry/api/config" ``` **Response:** ```json { "deployment_mode": "with-gateway", "registry_mode": "full", "nginx_updates_enabled": true, "asset_lifecycle_statuses": ["active", "deprecated", "experimental"], "features": { "mcp_servers": true, "agents": true, "skills": true, "virtual_servers": true, "federation": true, "gateway_proxy": true } } ``` ### GET /api/config/full Returns complete configuration grouped by category (admin only). ```bash curl -X GET "https://your-registry/api/config/full" \ -H "Cookie: session=" ``` **Response:** ```json { "groups": { "deployment": { "title": "Deployment Mode", "order": 1, "fields": { "deployment_mode": { "label": "Deployment Mode", "value": { "raw": "with-gateway", "display": "with-gateway", "is_masked": false } } } } }, "generated_at": "2025-01-15T10:30:00Z" } ``` ### GET /api/config/export Export configuration in various formats (admin only). ```bash # Export as ENV format curl -X GET "https://your-registry/api/config/export?format=env" \ -H "Cookie: session=" # Export as JSON with sensitive values curl -X GET "https://your-registry/api/config/export?format=json&include_sensitive=true" \ -H "Cookie: session=" ``` **Query Parameters:** | Parameter | Values | Default | Description | |-----------|--------|---------|-------------| | `format` | `env`, `json`, `tfvars`, `yaml` | `env` | Export format | | `include_sensitive` | `true`, `false` | `false` | Include sensitive values (use with caution) | **Rate Limiting**: These endpoints are rate-limited to 10 requests per minute per user. ================================================ FILE: docs/custom-metadata.md ================================================ # Custom Metadata for Servers & Agents Enrich your MCP servers and agents with custom metadata for organization, compliance tracking, and integration purposes. All metadata is fully searchable via semantic search. ## Use Cases ### Organization & Team Management ```json { "team": "data-platform", "owner": "alice@example.com", "department": "engineering" } ``` *Search by: "team:data-platform servers", "alice@example.com owned services"* ### Compliance & Governance ```json { "compliance_level": "PCI-DSS", "data_classification": "confidential", "regulatory_requirements": ["GDPR", "HIPAA"], "audit_logging": true } ``` *Search by: "PCI-DSS compliant servers", "HIPAA regulated services"* ### Cost & Project Tracking ```json { "cost_center": "analytics-dept", "project_code": "AI-2024-Q1", "budget_allocation": "R&D" } ``` *Search by: "cost center analytics", "project AI-2024-Q1"* ### Deployment & Integration ```json { "deployment_region": "us-east-1", "environment": "production", "jira_ticket": "MCPGW-123", "version": "2.1.0" } ``` *Search by: "us-east-1 deployed services", "JIRA MCPGW-123", "version 2.1.0"* ## API Usage ### Register MCP Server with Metadata ```bash curl -X POST https://registry.example.com/api/services/register \ -H "Content-Type: application/json" \ -d '{ "name": "payment-processor", "description": "Payment processing service", "path": "/payment-processor", "proxy_pass_url": "http://payment:8080", "metadata": { "team": "finance-platform", "owner": "alice@example.com", "compliance_level": "PCI-DSS", "cost_center": "finance-ops", "deployment_region": "us-east-1" } }' ``` ### Register A2A Agent with Metadata ```bash curl -X POST https://registry.example.com/api/agents/register \ -H "Content-Type: application/json" \ -d '{ "name": "analytics-agent", "description": "Data analytics agent", "metadata": { "team": "data-science", "owner": "bob@example.com", "version": "3.2.1", "cost_center": "analytics-dept" } }' ``` ### Search by Metadata ```bash # Find servers by team curl "https://registry.example.com/api/search?q=team:finance-platform" # Find PCI-DSS compliant services curl "https://registry.example.com/api/search?q=PCI-DSS compliant services" # Find services by owner curl "https://registry.example.com/api/search?q=alice@example.com owned" # Find services in specific region curl "https://registry.example.com/api/search?q=us-east-1 deployed" ``` ## Key Features - **Flexible Schema:** Store any JSON-serializable data (strings, numbers, booleans, nested objects, arrays) - **Fully Searchable:** All metadata included in semantic search embeddings - **Backward Compatible:** Optional field - existing registrations work without modification - **Type-Safe:** Pydantic validation ensures data integrity - **REST API:** Full CRUD support via standard API endpoints ## Related Documentation - [Service Management Guide](service-management.md) - [A2A Agent Guide](a2a.md) - [Semantic Search](design/hybrid-search-architecture.md) ================================================ FILE: docs/database-design.md ================================================ # Database Design - MCP Gateway Registry ## Overview The MCP Gateway Registry supports three storage backends for data persistence: 1. **File-Based Backend** (Legacy, Backwards Compatible) - JSON file storage in the local filesystem - Maintained for backwards compatibility - Single-node deployments only - FAISS-based vector search 2. **MongoDB CE** (Local Development) - MongoDB Community Edition 8.2 - Docker-based local deployment - Application-level vector search - Development and testing environments 3. **AWS DocumentDB** (Production, Recommended) - MongoDB-compatible managed service - Supports clustering configuration - Native vector search with HNSW indexes - Multi-tenancy support via namespaces - Recommended for all production deployments The default configuration for local development uses **MongoDB CE**, while production deployments use **AWS DocumentDB**. --- ## Quick Architecture Reference ``` Application Services │ ▼ Repository Factory (factory.py) │ ├─> File Backend (legacy) │ └─> Local JSON files + FAISS │ ├─> MongoDB CE (local dev) │ └─> Docker container + app-level vector search │ └─> AWS DocumentDB (production) └─> Managed service + native vector search ``` --- ## Storage Backend Comparison | Feature | File | MongoDB CE | AWS DocumentDB | |---------|------|------------|----------------| | **Use Case** | Legacy/Testing | Local Development | Production | | **Setup** | None | Docker Compose | Terraform | | **Scalability** | ~1,000 entities | ~10,000 | Millions | | **Vector Search** | FAISS (local) | Python (app-level) | HNSW (native) | | **Query Latency** | 50-100ms | 50-200ms | 10-50ms | | **Concurrency** | Limited | Good | Excellent | | **HA/Clustering** | No | Manual | Automatic | | **Multi-tenancy** | No | Via namespace | Via namespace | | **Cost** | Free | Free | AWS pricing | | **Best For** | Quick start | Feature development | Production | --- ## MongoDB CE & DocumentDB Architecture For detailed information about the MongoDB and DocumentDB backends, see: **[Storage Architecture: MongoDB CE & AWS DocumentDB](./design/storage-architecture-mongodb-documentdb.md)** This comprehensive guide covers: - MongoDB CE local development setup - AWS DocumentDB production deployment - Vector search implementation (app-level vs. native) - Build and run process with `build_and_run.sh` - Collection schemas and indexes - Migration strategies - Performance characteristics ### Quick Summary **Collections (both MongoDB CE and DocumentDB):** All collections are suffixed with the configured namespace (e.g., `_default`, `_production`): 1. **mcp_servers_{namespace}** - Server definitions 2. **mcp_agents_{namespace}** - Agent cards 3. **mcp_scopes_{namespace}** - Authorization scopes 4. **mcp_embeddings_1536_{namespace}** - Vector embeddings 5. **mcp_security_scans_{namespace}** - Security scan results 6. **mcp_federation_config_{namespace}** - Federation configuration **Key Differences:** | Aspect | MongoDB CE | AWS DocumentDB | |--------|------------|----------------| | Vector Search | Python cosine similarity | HNSW index | | Connection | `mongodb://mongodb:27017` | `mongodb://cluster.docdb.amazonaws.com:27017` | | Authentication | None (local) | Username/Password or IAM | | TLS | Disabled | Required | | Deployment | Docker Compose | Terraform | --- ## Collection Schemas ### 1. MCP Servers **Collection:** `mcp_servers_{namespace}` Stores MCP server definitions and metadata. **Document Structure:** ```json { "_id": "/servers/financial-data", "server_name": "Financial Data Server", "description": "Provides stock market data and analysis", "path": "/servers/financial-data", "proxy_pass_url": "http://financial-server:8000", "supported_transports": ["stdio", "sse"], "auth_type": "oauth", "tags": ["finance", "data", "stocks"], "num_tools": 15, "tool_list": [ { "name": "get_stock_price", "description": "Get current stock price", "schema": { /* JSON schema */ } } ], "is_enabled": true, "registered_at": "2026-01-03T10:00:00Z", "updated_at": "2026-01-03T12:30:00Z", "ans_metadata": null } ``` The `ans_metadata` field follows the same structure as in the agents collection (see below). It is `null` when no ANS link is configured. **Indexes:** - `path` (unique) - Primary key - `is_enabled` - Filter active servers - `tags` - Tag-based filtering - `server_name` - Text search --- ### 2. A2A Agents **Collection:** `mcp_agents_{namespace}` Stores Agent-to-Agent (A2A) agent cards and capabilities. **Document Structure:** ```json { "_id": "/agents/financial-analyst", "protocol_version": "1.0", "name": "Financial Analysis Agent", "description": "Analyzes financial data and provides insights", "path": "/agents/financial-analyst", "url": "https://registry.example.com/agents/financial-analyst", "version": "2.1.0", "capabilities": ["analysis", "reporting", "forecasting"], "tags": ["finance", "analysis"], "is_enabled": true, "visibility": "public", "trust_level": "high", "registered_at": "2026-01-02T09:00:00Z", "updated_at": "2026-01-03T11:00:00Z", "ans_metadata": { "ans_agent_id": "ans://v1.0.0.agent.example.com", "linked_at": "2026-01-02T09:00:00Z", "last_verified": "2026-01-02T09:00:00Z", "status": "verified", "domain": "agent.example.com", "organization": null, "ans_name": "ans://v1.0.0.agent.example.com", "ans_display_name": "Financial Analysis Agent", "ans_version": "1.0.0", "registered_with_ans_at": "2026-01-01T12:00:00Z", "certificate": null, "endpoints": [ { "type": "http", "url": "https://agent.example.com/a2a", "protocol": "A2A", "transports": ["STREAMABLE-HTTP"], "functions": [] } ], "links": [ { "rel": "self", "href": "https://api.godaddy.com/v1/agents/uuid" }, { "rel": "server-certificates", "href": "https://api.godaddy.com/v1/agents/uuid/certificates/server" }, { "rel": "identity-certificates", "href": "https://api.godaddy.com/v1/agents/uuid/certificates/identity" } ], "raw_ans_response": {} } } ``` The `ans_metadata` field is `null` when no ANS Agent ID is linked. It is populated when an agent is linked to the GoDaddy Agent Name Service (ANS) for PKI-based identity verification. **Indexes:** - `path` (unique) - Primary key - `is_enabled` - Filter active agents - `tags` - Tag-based filtering - `name` - Text search - `visibility` - Access control - `ans_metadata.status` - Filter by ANS verification status --- ### 3. Authorization Scopes **Collection:** `mcp_scopes_{namespace}` Stores authorization scopes, permission mappings, and UI access control. **Document Types:** The scopes collection stores three document types, distinguished by `scope_type`: #### Server Scope Document ```json { "_id": "scope:admin_access", "scope_type": "server_scope", "scope_name": "admin_access", "server_access": [ { "server": "financial_server", "methods": ["GET", "POST", "PUT"], "tools": ["analyze_data", "generate_report"] } ], "description": "Full access to financial servers", "created_at": "2026-01-01T08:00:00Z", "updated_at": "2026-01-03T10:00:00Z" } ``` #### Group Mapping Document ```json { "_id": "group:finance_team", "scope_type": "group_mapping", "group_name": "finance_team", "group_mappings": ["admin_access", "read_only_access"], "created_at": "2026-01-01T08:00:00Z", "updated_at": "2026-01-03T10:00:00Z" } ``` #### UI Scope Document ```json { "_id": "ui:finance_team", "scope_type": "ui_scope", "scope_name": "finance_team", "ui_permissions": { "list_service": ["financial_server", "analytics_server"] }, "created_at": "2026-01-01T08:00:00Z", "updated_at": "2026-01-03T10:00:00Z" } ``` **Indexes:** - `_id` (unique) - Primary key - `scope_type` - Document type filter - `scope_name` - Scope lookup - `group_name` - Group lookup --- ### 4. Vector Embeddings **Collection:** `mcp_embeddings_{dimensions}_{namespace}` Example: `mcp_embeddings_1536_default` for 1536-dimensional embeddings Stores vector embeddings for semantic search across servers and agents. **Document Structure:** ```json { "_id": "/servers/financial-data", "entity_type": "mcp_server", "path": "/servers/financial-data", "name": "Financial Data Server", "description": "Provides stock market data and analysis", "tags": ["finance", "data"], "is_enabled": true, "text_for_embedding": "Financial Data Server. Provides stock market data and analysis. Tools: get_stock_price, analyze_portfolio", "embedding": [0.125, -0.342, 0.098, ...], // 1536 floats "embedding_metadata": { "model": "amazon.titan-embed-text-v1", "provider": "litellm", "dimensions": 1536, "created_at": "2026-01-03T10:30:00Z" }, "tools": [ {"name": "get_stock_price", "description": "Get current stock price"} ], "metadata": { /* full server info */ }, "indexed_at": "2026-01-03T10:30:00Z" } ``` **Indexes:** - `path` (unique) - Primary key - `entity_type` - Filter by entity type - `embedding` (vector) - **DocumentDB only:** HNSW vector index for fast similarity search ```javascript // HNSW index configuration (DocumentDB) { "type": "hnsw", "similarity": "cosine", "dimensions": 1536, "m": 16, "efConstruction": 128 } ``` **Vector Search:** - **MongoDB CE:** Application-level cosine similarity in Python - **DocumentDB:** Native HNSW index for sub-100ms queries --- ### 5. Security Scans **Collection:** `mcp_security_scans_{namespace}` Stores security vulnerability scan results. **Document Structure:** ```json { "_id": "scan:financial_server:2026-01-03", "server_path": "/servers/financial-data", "scan_timestamp": "2026-01-03T14:00:00Z", "scan_status": "unsafe", "vulnerabilities": [ { "severity": "high", "title": "SQL Injection vulnerability", "description": "User input not sanitized", "cve_id": "CVE-2024-12345", "package_name": "db-connector", "package_version": "2.1.0", "fixed_version": "2.1.5" } ], "risk_score": 0.75, "total_vulnerabilities": 2, "critical_count": 0, "high_count": 1, "medium_count": 1, "low_count": 0 } ``` **Indexes:** - `server_path` - Lookup scans by server - `scan_status` - Filter by status - `scan_timestamp` (descending) - Get latest scans --- ### 6. Federation Config **Collection:** `mcp_federation_config_{namespace}` Stores federation configuration for external registries (Anthropic, ASOR). **Document Structure:** ```json { "_id": "federation-config", "anthropic": { "enabled": true, "endpoint": "https://registry.modelcontextprotocol.io", "sync_on_startup": true, "servers": [ {"name": "weather-service"}, {"name": "news-aggregator"} ] }, "asor": { "enabled": false, "endpoint": "https://asor-registry.example.com", "auth_env_var": "ASOR_AUTH_TOKEN", "sync_on_startup": false, "agents": [] }, "updated_at": "2026-01-03T12:00:00Z" } ``` **Indexes:** - `_id` (unique) - Single config per namespace --- ## Vector Search Architecture ### Embedding Generation **Module:** `registry/embeddings/` **Supported Providers:** 1. **Sentence Transformers** (Default, Local) - Model: `all-MiniLM-L6-v2` (384 dimensions) - Runs locally, no API costs - Good for development 2. **OpenAI** (Cloud) - Model: `text-embedding-ada-002` (1536 dimensions) - Requires API key - High quality embeddings 3. **Amazon Bedrock Titan** (Cloud) - Model: `amazon.titan-embed-text-v1` (1536 dimensions) - Uses IAM authentication - AWS-native integration ### Search Implementation **See:** [Storage Architecture: MongoDB CE & AWS DocumentDB](./design/storage-architecture-mongodb-documentdb.md) for detailed search implementation. **Summary:** | Backend | Algorithm | Complexity | Latency | |---------|-----------|------------|---------| | MongoDB CE | Python cosine similarity | O(n) | 50-200ms | | DocumentDB | HNSW index | O(log n) | 10-50ms | ### Hybrid Search Both backends combine: - **Vector similarity** (semantic matching) - Primary ranking - **Text matching** (keyword boosting) - Secondary bonus **Formula:** ``` final_score = vector_score + (text_boost * 0.03) Where: vector_score = cosine_similarity(query_embedding, doc_embedding) // 0-1 text_boost = 3.0 (name match) + 2.0 (description match) // 0-5 ``` --- ## Configuration ### Environment Variables **File:** `.env` ```bash # Storage Backend Selection # Options: # "file" - JSON files (legacy) # "mongodb-ce" - MongoDB Community Edition (local dev) # "documentdb" - AWS DocumentDB (production) STORAGE_BACKEND=mongodb-ce # MongoDB/DocumentDB Connection DOCUMENTDB_HOST=mongodb # Local: "mongodb", Prod: "cluster.docdb.amazonaws.com" DOCUMENTDB_PORT=27017 DOCUMENTDB_DATABASE=mcp_registry DOCUMENTDB_NAMESPACE=default # Multi-tenancy: dev, staging, production # Authentication (not needed for MongoDB CE) DOCUMENTDB_USERNAME=admin DOCUMENTDB_PASSWORD=secure_password # TLS (MongoDB CE: false, DocumentDB: true) DOCUMENTDB_USE_TLS=false DOCUMENTDB_TLS_CA_FILE=global-bundle.pem DOCUMENTDB_USE_IAM=false # Replica Set DOCUMENTDB_REPLICA_SET=rs0 DOCUMENTDB_READ_PREFERENCE=secondaryPreferred # Embeddings Configuration EMBEDDINGS_PROVIDER=sentence-transformers # Or: litellm EMBEDDINGS_MODEL_NAME=all-MiniLM-L6-v2 # Or: openai/text-embedding-ada-002 EMBEDDINGS_MODEL_DIMENSIONS=384 # Or: 1536 ``` ### Initialization **MongoDB CE:** ```bash # Start MongoDB and initialize docker compose up -d mongodb docker compose up mongodb-init # Verify docker exec mcp-mongodb mongosh --eval "use mcp_registry; show collections" ``` **AWS DocumentDB:** ```bash # Deploy with Terraform cd terraform/aws-ecs terraform apply # Collections and indexes created automatically on first application startup ``` --- ## Repository Layer All database operations go through repository interfaces defined in [`registry/repositories/interfaces.py`](../registry/repositories/interfaces.py): - **ServerRepositoryBase:** Server CRUD operations - **AgentRepositoryBase:** Agent card CRUD operations - **ScopeRepositoryBase:** Authorization scope management - **SecurityScanRepositoryBase:** Vulnerability scan storage - **FederationConfigRepositoryBase:** Federation configuration - **SearchRepositoryBase:** Vector search operations **Factory:** `registry/repositories/factory.py` The repository factory automatically selects the correct implementation based on `STORAGE_BACKEND`: ```python if backend in ["documentdb", "mongodb-ce"]: from .documentdb.server_repository import DocumentDBServerRepository return DocumentDBServerRepository() else: from .file.server_repository import FileServerRepository return FileServerRepository() ``` **Key Point:** `mongodb-ce` and `documentdb` use the **same repository code**. The only difference is the connection configuration. --- ## Migration from File Backend ### To MongoDB CE (Local Development) 1. **Update configuration:** ```bash # In .env STORAGE_BACKEND=mongodb-ce ``` 2. **Start MongoDB:** ```bash docker compose up -d mongodb docker compose up mongodb-init ``` 3. **Re-register servers and agents:** ```bash # Use API to register from backup files for file in backup/*.json; do curl -X POST http://localhost:7860/servers \ -H "Content-Type: application/json" \ -d @"$file" done ``` ### To AWS DocumentDB (Production) 1. **Deploy infrastructure:** ```bash cd terraform/aws-ecs terraform apply ``` 2. **Update configuration:** ```bash STORAGE_BACKEND=documentdb DOCUMENTDB_HOST= DOCUMENTDB_USERNAME= DOCUMENTDB_PASSWORD= DOCUMENTDB_USE_TLS=true ``` 3. **Import data:** ```bash # Use mongodump/mongorestore or API mongorestore --host= --ssl --db=mcp_registry ./backup ``` --- ## Performance Considerations ### MongoDB CE (Local Development) - **Good for:** <10,000 documents - **Search latency:** 50-200ms (O(n) scan) - **Indexing:** Fast document insertion - **Scaling:** Limited to single container resources ### AWS DocumentDB (Production) - **Good for:** Millions of documents - **Search latency:** 10-50ms (O(log n) HNSW) - **Indexing:** Distributed across cluster - **Scaling:** Horizontal (add read replicas), vertical (instance size) ### Optimization Tips 1. **Use appropriate instance sizes** (DocumentDB) - `db.r5.large` for development - `db.r5.xlarge` or larger for production 2. **Enable read replicas** for high read throughput 3. **Tune HNSW parameters** (DocumentDB) - `m=16, efConstruction=128` balances accuracy and speed - Increase for higher accuracy (slower) - Decrease for faster search (lower accuracy) 4. **Monitor query patterns** and create additional indexes as needed --- ## See Also - **[Storage Architecture: MongoDB CE & AWS DocumentDB](./design/storage-architecture-mongodb-documentdb.md)** - Comprehensive guide - **[Database Abstraction Layer Design](./design/database-abstraction-layer.md)** - Repository pattern details - **[Embeddings Configuration](./embeddings.md)** - Vector embedding setup - **[Configuration Guide](./configuration.md)** - Full configuration reference - [MongoDB Documentation](https://www.mongodb.com/docs/manual/) - [AWS DocumentDB Documentation](https://docs.aws.amazon.com/documentdb/) ================================================ FILE: docs/datastore-management.md ================================================ # Datastore Management Guide **Last Updated:** January 3, 2026 **Applies to:** All storage backends (MongoDB CE, AWS DocumentDB) --- ## Table of Contents 1. [Overview](#overview) 2. [Local Development - MongoDB CE](#local-development---mongodb-ce) 3. [Production - AWS DocumentDB via ECS](#production---aws-documentdb-via-ecs) 4. [Common Operations](#common-operations) 5. [Troubleshooting](#troubleshooting) --- ## Overview The MCP Gateway Registry uses MongoDB-compatible datastores for storage: - **Local Development:** MongoDB Community Edition 8.2 in Docker - **Production:** AWS DocumentDB (MongoDB-compatible managed service) This guide explains how to access and manage datastores in both environments. --- ## Local Development - MongoDB CE ### Prerequisites - MongoDB container running: `docker compose ps mongodb` shows "healthy" - No authentication required (configured for local dev simplicity) ### Accessing the Datastore (mongosh) #### Method 1: Direct Docker Exec (Recommended) ```bash # Connect to MongoDB shell docker exec -it mcp-mongodb mongosh # You should see: # Current Mongosh Log ID: ... # Connecting to: mongodb://127.0.0.1:27017/?directConnection=true # ... # rs0 [direct: primary] test> ``` #### Method 2: Connect from Host Machine If you have `mongosh` installed locally: ```bash mongosh mongodb://localhost:27017/mcp_registry ``` ### Basic Datastore Operations Once connected to mongosh: ```javascript // Switch to the registry database use mcp_registry // List all collections show collections // Expected output: // mcp_agents_default // mcp_embeddings_1536_default // mcp_federation_config_default // mcp_scopes_default // mcp_security_scans_default // mcp_servers_default // Check replica set status rs.status() // View database statistics db.stats() ``` ### Viewing Collection Contents #### List All Servers ```javascript // Count total servers db.mcp_servers_default.countDocuments() // View all servers (formatted) db.mcp_servers_default.find().pretty() // View specific server by path db.mcp_servers_default.findOne({ path: "/servers/financial-data" }) // List only server names and paths db.mcp_servers_default.find( {}, { "manifest.serverInfo.name": 1, path: 1, _id: 0 } ) ``` #### List All Agents ```javascript // Count total agents db.mcp_agents_default.countDocuments() // View all agents db.mcp_agents_default.find().pretty() // Find agents by tag db.mcp_agents_default.find({ tags: "finance" }).pretty() ``` #### View Vector Embeddings ```javascript // Count embeddings db.mcp_embeddings_1536_default.countDocuments() // View embedding metadata (without the large vector array) db.mcp_embeddings_1536_default.find( {}, { path: 1, entity_type: 1, name: 1, embedding_metadata: 1, indexed_at: 1, _id: 0 } ).pretty() // Check specific embedding db.mcp_embeddings_1536_default.findOne({ path: "/servers/financial-data" }) ``` #### View Scopes ```javascript // List all scopes db.mcp_scopes_default.find().pretty() // Find server scopes db.mcp_scopes_default.find({ scope_type: "server_scope" }).pretty() // Find group mappings db.mcp_scopes_default.find({ scope_type: "group_mapping" }).pretty() ``` #### View Security Scans ```javascript // Count security scans db.mcp_security_scans_default.countDocuments() // View latest scans db.mcp_security_scans_default.find().sort({ scan_timestamp: -1 }).limit(5).pretty() // Find scans for specific server db.mcp_security_scans_default.find({ server_path: "/servers/financial-data" }).pretty() ``` ### Collection Indexes ```javascript // View indexes on servers collection db.mcp_servers_default.getIndexes() // View indexes on embeddings collection db.mcp_embeddings_1536_default.getIndexes() // Check index usage stats db.mcp_servers_default.aggregate([{ $indexStats: {} }]) ``` ### Query Performance Analysis ```javascript // Explain query execution plan db.mcp_servers_default.find({ path: "/servers/financial-data" }).explain("executionStats") // Find slow operations (if profiling enabled) db.system.profile.find({ millis: { $gt: 100 } }).sort({ ts: -1 }).limit(5).pretty() ``` ### Exiting mongosh ```javascript // Exit the shell exit ``` Or press `Ctrl+D` --- ## Production - AWS DocumentDB via ECS ### Prerequisites - AWS ECS cluster running with DocumentDB - ECS exec permissions configured - `manage-documentdb.py` script available in registry container ### Accessing DocumentDB via ECS Exec #### Step 1: SSH into Registry Container ```bash # From your local machine # Use the ecs-ssh helper script cd terraform/aws-ecs ./scripts/ecs-ssh.sh registry # Or manually with AWS CLI aws ecs execute-command \ --cluster mcp-gateway-ecs-cluster \ --task \ --container registry \ --interactive \ --command "/bin/bash" ``` #### Step 2: Activate Python Virtual Environment ```bash # Inside the ECS container source .venv/bin/activate # Verify Python environment which python # Should show: /app/.venv/bin/python ``` #### Step 3: Run DocumentDB Management Script The `manage-documentdb.py` script provides commands for managing collections and querying data. ##### List All Collections ```bash python scripts/manage-documentdb.py list ``` ##### Inspect a Collection ```bash # Show collection schema and indexes python scripts/manage-documentdb.py inspect --collection mcp_servers_default ``` ##### Count Documents ```bash # Count all documents in collection python scripts/manage-documentdb.py count --collection mcp_servers_default ``` ##### Search Documents ```bash # List documents with optional limit python scripts/manage-documentdb.py search --collection mcp_servers_default --limit 5 # Search specific collection python scripts/manage-documentdb.py search --collection mcp_agents_default --limit 10 ``` ##### View Sample Document ```bash # Show one sample document from collection python scripts/manage-documentdb.py sample --collection mcp_servers_default ``` ##### Query with Filter ```bash # Query with MongoDB filter syntax python scripts/manage-documentdb.py query \ --collection mcp_servers_default \ --filter '{"path": "/servers/financial-data"}' # Query enabled servers python scripts/manage-documentdb.py query \ --collection mcp_servers_default \ --filter '{"enabled": true}' # Query by tags python scripts/manage-documentdb.py query \ --collection mcp_servers_default \ --filter '{"tags": "finance"}' ``` ##### View Embeddings ```bash # Sample embedding document (shows structure without large vector array) python scripts/manage-documentdb.py sample --collection mcp_embeddings_1536_default # Count total embeddings python scripts/manage-documentdb.py count --collection mcp_embeddings_1536_default ``` **Note:** The script automatically reads connection parameters from environment variables in the ECS container (`DOCUMENTDB_HOST`, `DOCUMENTDB_USERNAME`, `DOCUMENTDB_PASSWORD`, etc.). --- ## Common Operations ### Checking Datastore Health #### Local (MongoDB CE) ```javascript // In mongosh db.serverStatus() db.stats() rs.status() ``` #### Production (DocumentDB) ```bash # Use count command to verify connection and check collections python scripts/manage-documentdb.py list # Check specific collection python scripts/manage-documentdb.py count --collection mcp_servers_default ``` ### Searching for Specific Documents #### Local (MongoDB CE) ```javascript // Search servers by tag db.mcp_servers_default.find({ tags: "finance" }) // Search by partial name match db.mcp_servers_default.find({ "manifest.serverInfo.name": /financial/i }) // Complex query with multiple conditions db.mcp_servers_default.find({ enabled: true, tags: { $in: ["finance", "data"] } }) ``` #### Production (DocumentDB) ```bash # Search by tags python scripts/manage-documentdb.py query \ --collection mcp_servers_default \ --filter '{"tags": "finance"}' # Query enabled servers python scripts/manage-documentdb.py query \ --collection mcp_servers_default \ --filter '{"enabled": true}' ``` ### Viewing Recent Activity #### Local (MongoDB CE) ```javascript // Recent server registrations db.mcp_servers_default.find().sort({ registered_at: -1 }).limit(5) // Recent embeddings db.mcp_embeddings_1536_default.find().sort({ indexed_at: -1 }).limit(5) // Recent security scans db.mcp_security_scans_default.find().sort({ scan_timestamp: -1 }).limit(5) ``` #### Production (DocumentDB) ```bash # View recent servers (sorted by registration) python scripts/manage-documentdb.py search \ --collection mcp_servers_default \ --limit 5 # View recent embeddings python scripts/manage-documentdb.py search \ --collection mcp_embeddings_1536_default \ --limit 5 ``` ### Backup and Export #### Local (MongoDB CE) ##### Option 1: Binary Backup (mongodump) - Recommended for Full Backups ```bash # Export entire database (BSON format - preserves data types) docker exec mcp-mongodb mongodump \ --db=mcp_registry \ --out=/tmp/mongodb-backup # Copy backup from container to host docker cp mcp-mongodb:/tmp/mongodb-backup ./mongodb-backup-$(date +%Y%m%d) # Restore from backup (if needed) docker cp ./mongodb-backup-20260103 mcp-mongodb:/tmp/restore-backup docker exec mcp-mongodb mongorestore \ --db=mcp_registry \ /tmp/restore-backup/mcp_registry ``` ##### Option 2: JSON Export (mongoexport) - Human-Readable, Portable ```bash # Export specific collection to JSON (one document per line) docker exec mcp-mongodb mongoexport \ --db=mcp_registry \ --collection=mcp_servers_default \ --out=/tmp/servers.json # Copy to host docker cp mcp-mongodb:/tmp/servers.json ./servers-backup-$(date +%Y%m%d).json # Pretty-print JSON (optional, for readability) docker exec mcp-mongodb mongoexport \ --db=mcp_registry \ --collection=mcp_servers_default \ --jsonArray \ --pretty \ --out=/tmp/servers-pretty.json # Import from JSON (if needed) docker cp ./servers-backup-20260103.json mcp-mongodb:/tmp/import-servers.json docker exec mcp-mongodb mongoimport \ --db=mcp_registry \ --collection=mcp_servers_default \ --file=/tmp/import-servers.json ``` ##### Export All Collections ```bash # Export all collections to JSON COLLECTIONS="mcp_servers_default mcp_agents_default mcp_scopes_default mcp_embeddings_1536_default mcp_security_scans_default mcp_federation_config_default" for collection in $COLLECTIONS; do echo "Exporting $collection..." docker exec mcp-mongodb mongoexport \ --db=mcp_registry \ --collection=$collection \ --out=/tmp/${collection}.json docker cp mcp-mongodb:/tmp/${collection}.json ./${collection}-$(date +%Y%m%d).json done ``` #### Production (DocumentDB) ##### Option 1: AWS Automated Backups (Recommended) AWS DocumentDB provides automated continuous backups with point-in-time recovery: ```bash # Create manual snapshot (from local machine with AWS CLI) aws docdb create-db-cluster-snapshot \ --db-cluster-snapshot-identifier mcp-registry-manual-$(date +%Y%m%d) \ --db-cluster-identifier mcp-registry-prod # List available snapshots aws docdb describe-db-cluster-snapshots \ --db-cluster-identifier mcp-registry-prod # Restore from snapshot (creates new cluster) aws docdb restore-db-cluster-from-snapshot \ --db-cluster-identifier mcp-registry-restored \ --snapshot-identifier mcp-registry-manual-20260103 \ --engine docdb ``` ##### Option 2: Binary Backup with mongodump (from ECS Container) ```bash # SSH into ECS container cd terraform/aws-ecs ./scripts/ecs-ssh.sh registry source .venv/bin/activate # Export entire database to BSON mongodump \ --host=$DOCUMENTDB_HOST \ --port=27017 \ --username=$DOCUMENTDB_USERNAME \ --password=$DOCUMENTDB_PASSWORD \ --ssl \ --sslCAFile=/app/global-bundle.pem \ --db=mcp_registry \ --out=/tmp/documentdb-backup # Upload to S3 BACKUP_DATE=$(date +%Y%m%d-%H%M%S) aws s3 cp /tmp/documentdb-backup \ s3://mcp-gateway-backups/documentdb-backup-${BACKUP_DATE}/ \ --recursive # Cleanup temporary files rm -rf /tmp/documentdb-backup echo "Backup uploaded to: s3://mcp-gateway-backups/documentdb-backup-${BACKUP_DATE}/" ``` ##### Option 3: JSON Export of Specific Collections (from ECS Container) ```bash # SSH into ECS container cd terraform/aws-ecs ./scripts/ecs-ssh.sh registry source .venv/bin/activate # Export specific collection to JSON mongoexport \ --host=$DOCUMENTDB_HOST \ --port=27017 \ --username=$DOCUMENTDB_USERNAME \ --password=$DOCUMENTDB_PASSWORD \ --ssl \ --sslCAFile=/app/global-bundle.pem \ --db=mcp_registry \ --collection=mcp_servers_default \ --out=/tmp/servers-export.json # Upload to S3 aws s3 cp /tmp/servers-export.json \ s3://mcp-gateway-backups/exports/servers-$(date +%Y%m%d).json # Cleanup rm /tmp/servers-export.json ``` ##### Restore from S3 Backup ```bash # SSH into ECS container cd terraform/aws-ecs ./scripts/ecs-ssh.sh registry source .venv/bin/activate # Download backup from S3 aws s3 cp s3://mcp-gateway-backups/documentdb-backup-20260103-120000/ \ /tmp/restore-backup/ \ --recursive # Restore using mongorestore mongorestore \ --host=$DOCUMENTDB_HOST \ --port=27017 \ --username=$DOCUMENTDB_USERNAME \ --password=$DOCUMENTDB_PASSWORD \ --ssl \ --sslCAFile=/app/global-bundle.pem \ --db=mcp_registry \ /tmp/restore-backup/mcp_registry # Cleanup rm -rf /tmp/restore-backup ``` **Important Notes:** - **mongodump/mongorestore**: Binary format (BSON), preserves all data types including binary data and dates - **mongoexport/mongoimport**: JSON format, human-readable but may lose type information - **For production**: Use AWS automated backups for disaster recovery, manual exports for data migration - **S3 bucket**: Replace `mcp-gateway-backups` with your actual S3 bucket name - **Embeddings**: Vector embeddings are large; consider excluding from exports if not needed: ```bash mongodump --excludeCollection=mcp_embeddings_1536_default ... ``` --- ## Troubleshooting ### Cannot Connect to MongoDB (Local) **Problem:** `docker exec -it mcp-mongodb mongosh` fails **Solutions:** ```bash # Check if container is running docker compose ps mongodb # Check container logs docker compose logs mongodb # Restart MongoDB docker compose restart mongodb # If needed, recreate container docker compose up -d mongodb ``` ### Cannot Connect to DocumentDB (Production) **Problem:** `manage-documentdb.py` commands fail with connection errors **Solutions:** ```bash # 1. Verify you're in the ECS container with activated venv source .venv/bin/activate # 2. Check environment variables are set env | grep DOCUMENTDB # 3. Test connection with simple list command python scripts/manage-documentdb.py list # 4. Check security group allows access from ECS tasks (from local machine) aws ec2 describe-security-groups --group-ids # 5. Verify DocumentDB endpoint (from local machine) aws docdb describe-db-clusters --db-cluster-identifier mcp-registry-prod ``` ### Replica Set Not Initialized (Local) **Problem:** `rs.status()` shows "not initialized" **Solutions:** ```bash # Re-run initialization docker compose up mongodb-init # Or manually initialize docker exec -it mcp-mongodb mongosh --eval 'rs.initiate({_id: "rs0", members: [{_id: 0, host: "mongodb:27017"}]})' ``` ### Collections Not Found **Problem:** `show collections` returns empty **Solutions:** ```bash # Verify you're in correct database # In mongosh: db.getName() // Should show "mcp_registry" # Re-run initialization docker compose up mongodb-init # Check if data is in different namespace db.getCollectionNames() ``` ### Slow Queries **Problem:** Queries taking too long **Solutions (Local MongoDB CE):** ```javascript // In mongosh - Check if indexes exist db.mcp_servers_default.getIndexes() // Analyze query plan db.mcp_servers_default.find({ path: "..." }).explain("executionStats") // Check embeddings indexes db.mcp_embeddings_1536_default.getIndexes() ``` **Solutions (Production DocumentDB):** ```bash # Use inspect command to check indexes python scripts/manage-documentdb.py inspect --collection mcp_servers_default # Check embeddings collection indexes python scripts/manage-documentdb.py inspect --collection mcp_embeddings_1536_default ``` --- ## Quick Reference ### Connection Strings **Local MongoDB CE:** ``` mongodb://localhost:27017/mcp_registry ``` **Production DocumentDB:** ``` mongodb://:@:27017/mcp_registry?tls=true&tlsCAFile=global-bundle.pem&replicaSet=rs0 ``` ### Common mongosh Commands | Command | Description | |---------|-------------| | `show dbs` | List all databases | | `use mcp_registry` | Switch to mcp_registry database | | `show collections` | List all collections | | `db.stats()` | Database statistics | | `rs.status()` | Replica set status | | `db.mcp_servers_default.find()` | List all servers | | `db.mcp_servers_default.countDocuments()` | Count documents | | `.pretty()` | Format output nicely | | `exit` | Exit mongosh | ### Environment Variables Reference **Local (.env):** ```bash STORAGE_BACKEND=mongodb-ce DOCUMENTDB_HOST=mongodb DOCUMENTDB_PORT=27017 DOCUMENTDB_DATABASE=mcp_registry DOCUMENTDB_NAMESPACE=default DOCUMENTDB_USE_TLS=false ``` **Production (ECS Task Definition):** ```bash STORAGE_BACKEND=documentdb DOCUMENTDB_HOST= DOCUMENTDB_PORT=27017 DOCUMENTDB_DATABASE=mcp_registry DOCUMENTDB_NAMESPACE=production DOCUMENTDB_USERNAME= DOCUMENTDB_PASSWORD= DOCUMENTDB_USE_TLS=true DOCUMENTDB_TLS_CA_FILE=/app/global-bundle.pem DOCUMENTDB_REPLICA_SET=rs0 ``` --- ## See Also - [Storage Architecture: MongoDB CE & AWS DocumentDB](design/storage-architecture-mongodb-documentdb.md) - [Datastore Schema Design](database-design.md) - [Configuration Guide](configuration.md) - [MongoDB Documentation](https://www.mongodb.com/docs/manual/) - [AWS DocumentDB Documentation](https://docs.aws.amazon.com/documentdb/) ================================================ FILE: docs/deployment-modes.md ================================================ # MCP Gateway Deployment Modes This guide describes the three deployment modes available for MCP Gateway Registry on AWS ECS. ## Deployment Mode Decision Matrix | Scenario | Recommended Mode | `enable_cloudfront` | `enable_route53_dns` | |----------|------------------|---------------------|----------------------| | Custom domain with Route53/ACM | Custom Domain | `false` | `true` | | HTTPS without custom domain | CloudFront | `true` | `false` | | Local development/testing | Development | `false` | `false` | | Both access paths needed | Dual Ingress | `true` | `true` | ## Terraform Output: `deployment_mode` The `deployment_mode` output indicates the active configuration: | Mode | `enable_cloudfront` | `enable_route53_dns` | Output Value | |------|---------------------|----------------------|--------------| | CloudFront | `true` | `false` | `cloudfront` | | Custom Domain | `false` | `true` | `custom-domain` | | Dual Ingress | `true` | `true` | `custom-domain` | | Development | `false` | `false` | `development` | > **Note:** Dual Ingress reports as `custom-domain` since Route53 DNS is the primary access path. ## Architecture Overview ```mermaid flowchart TB subgraph "Custom Domain Mode" U1[Users] -->|HTTPS| R53[Route53 DNS] R53 --> ACM1[ACM Certificate] ACM1 --> ALB1[ALB with HTTPS] ALB1 --> ECS1[ECS Services] end subgraph "CloudFront Mode" U2[Users] -->|HTTPS| CF[CloudFront] CF -->|HTTP + Custom Header| ALB2[ALB] ALB2 --> ECS2[ECS Services] end subgraph "Development Mode" U3[Users] -->|HTTP| ALB3[ALB DNS] ALB3 --> ECS3[ECS Services] end ``` ## Mode 1: Custom Domain (Route53/ACM) **Use when:** You have a Route53 hosted zone and want custom domain URLs. **Configuration:** ```hcl enable_cloudfront = false enable_route53_dns = true base_domain = "mycorp.click" ``` **URLs:** - Registry: `https://registry.us-west-2.mycorp.click` - Keycloak: `https://kc.us-west-2.mycorp.click` **Features:** - ACM certificates for HTTPS - Custom domain names - Route53 DNS records ## Mode 2: CloudFront (No Custom Domain) **Use when:** You need HTTPS but don't have a custom domain or Route53 hosted zone. Ideal for workshops, demos, evaluations, or any deployment where custom DNS isn't available. **Configuration:** ```hcl enable_cloudfront = true enable_route53_dns = false ``` **URLs:** - Registry: `https://d1234abcd.cloudfront.net` - Keycloak: `https://d5678efgh.cloudfront.net` **Features:** - Default CloudFront certificates (`*.cloudfront.net`) - No custom domain required - HTTPS via CloudFront TLS termination - Custom `X-Cloudfront-Forwarded-Proto` header for correct HTTPS detection ## Mode 3: Development (HTTP Only) **Use when:** Testing locally or in non-production environments. **Configuration:** ```hcl enable_cloudfront = false enable_route53_dns = false ``` **URLs:** - Registry: `http://` - Keycloak: `http://` **Features:** - HTTP only (no HTTPS) - Direct ALB access - Simplest configuration ## Mode 4: Dual Ingress (Both) **Use when:** You need both CloudFront and custom domain access paths. **Configuration:** ```hcl enable_cloudfront = true enable_route53_dns = true base_domain = "mycorp.click" ``` **URLs:** - Registry (CloudFront): `https://d1234abcd.cloudfront.net` - Registry (Custom): `https://registry.us-west-2.mycorp.click` - Keycloak (CloudFront): `https://d5678efgh.cloudfront.net` - Keycloak (Custom): `https://kc.us-west-2.mycorp.click` > **Note:** This is NOT a security risk, but may cause user confusion. A warning is displayed during `terraform apply`. ## Environment Variables | Variable | Description | Required For | |----------|-------------|--------------| | `enable_cloudfront` | Enable CloudFront distributions | CloudFront mode | | `enable_route53_dns` | Enable Route53 DNS and ACM certificates | Custom Domain mode | | `base_domain` | Base domain for regional URLs | Custom Domain mode | | `keycloak_domain` | Full Keycloak domain (non-regional) | Custom Domain mode | | `root_domain` | Root domain (non-regional) | Custom Domain mode | ## HTTPS Detection The application detects HTTPS using the following header priority: 1. `X-Forwarded-Proto: https` (CloudFront and ALB deployments) 2. Request URL scheme (direct access) CloudFront is configured to send `X-Forwarded-Proto: https` as a custom origin header. This is the same header that ALB uses, so the application code works consistently across deployment modes. > **Note:** We use `X-Forwarded-Proto` (not a custom header like `X-Cloudfront-Forwarded-Proto`) because Keycloak natively recognizes this header for HTTPS detection. ## Troubleshooting ### Session cookies not working with CloudFront **Symptom:** Login succeeds but user is immediately logged out. **Cause:** The `Secure` flag on cookies requires HTTPS detection to work correctly. **Solution:** Verify the `X-Forwarded-Proto` header is being set by CloudFront. Check CloudFront distribution origin settings. ### OAuth2 redirect fails with "Invalid parameter: redirect_uri" **Symptom:** After clicking login, Keycloak shows "Invalid parameter: redirect_uri" error. **Cause:** The CloudFront URL is not in Keycloak's allowed redirect URIs for the `mcp-gateway-web` client. **Solution:** 1. Re-run `init-keycloak.sh` after generating fresh terraform outputs: ```bash cd terraform/aws-ecs terraform output -json > scripts/terraform-outputs.json export INITIAL_ADMIN_PASSWORD="your-password" ./scripts/init-keycloak.sh ``` 2. Or manually add the CloudFront URL to Keycloak: - Go to Keycloak Admin → mcp-gateway realm → Clients → mcp-gateway-web - Add `https:///*` and `https:///oauth2/callback/keycloak` to Valid Redirect URIs - Add `https://` to Web Origins ### OAuth2 redirect_uri is malformed (missing hostname) **Symptom:** The redirect_uri in the OAuth2 request looks like `https:/oauth2/callback/keycloak` (missing hostname). **Cause:** The MCP Gateway ECS task doesn't have the correct `REGISTRY_URL` environment variable set. **Solution:** Ensure `domain_name` is passed to the MCP Gateway module in `main.tf`: ```hcl domain_name = var.enable_route53_dns ? "registry.${local.root_domain}" : ( var.enable_cloudfront ? aws_cloudfront_distribution.mcp_gateway[0].domain_name : "" ) ``` Then run `terraform apply` to update the ECS task definition. ### Keycloak shows "HTTPS required" error **Symptom:** Keycloak returns an error about HTTPS being required. **Cause:** Keycloak doesn't recognize it's behind HTTPS when accessed via CloudFront. **Solution:** Ensure CloudFront is sending `X-Forwarded-Proto: https` header (not a custom header name). In `cloudfront.tf`: ```hcl custom_header { name = "X-Forwarded-Proto" value = "https" } ``` Also ensure Keycloak is configured with `KC_HOSTNAME_URL` (full URL with `https://`) instead of just `KC_HOSTNAME`. ### API returns 403 Forbidden after login **Symptom:** Login succeeds but API calls return 403 "Access forbidden". **Cause:** Either the user doesn't have required group memberships, or the MCP scopes haven't been initialized on EFS. **Solution:** 1. Check user groups in Keycloak Admin → mcp-gateway realm → Users → select user → Groups 2. Ensure user is in `mcp-registry-admin` or `mcp-registry-user` group 3. Run the scopes init task: ```bash ./scripts/run-scopes-init-task.sh --skip-build ``` 4. Restart the registry and auth services: ```bash aws ecs update-service --cluster mcp-gateway-ecs-cluster --service mcp-gateway-v2-registry --force-new-deployment --region us-west-2 aws ecs update-service --cluster mcp-gateway-ecs-cluster --service mcp-gateway-v2-auth --force-new-deployment --region us-west-2 ``` ### Nginx returns default page instead of registry **Symptom:** Accessing the registry URL shows the default nginx welcome page. **Cause:** The nginx default site configuration is intercepting requests before they reach the registry. **Solution:** The `docker/registry-entrypoint.sh` should remove the default site: ```bash rm -f /etc/nginx/sites-enabled/default ``` Rebuild and redeploy the registry container. ### Certificate validation timeout **Symptom:** `terraform apply` hangs on ACM certificate validation. **Cause:** Route53 hosted zone doesn't exist or DNS propagation is slow. **Solution:** 1. Verify the hosted zone exists: `aws route53 list-hosted-zones` 2. Check the `base_domain` matches your hosted zone 3. Wait for DNS propagation (up to 5 minutes) ### CloudFront 502 errors **Symptom:** CloudFront returns 502 Bad Gateway. **Cause:** ALB is not responding or security group blocks CloudFront. **Solution:** 1. Verify ALB health checks are passing 2. Ensure ALB security group allows inbound from CloudFront (via prefix list or `0.0.0.0/0`) 3. Check ECS service is running and healthy ## Custom Domain with CloudFront While out of scope for automation, you can manually configure a custom domain in front of CloudFront: 1. Create an ACM certificate in `us-east-1` (required for CloudFront) 2. Add the custom domain as an alternate domain name (CNAME) in CloudFront 3. Create a Route53 ALIAS record pointing to the CloudFront distribution 4. Update Keycloak `KC_HOSTNAME` to use the custom domain Refer to [AWS CloudFront documentation](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/CNAMEs.html) for detailed instructions. ================================================ FILE: docs/design/a2a-protocol-integration.md ================================================ # A2A Protocol Integration: Comprehensive Developer Guide This guide documents the Agent-to-Agent (A2A) protocol implementation in the MCP Gateway Registry. Rather than a specification, this is a practical guide to understanding how the system works today, how agents register themselves, how discovery works, and how access control is enforced across the entire stack. ## Table of Contents 1. [What We Built](#what-we-built) 2. [The Big Picture: Request Flow](#the-big-picture-request-flow) 3. [How Requests Get Authenticated](#how-requests-get-authenticated) 4. [The Agent Card: Machine-Readable Profile](#the-agent-card-machine-readable-profile) 5. [CRUD Operations: Agents Registering Themselves](#crud-operations-agents-registering-themselves) 6. [Discovery: How Agents Find Other Agents](#discovery-how-agents-find-other-agents) 7. [Access Control: Three-Tier Permission System](#access-control-three-tier-permission-system) 8. [The Code: Where Everything Lives](#the-code-where-everything-lives) --- ## What We Built The MCP Gateway Registry now supports Agent-to-Agent (A2A) communication through a **registry-only design**. This means: - Agents can register their capabilities and metadata with the registry - Agents can discover other agents they have permission to access - Agents communicate directly with each other using URLs returned by the registry - **The registry itself is NOT involved in agent-to-agent communication** This is fundamentally different from how the MCP Gateway works. The gateway proxies MCP server requests, but for A2A agents, it simply acts as a discovery and validation service. Once agents find each other through the registry, they communicate peer-to-peer with no registry intermediation. ### Why This Matters Building an autonomous agent ecosystem requires that agents be able to find each other without a central orchestrator. This architecture enables: - **Decentralized coordination**: Agents discover and contact each other directly - **Scalability**: No bottleneck at the registry for agent-to-agent communication - **Security**: Each agent maintains its own authentication and authorization - **Autonomy**: Agents can operate independently after discovery --- ## The Big Picture: Request Flow When an agent wants to register or discover other agents, here's the complete journey of a request: ``` Agent (AI Code) ↓ M2M Token (from Keycloak Service Account) ↓ [Port 80 - Nginx Reverse Proxy] ↓ [Auth Validation] Nginx calls auth-server:/validate Returns groups and scopes ↓ [FastAPI Routes] /api/agents/register /api/agents /api/agents/{path} /api/agents/discover/semantic etc. ↓ [Authorization Enforcement] Check if user has permission for requested action Filter results based on access control ↓ [Business Logic] Registry Services (agent_service.py) File-based persistence (agent_state.json) FAISS semantic search ↓ [Response] Agent cards, discovery results, or error ``` ### The Key Difference from MCP ``` MCP Request Flow: Agent → Nginx → Auth → FastAPI → Gateway Proxy → MCP Server → Agent A2A Request Flow: Agent → Nginx → Auth → FastAPI → Registry Service ↓ Returns: Agent Card + Direct URL Agent ← [Agents now communicate directly, registry is done] → Other Agent ``` --- ## How Requests Get Authenticated Every request to the A2A agent API must include a valid JWT token from Keycloak. Here's the authentication journey: ### 1. Token Generation (M2M Service Account) The `mcp-gateway-m2m` Keycloak service account generates tokens that are used for all A2A operations: ```bash # Service Account Details Client ID: mcp-gateway-m2m Service User: service-account-mcp-gateway-m2m Token File: .oauth-tokens/ingress.json (generated by credentials-provider/generate_creds.sh) TTL: 5 minutes (expiration is critical) ``` When a token is generated, it contains: ```json { "exp": 1761942660, "iat": 1761942360, "iss": "http://localhost:8080/realms/mcp-gateway", "sub": "user-id-uuid", "typ": "Bearer", "azp": "mcp-gateway-m2m", "client_id": "mcp-gateway-m2m", "preferred_username": "service-account-mcp-gateway-m2m", "groups": [ "mcp-servers-unrestricted", "a2a-agent-admin" ], "scope": "profile email mcp-servers-unrestricted/read mcp-servers-unrestricted/execute a2a-agent-admin" } ``` The critical fields are: - `groups`: List of Keycloak groups the account belongs to (controls what agents it can access) - `exp`: Expiration timestamp (checked for token validity) ### 2. Nginx Reverse Proxy Intercepts Request Nginx runs on port 80 and intercepts all requests to `/api/` paths. It extracts the JWT and calls the auth-server to validate it: ``` curl -H "Authorization: Bearer $TOKEN" \ http://localhost/api/agents/register Nginx intercepts → Calls auth-server:/validate ``` The auth-server validates the JWT and maps the groups in the token to internal scope names. ### 3. Auth-Server Validates and Maps Groups The auth-server decodes the JWT, extracts the groups, and looks them up in `auth_server/scopes.yml`: ```yaml # Example from scopes.yml mcp-registry-admin: - mcp-registry-admin - mcp-servers-unrestricted/read - mcp-servers-unrestricted/execute a2a-agent-admin: - a2a-agent-admin # Implicit (service accounts have special mapping) ``` The auth-server returns: ```json { "groups": ["mcp-servers-unrestricted", "a2a-agent-admin"], "scopes": ["mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute", "a2a-agent-admin"], "username": "service-account-mcp-gateway-m2m" } ``` ### 4. Nginx Forwards to FastAPI with Scopes Nginx adds a header with the scopes and forwards the request: ``` X-Scopes: a2a-agent-admin, mcp-servers-unrestricted/read, mcp-servers-unrestricted/execute Authorization: Bearer $TOKEN ``` ### 5. FastAPI Endpoint Checks Permissions The FastAPI endpoint reads the scopes and enforces permissions: ```python @router.post("/agents/register") async def register_agent( request: Request, agent_card: AgentCard, user_context: dict = Depends(enhanced_auth) ): # Check if user has a2a-agent-admin scope if "a2a-agent-admin" not in user_context.get("scopes", []): raise HTTPException(status_code=403, detail="Not authorized") # Proceed with registration ``` ### 6. Agent State Persisted The registered agent is saved to `registry/agents/agent_state.json` in the format: ```json { "agents": { "/code-reviewer": { "name": "Code Reviewer Agent", "path": "/code-reviewer", "url": "https://agent.example.com/code-reviewer", "protocol_version": "1.0", "is_enabled": true, "registered_at": "2025-11-09T10:30:00Z", "registered_by": "service-account-mcp-gateway-m2m", "visibility": "public" } } } ``` ### Token Validation in CLI The CLI (`cli/agent_mgmt.py`) validates tokens before making requests. It checks: 1. **Token exists**: `.oauth-tokens/ingress.json` file is present 2. **Token is not expired**: Decodes JWT payload, checks `exp` claim against current timestamp 3. **Token has correct groups**: Verifies `groups` claim includes required groups This ensures requests fail fast with clear messages if credentials are stale (tokens expire in 5 minutes). --- ## The Agent Card: Machine-Readable Profile An agent card is a JSON document that describes what an agent does, how to reach it, and what capabilities it offers. The registry stores these cards and returns them during discovery. ### Complete Agent Card Structure ```json { "protocol_version": "1.0", "name": "Code Reviewer Agent", "description": "Analyzes Python and JavaScript code for bugs, style issues, and security vulnerabilities", "url": "https://agents.example.com/code-reviewer", "version": "2.1.0", "provider": "Acme Corp", "skills": [ { "id": "review-python-code", "name": "Review Python Code", "description": "Performs static analysis on Python source code", "parameters": { "type": "object", "properties": { "code": { "type": "string", "description": "Python source code to review" }, "strict_mode": { "type": "boolean", "default": false, "description": "Enable strict analysis rules" } }, "required": ["code"] }, "tags": ["python", "code-review", "security"] }, { "id": "review-javascript-code", "name": "Review JavaScript Code", "description": "Performs static analysis on JavaScript source code", "parameters": { "type": "object", "properties": { "code": { "type": "string" }, "strict_mode": { "type": "boolean", "default": false } }, "required": ["code"] }, "tags": ["javascript", "code-review", "security"] } ], "security_schemes": { "bearer": { "type": "http", "scheme": "bearer", "bearer_format": "JWT" } }, "security": [{"bearer": []}], "streaming": false, "path": "/code-reviewer", "tags": [ "code-review", "security", "static-analysis" ], "is_enabled": true, "num_stars": 0, "license": "MIT", "visibility": "public", "allowed_groups": [], "trust_level": "community", "registered_at": "2025-11-09T10:30:00Z", "registered_by": "service-account-mcp-gateway-m2m", "updated_at": "2025-11-09T10:35:00Z" } ``` ### Field Descriptions **Core A2A Fields** (required): - `protocol_version`: A2A protocol version (currently "1.0") - `name`: Human-readable agent name - `description`: What the agent does - `url`: Direct URL to reach the agent (used by other agents after discovery) **Capabilities**: - `skills`: List of capabilities the agent offers. Each skill has: - `id`: Unique identifier within the agent - `name`: Human-readable name - `description`: What the skill does - `parameters`: JSON Schema defining input parameters - `tags`: Categorization for discovery **Security**: - `security_schemes`: How to authenticate with the agent (bearer, OAuth2, etc.) - `security`: Which schemes are required - `trust_level`: Verification status (unverified, community, verified, trusted) **Registry Metadata**: - `path`: Registry path (like `/code-reviewer`) - `visibility`: Who can see it (public, private, group-restricted) - `is_enabled`: Whether it's active in the registry - `registered_at`: When it was registered - `registered_by`: Which service account registered it ### Why the Agent Card Matters The agent card is the contract between agents. When Agent B discovers Agent A, it gets the agent card which tells it: - How to reach Agent A (`url`) - What Agent A can do (`skills`) - How to authenticate with Agent A (`security_schemes`) - Whether it should trust Agent A (`trust_level`) --- ## CRUD Operations: Agents Registering Themselves All CRUD (Create, Read, Update, Delete) operations happen through REST API endpoints. Every operation requires authentication and goes through the permission check. ### Creating: POST /api/agents/register An agent registers itself by POSTing a card to the registry: ```bash curl -X POST http://localhost/api/agents/register \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d @agent-card.json ``` **What Happens**: 1. **Nginx** receives request, validates JWT, calls auth-server 2. **Auth-server** maps groups to scopes, returns `a2a-agent-admin` scope 3. **Nginx** forwards request with `X-Scopes: a2a-agent-admin` header 4. **FastAPI endpoint** checks if scopes include `a2a-agent-admin` 5. **agent_routes.py** validates the agent card using `agent_validator.py`: - Schema validation (Pydantic) - Unique path check - Skills have unique IDs - Security schemes are properly configured 6. **agent_service.py** saves to `agent_state.json` 7. **FAISS service** indexes the agent for semantic search 8. **Response**: 201 Created with registered agent info **Success Response**: ```json { "message": "Agent registered successfully", "agent": { "name": "Code Reviewer Agent", "path": "/code-reviewer", "url": "https://agents.example.com/code-reviewer", "num_skills": 2, "registered_at": "2025-11-09T10:30:00Z", "is_enabled": false } } ``` **Error Responses**: - 400 Bad Request: Invalid agent card format - 409 Conflict: Agent path already exists - 403 Forbidden: User lacks `a2a-agent-admin` scope - 422 Unprocessable Entity: Validation failed ### Reading: GET /api/agents/{path} and GET /api/agents **Get Single Agent**: ```bash curl -H "Authorization: Bearer $TOKEN" \ http://localhost/api/agents/code-reviewer ``` Returns the complete agent card (if user has permission). **List All Agents**: ```bash curl -H "Authorization: Bearer $TOKEN" \ http://localhost/api/agents ``` **What Happens**: 1. Auth checks what groups the user belongs to 2. Loads all agents from `agent_state.json` 3. **Filters by access control**: Only returns agents the user is authorized to see 4. Returns list with summaries Example: If user is in `registry-users-lob1` group, they only see agents in their scope (`/code-reviewer`, `/test-automation`). ### Updating: PUT /api/agents/{path} An agent can update its own card: ```bash curl -X PUT http://localhost/api/agents/code-reviewer \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d @updated-card.json ``` **What Happens**: 1. **Check permissions**: User must have `modify_agent` scope for this path 2. **Validate new card**: Same validation as registration 3. **Update in storage**: Modify `agent_state.json` 4. **Re-index in FAISS**: Update semantic search index 5. **Update timestamp**: Set `updated_at` to current time 6. **Return**: Updated agent card ### Deleting: DELETE /api/agents/{path} Remove an agent from the registry: ```bash curl -X DELETE http://localhost/api/agents/code-reviewer \ -H "Authorization: Bearer $TOKEN" ``` **What Happens**: 1. **Check permissions**: User must have `delete_agent` scope 2. **Remove from storage**: Delete from `agent_state.json` 3. **Remove from FAISS**: Delete from semantic search index 4. **Return**: 204 No Content or success message ### Toggling: POST /api/agents/{path}/toggle Enable or disable an agent without deleting it: ```bash curl -X POST http://localhost/api/agents/code-reviewer/toggle?enabled=true \ -H "Authorization: Bearer $TOKEN" ``` **What Happens**: 1. **Check permissions**: User must have modify permissions 2. **Toggle state**: Set `is_enabled` to true or false 3. **Update FAISS**: Enabled status affects search results 4. **Return**: Updated agent info --- ## Discovery: How Agents Find Other Agents Once agents are registered, other agents can discover them through two mechanisms: semantic search and direct queries. ### Semantic Search: POST /api/agents/discover/semantic An agent asks a natural language question to find other agents: ```bash curl -X POST http://localhost/api/agents/discover/semantic \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "query": "I need an agent that can review Python code for security vulnerabilities", "max_results": 5, "entity_types": ["a2a_agent"] }' ``` **How It Works**: 1. **Query Embedding**: The natural language query is converted to a vector using an embedding model 2. **FAISS Search**: The vector is compared against all agent cards stored in the FAISS index 3. **Ranking**: Results ranked by similarity score 4. **Filtering**: Only agents visible to this user (based on groups and visibility) 5. **Return**: Agents with `relevance_score` **Response**: ```json { "entities": [ { "entity_type": "a2a_agent", "name": "Code Reviewer Agent", "path": "/code-reviewer", "description": "Analyzes Python and JavaScript code...", "url": "https://agents.example.com/code-reviewer", "relevance_score": 0.92, "skills": ["review-python-code", "review-javascript-code"], "trust_level": "community" } ], "query": "I need an agent that can review Python code..." } ``` ### How FAISS Indexing Works When an agent is registered, the registry creates an embedding for it: ```python # From agent_service.py def _get_agent_text_for_embedding(agent_card): """Prepare agent card for semantic search""" name = agent_card["name"] description = agent_card["description"] # Extract skill information skills_text = "\n".join([ f"{s['name']}: {s['description']}" for s in agent_card.get("skills", []) ]) # Combine all searchable text text = f""" Name: {name} Description: {description} Skills: {skills_text} Tags: {', '.join(agent_card.get('tags', []))} """ return text.strip() # This text is embedded and stored in FAISS # When someone searches, their query is embedded and compared ``` The FAISS index maintains metadata about each entity: ```json { "id": 42, "entity_type": "a2a_agent", "path": "/code-reviewer", "text_for_embedding": "Name: Code Reviewer...", "full_entity_info": { /* complete agent card */ }, "is_enabled": true } ``` ### Direct API Queries For more precise queries, agents can also search using filters: ```bash curl -X POST http://localhost/api/agents/discover \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "skills": ["review-python-code"], "tags": ["security", "python"], "max_results": 10 }' ``` --- ## Access Control: Three-Tier Permission System Access control is enforced at three levels: UI scopes, group mappings, and individual agent permissions. All three work together to determine what an authenticated user can do. ### Tier 1: UI-Scopes (High-Level Actions) The `UI-Scopes` section in `auth_server/scopes.yml` defines what high-level actions each group can perform: ```yaml UI-Scopes: mcp-registry-admin: list_agents: [all] get_agent: [all] publish_agent: [all] modify_agent: [all] delete_agent: [all] registry-users-lob1: list_agents: - /code-reviewer - /test-automation get_agent: - /code-reviewer - /test-automation publish_agent: - /code-reviewer - /test-automation modify_agent: - /code-reviewer - /test-automation delete_agent: - /code-reviewer - /test-automation ``` This says: - `mcp-registry-admin` can list ALL agents - `registry-users-lob1` can ONLY list `/code-reviewer` and `/test-automation` ### Tier 2: Group Mappings (Keycloak to Internal Scopes) The `group_mappings` section maps Keycloak groups to internal scope names: ```yaml group_mappings: mcp-registry-admin: - mcp-registry-admin - mcp-servers-unrestricted/read - mcp-servers-unrestricted/execute registry-users-lob1: - registry-users-lob1 ``` When a user authenticates with Keycloak, their JWT includes groups. The auth-server uses this mapping to determine which internal scopes apply. ### Tier 3: Individual Group Scopes (Detailed Permissions) The bottom of `scopes.yml` defines detailed permissions for each group: ```yaml registry-users-lob1: - server: currenttime methods: - initialize - tools/list - tools/call - agents: actions: - action: list_agents resources: - /code-reviewer - /test-automation - action: get_agent resources: - /code-reviewer - /test-automation - action: publish_agent resources: - /code-reviewer - /test-automation - action: modify_agent resources: - /code-reviewer - /test-automation - action: delete_agent resources: - /code-reviewer - /test-automation ``` This defines: - Which MCP servers the group can access (currenttime, mcpgw) - Which agent actions are allowed (list, get, publish, modify, delete) - Which agent paths apply (only /code-reviewer and /test-automation) ### How Permission Checking Works in Code When a request comes in to list agents: ```python @router.get("/agents") async def list_agents( request: Request, user_context: dict = Depends(enhanced_auth) ): # user_context contains: # { # "username": "service-account-mcp-gateway-m2m", # "groups": ["mcp-registry-admin"], # "scopes": ["mcp-registry-admin", "mcp-servers-unrestricted/read", ...] # } # Load all agents agents = agent_service.load_agents() # Filter based on scopes accessible_agents = _filter_agents_by_access( agents, user_context ) return {"agents": accessible_agents} def _filter_agents_by_access(agents, user_context): """Filter agents based on user's access permissions""" groups = user_context.get("groups", []) scopes = user_context.get("scopes", []) # Admin can see all if "mcp-registry-admin" in groups: return agents # LOB1 can only see LOB1 agents if "registry-users-lob1" in groups: return [a for a in agents if a["path"] in [ "/code-reviewer", "/test-automation" ]] # LOB2 can only see LOB2 agents if "registry-users-lob2" in groups: return [a for a in agents if a["path"] in [ "/data-analysis", "/security-analyzer" ]] # Unknown group sees nothing return [] ``` ### Agent Visibility Levels Beyond group-based access, agents also have visibility settings: - **public**: Visible to all authenticated users - **private**: Only visible to owner and admins - **group-restricted**: Only visible to specific groups The filtering considers both the user's groups and the agent's visibility setting. --- ## The Code: Where Everything Lives This section maps the implementation to actual files and shows how the pieces fit together. ### API Routes (registry/api/agent_routes.py - 838 lines) This file defines 8 REST API endpoints: ```python @router.post("/agents/register") async def register_agent(request: Request, agent_card: AgentCard): """Register a new agent (requires a2a-agent-admin scope)""" # 1. Check user has a2a-agent-admin scope # 2. Validate agent card using agent_validator # 3. Check path is unique # 4. Save to agent_state.json # 5. Index in FAISS # 6. Return 201 Created @router.get("/agents") async def list_agents(request: Request): """List agents (filtered by user permissions)""" # 1. Load all agents # 2. Filter by user's groups and visibility # 3. Return summary list @router.get("/agents/{path}") async def get_agent(request: Request, path: str): """Get complete agent card by path""" # 1. Check user has permission for this agent # 2. Load from agent_state.json # 3. Return full agent card @router.put("/agents/{path}") async def update_agent(request: Request, path: str, agent_card: AgentCard): """Update agent card (requires modify_agent scope for this path)""" # 1. Check user has modify_agent scope # 2. Validate new card # 3. Update agent_state.json # 4. Re-index in FAISS # 5. Return updated card @router.delete("/agents/{path}") async def delete_agent(request: Request, path: str): """Delete agent (requires delete_agent scope for this path)""" # 1. Check user has delete_agent scope # 2. Remove from agent_state.json # 3. Remove from FAISS index # 4. Return 204 No Content @router.post("/agents/{path}/toggle") async def toggle_agent(request: Request, path: str, enabled: bool): """Enable or disable agent""" # 1. Check user has modify_agent scope # 2. Update is_enabled flag # 3. Return updated agent info @router.post("/agents/discover/semantic") async def discover_agents_semantic(request: Request, query: DiscoveryQuery): """Semantic search for agents""" # 1. Embed the natural language query # 2. Search FAISS index # 3. Filter results by user permissions # 4. Return ranked results ``` ### Business Logic (registry/services/agent_service.py - 695 lines) This file handles all agent operations: ```python class AgentService: """CRUD operations for agents""" def load_agents_and_state(self) -> dict: """Load all agents from agent_state.json""" # Returns: {"agents": {"/code-reviewer": {...}, ...}} def register_agent(self, agent_card: AgentCard) -> AgentCard: """Register a new agent""" # 1. Validate path is unique # 2. Generate registered_at timestamp # 3. Add to agent_state.json # 4. Return registered card def get_agent(self, path: str) -> AgentCard: """Get agent by path""" # Returns agent card or raises HTTPException(404) def update_agent(self, path: str, card: AgentCard) -> AgentCard: """Update existing agent""" # 1. Load current agent # 2. Merge updates # 3. Update agent_state.json # 4. Return updated card def delete_agent(self, path: str) -> None: """Delete agent by path""" # Remove from agent_state.json def toggle_agent(self, path: str, enabled: bool) -> AgentCard: """Enable or disable agent""" # Update is_enabled flag in agent_state.json def list_agents(self) -> List[AgentInfo]: """Get all agents as summaries""" # Returns list of simplified agent info ``` ### Data Models (registry/schemas/agent_models.py - 603 lines) Pydantic models for validation: ```python class SecurityScheme(BaseModel): """How to authenticate with an agent""" type: str # "apiKey", "http", "oauth2", "openIdConnect" scheme: Optional[str] = None in_: Optional[str] = None name: Optional[str] = None class Skill(BaseModel): """A capability an agent offers""" id: str name: str description: str parameters: Optional[Dict[str, Any]] = None tags: List[str] = [] class AgentCard(BaseModel): """Complete agent profile""" protocol_version: str name: str description: str url: str # Direct URL for peer-to-peer communication skills: List[Skill] = [] security_schemes: Dict[str, SecurityScheme] = {} security: Optional[List[Dict[str, List[str]]]] = None path: str # Registry path: /agents/code-reviewer visibility: str = "public" is_enabled: bool = False trust_level: str = "unverified" registered_at: Optional[datetime] = None registered_by: Optional[str] = None updated_at: Optional[datetime] = None ``` ### Validation (registry/utils/agent_validator.py - 343 lines) Ensures agent cards are valid: ```python class AgentValidator: """Validate agent cards""" async def validate_agent_card( self, card: AgentCard, verify_endpoint: bool = True ) -> ValidationResult: """ Validate agent card: - Schema validation (Pydantic) - Unique skill IDs - Valid security schemes - Endpoint reachability (optional) """ ``` ### Storage (registry/agents/agent_state.json) Central file tracking all registered agents: ```json { "agents": { "/code-reviewer": { "name": "Code Reviewer Agent", "path": "/code-reviewer", "url": "https://agents.example.com/code-reviewer", "protocol_version": "1.0", "is_enabled": true, "registered_at": "2025-11-09T10:30:00Z", "registered_by": "service-account-mcp-gateway-m2m" }, "/test-automation": { "name": "Test Automation Agent", "path": "/test-automation", "url": "https://agents.example.com/test-automation", "protocol_version": "1.0", "is_enabled": true, "registered_at": "2025-11-09T10:31:00Z", "registered_by": "service-account-mcp-gateway-m2m" } } } ``` ### FAISS Search Integration (registry/search/service.py) The FAISS service indexes both MCP servers and agents: ```python class FaissService: """Semantic search for MCP servers and A2A agents""" async def add_or_update_entity( self, entity_path: str, entity_info: Dict[str, Any], entity_type: str # "mcp_server" or "a2a_agent" ): """Add or update entity in FAISS index""" # Generate text for embedding if entity_type == "a2a_agent": text = self._get_agent_text_for_embedding(entity_info) else: text = self._get_server_text_for_embedding(entity_info) # Create embedding and add to index embedding = self.embedding_model.embed(text) self.faiss_index.add(embedding) # Store metadata metadata = { "entity_type": entity_type, "path": entity_path, "text_for_embedding": text, "full_entity_info": entity_info } ``` ### Authentication (Keycloak + Auth Server) The M2M service account `mcp-gateway-m2m` has: ```yaml # Auto-assigned Keycloak groups (from keycloak/setup/init-keycloak.sh): - mcp-servers-unrestricted # Full MCP server access - a2a-agent-admin # Full agent management # Mapped scopes (from auth_server/scopes.yml): - mcp-servers-unrestricted/read - mcp-servers-unrestricted/execute - a2a-agent-admin ``` The token is generated every 5 minutes and stored in `.oauth-tokens/ingress.json`. --- ## Putting It All Together: Complete Request Example Here's what happens when an agent registers itself: **1. Agent Prepares Card** ```json { "name": "Code Reviewer Agent", "description": "Reviews Python code", "url": "https://agents.example.com/code-reviewer", "path": "/code-reviewer", "protocol_version": "1.0", "skills": [ { "id": "review-python", "name": "Review Python Code", "description": "Analyzes Python source code", "parameters": {"type": "object", "properties": {"code": {"type": "string"}}}, "tags": ["python", "review"] } ], "security_schemes": { "bearer": {"type": "http", "scheme": "bearer", "bearer_format": "JWT"} }, "security": [{"bearer": []}], "tags": "code-review,security" } ``` **2. Agent Gets JWT Token** ```bash $ ./credentials-provider/generate_creds.sh # Generates .oauth-tokens/ingress.json with 5-minute TTL ``` **3. Agent POSTs to Registry** ```bash curl -X POST http://localhost/api/agents/register \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d @agent-card.json ``` **4. Nginx Intercepts** - Extracts JWT from Authorization header - Calls auth-server:/validate with the token - Auth-server decodes JWT and returns scopes **5. Auth-Server Returns** ```json { "username": "service-account-mcp-gateway-m2m", "groups": ["mcp-servers-unrestricted", "a2a-agent-admin"], "scopes": ["mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute", "a2a-agent-admin"] } ``` **6. Nginx Forwards to FastAPI** ``` POST /api/agents/register HTTP/1.1 Authorization: Bearer $TOKEN X-Scopes: mcp-servers-unrestricted/read,mcp-servers-unrestricted/execute,a2a-agent-admin ``` **7. FastAPI Endpoint Executes** - Checks `a2a-agent-admin` in scopes ✓ - Validates agent card with Pydantic ✓ - Checks path `/code-reviewer` doesn't exist ✓ - Calls agent_service.register_agent() **8. Agent Service Saves** - Loads current agent_state.json - Adds `/code-reviewer` entry - Saves back to disk - Returns registered agent **9. FAISS Indexing** - Generates embedding text from agent card - Converts to vector using embedding model - Adds to FAISS index with metadata **10. Response to Agent** ```json { "message": "Agent registered successfully", "agent": { "name": "Code Reviewer Agent", "path": "/code-reviewer", "url": "https://agents.example.com/code-reviewer", "num_skills": 1, "registered_at": "2025-11-09T10:30:00Z", "is_enabled": false } } ``` **11. Agent Enables Itself (Optional)** ```bash curl -X POST http://localhost/api/agents/code-reviewer/toggle?enabled=true \ -H "Authorization: Bearer $TOKEN" ``` Now the agent is discoverable by other agents and will appear in semantic searches. --- ## Summary: The Key Concepts **Registry-Only Design**: The registry handles discovery and validation, not communication. Once agents find each other, they talk directly. **Authentication Layer**: All requests require a valid JWT token from a Keycloak service account. Tokens are validated at three points: Nginx, Auth-Server, and FastAPI. **Three-Tier Access Control**: 1. UI-Scopes define high-level actions 2. Group Mappings connect Keycloak groups to scopes 3. Individual Group Scopes define detailed permissions **Agent Card**: The machine-readable profile that agents register. Contains name, description, URL, skills, security requirements, and metadata. **CRUD Operations**: Agents can register, read, update, delete, and toggle themselves through REST APIs. **Discovery**: Agents discover other agents using semantic search (natural language) or direct queries (by skill/tag). **File-Based Persistence**: Agent state is stored in `agent_state.json` with simple JSON format. **FAISS Indexing**: Agents are automatically indexed for semantic search alongside MCP servers. This design enables autonomous agent ecosystems where agents discover and coordinate with each other while maintaining security and access control features. ================================================ FILE: docs/design/agent-skills-architecture.md ================================================ # Agent Skills Architecture Design This document describes the architecture and data model for the Agent Skills feature in MCP Gateway Registry. ## Overview Agent Skills are reusable, shareable instruction sets that augment AI coding assistants with specialized capabilities. Unlike MCP servers (which provide tools), skills provide context, workflows, and behavioral guidance that help AI assistants perform specific tasks more effectively. The Agent Skills feature follows the [agentskills.io](https://agentskills.io) specification, providing a standardized way to discover, share, and manage skills across AI coding environments. ## Design Principles ### Separation of Concerns Skills and Servers serve different purposes: | Aspect | MCP Servers | Agent Skills | |--------|------------|--------------| | Primary Function | Provide executable tools | Provide behavioral guidance | | Content Type | Code, APIs, integrations | Markdown instructions, workflows | | Execution | Server-side execution | Client-side interpretation | | State | Stateful (running processes) | Stateless (document-based) | ### URL-Based Discovery Skills are referenced by a single URL pointing to a `SKILL.md` file: ``` https://github.com/org/repo/blob/main/skills/pdf-processing/SKILL.md ``` The registry: 1. Accepts the user-provided URL (blob URL for GitHub) 2. Auto-translates to raw content URL for fetching 3. Stores both URLs for different use cases ### Progressive Disclosure Skills support multiple detail tiers to avoid overwhelming AI assistants: 1. **Card View**: Name, description, tags (for discovery) 2. **Summary View**: Plus requirements, tools, target agents 3. **Full View**: Complete SKILL.md content with all details ## Data Model ### SkillCard Entity The primary entity representing a registered skill: ``` SkillCard ├── Identification │ ├── path: /skills/{name} # Unique, immutable path │ ├── name: string # Lowercase alphanumeric with hyphens │ └── description: string # What the skill does │ ├── URLs │ ├── skill_md_url: HttpUrl # User-provided URL (e.g., GitHub blob) │ ├── skill_md_raw_url: HttpUrl # Auto-translated raw content URL │ └── repository_url: HttpUrl # Optional git repository │ ├── Metadata │ ├── version: string # Skill version │ ├── author: string # Skill author │ ├── license: string # License identifier │ ├── compatibility: string # Human-readable requirements │ └── tags: string[] # Categorization tags │ ├── Requirements │ ├── requirements: CompatibilityRequirement[] # Machine-readable │ ├── target_agents: string[] # Target AI assistants │ └── allowed_tools: ToolReference[] # Required MCP tools │ ├── Access Control │ ├── visibility: public|private|group │ ├── allowed_groups: string[] # For group visibility │ └── owner: string # For private visibility │ ├── State │ ├── is_enabled: boolean # Enable/disable toggle │ ├── registry_name: string # Source registry (for federation) │ ├── health_status: healthy|unhealthy|unknown │ └── last_checked_time: datetime # Last health check │ ├── Ratings │ ├── num_stars: float # Average rating (0-5) │ └── rating_details: RatingDetail[] # Individual ratings │ └── Timestamps ├── created_at: datetime └── updated_at: datetime ``` ### ToolReference Links skills to required MCP server tools: ```python class ToolReference: tool_name: str # Tool name (e.g., "Read", "Bash") server_path: str | None # MCP server path (e.g., "/servers/claude-tools") version: str | None # Optional version constraint capabilities: list[str] # Capability filters (e.g., ["git:*"]) ``` ### CompatibilityRequirement Machine-readable compatibility constraints: ```python class CompatibilityRequirement: type: "product" | "tool" | "api" | "environment" target: str # Target identifier min_version: str | None # Minimum version max_version: str | None # Maximum version required: bool # False = optional enhancement ``` ## URL Translation The registry automatically translates user-friendly URLs to raw content URLs: ### GitHub Translation ``` Input: https://github.com/org/repo/blob/main/skills/name/SKILL.md Output: https://raw.githubusercontent.com/org/repo/main/skills/name/SKILL.md ``` ### GitLab Translation ``` Input: https://gitlab.com/org/repo/-/blob/main/skills/name/SKILL.md Output: https://gitlab.com/org/repo/-/raw/main/skills/name/SKILL.md ``` ### Bitbucket Translation ``` Input: https://bitbucket.org/org/repo/src/main/skills/name/SKILL.md Output: https://bitbucket.org/org/repo/raw/main/skills/name/SKILL.md ``` ## Access Control Skills support three visibility levels: ### Public Skills - Visible to all authenticated users - Discoverable via search and listing - Exportable via federation ### Private Skills - Visible only to the owner - Not discoverable by others - Not exportable via federation ### Group Skills - Visible to members of specified groups - Groups are managed via IdP integration (Entra ID, Cognito, etc.) - Requires `allowed_groups` to be specified ## Health Checking Skills are health-checked by verifying SKILL.md accessibility: 1. **HEAD Request**: Verify the raw URL is accessible 2. **Status Codes**: 2xx = healthy, others = unhealthy 3. **Trusted Domains**: Only allowed domains are checked (SSRF protection) 4. **Caching**: Results cached with `last_checked_time` ### Trusted Domains ```python TRUSTED_DOMAINS = [ "raw.githubusercontent.com", "gitlab.com", "bitbucket.org", "gist.githubusercontent.com", ] ``` ## Rating System Skills use the same rating system as servers and agents: 1. **Star Rating**: 1-5 stars 2. **Per-User**: One rating per user per skill 3. **Updates**: Users can update their rating 4. **Average**: Displayed as average of all ratings ## Tool Validation Skills can reference MCP server tools. The registry validates tool availability: 1. **Check Registration**: Verify referenced servers exist 2. **Check Tools**: Verify tools are exposed by servers 3. **Report Status**: Return availability status per tool ## API Endpoints ### Skill Management | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/skills` | List skills (with visibility filtering) | | GET | `/api/skills/{path}` | Get skill details | | POST | `/api/skills` | Register new skill | | PUT | `/api/skills/{path}` | Update skill | | DELETE | `/api/skills/{path}` | Delete skill | ### Skill State | Method | Endpoint | Description | |--------|----------|-------------| | PUT | `/api/skills/{path}/enable` | Enable skill | | PUT | `/api/skills/{path}/disable` | Disable skill | | GET | `/api/skills/{path}/health` | Check skill health | ### Skill Content | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/skills/{path}/content` | Fetch SKILL.md content | | GET | `/api/skills/{path}/tools` | Check tool availability | ### Ratings | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/skills/{path}/rating` | Get rating info | | POST | `/api/skills/{path}/rate` | Submit/update rating | ## Database Schema Skills are stored in MongoDB/DocumentDB with the following indexes: ```javascript // Unique index on name db.agent_skills.createIndex({ "name": 1 }, { unique: true }) // Tags for filtering db.agent_skills.createIndex({ "tags": 1 }) // Visibility for access control db.agent_skills.createIndex({ "visibility": 1 }) // Registry name for federation db.agent_skills.createIndex({ "registry_name": 1 }) // Owner for private skills db.agent_skills.createIndex({ "owner": 1 }) // Compound index for common queries db.agent_skills.createIndex({ "visibility": 1, "is_enabled": 1, "registry_name": 1 }) ``` ## Federation Support Skills participate in peer-to-peer federation: 1. **Export**: Public skills are exported to peer registries 2. **Import**: Skills from peers are imported with `registry_name` set 3. **Sync Modes**: All, whitelist, or tag-based filtering 4. **Ownership**: Federated skills retain original registry attribution ## Future Considerations ### Content Caching - Cache SKILL.md content to reduce external fetches - Use `content_version` hash for cache invalidation - Track `content_updated_at` for freshness ### Skill Bundles - Group related skills into bundles - Enable/disable bundles atomically - Share bundle configurations ### Usage Analytics - Track skill usage across clients - Surface popular skills in discovery - Enable skill recommendations ### Versioning - Track skill version history - Support rollback to previous versions - Version-aware federation sync ================================================ FILE: docs/design/agentcore-scanner-design.md ================================================ # AgentCore Auto-Registration -- Low-Level Design *Created: 2026-04-03* *Updated: 2026-04-04* ## Purpose Discover AWS Bedrock AgentCore Gateways and Agent Runtimes in one or more AWS accounts and register them with the MCP Gateway Registry. Auth tokens for CUSTOM_JWT gateways are managed by a separate token refresher process that runs as a cron job or sidecar. ## Components | Component | File | Runs | |-----------|------|------| | **Scanner + Registrar** | `cli/agentcore/` | On-demand or scheduled | | **Token Refresher** | `cli/agentcore/token_refresher.py` | Cron every 45 min or sidecar | ``` Phase 1: Registration Phase 2: Token Refresh (on-demand) (cron every 45 min) +----------------+ +--------------------+ +--------------------+ | AgentCore API |---->| Scanner+Registrar |--register--> | MCP Gateway | | (AWS) | | cli/agentcore/ | | Registry | +----------------+ +--------------------+ +--------------------+ | ^ | writes | PATCH auth_credential v | +-------------------------+ +--------------------+ | token_refresh_manifest |--read------->| Token Refresher | | .json (gitignored) | | token_refresher.py | +-------------------------+ +--------------------+ | +------+------+ | | GET OIDC POST token discovery endpoint | | +----v-------------v----+ | IdP (Cognito, Auth0, | | Okta, Entra, etc.) | +-----------------------+ ``` --- ## Background: What AgentCore Returns Every CUSTOM_JWT gateway from the AgentCore API includes OIDC metadata: ```json { "name": "customersupport-gw", "gatewayUrl": "https://gateway.example.com", "authorizerType": "CUSTOM_JWT", "authorizerConfiguration": { "customJWTAuthorizer": { "discoveryUrl": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_pnikLWYzO/.well-known/openid-configuration", "allowedClients": ["7kqi2l0n47mnfmhfapsf29ch4h"] } } } ``` The `discoveryUrl` is an OIDC Discovery endpoint (standard across all providers). GETting it returns: ```json { "issuer": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_pnikLWYzO", "token_endpoint": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_pnikLWYzO/oauth2/token", "jwks_uri": "..." } ``` The `token_endpoint` works for standard OAuth2 `client_credentials` grant. This is identical across Cognito, Auth0, Okta, Entra, Keycloak -- no provider-specific code needed for token generation. The IdP vendor is always identifiable from the `discoveryUrl`: | IdP | Pattern in discoveryUrl | |-----|------------------------| | Cognito | `cognito-idp` | | Auth0 | `auth0.com` | | Okta | `okta.com` | | Entra | `microsoftonline.com` | | Keycloak | `/realms/` | This matters because Cognito allows auto-retrieval of `client_secret` via the AWS API (`describe_user_pool_client`), while other providers require the secret to be configured as an environment variable. --- ## Phase 1: Scanner + Registrar ### CLI Interface ```bash # List resources (discovery only, no registration) uv run python -m cli.agentcore list \ --region us-east-1 \ --output json # Dry run uv run python -m cli.agentcore sync \ --registry-url https://registry.example.com \ --token-file .token \ --region us-east-1 \ --dry-run # Register uv run python -m cli.agentcore sync \ --registry-url https://registry.example.com \ --token-file .token \ --region us-east-1 # Cross-account uv run python -m cli.agentcore sync \ --registry-url https://registry.example.com \ --token-file .token \ --region us-east-1 \ --accounts 111122223333,444455556666 ``` **Flags:** | Flag | Default | Description | |------|---------|-------------| | `--registry-url` | `REGISTRY_URL` env or `http://localhost` | Registry base URL | | `--token-file` | `REGISTRY_TOKEN_FILE` env or `.token` | Path to registry auth token file | | `--region` | `AWS_REGION` env or `us-east-1` | AWS region | | `--timeout` | `30` | AWS API call timeout (seconds) | | `--dry-run` | false | Preview without registering | | `--overwrite` | false | Overwrite existing registrations | | `--gateways-only` | false | Skip runtimes | | `--runtimes-only` | false | Skip gateways | | `--include-mcp-targets` | false | Register mcpServer gateway targets as separate servers | | `--accounts` | current account | Comma-separated account IDs for cross-account | | `--assume-role-name` | `AgentCoreSyncRole` | IAM role to assume in target accounts | | `--output` | `text` | Output format: `text` or `json` | | `--manifest` | `token_refresh_manifest.json` | Output path for token refresh manifest | | `--visibility` | `internal` | Registration visibility | | `--debug` | false | Enable DEBUG logging | ### Sequence Diagram ``` User cmd_sync Scanner AgentCore API RegistryClient | | | | | |-- sync ---------->| | | | | | | | | | |-- scan_gateways() | | | |---------------->| | | | | |-- list_gateways->| | | | |<-- gw summaries -| | | | |-- get_gateway() ->| | | | |<-- full gateway --| | | | | (includes authorizerConfiguration)| | | |-- list_targets() ->| | | | |<-- targets --------| | | |<-- gateways ----| | | | | | | | | For each gateway: | | | | | | | | 1. Build registration model | | | | - proxy_pass_url = gatewayUrl | | | | - auth_scheme = "bearer" | | | | (for CUSTOM_JWT and AWS_IAM) | | | | - auth_credential = null | | | | - metadata includes: | | | | discovery_url | | | | allowed_clients | | | | idp_vendor | | | | | | | | 2. Register with registry | | | |-- POST /internal/services ----------------------> | | |<-- 201 Created ----------------------------------| | | | | | | | 3. If CUSTOM_JWT: add to manifest entries | | | | | | |-- scan_runtimes() | | | |---------------->| | | | | |-- list_runtimes->| | | | |<-- runtimes -----| | | |<-- runtimes ----| | | | | | | | | For each runtime: | | | | - MCP protocol -> register as MCP Server | | | - HTTP/A2A -> register as Agent | | | (no token needed, health check | | | | falls back to ping) | | | | | | | |-- write token_refresh_manifest.json| | | |-- print summary | | | | | | |<-- summary -------| | | ``` ### Files | File | Lines (approx) | Responsibility | |------|----------------|----------------| | `cli/agentcore/__init__.py` | 10 | Package init | | `cli/agentcore/__main__.py` | 7 | `python -m cli.agentcore` entry point | | `cli/agentcore/sync.py` | ~300 | CLI parsing (`argparse`), `cmd_sync()`, `cmd_list()` | | `cli/agentcore/discovery.py` | ~200 | `AgentCoreScanner` -- paginated AWS API calls | | `cli/agentcore/registration.py` | ~500 | `RegistrationBuilder`, `SyncOrchestrator` | | `cli/agentcore/models.py` | ~200 | Pydantic models, helper functions | ### Key Data Structures #### AgentCoreScanner ```python class AgentCoreScanner: """Scans AgentCore resources via boto3 bedrock-agentcore-control client.""" def __init__( self, region: str, timeout: int = 30, session: boto3.Session | None = None, ) -> None: ... def scan_gateways(self) -> list[dict[str, Any]]: """List gateways, filter to READY, get details + targets.""" ... def scan_runtimes(self) -> list[dict[str, Any]]: """List runtimes, filter to READY, get details + endpoints.""" ... ``` Both methods paginate via `nextToken` and only return resources with `status == "READY"`. #### RegistrationBuilder Converts raw AWS dicts into registry registration models. ```python class RegistrationBuilder: def __init__( self, region: str, visibility: str = "internal", session: boto3.Session | None = None, ) -> None: self.region = region self.visibility = visibility self.account_id = self._get_account_id() def build_gateway_registration( self, gateway: dict[str, Any], ) -> InternalServiceRegistration: """Build MCP Server registration from a gateway. Extracts OIDC metadata (discovery_url, allowed_clients, idp_vendor) from authorizerConfiguration and stores in metadata field. """ name = gateway.get("name", gateway["gatewayId"]) gateway_url = gateway.get("gatewayUrl", "") authorizer_type = gateway.get("authorizerType", "NONE") # Extract OIDC metadata for CUSTOM_JWT gateways authorizer_config = gateway.get("authorizerConfiguration", {}) jwt_config = authorizer_config.get("customJWTAuthorizer", {}) discovery_url = jwt_config.get("discoveryUrl", "") allowed_clients = jwt_config.get("allowedClients", []) idp_vendor = _detect_idp_vendor(discovery_url) if discovery_url else "" metadata = { "source": "agentcore-sync", "gateway_arn": gateway.get("gatewayArn"), "gateway_id": gateway.get("gatewayId"), "authorizer_type": authorizer_type, "region": self.region, "account_id": self.account_id, } if authorizer_type == "CUSTOM_JWT" and discovery_url: metadata["discovery_url"] = discovery_url metadata["allowed_clients"] = allowed_clients metadata["idp_vendor"] = idp_vendor return InternalServiceRegistration( path=f"/{_slugify(name)}", name=name, description=gateway.get("description", f"AgentCore Gateway: {name}"), proxy_pass_url=gateway_url, mcp_endpoint=gateway_url, auth_provider="bedrock-agentcore", auth_scheme=_get_auth_scheme(authorizer_type), supported_transports=["streamable-http"], tags=["agentcore", "gateway", "auto-registered"], overwrite=False, metadata=metadata, ) def build_runtime_mcp_registration( self, runtime: dict[str, Any], ) -> InternalServiceRegistration: """Build MCP Server registration from a MCP-protocol runtime.""" ... def build_runtime_agent_registration( self, runtime: dict[str, Any], ) -> AgentRegistration: """Build A2A Agent registration from an HTTP/A2A-protocol runtime.""" ... ``` #### SyncOrchestrator Coordinates scan, build, register, and manifest output. ```python class SyncOrchestrator: """Orchestrates discovery, registration, and manifest generation. 1. Scan gateways / runtimes via AgentCoreScanner 2. Build registrations via RegistrationBuilder 3. Register with the registry via RegistryClient 4. Write token_refresh_manifest.json for CUSTOM_JWT gateways """ def __init__( self, scanner: AgentCoreScanner, builder: RegistrationBuilder, registry_client: RegistryClient, dry_run: bool = False, overwrite: bool = False, include_mcp_targets: bool = False, output_format: str = "text", manifest_path: str = "token_refresh_manifest.json", ) -> None: self.scanner = scanner self.builder = builder self.registry = registry_client self.dry_run = dry_run self.overwrite = overwrite self.include_mcp_targets = include_mcp_targets self.output_format = output_format self.manifest_path = manifest_path self.results: list[dict[str, Any]] = [] self._manifest_entries: list[dict[str, Any]] = [] def sync_gateways(self) -> None: """Scan and register all gateways.""" gateways = self.scanner.scan_gateways() for gateway in gateways: self._register_gateway(gateway) if self.include_mcp_targets: for target in gateway.get("targets", []): self._register_target(gateway, target) def sync_runtimes(self) -> None: """Scan and register all runtimes.""" runtimes = self.scanner.scan_runtimes() for runtime in runtimes: self._register_runtime(runtime) def write_manifest(self) -> None: """Write token_refresh_manifest.json for CUSTOM_JWT gateways.""" if self.dry_run: logger.info( f"[DRY-RUN] Would write manifest with " f"{len(self._manifest_entries)} entries" ) return if not self._manifest_entries: logger.info("No CUSTOM_JWT gateways -- skipping manifest") return with open(self.manifest_path, "w") as f: json.dump(self._manifest_entries, f, indent=2) logger.info( f"Wrote {len(self._manifest_entries)} entries " f"to {self.manifest_path}" ) def print_summary(self) -> None: """Print sync summary in text or JSON format.""" ... ``` #### `_register_gateway()` -- core registration logic ```python def _register_gateway( self, gateway: dict[str, Any], ) -> None: """Register a single gateway with the registry.""" gateway_name = gateway.get("name", gateway["gatewayId"]) gateway_url = gateway.get("gatewayUrl", "") gateway_arn = gateway.get("gatewayArn", "") if not _validate_https_url(gateway_url, gateway_name): self.results.append({ "resource_type": "gateway", "resource_name": gateway_name, "resource_arn": gateway_arn, "registration_type": "mcp_server", "path": f"/{_slugify(gateway_name)}", "status": "skipped", "message": "Invalid URL (must be HTTPS)", }) return registration = self.builder.build_gateway_registration(gateway) registration.overwrite = self.overwrite result: dict[str, Any] = { "resource_type": "gateway", "resource_name": gateway_name, "resource_arn": gateway_arn, "registration_type": "mcp_server", "path": registration.service_path, } if self.dry_run: result["status"] = "dry_run" result["message"] = "Would register as MCP Server" self.results.append(result) self._collect_manifest_entry(gateway, registration.service_path) return try: self._register_service_with_retry(registration) result["status"] = "registered" result["message"] = "Successfully registered" except Exception as e: if _is_conflict_error(e) and not self.overwrite: result["status"] = "skipped" result["message"] = "Already registered (use --overwrite)" else: result["status"] = "failed" result["message"] = str(e) logger.error(f"Failed to register gateway: {e}") self.results.append(result) return self.results.append(result) self._collect_manifest_entry(gateway, registration.service_path) def _collect_manifest_entry( self, gateway: dict[str, Any], server_path: str, ) -> None: """Add a CUSTOM_JWT gateway to the token refresh manifest.""" if gateway.get("authorizerType") != "CUSTOM_JWT": return jwt_config = gateway.get("authorizerConfiguration", {}).get( "customJWTAuthorizer", {} ) discovery_url = jwt_config.get("discoveryUrl", "") if not discovery_url: return self._manifest_entries.append({ "server_path": server_path, "gateway_arn": gateway.get("gatewayArn", ""), "discovery_url": discovery_url, "allowed_clients": jwt_config.get("allowedClients", []), "idp_vendor": _detect_idp_vendor(discovery_url), }) ``` ### Helper Functions ```python IDP_PATTERNS: dict[str, str] = { "cognito-idp": "cognito", "auth0.com": "auth0", "okta.com": "okta", "microsoftonline.com": "entra", "/realms/": "keycloak", } def _detect_idp_vendor( discovery_url: str, ) -> str: """Detect IdP vendor from OIDC discovery URL.""" for pattern, vendor in IDP_PATTERNS.items(): if pattern in discovery_url: return vendor return "unknown" def _slugify(name: str) -> str: """Convert name to URL-safe slug.""" ... def _validate_https_url(url: str, resource_name: str) -> bool: """Validate that URL uses HTTPS.""" ... def _get_auth_scheme(authorizer_type: str) -> str: """Map AgentCore authorizer type to registry auth scheme. CUSTOM_JWT -> bearer, AWS_IAM -> bearer, NONE -> none. """ ... ``` ### Manifest File Format Output: `token_refresh_manifest.json` (add to `.gitignore`) ```json [ { "server_path": "/customersupport-gw", "gateway_arn": "arn:aws:bedrock:us-east-1:123456789012:gateway/gw-abc", "discovery_url": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_pnikLWYzO/.well-known/openid-configuration", "allowed_clients": ["7kqi2l0n47mnfmhfapsf29ch4h"], "idp_vendor": "cognito" }, { "server_path": "/enterprise-gw", "gateway_arn": "arn:aws:bedrock:us-east-1:123456789012:gateway/gw-def", "discovery_url": "https://myorg.okta.com/.well-known/openid-configuration", "allowed_clients": ["0oa1234567abcdefg"], "idp_vendor": "okta" } ] ``` ### Runtime Registration Runtimes are registered without tokens. The registry health check falls back to a ping for servers without `auth_credential`. - **MCP protocol runtime** -> registered as MCP Server (via `InternalServiceRegistration`) - **HTTP/A2A protocol runtime** -> registered as A2A Agent (via `AgentRegistration` with SigV4 security scheme) No manifest entry is created for runtimes. > **Note:** Agents imported from runtimes are registered with an empty skills array. To add skills, use the agent edit dialog in the UI or the `PUT /api/agents/{path}` API endpoint. Updating skills triggers a security rescan of the agent. #### Agent Overwrite Handling `AgentRegistration` does not have an `overwrite` field (unlike `InternalServiceRegistration`). When `--overwrite` is used and an agent already exists (409 Conflict), the orchestrator catches the conflict and calls `update_agent()` (PUT) to update the existing registration: 1. Attempt `register_agent()` (POST) 2. If 409 Conflict and `--overwrite` is set -> call `update_agent()` (PUT) 3. If 409 Conflict without `--overwrite` -> mark as "skipped" ### Cross-Account Support For multi-account scanning, the CLI accepts `--accounts 111122223333,444455556666`. For each account: 1. `sts:AssumeRole` into `arn:aws:iam::{account}:role/{assume_role_name}` 2. Create a boto3 Session from the assumed role credentials 3. Pass that session to `AgentCoreScanner` and `RegistrationBuilder` 4. Register all discovered resources into the same registry --- ## Phase 2: Token Refresher ### File: `cli/agentcore/token_refresher.py` Standalone script (~250 lines) that reads the manifest, resolves client secrets, fetches tokens, and updates the registry. ### CLI Interface ```bash # One-time refresh (Cognito auto-retrieval needs no env vars) uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token # With per-client env vars (highest priority) OAUTH_CLIENT_SECRET_49ujl0b9ser72gnp6q1ph9v6vs=mysecret \ uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token # With vendor-level env vars (fallback for non-Cognito IdPs) AUTH0_CLIENT_SECRET=xxx OKTA_CLIENT_SECRET=yyy \ uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token # Continuous mode (run as sidecar) uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token \ --loop --interval 2700 ``` **Flags:** | Flag | Default | Description | |------|---------|-------------| | `--manifest` | `token_refresh_manifest.json` | Path to manifest file | | `--registry-url` | `REGISTRY_URL` env or `http://localhost` | Registry base URL | | `--token-file` | `REGISTRY_TOKEN_FILE` env or `.token` | Registry auth token file | | `--loop` | false | Run continuously | | `--interval` | `2700` (45 min) | Refresh interval in seconds | | `--scan` / `--no-scan` | `--scan` (enabled) | Trigger security rescan after each credential update | | `--debug` | false | Enable DEBUG logging | ### Sequence Diagram ``` token_refresher.py Manifest Env Vars Cognito API OIDC Discovery Registry | | | | | | |-- read -------->| | | | | |<-- entries[] ---| | | | | | | | | | | For each entry: | | | | | | | | | | 1. Resolve client_secret (3-tier priority) | | | | [Priority 1: per-client] | | | | |-- check OAUTH_CLIENT_SECRET_| | | | | if found -> use it | | | | | | | | | | [Priority 2: cognito auto] | | | | | (if cognito + no per-client)---------------->| | | | describe_user_pool_client() | | | | | <-- ClientSecret -----------|---------------| | | | | | | | | [Priority 3: vendor env] | | | | |-- check AUTH0_/OKTA_/etc ------>| | | | | | | | | | 2. Get token_endpoint | | | | |-- GET discovery_url ------------------------------------------>| | |<-- {token_endpoint: "..."} ------------------------------------| | | | | | | | 3. Get token (OAuth2 client_credentials) | | | |-- POST token_endpoint ---------------------------------------->| | | {grant_type: client_credentials, | | | | client_id: allowed_clients[0], | | | | client_secret: from step 1} | | | |<-- {access_token: "eyJ..."} -----------------------------------| | | | | | | | 4. Update registry | | | | |-- PATCH /api/servers/{path}/auth-credential ---------------------------> | | {auth_scheme: "bearer", auth_credential: "eyJ..."} | |<-- 200 OK --------------------------------------------------------------------| | | | | | | 5. Trigger security rescan | | | | |-- POST /api/servers/{path}/rescan ----------------------------------------> | |<-- scan results (is_safe, severity counts) ------------------------------------| | | | | | | Write last_refreshed timestamp to manifest | | | ``` ### Client Secret Resolution (3-Tier Priority) For each manifest entry, the token refresher resolves the client secret using this priority order: | Priority | Method | Env Var / Mechanism | When Used | |----------|--------|---------------------|-----------| | **1** | Per-client env var | `OAUTH_CLIENT_SECRET_=` | Any IdP -- overrides all other methods | | **2** | Cognito auto-retrieval | `boto3.describe_user_pool_client()` | `cognito` only -- parses pool_id/region from discovery URL | | **3** | Vendor-specific env var | `AUTH0_CLIENT_SECRET`, `OKTA_CLIENT_SECRET`, `ENTRA_CLIENT_SECRET`, `KEYCLOAK_CLIENT_SECRET` | Non-Cognito IdPs -- one secret shared across all gateways for that vendor | If none of the tiers produce a secret, the entry is skipped with a warning. **Per-client env var** (Priority 1) is useful when multiple gateways use the same IdP but have different client secrets. The env var name is `OAUTH_CLIENT_SECRET_` followed by the `client_id` (from `allowed_clients[0]` in the manifest). **Cognito auto-retrieval** (Priority 2) parses region and pool_id from the discovery URL: ``` https://cognito-idp.us-east-1.amazonaws.com/us-east-1_pnikLWYzO/.well-known/openid-configuration ^^^^^^^^^ ^^^^^^^^^^^^^^^^^ region user_pool_id ``` Then calls `describe_user_pool_client(UserPoolId=pool_id, ClientId=client_id)` to auto-retrieve the secret. Requires IAM permissions for `cognito-idp:DescribeUserPoolClient`. **Vendor env vars** (Priority 3) are shared across all gateways for a given IdP. One secret per vendor. | IdP Vendor | Env Var | |------------|---------| | `auth0` | `AUTH0_CLIENT_SECRET` | | `okta` | `OKTA_CLIENT_SECRET` | | `entra` | `ENTRA_CLIENT_SECRET` | | `keycloak` | `KEYCLOAK_CLIENT_SECRET` | | `unknown` | Skipped with warning | ### Code Structure All private functions at the top, public functions below. One parameter per line. Modular functions (30-50 lines max). #### Constants ```python OIDC_DISCOVERY_TIMEOUT: int = 10 TOKEN_REQUEST_TIMEOUT: int = 15 REGISTRY_REQUEST_TIMEOUT: int = 15 IDP_PATTERNS: dict[str, str] = { "cognito-idp": "cognito", "auth0.com": "auth0", "okta.com": "okta", "microsoftonline.com": "entra", "/realms/": "keycloak", } IDP_SECRET_ENV_VARS: dict[str, str] = { "auth0": "AUTH0_CLIENT_SECRET", "okta": "OKTA_CLIENT_SECRET", "entra": "ENTRA_CLIENT_SECRET", "keycloak": "KEYCLOAK_CLIENT_SECRET", } ENV_VAR_PREFIX: str = "OAUTH_CLIENT_SECRET_" ``` #### Private Functions ```python def _read_manifest( manifest_path: str, ) -> list[dict[str, Any]]: """Read token refresh manifest from JSON file.""" ... def _detect_idp_vendor( discovery_url: str, ) -> str: """Detect IdP vendor from OIDC discovery URL. Matches known patterns in the URL string. """ for pattern, vendor in IDP_PATTERNS.items(): if pattern in discovery_url: return vendor return "unknown" def _get_cognito_client_secret( discovery_url: str, client_id: str, ) -> str | None: """Auto-retrieve client secret from Cognito. Parses user_pool_id and region from the discoveryUrl, calls describe_user_pool_client() via boto3. """ # Parse: https://cognito-idp.{region}.amazonaws.com/{pool_id}/... region = discovery_url.split("cognito-idp.")[1].split(".amazonaws")[0] pool_id = discovery_url.split("amazonaws.com/")[1].split("/")[0] client = boto3.client("cognito-idp", region_name=region) response = client.describe_user_pool_client( UserPoolId=pool_id, ClientId=client_id, ) return response["UserPoolClient"].get("ClientSecret") def _get_client_secret( idp_vendor: str, discovery_url: str, client_id: str, ) -> str | None: """Resolve client secret using 3-tier priority: 1. Per-client env var: OAUTH_CLIENT_SECRET_ 2. Cognito auto-retrieval via AWS API (cognito only) 3. Vendor env var: AUTH0_CLIENT_SECRET, OKTA_CLIENT_SECRET, etc. """ # Priority 1: per-client env var (OAUTH_CLIENT_SECRET_) env_var_name = f"{ENV_VAR_PREFIX}{client_id}" secret = os.environ.get(env_var_name) if secret: logger.info(f"Using client secret from env var {env_var_name}") return secret # Priority 2: Cognito auto-retrieval via AWS API if idp_vendor == "cognito": return _get_cognito_client_secret(discovery_url, client_id) # Priority 3: vendor-specific env var vendor_env_var = IDP_SECRET_ENV_VARS.get(idp_vendor) if not vendor_env_var: logger.warning(f"No env var mapping for IdP vendor: {idp_vendor}") return None secret = os.environ.get(vendor_env_var) if not secret: logger.warning(f"Env var {vendor_env_var} not set for {idp_vendor}") return secret def _get_token_endpoint( discovery_url: str, ) -> str | None: """Fetch token_endpoint from OIDC discovery document. GETs the discoveryUrl and extracts the token_endpoint field. Standard OIDC -- works for all providers. """ response = requests.get(discovery_url, timeout=OIDC_DISCOVERY_TIMEOUT) response.raise_for_status() return response.json().get("token_endpoint") def _request_token( token_endpoint: str, client_id: str, client_secret: str, ) -> str | None: """Request access token via OAuth2 client_credentials grant.""" response = requests.post( token_endpoint, headers={"Content-Type": "application/x-www-form-urlencoded"}, data={ "grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret, }, timeout=TOKEN_REQUEST_TIMEOUT, ) response.raise_for_status() return response.json().get("access_token") def _update_registry_credential( registry_url: str, registry_token: str, server_path: str, auth_credential: str, ) -> bool: """PATCH auth_credential for a server in the registry.""" url = f"{registry_url.rstrip('/')}/api/servers{server_path}/auth-credential" response = requests.patch( url, headers={ "Authorization": f"Bearer {registry_token}", "Content-Type": "application/json", }, json={ "auth_scheme": "bearer", "auth_credential": auth_credential, }, timeout=REGISTRY_REQUEST_TIMEOUT, ) response.raise_for_status() return True def _load_registry_token( token_file: str, ) -> str: """Load registry auth token from JSON file. Supports two formats: - Flat: {"access_token": "..."} or {"token": "..."} - Nested: {"tokens": {"access_token": "..."}} """ ... ``` #### Public Function ```python def refresh_all( manifest_path: str, registry_url: str, registry_token: str, run_scan: bool = True, ) -> dict[str, Any]: """Refresh tokens for all entries in the manifest. For each CUSTOM_JWT gateway: 1. Resolve client_secret (per-client env -> Cognito auto -> vendor env) 2. GET discoveryUrl -> extract token_endpoint 3. POST client_credentials grant -> get access_token 4. PATCH auth_credential in the registry 5. Trigger security rescan (if run_scan is True) Returns summary dict with success/failure/skipped/scan counts. """ entries = _read_manifest(manifest_path) start_time = time.time() success_count = 0 failure_count = 0 skipped_count = 0 for entry in entries: server_path = entry["server_path"] discovery_url = entry["discovery_url"] allowed_clients = entry.get("allowed_clients", []) idp_vendor = entry.get("idp_vendor") or _detect_idp_vendor(discovery_url) if not allowed_clients: logger.warning(f"No allowed_clients for {server_path} -- skipping") skipped_count += 1 continue client_id = allowed_clients[0] # Step 1: Resolve client_secret client_secret = _get_client_secret(idp_vendor, discovery_url, client_id) if not client_secret: skipped_count += 1 continue # Step 2: Get token_endpoint via OIDC discovery token_endpoint = _get_token_endpoint(discovery_url) if not token_endpoint: failure_count += 1 continue # Step 3: Request token token = _request_token(token_endpoint, client_id, client_secret) if not token: failure_count += 1 continue # Step 4: Update registry updated = _update_registry_credential( registry_url, registry_token, server_path, token ) if updated: success_count += 1 entry["last_refreshed"] = datetime.now(timezone.utc).isoformat() else: failure_count += 1 # Update manifest with timestamps with open(manifest_path, "w") as f: json.dump(entries, f, indent=2) elapsed = time.time() - start_time summary = { "total": len(entries), "success": success_count, "failed": failure_count, "skipped": skipped_count, "elapsed_seconds": round(elapsed, 1), } logger.info(f"Token refresh complete: {json.dumps(summary)}") return summary ``` #### Main Function ```python def main() -> None: """Parse arguments and run token refresh.""" parser = argparse.ArgumentParser( description="Refresh auth tokens for AgentCore CUSTOM_JWT gateways", ) parser.add_argument("--manifest", default="token_refresh_manifest.json") parser.add_argument("--registry-url", default=os.environ.get("REGISTRY_URL", "http://localhost")) parser.add_argument("--token-file", default=os.environ.get("REGISTRY_TOKEN_FILE", ".token")) parser.add_argument("--loop", action="store_true") parser.add_argument("--interval", type=int, default=2700) parser.add_argument("--debug", action="store_true") args = parser.parse_args() if args.debug: logging.getLogger().setLevel(logging.DEBUG) registry_token = _load_registry_token(args.token_file) if args.loop: while True: refresh_all(args.manifest, args.registry_url, registry_token) time.sleep(args.interval) else: refresh_all(args.manifest, args.registry_url, registry_token) ``` ### Cron Setup ```bash # Refresh every 45 minutes (tokens typically expire in 60 min) */45 * * * * cd /app && uv run python -m cli.agentcore.token_refresher \ --manifest token_refresh_manifest.json \ --registry-url https://registry.example.com \ --token-file .token \ >> /var/log/token-refresher.log 2>&1 ``` Or run as a sidecar with `--loop --interval 2700`. ### Registry API Requirement The token refresher uses the PATCH endpoint: ``` PATCH /api/servers/{path}/auth-credential Authorization: Bearer {registry_token} Content-Type: application/json {"auth_scheme": "bearer", "auth_credential": "eyJhbGciOiJSUzI1NiIs..."} ``` The registry encrypts the credential before storing (existing behavior for `auth_credential` on POST). **Note:** If the registry token has expired, the PATCH will fail with an HTTP 500 from nginx (HTML response, not JSON). The token refresher detects this and logs a diagnostic message suggesting token regeneration. --- ## Example: 12 Gateways Across 3 IdPs ``` Gateways 1-5: Cognito (discoveryUrl contains "cognito-idp") Gateways 6-8: Auth0 (discoveryUrl contains "auth0.com") Gateways 9-12: Entra (discoveryUrl contains "microsoftonline.com") ``` **Phase 1** -- `sync` registers all 12 gateways without tokens. Outputs manifest with 12 entries. **Phase 2** -- `token_refresher` processes the manifest using 3-tier secret resolution: - Gateways 1-5: detects `cognito`, auto-retrieves secret via `describe_user_pool_client()` (zero config) - Gateways 6-8: detects `auth0`, reads `AUTH0_CLIENT_SECRET` from env (one secret for all 3) - Gateways 9-12: detects `entra`, reads `ENTRA_CLIENT_SECRET` from env (one secret for all 4) - For all 12: GETs discoveryUrl -> token_endpoint, POSTs client_credentials, PATCHes registry **Total config needed**: two env vars (`AUTH0_CLIENT_SECRET`, `ENTRA_CLIENT_SECRET`). Cognito needs nothing. **Override example**: If Auth0 gateway #7 uses a different client secret than gateways #6 and #8: ```bash # Per-client override takes priority over AUTH0_CLIENT_SECRET OAUTH_CLIENT_SECRET_gw7clientid=different-secret AUTH0_CLIENT_SECRET=shared-secret \ uv run python -m cli.agentcore.token_refresher --manifest token_refresh_manifest.json ``` --- ## Test Plan | Test | What It Validates | |------|-------------------| | `test_detect_idp_vendor_cognito` | `_detect_idp_vendor()` returns `"cognito"` for cognito-idp URLs | | `test_detect_idp_vendor_auth0` | Returns `"auth0"` for auth0.com URLs | | `test_detect_idp_vendor_unknown` | Returns `"unknown"` for unrecognized URLs | | `test_build_gateway_registration_custom_jwt` | Metadata includes `discovery_url`, `allowed_clients`, `idp_vendor` | | `test_build_gateway_registration_none_auth` | Metadata does not include OIDC fields | | `test_register_gateway_collects_manifest` | `_manifest_entries` populated for CUSTOM_JWT gateways | | `test_register_gateway_no_manifest_for_iam` | `_manifest_entries` empty for AWS_IAM gateways | | `test_write_manifest_creates_file` | JSON file written with correct structure | | `test_write_manifest_dry_run_skips` | No file written in dry-run mode | | `test_sync_gateways_end_to_end` | Full flow: scan -> register -> manifest (mocked AWS + registry) | | `test_per_client_env_var_takes_priority` | `OAUTH_CLIENT_SECRET_` takes priority over Cognito auto and vendor env | | `test_get_cognito_client_secret` | Parses pool_id/region from URL, calls describe_user_pool_client | | `test_get_client_secret_auth0_from_env` | Reads AUTH0_CLIENT_SECRET | | `test_get_client_secret_missing_env` | Returns None, logs warning | | `test_get_token_endpoint_from_discovery` | GETs discovery URL, extracts token_endpoint | | `test_request_token_success` | Standard client_credentials grant | | `test_update_registry_credential` | PATCHes auth_credential via `/api/servers/{path}/auth-credential` | | `test_refresh_all_mixed_idps` | End-to-end: Cognito auto + Auth0 env + skip unknown | | `test_refresh_all_writes_timestamps` | Manifest updated with last_refreshed | | `test_runtime_no_manifest_entry` | Runtimes do not appear in manifest | | `test_agent_conflict_with_overwrite_calls_update` | Agent `--overwrite` uses `update_agent()` PUT on conflict | | `test_agent_conflict_without_overwrite_skips` | Agent conflict without `--overwrite` shows "skipped" | ================================================ FILE: docs/design/ans-integration.md ================================================ # ANS (Agent Name Service) Integration **Demo Video:** [ANS Integration Walkthrough](https://app.vidcast.io/share/c2240a78-8899-46ad-9375-6fb0cc1345f3?playerMode=vidcast) This document describes the ANS integration architecture, configuration, API usage, and operational procedures for the MCP Gateway Registry. ## Overview ANS (Agent Name Service) is a PKI-based trust verification service operated by GoDaddy that provides cryptographic identity verification for AI agents. The MCP Gateway Registry integrates with ANS using a **read-only "Bring Your Own ANS ID"** approach -- the registry never manages PKI certificates or identities directly. Instead, agent owners register with ANS independently and then link their ANS Agent ID to their registry entry for trust verification. ### What ANS Provides - Cryptographic identity verification for AI agents - Domain ownership proof via PKI certificates - Agent identity metadata (name, description, version, organization) - Endpoint and protocol registration (A2A, MCP, HTTP-API) - Certificate lifecycle management (issuance, expiration, revocation) ### What the Registry Does - Stores ANS verification metadata on agent and server records - Displays trust badges on agent/server cards in the UI - Periodically re-verifies ANS status via background sync - Provides admin visibility into ANS integration health ``` Integration Architecture: Agent Owner AI Registry GoDaddy ANS API ----------- ----------- --------------- | | | | 1. Register with ANS | | | (out-of-band) ========================> | | | | | 2. Link ANS ID | | | POST /agents/{p}/ans/link | | ======================> | | | | 3. Verify with ANS API | | | ========================> | | | <== ANS Metadata ====== | | | | | 4. Trust badge shown | | | <===================== | | | | | | | 5. Background re-verify | | | (every 6 hours) | | | ========================> | ``` ## Configuration All ANS configuration is managed via environment variables that map to Pydantic Settings fields in `registry/core/config.py`. ### Required Configuration | Parameter | Environment Variable | Description | Default | |-----------|---------------------|-------------|---------| | `ans_integration_enabled` | `ANS_INTEGRATION_ENABLED` | Master switch for ANS integration | `false` | | `ans_api_key` | `ANS_API_KEY` | GoDaddy API key for authentication | `""` | | `ans_api_secret` | `ANS_API_SECRET` | GoDaddy API secret for authentication | `""` | ### Optional Configuration | Parameter | Environment Variable | Description | Default | |-----------|---------------------|-------------|---------| | `ans_api_endpoint` | `ANS_API_ENDPOINT` | ANS API base URL | `https://api.godaddy.com` | | `ans_api_timeout_seconds` | `ANS_API_TIMEOUT_SECONDS` | HTTP request timeout for ANS calls | `30` | | `ans_sync_interval_hours` | `ANS_SYNC_INTERVAL_HOURS` | Background verification sync interval | `6` | | `ans_verification_cache_ttl_seconds` | `ANS_VERIFICATION_CACHE_TTL_SECONDS` | Cache TTL for verification results | `3600` | ### Environment File Example ```bash # ANS Integration ANS_INTEGRATION_ENABLED=true ANS_API_ENDPOINT=https://api.godaddy.com ANS_API_KEY=your-godaddy-api-key ANS_API_SECRET=your-godaddy-api-secret ANS_API_TIMEOUT_SECONDS=30 ANS_SYNC_INTERVAL_HOURS=6 ``` ### Terraform Configuration For ECS deployments, set these in `terraform/aws-ecs/terraform.tfvars`: ```hcl ans_integration_enabled = true ans_api_endpoint = "https://api.godaddy.com" ans_api_key = "your-api-key" ans_api_secret = "your-api-secret" ``` ### System Configuration Page ANS configuration is visible in the admin System Configuration page under the "ANS Integration" group. Navigate to the registry UI and open the system configuration panel to view and export current ANS settings. ## API Endpoints ### Agent ANS Endpoints #### Link ANS ID to Agent Links an ANS Agent ID to a registered agent. The registry calls the ANS API to verify the identity and stores the metadata. ```bash POST /api/agents/{agent_path}/ans/link Content-Type: application/json Authorization: Bearer X-CSRF-Token: { "ans_agent_id": "ans://v1.0.0.myagent.example.com" } ``` **Response (200):** ```json { "success": true, "message": "ANS identity linked and verified", "ans_metadata": { "ans_agent_id": "89a5061b-4f89-452b-9b66-dd9ca8baad7f", "status": "verified", "domain": "example.com", "organization": "Example Corp", "ans_name": "myagent.example.com", "ans_display_name": "My Agent", "certificate": { "not_before": "2025-01-01T00:00:00Z", "not_after": "2026-01-01T00:00:00Z", "subject_dn": "CN=myagent.example.com", "issuer_dn": "CN=ANS CA" }, "endpoints": [], "linked_at": "2026-03-26T12:00:00Z", "last_verified": "2026-03-26T12:00:00Z" } } ``` **Requirements:** - User must be authenticated - User must own the agent (`registered_by` field matches username) - Rate limited: 10 link operations per user per hour - CSRF token required #### Get ANS Status ```bash GET /api/agents/{agent_path}/ans/status Authorization: Bearer ``` **Response (200):** Returns full ANS metadata for the agent. **Response (404):** Agent has no ANS link. #### Unlink ANS from Agent ```bash DELETE /api/agents/{agent_path}/ans/link Authorization: Bearer X-CSRF-Token: ``` **Response (200):** ```json { "success": true, "message": "ANS identity unlinked" } ``` ### Server ANS Endpoints Servers follow the same pattern as agents: | Method | Path | Description | |--------|------|-------------| | POST | `/api/servers/{path}/ans/link` | Link ANS ID to server | | GET | `/api/servers/{path}/ans/status` | Get server ANS status | | DELETE | `/api/servers/{path}/ans/link` | Unlink ANS from server | ### Admin Endpoints #### Trigger Manual Sync Forces an immediate re-verification of all linked ANS identities. ```bash POST /api/admin/ans/sync Authorization: Bearer X-CSRF-Token: ``` **Response (200):** ```json { "total": 15, "updated": 12, "errors": 1, "duration_seconds": 4.2 } ``` **Requires:** Admin group membership or `ans-admin/manage` scope. #### Get ANS Metrics ```bash GET /api/admin/ans/metrics Authorization: Bearer ``` **Response (200):** ```json { "total_linked": 15, "by_status": { "verified": 12, "expired": 2, "not_found": 1 }, "by_asset_type": { "agent": 10, "server": 5 }, "sync_history": [ { "timestamp": "2026-03-26T06:00:00Z", "total": 15, "updated": 2, "errors": 0, "duration_seconds": 3.8 } ] } ``` #### Check ANS API Health ```bash GET /api/admin/ans/health Authorization: Bearer ``` **Response (200):** ```json { "status": "healthy", "api_reachable": true, "api_status_code": 200 } ``` Possible status values: `healthy`, `degraded`, `unhealthy`. ## CLI Usage The registry management CLI can be used to interact with ANS endpoints. ### Link an Agent to ANS ```bash # Using curl with token file TOKEN=$(cat .token) REGISTRY_URL="https://your-registry.example.com" # Link ANS identity curl -X POST "${REGISTRY_URL}/api/agents/my-agent/ans/link" \ -H "Authorization: Bearer ${TOKEN}" \ -H "Content-Type: application/json" \ -d '{"ans_agent_id": "ans://v1.0.0.myagent.example.com"}' ``` ### Check ANS Status ```bash curl -s "${REGISTRY_URL}/api/agents/my-agent/ans/status" \ -H "Authorization: Bearer ${TOKEN}" | python -m json.tool ``` ### Admin: Trigger Manual Sync ```bash curl -X POST "${REGISTRY_URL}/api/admin/ans/sync" \ -H "Authorization: Bearer ${TOKEN}" ``` ### Admin: View Metrics ```bash curl -s "${REGISTRY_URL}/api/admin/ans/metrics" \ -H "Authorization: Bearer ${TOKEN}" | python -m json.tool ``` ### Admin: Check API Health ```bash curl -s "${REGISTRY_URL}/api/admin/ans/health" \ -H "Authorization: Bearer ${TOKEN}" | python -m json.tool ``` ### Verify Agent Has ANS Data List an agent and check the `ans_metadata` field: ```bash curl -s "${REGISTRY_URL}/api/agents/my-agent" \ -H "Authorization: Bearer ${TOKEN}" | python -m json.tool | grep -A 20 ans_metadata ``` ## ANS Agent ID Format ANS Agent IDs follow the URI format: ``` ans://v1.0.0.agentname.domain.com ``` Components: - `ans://` - Protocol scheme - `v1.0.0` - Version identifier - `agentname` - Agent name within the domain - `domain.com` - Verified domain The registry also accepts raw UUIDs (e.g., `89a5061b-4f89-452b-9b66-dd9ca8baad7f`). When an `ans://` URI is provided, the client resolves it to a UUID by searching the ANS API. ## Verification Status Values | Status | Meaning | Badge Color | |--------|---------|-------------| | `verified` | Agent identity is valid and certificate is current | Green | | `expired` | Certificate has passed its `notAfter` date | Yellow | | `revoked` | Agent or certificate has been explicitly revoked | Red | | `not_found` | ANS Agent ID no longer exists in ANS | Gray | | `pending` | Verification is in progress | Blue | ## Architecture Components ### Service Layer | File | Purpose | |------|---------| | `registry/services/ans_client.py` | Low-level HTTP client for GoDaddy ANS API | | `registry/services/ans_service.py` | Business logic for link/unlink/sync operations | | `registry/services/ans_sync_scheduler.py` | Background task that re-verifies all linked identities | ### API Layer | File | Purpose | |------|---------| | `registry/api/ans_routes.py` | FastAPI router with all ANS endpoints | ### Data Models | File | Purpose | |------|---------| | `registry/schemas/ans_models.py` | Pydantic models for ANS metadata, certificates, endpoints | | `registry/schemas/agent_models.py` | Agent model with `ans_metadata` field | | `registry/core/schemas.py` | Server model with `ans_metadata` field | ### Frontend | File | Purpose | |------|---------| | `frontend/src/components/ANSBadge.tsx` | Badge component and certificate detail modal | | `frontend/src/components/AgentCard.tsx` | Displays ANS badge on agent cards | | `frontend/src/components/ServerCard.tsx` | Displays ANS badge on server cards | ## Background Sync The ANS sync scheduler runs as an async background task within the FastAPI application lifecycle. ### How It Works 1. On application startup, if `ans_integration_enabled=True`, the scheduler starts 2. Every `ans_sync_interval_hours` (default 6), it runs `sync_all_ans_status()` 3. The sync queries all agents and servers with non-null `ans_metadata` 4. For each linked asset, it calls the ANS API to re-verify the identity 5. Updates `ans_metadata.status` and `ans_metadata.last_verified` timestamp 6. Stores sync results in memory (last 20 runs), viewable via admin metrics endpoint ### Sync Lifecycle ``` Application Start | v [ANS Enabled?] --No--> Skip | Yes v Start Scheduler Loop | v Sleep(sync_interval_hours) | v sync_all_ans_status() | +---> For each agent with ans_metadata: | verify_ans_agent(ans_agent_id) | Update status + last_verified | v Store sync stats | v Loop back to Sleep ``` ## Resilience Features ### Circuit Breaker The ANS client implements a circuit breaker to prevent cascading failures when the ANS API is unavailable. | Parameter | Value | |-----------|-------| | Failure threshold | 5 consecutive failures | | Reset timeout | 3600 seconds (1 hour) | | Behavior when open | Returns `None` immediately without calling API | ### Retry Logic Each ANS API call includes automatic retries: | Parameter | Value | |-----------|-------| | Max retries | 3 | | Backoff strategy | Exponential (1s, 2s, 4s) | | Timeout per request | `ans_api_timeout_seconds` (default 30s) | ### Rate Limiting Per-user rate limiting on link operations prevents abuse: | Parameter | Value | |-----------|-------| | Max requests | 10 per user | | Window | 3600 seconds (1 hour) | ## Authentication with ANS API The registry authenticates with GoDaddy's ANS API using SSO-key authentication: ``` Authorization: sso-key {ans_api_key}:{ans_api_secret} ``` This is a GoDaddy-specific authentication scheme. API keys are obtained from the GoDaddy developer portal. ### API Endpoints Used | Method | ANS API Path | Purpose | |--------|-------------|---------| | GET | `/v1/agents/{uuid}` | Fetch agent details and certificate info | | GET | `/v1/agents?name={name}` | Resolve `ans://` URI to UUID | ## Data Storage ANS metadata is stored as a `dict[str, Any]` field on both agent and server MongoDB documents. This allows schema evolution without database migrations. ### MongoDB Field ```json { "path": "/my-agent", "name": "My Agent", "ans_metadata": { "ans_agent_id": "89a5061b-4f89-452b-9b66-dd9ca8baad7f", "status": "verified", "domain": "example.com", "organization": "Example Corp", "ans_name": "myagent.example.com", "certificate": { ... }, "endpoints": [ ... ], "linked_at": "2026-03-26T12:00:00Z", "last_verified": "2026-03-26T12:00:00Z" } } ``` ### ANS Metadata in Agent and Server List APIs The agent list (`GET /api/agents`) and server list (`GET /api/servers`) API responses now include `ans_metadata` for each entry. This is reflected in the OpenAPI spec (`openapi.json`). When an agent or server has a linked ANS identity, the full metadata object is returned inline, allowing API consumers to display trust information without making additional calls. ```bash # List agents - each entry includes ans_metadata when linked curl -s "${REGISTRY_URL}/api/agents" \ -H "Authorization: Bearer ${TOKEN}" | python -m json.tool ``` Example agent entry in the list response: ```json { "path": "/jewel-homes-support-agent", "name": "Jewel Homes Support Agent", "description": "Real estate support agent", "ans_metadata": { "ans_agent_id": "89a5061b-4f89-452b-9b66-dd9ca8baad7f", "status": "verified", "domain": "helpagent.club", "organization": "Jewel Homes", "last_verified": "2026-03-26T12:00:00Z" } } ``` When no ANS identity is linked, `ans_metadata` is `null`. ### ANS Trust Verified in Semantic Search Results The semantic search API (`GET /api/search`) now returns a `trust_verified` boolean field in each result. This field is derived from `ans_metadata.status == "verified"` and provides a simple flag for consumers to identify agents with valid ANS verification without needing to parse the full metadata. ```bash # Semantic search - results include trust_verified field curl -s "${REGISTRY_URL}/api/search?q=real+estate+support" \ -H "Authorization: Bearer ${TOKEN}" | python -m json.tool ``` Example search result: ```json { "results": [ { "path": "/jewel-homes-support-agent", "name": "Jewel Homes Support Agent", "description": "Real estate support agent", "score": 0.92, "trust_verified": true }, { "path": "/generic-helper", "name": "Generic Helper", "description": "General purpose helper", "score": 0.78, "trust_verified": false } ] } ``` This allows search consumers to prioritize or filter results by trust status. Agents with `trust_verified: true` have a valid, non-expired, non-revoked ANS identity. ## Linking During Agent Registration When registering a new agent, an optional `ans_agent_id` field can be included in the request body. If provided and ANS integration is enabled, the registry will attempt to link and verify the ANS identity as part of registration. This is a best-effort operation -- if ANS verification fails, the agent is still registered and can be linked later via the dedicated endpoint. ```bash curl -X POST "${REGISTRY_URL}/api/agents" \ -H "Authorization: Bearer ${TOKEN}" \ -H "Content-Type: application/json" \ -d '{ "name": "My Agent", "path": "/my-agent", "description": "An example agent", "ans_agent_id": "ans://v1.0.0.myagent.example.com" }' ``` ## Route Ordering ANS routes must be registered in `registry/main.py` **before** the agent router because the agent router contains a catch-all `{path:path}` route that would otherwise consume ANS-specific paths like `/agents/{path}/ans/status`. ```python # In registry/main.py - order matters app.include_router(ans_router, prefix="/api", tags=["ANS Integration"]) # BEFORE agent_router app.include_router(agent_router, prefix="/api", tags=["Agent Management"]) # catch-all {path:path} here ``` ## Troubleshooting ### ANS Badge Not Showing 1. **Check ANS is enabled:** Verify `ANS_INTEGRATION_ENABLED=true` in environment 2. **Check API credentials:** Verify `ANS_API_KEY` and `ANS_API_SECRET` are set 3. **Check route ordering:** ANS router must be registered before agent router in `main.py` 4. **Check agent has metadata:** `GET /api/agents/{path}` should show `ans_metadata` field 5. **Check API health:** `GET /api/admin/ans/health` should return `healthy` ### ANS API Returning Errors 1. **Check circuit breaker:** If 5+ consecutive failures, circuit opens for 1 hour 2. **Check API endpoint:** Verify `ANS_API_ENDPOINT` points to correct URL 3. **Check credentials:** Test with `GET /api/admin/ans/health` 4. **Check timeout:** Increase `ANS_API_TIMEOUT_SECONDS` if requests are timing out ### Verification Status Stuck on "pending" This typically means the initial verification call failed or timed out. Try: 1. Unlink: `DELETE /api/agents/{path}/ans/link` 2. Re-link: `POST /api/agents/{path}/ans/link` with the ANS Agent ID 3. Or trigger manual sync: `POST /api/admin/ans/sync` ### Security Scan Routes Returning 404 Similar to ANS routes, security scan routes (`/agents/{path}/security-scan`) must be defined before the catch-all `{path:path}` route in `agent_routes.py`. If these return 404, check route ordering. ================================================ FILE: docs/design/anthropic-api-implementation.md ================================================ # Anthropic MCP Registry API - Implementation Guide > **Note**: The Anthropic API version (v0.1) is defined as a constant `ANTHROPIC_API_VERSION` in `registry/constants.py`. All code references this constant rather than hardcoding the version string. --- ## Overview This implementation provides full compatibility with the [Anthropic MCP Registry REST API v0.1 specification](https://github.com/modelcontextprotocol/registry), enabling seamless integration with MCP ecosystem tools and downstream applications. ### Key Features - ✅ **3 REST API endpoints** for server discovery - ✅ **JWT Bearer token authentication** via Keycloak - ✅ **Cursor-based pagination** for server lists - ✅ **Permission-based filtering** using MCP scopes - ✅ **Complete Pydantic models** matching Anthropic spec - ✅ **Automatic data transformation** from internal format --- ## Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ Client (Authorization: Bearer ) │ └────────────────────┬────────────────────────────────────────┘ │ HTTP Request ▼ ┌─────────────────────────────────────────────────────────────┐ │ Nginx (:80/:443) │ │ └─ /v0.1/* location │ │ └─ auth_request /validate ────────────────┐ │ └────────────────────┬───────────────────────────┼────────────┘ │ │ │ ▼ │ ┌─────────────────────────┐ │ │ Auth Server (:8888) │ │ │ - Validates JWT │ │ │ - Checks Keycloak │ │ │ - Returns headers │ │ └─────────────┬───────────┘ │ │ │ ◄──────────────────────────┘ │ X-User, X-Scopes, X-Username ▼ ┌─────────────────────────────────────────────────────────────┐ │ Registry FastAPI (:7860) │ │ ├─ nginx_proxied_auth() - Reads headers │ │ ├─ registry_routes.py - API endpoints │ │ ├─ server_service - Data access │ │ └─ transform_service - Format conversion │ └────────────────────┬────────────────────────────────────────┘ │ ▼ Anthropic Schema Response ``` --- ## File Structure ### New Files | File | Purpose | |------|---------| | `registry/constants.py` | Anthropic API constants (`ANTHROPIC_SERVER_NAMESPACE`, limits) | | `registry/schemas/anthropic_schema.py` | 9 Pydantic models for Anthropic spec | | `registry/services/transform_service.py` | Data transformation between formats | | `registry/api/registry_routes.py` | 3 REST endpoints with JWT auth | | `tests/unit/api/test_registry_routes.py` | API endpoint tests | | `tests/unit/services/test_transform_service.py` | Transformation tests | | `docs/design/anthropic-api-test-commands.md` | 20 test scenarios with curl | ### Modified Files | File | Changes | |------|---------| | `registry/main.py` | Registered v0.1 router | | `registry/auth/dependencies.py` | Added `nginx_proxied_auth()` function | | `docker/nginx_rev_proxy_*.conf` | Added `/v0.1/` location with auth validation | | `.gitignore` | Added `tests/reports/` | --- ## Constants Configuration All hardcoded values are centralized in `registry/constants.py`: ```python class RegistryConstants(BaseModel): # Anthropic Registry API v0.1 constants ANTHROPIC_SERVER_NAMESPACE: str = "io.mcpgateway" ANTHROPIC_API_DEFAULT_LIMIT: int = 100 ANTHROPIC_API_MAX_LIMIT: int = 1000 ``` **Usage**: Import with `from ..constants import REGISTRY_CONSTANTS` --- ## API Endpoints ### 1. List Servers ``` GET /v0.1/servers?cursor={cursor}&limit={limit} ``` **Purpose**: List all MCP servers the authenticated user can access. **Query Parameters**: - `cursor` (optional): Pagination cursor from previous response - `limit` (optional): Results per page (1-1000, default 100) **Response**: `ServerList` with pagination metadata **Example**: ```bash curl "http://localhost/v0.1/servers?limit=5" \ -H "Authorization: Bearer $TOKEN" ``` ### 2. List Server Versions ``` GET /v0.1/servers/{serverName:path}/versions ``` **Purpose**: List all available versions for a specific server. **URL Parameters**: - `serverName`: URL-encoded name (e.g., `io.mcpgateway%2Ffininfo`) **Response**: `ServerList` (currently single version per server) **Important**: Note `:path` route converter to handle `/` in server names. **Example**: ```bash curl "http://localhost/v0.1/servers/io.mcpgateway%2Ffininfo/versions" \ -H "Authorization: Bearer $TOKEN" ``` ### 3. Get Server Version Details ``` GET /v0.1/servers/{serverName:path}/versions/{version} ``` **Purpose**: Get detailed information for a specific server version. **URL Parameters**: - `serverName`: URL-encoded name (e.g., `io.mcpgateway%2Ffininfo`) - `version`: Version string (use `latest` for current version) **Response**: `ServerResponse` with full server details **Example**: ```bash curl "http://localhost/v0.1/servers/io.mcpgateway%2Ffininfo/versions/latest" \ -H "Authorization: Bearer $TOKEN" ``` --- ## Authentication Flow ### 1. JWT Bearer Token Validation **Client → Nginx**: ``` GET /v0.1/servers Authorization: Bearer eyJhbGci... ``` **Nginx → Auth Server** (`/validate` endpoint): ``` GET /validate X-Authorization: Bearer eyJhbGci... X-Original-URL: http://localhost/v0.1/servers ``` **Auth Server Processing**: 1. Validates JWT signature using Keycloak JWKS 2. Checks expiration, issuer (3-tier validation), audience - Tries external URL: `https://mcpgateway.ddns.net/realms/mcp-gateway` - Tries internal URL: `http://keycloak:8080/realms/mcp-gateway` - Tries localhost URL: `http://localhost:8080/realms/mcp-gateway` 3. Extracts user info: `preferred_username`, `groups`, `scope` 4. Maps Keycloak groups to MCP scopes **Auth Server → Nginx** (response headers): ``` X-User: service-account-mcp-gateway-m2m X-Username: service-account-mcp-gateway-m2m X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute X-Auth-Method: keycloak ``` **Nginx → FastAPI**: ``` GET /v0.1/servers X-User: service-account-mcp-gateway-m2m X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute Authorization: Bearer eyJhbGci... ``` ### 2. nginx Configuration **Critical Setup** in `/v0.1/` location block: ```nginx location /v0.1/ { # Authenticate via auth-server auth_request /validate; # Capture auth server response headers auth_request_set $auth_user $upstream_http_x_user; auth_request_set $auth_username $upstream_http_x_username; auth_request_set $auth_scopes $upstream_http_x_scopes; auth_request_set $auth_method $upstream_http_x_auth_method; # Forward to FastAPI with auth context proxy_pass http://127.0.0.1:7860/v0.1/; proxy_set_header X-User $auth_user; proxy_set_header X-Username $auth_username; proxy_set_header X-Scopes $auth_scopes; proxy_set_header X-Auth-Method $auth_method; proxy_set_header Authorization $http_authorization; } ``` **Key Fix**: `/validate` endpoint must forward `Authorization` as `X-Authorization`: ```nginx location = /validate { proxy_pass http://auth-server:8888/validate; # CRITICAL: Read from $http_authorization (client's Authorization header) proxy_set_header X-Authorization $http_authorization; } ``` ### 3. FastAPI Authentication Dependency **Function**: `nginx_proxied_auth()` in `registry/auth/dependencies.py` **Supports Two Modes**: 1. **JWT Flow** (primary): Reads nginx headers from auth validation 2. **Cookie Flow** (fallback): Reads session cookies for backward compatibility ```python def nginx_proxied_auth( request: Request, session: Cookie = None, x_user: Header = None, x_username: Header = None, x_scopes: Header = None, x_auth_method: Header = None, ) -> Dict[str, Any]: # Try nginx headers first (JWT Bearer token) if x_user or x_username: username = x_username or x_user scopes = x_scopes.split() if x_scopes else [] # Map scopes to groups if 'mcp-servers-unrestricted/read' in scopes: groups = ['mcp-registry-admin'] else: groups = ['mcp-registry-user'] # Get accessible servers from scopes accessible_servers = get_user_accessible_servers(scopes) return { 'username': username, 'groups': groups, 'scopes': scopes, 'accessible_servers': accessible_servers, 'is_admin': 'mcp-registry-admin' in groups, # ... more fields } # Fallback to session cookie return enhanced_auth(session) ``` --- ## Permission Checks ### Scope-Based Access Control **IMPORTANT**: v0.1 API uses `accessible_servers` (MCP scopes), NOT `accessible_services` (UI scopes). ```python # CORRECT - Check against accessible_servers accessible_servers = user_context.get("accessible_servers", []) if server_name not in accessible_servers: raise HTTPException(404, "Server not found") ``` **Why**: - `accessible_services` = UI-level services ("auth_server", "mcpgw") - `accessible_servers` = MCP server names ("fininfo", "currenttime") - M2M tokens have MCP scopes but no UI scopes ### User Context Structure ```python { "username": "service-account-mcp-gateway-m2m", "groups": ["mcp-registry-admin"], "scopes": [ "mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute", "mcp-servers-restricted/read", "mcp-servers-restricted/execute" ], "auth_method": "keycloak", "provider": "keycloak", "accessible_servers": [ "currenttime", "fininfo", "mcpgw", "realserverfaketools", "sre-gateway" ], "accessible_services": [], # Empty for M2M tokens "is_admin": True, "can_modify_servers": False } ``` --- ## Data Transformation ### Namespace Convention **Internal Format**: `/fininfo`, `/currenttime/` **Anthropic Format**: `io.mcpgateway/fininfo`, `io.mcpgateway/currenttime` **Implementation** (`transform_service.py`): ```python def _create_server_name(server_info: Dict[str, Any]) -> str: path = server_info.get("path", "") clean_path = path.strip("/") namespace = REGISTRY_CONSTANTS.ANTHROPIC_SERVER_NAMESPACE return f"{namespace}/{clean_path}" ``` ### Server Detail Transformation ```python def transform_to_server_detail(server_info: Dict[str, Any]) -> ServerDetail: # Create Anthropic-format name name = _create_server_name(server_info) # Build package with transport config transport = _create_transport_config(server_info) package = Package( registryType="mcpb", identifier=name, version="1.0.0", transport=transport, runtimeHint="docker" ) # Add internal metadata namespace = REGISTRY_CONSTANTS.ANTHROPIC_SERVER_NAMESPACE meta = { f"{namespace}/internal": { "path": server_info.get("path"), "is_enabled": server_info.get("is_enabled"), "health_status": server_info.get("health_status"), "num_tools": server_info.get("num_tools"), "tags": server_info.get("tags", []), "license": server_info.get("license", "N/A") } } return ServerDetail(name=name, packages=[package], meta=meta, ...) ``` ### Response Structure ```json { "server": { "name": "io.mcpgateway/fininfo", "description": "Financial Information", "version": "1.0.0", "title": "Financial Info", "packages": [ { "registryType": "mcpb", "identifier": "io.mcpgateway/fininfo", "version": "1.0.0", "transport": { "type": "streamable-http", "url": "http://fininfo-server:8001/mcp/" }, "runtimeHint": "docker" } ], "_meta": { "io.mcpgateway/internal": { "path": "/fininfo", "is_enabled": true, "health_status": "healthy", "num_tools": 5, "tags": ["Finance", "Stocks", "Market"], "license": "MIT" } } }, "_meta": { "io.mcpgateway/registry": { "last_checked": "2025-10-12T19:25:09.378358+00:00", "health_status": "healthy" } } } ``` --- ## Pagination ### Cursor-Based Implementation **Algorithm** (`transform_service.py`): ```python def transform_to_server_list( servers_data: List[Dict[str, Any]], cursor: Optional[str] = None, limit: Optional[int] = None ) -> ServerList: # Apply defaults limit = limit or REGISTRY_CONSTANTS.ANTHROPIC_API_DEFAULT_LIMIT limit = min(limit, REGISTRY_CONSTANTS.ANTHROPIC_API_MAX_LIMIT) # Sort alphabetically for consistency sorted_servers = sorted(servers_data, key=lambda s: _create_server_name(s)) # Find cursor position start_index = 0 if cursor: for idx, server in enumerate(sorted_servers): if _create_server_name(server) == cursor: start_index = idx + 1 break # Slice page end_index = start_index + limit page_servers = sorted_servers[start_index:end_index] # Determine next cursor has_more = end_index < len(sorted_servers) next_cursor = _create_server_name(sorted_servers[end_index - 1]) if has_more else None # Transform and return return ServerList( servers=[transform_to_server_response(s) for s in page_servers], metadata=PaginationMetadata(nextCursor=next_cursor, count=len(page_servers)) ) ``` **Example Flow**: ``` Page 1: GET /v0.1/servers?limit=3 ← Returns: servers A, B, C with nextCursor="C" Page 2: GET /v0.1/servers?cursor=C&limit=3 ← Returns: servers D, E, F with nextCursor="F" Page 3: GET /v0.1/servers?cursor=F&limit=3 ← Returns: servers G, H with nextCursor=null (end) ``` --- ## Critical Implementation Details ### 1. Route Path Parameters **Problem**: Server names contain `/` which breaks FastAPI routing. **Solution**: Use `:path` converter in route definition. ```python # WRONG - Returns 404 for io.mcpgateway/fininfo @router.get("/servers/{serverName}/versions") # CORRECT - Captures full path including / @router.get("/servers/{serverName:path}/versions") ``` **Why**: FastAPI URL-decodes before routing. `io.mcpgateway%2Ffininfo` becomes `io.mcpgateway/fininfo`, which looks like extra path segments without `:path`. ### 2. Trailing Slash Handling **Problem**: Some servers have trailing slashes (`/currenttime/`), some don't (`/fininfo`). **Solution**: Try both forms when looking up servers. ```python # Construct path from server name lookup_path = "/" + decoded_name.replace(expected_prefix, "") # Try with and without trailing slash server_info = server_service.get_server_info(lookup_path) if not server_info: server_info = server_service.get_server_info(lookup_path + "/") # Use actual path from server_info for health checks path = server_info.get("path", lookup_path) # Has correct trailing slash health_data = health_service._get_service_health_data(path) ``` **Why**: Health data is indexed by exact path. Wrong path returns `"unknown"` status. ### 3. Namespace Constant Usage **All occurrences** of hardcoded `"io.mcpgateway"` replaced with constant: ```python from ..constants import REGISTRY_CONSTANTS namespace = REGISTRY_CONSTANTS.ANTHROPIC_SERVER_NAMESPACE expected_prefix = f"{namespace}/" # "io.mcpgateway/" ``` **Files using constant**: - `registry/api/registry_routes.py` - Validates server name format - `registry/services/transform_service.py` - Creates names and metadata keys --- ## Testing ### Generate Token ```bash # Generate fresh credentials (tokens expire after 5 minutes) ./generate_creds.sh # Load token export TOKEN=$(jq -r '.access_token' .oauth-tokens/ingress.json) # Verify token loaded echo "Token: ${TOKEN:0:50}..." ``` ### Test Endpoints ```bash # 1. List servers with pagination curl "http://localhost/v0.1/servers?limit=5" \ -H "Authorization: Bearer $TOKEN" | jq # 2. List versions for a server (note %2F = /) curl "http://localhost/v0.1/servers/io.mcpgateway%2Ffininfo/versions" \ -H "Authorization: Bearer $TOKEN" | jq # 3. Get specific version details curl "http://localhost/v0.1/servers/io.mcpgateway%2Ffininfo/versions/latest" \ -H "Authorization: Bearer $TOKEN" | jq # 4. Test pagination curl "http://localhost/v0.1/servers?limit=2" \ -H "Authorization: Bearer $TOKEN" | jq '.metadata' # Get nextCursor and use it: curl "http://localhost/v0.1/servers?cursor=io.mcpgateway%2Fcurrenttime&limit=2" \ -H "Authorization: Bearer $TOKEN" | jq ``` ### Comprehensive Test Suite See [docs/design/anthropic-api-test-commands.md](anthropic-api-test-commands.md) for 20 test scenarios. --- ## Common Issues & Solutions ### Issue: 404 on versions endpoint **Symptom**: `GET /v0.1/servers/io.mcpgateway%2Ffininfo/versions` returns 404 **Cause**: Missing `:path` in route parameter **Solution**: Ensure route uses `{serverName:path}` not `{serverName}` ### Issue: Health data shows "unknown" **Symptom**: `health_status: "unknown"`, `last_checked: null` **Cause**: Trailing slash mismatch in path lookup **Solution**: Use `server_info.get("path")` for health checks, not constructed path ### Issue: Empty server list **Symptom**: `{"servers": [], "metadata": {"count": 0}}` **Cause**: Checking `accessible_services` instead of `accessible_servers` **Solution**: Use `user_context["accessible_servers"]` for permission checks ### Issue: 401 Unauthorized **Symptom**: `{"detail": "Token has expired"}` **Cause**: JWT token expired (5 minute lifetime) **Solution**: Run `./generate_creds.sh` to get fresh token ### Issue: Token not forwarded **Symptom**: Auth server logs show `Authorization=False` **Cause**: nginx using `$http_x_authorization` instead of `$http_authorization` **Solution**: Update `/validate` location to use `$http_authorization` --- ## Schema Compliance **OpenAPI Spec**: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/openapi.yaml **Pydantic Models** (`registry/schemas/anthropic_schema.py`): - ✅ `ServerList` - Paginated server list - ✅ `ServerResponse` - Single server with metadata - ✅ `ServerDetail` - Complete server information - ✅ `Package` - Distribution package details - ✅ `Transport` - Union of transport types - ✅ `Repository` - Source code repository info - ✅ `PaginationMetadata` - Cursor and count - ✅ `ErrorResponse` - Error details **Field Aliases**: Pydantic handles `_meta` fields with `Field(alias="_meta")` --- ## Next Steps 1. ✅ **JWT Authentication** - Fully implemented 2. ✅ **Permission Filtering** - Uses MCP scopes 3. ✅ **Health Data** - Includes status and last checked 4. ✅ **Pagination** - Cursor-based with configurable limits 5. 🔄 **Read-Only API Access** - Optional: Create dedicated M2M client with minimal scopes (see `.scratchpad/registry-api-readonly-access.md`) 6. 🔄 **Rate Limiting** - Future: Add per-client rate limits 7. 🔄 **Caching** - Future: Cache server list responses --- ## References - **Issue**: [#175 - Support Anthropic MCP Registry REST API v0](https://github.com/agentic-community/mcp-gateway-registry/issues/175) - **OpenAPI Spec**: https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/api/openapi.yaml - **API Guide**: https://github.com/modelcontextprotocol/registry/blob/main/docs/guides/consuming/use-rest-api.md - **Test Commands**: [anthropic-api-test-commands.md](anthropic-api-test-commands.md) - **Progress Notes**: [.scratchpad/anthropic-api-v0-jwt-auth-progress.md](../../.scratchpad/anthropic-api-v0-jwt-auth-progress.md) ================================================ FILE: docs/design/anthropic-api-test-commands.md ================================================ # Anthropic Registry API Test Commands > **Note**: The Anthropic API version is defined in `registry/constants.py` as `ANTHROPIC_API_VERSION` for easy version management. ## Overview This document provides comprehensive curl commands to test all three endpoints of the Anthropic Registry API v0.1 implementation: 1. `GET /v0.1/servers` - List all MCP servers with pagination 2. `GET /v0.1/servers/{serverName}/versions` - List versions for a specific server 3. `GET /v0.1/servers/{serverName}/versions/{version}` - Get detailed info for a specific version ## Prerequisites ### 1. Start the MCP Gateway Registry ```bash # Build and start all services ./build_and_run.sh # Wait for services to be ready (check logs) docker compose logs -f registry ``` ### 2. Authentication Setup The v0.1 API requires JWT authentication via Keycloak. **Generate Fresh Token (Required)** The ingress token expires regularly, so you must generate a new one before testing: ```bash # Step 1: Generate fresh Keycloak credentials credentials-provider/generate_creds.sh # Step 2: Load the token from ingress.json export TOKEN=$(jq -r '.access_token' .oauth-tokens/ingress.json) # Step 3: Verify token was loaded echo "Token loaded: ${TOKEN:0:50}..." ``` **Important Notes**: - Tokens expire after 5 minutes - if you get authentication errors, regenerate with `./credentials-provider/generate_creds.sh` - The `generate_creds.sh` script creates a new M2M token in `.oauth-tokens/ingress.json` - This token has full access to all MCP servers (unrestricted + restricted scopes) - **Other bot tokens** (like `bot-008`, `agent-finance-bot`) may have limited or no access to MCP servers depending on their Keycloak configuration. Use `ingress.json` for testing. ### 3. Base URL The v0.1 API is accessible at: - **API Endpoint**: `http://localhost/v0.1` or `https://localhost/v0.1` **Authentication**: All endpoints require JWT Bearer token authentication via the `Authorization` header. ## Test Commands ### Test 1: List All Servers (Basic) **Description**: Get the first page of servers with default pagination (100 items) ```bash curl -X GET "http://localhost/v0.1/servers" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: ```json { "servers": [ { "server": { "name": "io.mcpgateway/fininfo", "description": "...", "version": "1.0.0", "title": "Financial Info Server", "packages": [...], "_meta": {...} }, "_meta": {...} }, ... ], "metadata": { "nextCursor": "io.mcpgateway/some-server", "count": 100 } } ``` ### Test 2: List Servers with Limit **Description**: Get first 5 servers only ```bash curl -X GET "http://localhost/v0.1/servers?limit=5" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: ServerList with 5 items and pagination metadata ### Test 3: List Servers with Pagination **Description**: Get the next page using cursor from previous response ```bash # First, get the first page and extract the cursor CURSOR=$(curl -s -X GET "http://localhost/v0.1/servers?limit=5" \ -H "Authorization: Bearer $TOKEN" | jq -r '.metadata.nextCursor') # Then fetch the next page curl -X GET "http://localhost/v0.1/servers?cursor=$CURSOR&limit=5" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: ServerList starting after the cursor position ### Test 4: List Servers with Maximum Limit **Description**: Test the maximum limit (1000 items) ```bash curl -X GET "http://localhost/v0.1/servers?limit=1000" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: ServerList with up to 1000 items ### Test 5: List Server Versions **Description**: Get all versions for the Financial Info server ```bash # Note: Server name must be URL-encoded curl -X GET "http://localhost/v0.1/servers/io.mcpgateway%2Ffininfo/versions" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: ```json { "servers": [ { "server": { "name": "io.mcpgateway/fininfo", "description": "...", "version": "1.0.0", ... }, "_meta": {...} } ], "metadata": { "nextCursor": null, "count": 1 } } ``` ### Test 6: List Versions for Different Server **Description**: Try with a different server (e.g., currenttime) ```bash curl -X GET "http://localhost/v0.1/servers/io.mcpgateway%2Fcurrenttime/versions" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq ``` ### Test 7: Get Specific Version (latest) **Description**: Get detailed information for the latest version of Financial Info server ```bash curl -X GET "http://localhost/v0.1/servers/io.mcpgateway%2Ffininfo/versions/latest" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: ```json { "server": { "name": "io.mcpgateway/fininfo", "description": "...", "version": "1.0.0", "title": "Financial Info Server", "repository": null, "websiteUrl": null, "packages": [ { "registryType": "mcpb", "identifier": "io.mcpgateway/fininfo", "version": "1.0.0", "transport": { "type": "streamable-http", "url": "http://fininfo:8001" }, "runtimeHint": "docker" } ], "_meta": { "io.mcpgateway/internal": { "path": "/fininfo", "is_enabled": true, "health_status": "healthy", "num_tools": 5, "tags": ["fininfo", "jira", "confluence"], "license": "MIT" } } }, "_meta": { "io.mcpgateway/registry": { "last_checked": "2025-10-12T18:00:00Z", "health_status": "healthy" } } } ``` ### Test 8: Get Specific Version (1.0.0) **Description**: Get detailed information using explicit version number ```bash curl -X GET "http://localhost/v0.1/servers/io.mcpgateway%2Ffininfo/versions/1.0.0" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: Same as Test 7 (we only support version 1.0.0 currently) ### Test 9: Invalid Version **Description**: Try to access a non-existent version ```bash curl -X GET "http://localhost/v0.1/servers/io.mcpgateway%2Ffininfo/versions/2.0.0" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: ```json { "detail": "Version 2.0.0 not found" } ``` **Expected Status Code**: 404 ### Test 10: Non-existent Server **Description**: Try to access a server that doesn't exist ```bash curl -X GET "http://localhost/v0.1/servers/io.mcpgateway%2Fnon-existent/versions" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: ```json { "detail": "Server not found" } ``` **Expected Status Code**: 404 ### Test 11: Invalid Server Name Format **Description**: Try to access a server with wrong namespace ```bash curl -X GET "http://localhost/v0.1/servers/com.example%2Fserver/versions" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: ```json { "detail": "Server not found" } ``` **Expected Status Code**: 404 ### Test 12: Unauthorized Access **Description**: Try to access API without authentication ```bash curl -X GET "http://localhost/v0.1/servers" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: ```json { "detail": "Not authenticated" } ``` **Expected Status Code**: 401 ### Test 13: Invalid Token **Description**: Try to access API with invalid token ```bash curl -X GET "http://localhost/v0.1/servers" \ -H "Authorization: Bearer invalid_token_here" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: ```json { "detail": "Could not validate credentials" } ``` **Expected Status Code**: 401 ### Test 14: Permission-based Filtering (Non-admin User) **Description**: Test that non-admin users only see servers they have access to First, create a test user with limited permissions via the auth service, then: ```bash # Get token for non-admin user export USER_TOKEN=$(curl -s -X POST http://localhost:8888/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=testuser&password=testpass" | jq -r '.access_token') # List servers as non-admin user curl -X GET "http://localhost/v0.1/servers" \ -H "Authorization: Bearer $USER_TOKEN" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: Only servers the user has access to (based on scopes.yml configuration) ### Test 15: Via Nginx Proxy (Production Path) **Description**: Test the API through Nginx reverse proxy ```bash curl -X GET "http://localhost/v0.1/servers?limit=5" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq ``` **Expected Response**: Same as Test 2, but routed through Nginx ### Test 16: Verbose Output with Headers **Description**: See full HTTP response including headers ```bash curl -v -X GET "http://localhost/v0.1/servers?limit=5" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" 2>&1 | grep -E "(< HTTP|< Content-Type|< X-)" ``` **Expected Headers**: - `HTTP/1.1 200 OK` - `Content-Type: application/json` ### Test 17: Test All Registered Servers **Description**: Iterate through all servers and test version endpoint for each ```bash # Get list of all servers SERVERS=$(curl -s -X GET "http://localhost/v0.1/servers?limit=100" \ -H "Authorization: Bearer $TOKEN" | jq -r '.servers[].server.name') # Test each server for server in $SERVERS; do echo "Testing server: $server" encoded_name=$(echo "$server" | sed 's/\//%2F/g') curl -s -X GET "http://localhost/v0.1/servers/$encoded_name/versions/latest" \ -H "Authorization: Bearer $TOKEN" | jq -c '{name: .server.name, version: .server.version, status: "ok"}' done ``` ### Test 18: Performance Test - Large Pagination **Description**: Test pagination performance with large result sets ```bash # Time the request time curl -s -X GET "http://localhost/v0.1/servers?limit=500" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" | jq '.metadata.count' ``` **Expected**: Response should complete in < 2 seconds ### Test 19: Concurrent Requests **Description**: Test API under concurrent load ```bash # Run 10 concurrent requests for i in {1..10}; do curl -s -X GET "http://localhost/v0.1/servers?limit=10" \ -H "Authorization: Bearer $TOKEN" & done wait echo "All concurrent requests completed" ``` ### Test 20: Pretty Print Server Details **Description**: Get nicely formatted output for a specific server ```bash curl -s -X GET "http://localhost/v0.1/servers/io.mcpgateway%2Ffininfo/versions/latest" \ -H "Authorization: Bearer $TOKEN" | jq '{ name: .server.name, title: .server.title, description: .server.description, version: .server.version, transport_url: .server.packages[0].transport.url, num_tools: .server._meta."io.mcpgateway/internal".num_tools, health: ._meta."io.mcpgateway/registry".health_status }' ``` ### Test 21: Permission-Based Filtering (Restricted vs Full Access) **Description**: Verify that users with restricted permissions only see authorized servers **Setup**: Create restricted bot account if it doesn't exist ```bash # Check if test-restricted-bot already exists if [ ! -f .oauth-tokens/test-restricted-bot.json ]; then echo "Creating test-restricted-bot..." # Load Keycloak admin password from .env export $(grep KEYCLOAK_ADMIN_PASSWORD .env | xargs) # Create restricted bot (only has access to restricted servers) ./cli/user_mgmt.sh create-m2m \ --name test-restricted-bot \ --groups 'mcp-servers-restricted' else echo "test-restricted-bot already exists, skipping creation" fi ``` **Test Commands**: ```bash # Step 1: Refresh the restricted bot's token ./scripts/refresh_m2m_token.sh test-restricted-bot # Step 2: Load the restricted bot's token export TOKEN_RESTRICTED=$(jq -r '.access_token' .oauth-tokens/test-restricted-bot-token.json) # Step 3: Test v0.1 API with restricted token - should see only ~3 servers echo "=== Testing with RESTRICTED token ===" curl -s "http://localhost/v0.1/servers" \ -H "Authorization: Bearer $TOKEN_RESTRICTED" | jq '{ total_servers: (.servers | length), server_names: [.servers[].server.name] }' # Step 4: Load the full access token for comparison export TOKEN_FULL=$(jq -r '.access_token' .oauth-tokens/ingress.json) # Step 5: Test v0.1 API with full access token - should see all servers echo "" echo "=== Testing with FULL ACCESS token ===" curl -s "http://localhost/v0.1/servers" \ -H "Authorization: Bearer $TOKEN_FULL" | jq '{ total_servers: (.servers | length), server_names: [.servers[].server.name] }' # Step 6: Compare the difference echo "" echo "=== COMPARISON ===" echo "Restricted bot sees: $(curl -s "http://localhost/v0.1/servers" -H "Authorization: Bearer $TOKEN_RESTRICTED" | jq '.servers | length') servers" echo "Full access sees: $(curl -s "http://localhost/v0.1/servers" -H "Authorization: Bearer $TOKEN_FULL" | jq '.servers | length') servers" ``` **Expected Results**: - **Restricted bot** (`mcp-servers-restricted` group): ~3 servers (currenttime, auth_server, mcpgw) - **Full access** (`ingress.json` token): ~7+ servers (all servers including fininfo, fininfo, sre-gateway) This demonstrates that the v0.1 API correctly enforces permission-based filtering based on Keycloak groups and MCP scopes! --- ## Verification Checklist After running the tests, verify: - [ ] All successful requests return 200 status code - [ ] Pagination works correctly (cursor-based) - [ ] Server name format follows `io.mcpgateway/{path}` convention - [ ] All responses conform to Anthropic schema - [ ] Authentication is required for all endpoints - [ ] Non-admin users only see authorized servers (Test 21) - [ ] Restricted users see only restricted servers (Test 21) - [ ] Error responses include proper status codes (404, 401) - [ ] Version "latest" and "1.0.0" both work - [ ] Transport configuration includes correct proxy URLs - [ ] Metadata includes health status and internal info - [ ] URL encoding works for server names with special characters ## Schema Validation To validate responses against the Anthropic OpenAPI specification: ```bash # Download the official OpenAPI spec curl -o /tmp/anthropic-openapi.yaml \ https://raw.githubusercontent.com/modelcontextprotocol/registry/refs/heads/main/docs/reference/api/openapi.yaml # Use a tool like openapi-spec-validator or similar # (Requires installation: pip install openapi-spec-validator) ``` ## Common Issues ### Issue 1: Token Expired **Symptom**: 401 Unauthorized or "Token has expired" error **Solution**: Generate fresh credentials and reload token ```bash # Step 1: Generate new token ./generate_creds.sh # Step 2: Reload the token export TOKEN=$(jq -r '.access_token' .oauth-tokens/ingress.json) # Step 3: Verify it works curl -s -X GET "http://localhost/v0.1/servers?limit=1" \ -H "Authorization: Bearer $TOKEN" | jq '.servers[0].server.name' ``` ### Issue 2: URL Encoding **Symptom**: 404 errors when server name contains `/` **Solution**: Always URL-encode the server name ```bash # Wrong: io.mcpgateway/server-name # Correct: io.mcpgateway%2Fserver-name ``` ### Issue 3: Empty Response **Symptom**: `{"servers": [], "metadata": {"count": 0}}` **Solution**: Check if servers are registered and enabled ```bash # List server files ls ~/mcp-gateway/servers/*.json # Check registry logs docker compose logs registry | grep -i "loading servers" ``` ## Integration with Anthropic Tools These endpoints are compatible with Anthropic MCP client tools. Example: ```python import httpx # Configure client client = httpx.Client( base_url="http://localhost:7860", headers={"Authorization": f"Bearer {token}"} ) # List servers response = client.get("/v0.1/servers", params={"limit": 10}) servers = response.json() # Get server details server_name = servers["servers"][0]["server"]["name"] encoded_name = server_name.replace("/", "%2F") details = client.get(f"/v0.1/servers/{encoded_name}/versions/latest") ``` ## Next Steps After testing: 1. Document any issues found 2. Test with real MCP clients (Claude Desktop, etc.) 3. Verify compatibility with Anthropic's official registry clients 4. Performance testing with larger datasets 5. Security testing (SQL injection, XSS, etc.) ## References - Issue #175: Support Anthropic MCP Registry REST API v0.1 - OpenAPI Spec: https://raw.githubusercontent.com/modelcontextprotocol/registry/refs/heads/main/docs/reference/api/openapi.yaml - API Guide: https://github.com/modelcontextprotocol/registry/blob/main/docs/guides/consuming/use-rest-api.md ================================================ FILE: docs/design/architectural-decision-reverse-proxy-vs-application-layer-gateway.md ================================================ # Core Architectural Decision: Reverse Proxy vs Application-Layer Gateway ## Executive Summary This document discusses two potential architectures that were considered during the design phase of this solution: a **reverse proxy architecture** and an alternative **tools gateway architecture**. We analyze both approaches from multiple perspectives: performance, security, long-term maintainability, scaling, and operational complexity, and explain why the reverse proxy approach was selected. The reverse proxy approach provides better performance, protocol independence, and allows continued Python development while leveraging Nginx for message routing. The tools gateway approach offers better developer experience and enterprise integration but requires Go/Rust implementation for enterprise performance requirements. These recommendations are not universal but represent the architectural choices we made while building this system. ## Architecture Overview ### Reverse Proxy Pattern (Current) ``` AI Agent/Coding Assistant | | Multiple Endpoints v ┌─────────────────┐ │ Nginx Gateway │ │ /fininfo/ │ ──auth_request──> Auth Server │ /mcpgw/ │ │ │ /currenttime/ │ <──auth_headers───────┘ └─────────────────┘ │ │ │ │ │ └─── localhost:8003 (currenttime) │ └───── localhost:8002 (mcpgw) └─────── localhost:8001 (fininfo) │ v Individual MCP Servers ``` **Key Characteristics:** - Path-based routing (`/fininfo/`, `/mcpgw/`, etc.) - Nginx handles auth validation and proxying - Direct streaming connections to backend servers - Protocol-agnostic (HTTP, WebSocket, SSE, etc.) ### Tools Gateway Pattern (Alternative) ``` AI Agent/Coding Assistant | | Single Endpoint v ┌─────────────────┐ │ Tools Gateway │ ──auth_request──> Auth Server │ /mcp │ │ │ (aggregates │ <──auth_headers───────┘ │ all tools) │ └─────────────────┘ │ | Tool routing logic v ┌─────────────────┐ │ MCP Client Pool │ │ fininfo_* │ ──> localhost:8001 (fininfo) │ mcpgw_* │ ──> localhost:8002 (mcpgw) │ currenttime_* │ ──> localhost:8003 (currenttime) └─────────────────┘ ``` **Key Characteristics:** - Single endpoint with tool aggregation - Gateway implements MCP protocol parsing - Connection termination and re-establishment - Tool name prefixing for disambiguation ## Architectural Comparison ### Performance | Aspect | Reverse Proxy (Current) | Tools Gateway | Preferable Approach | |--------|-------------------------|---------------|-------------------| | Latency | Direct proxy routing = minimal overhead (~1-2ms) | Additional hop through gateway logic (~5-10ms minimum) | Reverse Proxy | | Throughput | Each connection directly streams to target server | Gateway becomes bottleneck for all tool calls | Reverse Proxy | | Network Efficiency | Client maintains persistent connections to specific servers | Gateway must proxy all request/response payloads | Reverse Proxy | | CPU Usage | [Nginx](https://Nginx.org/) handles routing, minimal Python involvement | Gateway must parse, route, and proxy every MCP message | Reverse Proxy | | Memory | Low gateway memory usage, servers handle their own state | Gateway must buffer requests/responses, maintain backend connections | Reverse Proxy | | **Protocol Independence** | **Nginx passes through any protocol - not MCP-specific** | **Gateway must understand MCP protocol specifics** | **Reverse Proxy** | | Implementation Language | Python suitable due to Nginx handling message routing | **Requires Go/Rust for enterprise performance requirements** | Reverse Proxy | | **Implementation Complexity** | **Nginx handles protocol details, minimal state management needed** | **Requires elaborate state management, protocol awareness, connection lifecycle management** | **Reverse Proxy** | ### Security | Aspect | Reverse Proxy (Current) | Tools Gateway | Preferable Approach | |--------|-------------------------|---------------|-------------------| | Authentication | Nginx auth_request pattern = proven, well-documented | Gateway must implement auth validation | Equivalent | | Authorization | Fine-grained scope validation per server/tool before routing | Can implement same fine-grained scopes | Equivalent | | Audit Trail | Complete Nginx access logs + auth server logs + IdP logs | Gateway logs all tool calls | Equivalent | | Attack Surface | Direct server access blocked, only authenticated routes exposed | Single endpoint, easier to monitor but single point of failure | Equivalent | | Token Validation | Centralized in auth server, cached for performance | Must implement JWT/session validation | Equivalent | ### Maintainability | Aspect | Reverse Proxy (Current) | Tools Gateway | Preferable Approach | |--------|-------------------------|---------------|-------------------| | Service Registration & Configuration | Dynamic Nginx config generation and reload for new servers | Dynamic tool registration without infrastructure changes | Tools Gateway | | Debugging | Multi-component debugging (Nginx + auth server + target server) | Centralized logging and error handling | Tools Gateway | | Transport Support | Must handle SSE/HTTP variations per server | Must implement transport variations in gateway code | Equivalent | | Error Handling | Error propagation through multiple layers | Must implement error translation from backends | Equivalent | ### Scaling | Aspect | Reverse Proxy (Current) | Tools Gateway | Preferable Approach | |--------|-------------------------|---------------|-------------------| | Horizontal Scaling | Can load balance multiple gateway instances easily | Gateway must maintain backend connection pools | Reverse Proxy | | **Backend Scaling** | **Each MCP server scales independently** | **Gateway must implement backend load balancing** | **Reverse Proxy** | | **Resource Isolation** | **Both handle backend failures via health checks, but Nginx transparently proxies data plane traffic end-to-end** | **Gateway must maintain both data plane MCP connections AND separate health checks to backends** | **Reverse Proxy** | | Connection Pooling | Direct client connections to needed servers only | Gateway must manage M×N connection pools | Reverse Proxy | | Geographic Distribution | Can proxy to servers in different regions | Complex backend routing required | Reverse Proxy | | **Protocol Extensibility** | **Same architecture works for Agent-to-Agent (A2A) or other protocols** | **MCP-specific implementation limits future protocol support** | **Reverse Proxy** | ### Operational Complexity | Aspect | Reverse Proxy (Current) | Tools Gateway | Preferable Approach | |--------|-------------------------|---------------|-------------------| | Monitoring | Must monitor Nginx + auth server + N backend servers | Monitor gateway + auth server + N backend servers (simpler) | Tools Gateway | | Service Discovery | Complex Nginx config regeneration | Dynamic tool registration | Tools Gateway | | Health Checking | Health status triggers Nginx config regeneration and reload | Gateway makes runtime routing decisions based on health | Equivalent | | Certificate Management | Single domain cert for gateway endpoint | Only gateway needs external certs | Equivalent | | Log Aggregation | Focused logs per component (Nginx, auth, individual MCP servers) | All tool calls centralized in gateway logs | Equivalent | ### Enterprise Integration & User Experience | Aspect | Reverse Proxy (Current) | Tools Gateway | Preferable Approach | |--------|-------------------------|---------------|-------------------| | **Client Configuration & Mental Model** | **Must configure N server endpoints, understand Nginx routing + auth + backend servers** | **Single endpoint configuration, simple "one gateway, many tools" concept** | **Tools Gateway** | | Network Policies | Must allowlist N different paths | Single path to allowlist | Tools Gateway | | Change Management | Adding new server requires client reconfiguration | New tools appear automatically via discovery | Tools Gateway | | Vendor Integration | Each vendor needs separate endpoint configuration | Vendors configure single endpoint | Tools Gateway | | Tool Discovery | Discovery via Registry UI or MCPGW MCP server | Automatic through tools/list call | Equivalent | | Error Messages | May be confusing due to multiple layers | Clearer, centralized error formatting | Tools Gateway | | Testing | Must test each server endpoint individually | Single endpoint for all testing | Tools Gateway | ## Implementation Considerations ### Protocol Independence Benefits The reverse proxy architecture provides protocol independence: - **Future Protocols**: Can support Agent-to-Agent (A2A), custom protocols without gateway changes - **Protocol Evolution**: MCP protocol changes don't require gateway modifications - **Mixed Environments**: Can proxy HTTP, WebSocket, gRPC, or custom protocols simultaneously ### Tools Gateway Implementation Challenges A tools gateway requires: - **Language Choice**: Python insufficient for performance; requires Go/Rust implementation - **MCP Client Library**: Must embed full MCP client for backend communication and keep client updated with evolving MCP specification changes - **Protocol Parsing**: Must understand and parse all MCP message types - **Connection Handling**: Complex connection lifecycle management - **Error Translation**: Convert backend MCP errors to client-readable format ## Conclusion Both architectures have merits: - **Reverse Proxy**: Better performance, proven scalability, protocol independence, established Nginx foundation, allows Python implementation due to Nginx handling message routing - **Tools Gateway**: Better developer experience, easier enterprise adoption, simpler operations, requires Go/Rust implementation for enterprise performance requirements The choice depends on organizational priorities: - **Performance-first organizations** (high-frequency trading, real-time systems): Stay with reverse proxy - **Protocol-diverse environments** (supporting A2A, custom protocols): Reverse proxy provides flexibility - **Python-preferred development teams**: Reverse proxy allows continued Python development while Nginx handles performance-critical routing - **Developer experience-first organizations** (internal tooling, enterprise IT): Consider tools gateway but must invest in Go/Rust development expertise - **Hybrid organizations**: Implement both patterns and let teams choose The current implementation is suitable for deployment and protocol-independent. The reverse proxy approach provides more architectural flexibility for future protocol support while allowing the team to continue developing in Python. ================================================ FILE: docs/design/authentication-design.md ================================================ # Authentication and Authorization Design **Version:** 1.0 **Last Updated:** 2026-01-18 ## Related Documentation - [Multi-Provider IdP Support](./idp-provider-support.md) - Architecture for supporting multiple identity providers - [Authentication & Authorization Guide](../auth.md) - Operational guide with setup instructions - [Microsoft Entra ID Integration](../entra.md) - Entra ID-specific setup and configuration ## Overview The MCP Gateway Registry implements a comprehensive authentication and authorization system supporting three distinct identity scenarios: 1. **Human Users** - Interactive users accessing the Registry UI and generating API tokens 2. **Programmatic Access (API Tokens)** - Self-signed JWT tokens for CLI tools and AI coding assistants 3. **Workload Identity (M2M)** - Service accounts for AI agents and automated systems ## Identity Types ``` +------------------+------------------+------------------+ | Human Users | Programmatic | Workload | | | Access | Identity (M2M) | +------------------+------------------+------------------+ | | | | | - Interactive | - CLI tools | - AI Agents | | browser login | - AI coding | - Automated | | - OAuth2 flow | assistants | pipelines | | - Session-based | - Scripts | - Service-to- | | | | service | +------------------+------------------+------------------+ | | | | | Auth Method: | Auth Method: | Auth Method: | | Authorization | Self-signed | OAuth2 Client | | Code Flow | JWT (HS256) | Credentials Flow | | | | (RS256) | +------------------+------------------+------------------+ ``` --- ## Part 1: Human User Authentication ### 1.1 OAuth2 Authorization Code Flow Human users authenticate via the configured identity provider (Keycloak or Entra ID) using the standard OAuth2 Authorization Code flow. ``` +-------------+ +--------------+ +--------------+ +-------------+ | Browser | | Registry | | Auth Server | | Identity | | (User) | | Frontend | | | | Provider | +------+------+ +------+-------+ +------+-------+ +------+------+ | | | | | 1. Click "Login" | | | +------------------>| | | | | | | | 2. Redirect to | | | | Auth Server | | | |<------------------+ | | | | | | | 3. GET /oauth2/login/entra | | +--------------------------------------->| | | | | | | 4. Redirect to IdP authorize endpoint | |<-----------------------------------------------------------+ | | | | | 5. User authenticates with IdP | +------------------------------------------------------------>| |<------------------------------------------------------------+ | | | | | 6. Redirect with authorization code | | +--------------------------------------->| | | | | | | | 7. Exchange code | | | | for tokens | | | | +------------------->| | | |<-------------------+ | | | (ID token + | | | | access token) | | | | | | 8. Set session cookie + redirect | | |<---------------------------------------+ | | | | | | 9. Access Registry with session cookie | | +------------------>| | | | | | | ``` ### 1.2 Session Data After successful authentication, the auth server creates a session containing: ```json { "user_id": "user@example.onmicrosoft.com", "email": "user@example.com", "groups": ["5f605d68-06bc-4208-b992-bb378eee12c5"], "provider": "entra", "scopes": ["public-mcp-users"], "is_admin": false, "ui_permissions": { "list_service": ["all"], "list_agents": ["/flight-booking"], "get_agent": ["/flight-booking"] } } ``` ### 1.3 UI Permission Enforcement The Registry UI enforces feature access based on `ui_permissions` from the user's mapped scopes. #### Example: `public-mcp-users` Scope From `cli/examples/public-mcp-users.json`: ```json { "scope_name": "public-mcp-users", "ui_permissions": { "list_service": ["all"], "list_agents": ["/flight-booking"], "get_agent": ["/flight-booking"] } } ``` **What this user CAN do:** - View all MCP servers in the dashboard (`list_service: ["all"]`) - View the flight-booking agent details (`list_agents`, `get_agent` for `/flight-booking`) - Access public MCP servers: context7, cloudflare-docs (via `server_access` rules) **What this user CANNOT do:** - Publish, modify, or delete agents - Register or modify MCP servers - Toggle services on/off - Access health check for all servers (only context7, cloudflare-docs) - Access IAM management features #### Example: `registry-admins` Scope Admins have unrestricted access to all UI features: ```yaml # From scopes.yml UI-Scopes: registry-admins: list_agents: [all] get_agent: [all] publish_agent: [all] modify_agent: [all] delete_agent: [all] list_service: [all] register_service: [all] health_check_service: [all] toggle_service: [all] modify_service: [all] ``` ### 1.4 Frontend Permission Checks The frontend checks `ui_permissions` before rendering features: ```typescript // From Dashboard.tsx const hasUiPermission = useCallback((permission: string, servicePath: string): boolean => { const permissions = user?.ui_permissions?.[permission]; if (!permissions) return false; const serviceName = servicePath.replace(/^\//, ''); return permissions.includes('all') || permissions.includes(serviceName); }, [user?.ui_permissions]); // Usage in JSX ``` --- ## Part 2: Programmatic Access (Self-Signed JWT Tokens) Human users can generate API tokens for programmatic access (CLI tools, AI coding assistants) via the "Get JWT Token" button in the UI. ### 2.1 Token Generation Flow ``` +-------------+ +--------------+ +--------------+ +-------------+ | Browser | | Registry | | Auth Server | | MongoDB | | (User) | | Backend | | | | (Scopes) | +------+------+ +------+-------+ +------+-------+ +------+------+ | | | | | 1. Click "Get JWT Token" | | +------------------>| | | | | | | | | 2. POST /api/tokens/generate | | | (with session cookie) | | +------------------->| | | | | | | | 3. Validate session | | | Extract: username, groups, provider | | | | | | | | 4. Query group | | | | mappings | | | +------------------->| | | |<-------------------+ | | | (scopes for | | | | user's groups) | | | | | | | 5. Build JWT claims: | | | - iss: mcp-auth-server | | | - aud: mcp-registry | | | - sub: username | | | - groups: [group IDs] | | | - scope: mapped scopes | | | - exp: 8 hours | | | | | | | 6. Sign JWT with SECRET_KEY (HS256) | | | | | | | 7. Return JWT | | | |<-------------------+ | | | | | | 8. Display token | | | |<------------------+ | | ``` ### 2.2 Self-Signed JWT Structure ```json { "iss": "mcp-auth-server", "aud": "mcp-registry", "sub": "user@example.onmicrosoft.com", "preferred_username": "user@example.onmicrosoft.com", "email": "user@example.com", "groups": ["5f605d68-06bc-4208-b992-bb378eee12c5"], "scope": "public-mcp-users", "token_use": "access", "auth_method": "oauth2", "provider": "entra", "iat": 1768685565, "exp": 1768714365, "description": "Generated via sidebar" } ``` ### 2.3 Using the Token with CLI Tools ```bash # Save token to file echo "eyJhbGciOiJIUzI1NiIs..." > .token # Use with registry_management.py uv run python api/registry_management.py \ --token-file .token \ --registry-url http://localhost \ server-search --query "documentation" # Use with curl curl -H "Authorization: Bearer $(cat .token)" \ http://localhost/api/servers ``` ### 2.4 Token Validation Flow (API Usage) ``` +-------------+ +--------------+ +--------------+ +-------------+ | CLI / | | NGINX | | Auth Server | | MCP Server | | Client | | Gateway | | | | | +------+------+ +------+-------+ +------+-------+ +------+------+ | | | | | 1. API Request | | | | Authorization: Bearer | | +------------------>| | | | | | | | | 2. auth_request /validate | | +------------------->| | | | | | | | 3. Check token issuer | | | iss == "mcp-auth-server"? | | | | | | | 4. If yes: validate with | | | SECRET_KEY (HS256) | | | | | | | 5. If no: try IdP JWKS | | | validation (RS256) | | | | | | | 6. Extract scopes, validate | | | server/tool access | | | | | | | 7. 200 OK + X-User headers | | |<-------------------+ | | | | | | | 8. Proxy request | | | +--------------------------------------->| | | | | | 9. Response | | | |<------------------+ | | ``` --- ## Part 3: Workload Identity (M2M / Service Accounts) AI agents and automated systems use service accounts with client credentials for authentication. ### 3.1 M2M Identity in Identity Providers ``` +---------------------------+---------------------------+ | Keycloak | Microsoft Entra ID | +---------------------------+---------------------------+ | | | | Service Account Client: | App Registration: | | - Client ID | - Application (client) ID | | - Client Secret | - Client Secret | | - Service Account User | - Service Principal | | - Group Memberships | - Group Memberships | | | | +---------------------------+---------------------------+ ``` ### 3.2 M2M Account Creation Flow (Entra ID) ``` +-------------+ +--------------+ +--------------+ +-------------+ | Admin | | Registry | | Entra ID | | MongoDB | | CLI | | Backend | | Graph API | | | +------+------+ +------+-------+ +------+-------+ +------+------+ | | | | | 1. user-create-m2m --name pub-m2m-bot --groups public-mcp-users +------------------>| | | | | | | | | 2. Create App Registration | | +------------------->| | | |<-------------------+ | | | (app_id, object_id) | | | | | | | 3. Create Service Principal | | +------------------->| | | |<-------------------+ | | | (service_principal_id) | | | | | | | 4. Create Client Secret | | +------------------->| | | |<-------------------+ | | | (client_secret) | | | | | | | 5. Add SP to group (with retry) | | +------------------->| | | |<-------------------+ | | | | | | 6. Return credentials | | | client_id, client_secret | | |<------------------+ | | ``` ### 3.3 M2M Token Request Flow AI agents use OAuth2 Client Credentials flow to obtain access tokens: ``` +-------------+ +--------------+ +--------------+ | AI Agent | | Identity | | Registry | | (M2M) | | Provider | | API | +------+------+ +------+-------+ +------+-------+ | | | | 1. POST /oauth2/v2.0/token | | grant_type=client_credentials | | client_id=... | | client_secret=... | | scope=api://.../.default | +------------------>| | | | | | 2. Access Token (RS256, 1 hour) | |<------------------+ | | | | | 3. API Request with token | +--------------------------------------->| | | | | | 4. Validate via | | | IdP JWKS | | | | | 5. Response | | |<--------------------------------------+ ``` ### 3.4 Generating M2M Tokens Use the credentials provider script to generate tokens: **Identities File** (`.oauth-tokens/entra-identities.json`): ```json [ { "identity_name": "pub-m2m-bot", "tenant_id": "6e6ee81b-6bf3-495d-a7fc-d363a551f765", "client_id": "c50b03cf-6f7b-4fae-846e-7910a4100020", "client_secret": "your-client-secret", "scope": "api://1bd17ba1-aad3-447f-be0b-26f8f9ee859f/.default" } ] ``` **Generate Token:** ```bash cd credentials-provider uv run python entra/generate_tokens.py \ --identities-file ../.oauth-tokens/entra-identities.json \ --output-dir ../.oauth-tokens ``` **Output** (`.oauth-tokens/pub-m2m-bot.json`): ```json { "identity_name": "pub-m2m-bot", "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs...", "token_type": "Bearer", "expires_in": 3599, "generated_at": "2026-01-18T02:30:56.123456+00:00", "expires_at": "2026-01-18T03:30:55.123456+00:00", "provider": "entra", "tenant_id": "6e6ee81b-6bf3-495d-a7fc-d363a551f765", "client_id": "c50b03cf-6f7b-4fae-846e-7910a4100020", "scope": "api://1bd17ba1-aad3-447f-be0b-26f8f9ee859f/.default" } ``` ### 3.5 M2M Token Structure (from Entra ID) ```json { "aud": "api://1bd17ba1-aad3-447f-be0b-26f8f9ee859f", "iss": "https://sts.windows.net/6e6ee81b-6bf3-495d-a7fc-d363a551f765/", "iat": 1768703056, "nbf": 1768703056, "exp": 1768706956, "appid": "c50b03cf-6f7b-4fae-846e-7910a4100020", "appidacr": "1", "groups": ["5f605d68-06bc-4208-b992-bb378eee12c5"], "idp": "https://sts.windows.net/6e6ee81b-6bf3-495d-a7fc-d363a551f765/", "oid": "5d3d562c-4449-413a-9791-86920d4bf75f", "sub": "5d3d562c-4449-413a-9791-86920d4bf75f", "tid": "6e6ee81b-6bf3-495d-a7fc-d363a551f765", "ver": "1.0" } ``` **Key Differences from Self-Signed Tokens:** | Aspect | Self-Signed (Human) | IdP Token (M2M) | |--------|---------------------|-----------------| | Issuer | `mcp-auth-server` | `https://sts.windows.net/{tenant}/` | | Algorithm | HS256 (symmetric) | RS256 (asymmetric) | | Validation | SECRET_KEY | IdP JWKS endpoint | | Expiry | 8 hours | 1 hour | | Subject | username/email | Service principal object ID | --- ## Part 4: Authorization - Scope-Based Access Control ### 4.1 Scope Storage in MongoDB-CE/Amazon DocumentDB Scopes are stored in the `mcp_scopes_default` collection: ``` +-----------------------------------------------------------------------+ | mcp_scopes_default collection | +-----------------------------------------------------------------------+ | { | | "_id": "public-mcp-users", | | "group_mappings": [ | | "public-mcp-users", <-- Keycloak group | | "5f605d68-06bc-4208-b992-bb378eee12c5" <-- Entra ID Object ID | | ], | | "server_access": [ | | { | | "server": "context7", | | "methods": ["initialize", "tools/list", "tools/call"], | | "tools": ["*"] | | }, | | { | | "server": "api", | | "methods": ["initialize", "GET", "POST", "servers", ...], | | "tools": [] | | } | | ], | | "ui_permissions": { | | "list_service": ["all"], | | "list_agents": ["/flight-booking"], | | "get_agent": ["/flight-booking"] | | } | | } | +-----------------------------------------------------------------------+ ``` ### 4.2 Group-to-Scope Mapping Query The auth server queries MongoDB-CE/Amazon DocumentDB to find scopes for a given group: ```python # From scope_repository.py async def get_group_mappings(self, keycloak_group: str) -> List[str]: """Find all scopes where group_mappings array contains this group.""" collection = await self._get_collection() cursor = collection.find({"group_mappings": keycloak_group}) scope_names = [doc["_id"] async for doc in cursor] return scope_names ``` ### 4.3 Server/Tool Access Validation ``` +------------------+ +------------------+ +------------------+ | | | | | | | Request: | | User Scopes: | | Access | | POST /context7 | | [public-mcp- | | Decision: | | tools/call | | users] | | GRANTED | | | | | | | +--------+---------+ +--------+---------+ +--------+---------+ | | | | 1. Extract server | | | and method | | +----------------------->| | | | | | 2. For each scope, | | | check server_ | | | access rules | | | +----------------------->| | | | | 3. public-mcp-users | | | allows context7 | | | with tools/call | | | | | ``` **Validation Logic** (from `server.py`): ```python def validate_server_tool_access( server_name: str, method: str, tool_name: Optional[str], user_scopes: List[str] ) -> bool: """Check if user has access to server/method/tool.""" for scope_name in user_scopes: scope_config = get_scope_config(scope_name) for server_rule in scope_config.get("server_access", []): # Check server name match (exact or wildcard) if server_rule["server"] in (server_name, "*"): # Check method is allowed if method in server_rule["methods"] or "all" in server_rule["methods"]: # Check tool access if specified if tool_name is None or server_rule["tools"] == "*": return True if tool_name in server_rule["tools"]: return True return False ``` --- ## Summary Comparison | Aspect | Human User | Programmatic (API Token) | M2M (Workload) | |--------|------------|--------------------------|----------------| | **Use Case** | Browser UI | CLI, AI assistants | AI agents, automation | | **Auth Flow** | OAuth2 Authorization Code | N/A (derived from session) | OAuth2 Client Credentials | | **Token Issuer** | IdP (Keycloak/Entra) | `mcp-auth-server` | IdP (Keycloak/Entra) | | **Token Signing** | RS256 (IdP) | HS256 (SECRET_KEY) | RS256 (IdP) | | **Token Lifetime** | Session-based | 8 hours | 1 hour | | **Validation** | Session cookie | SECRET_KEY | IdP JWKS | | **Groups Source** | IdP groups claim | Copied from session | IdP groups claim | | **Scope Mapping** | Groups -> Datastore scopes | Embedded in token | Groups -> Datastore scopes | | **Credential Storage** | Browser session | Token file | Client ID/Secret | --- ## Related Documentation - [Multi-Provider IdP Support](./idp-provider-support.md) - [Microsoft Entra ID Integration](../entra.md) - [Management API Testing Guide](../../api/test-management-api-e2e.md) ================================================ FILE: docs/design/aws-agent-registry-federation.md ================================================ # AWS Agent Registry Federation -- Design This document describes the architecture and design decisions for federating Amazon Bedrock AgentCore registries into MCP Gateway Registry. ## Problem Statement Organizations using Amazon Bedrock AgentCore publish MCP servers, A2A agents, and agent skills into AgentCore registries. These assets need to be discoverable alongside locally registered assets in the MCP Gateway Registry. The federation must support: - Multiple registries (same account, cross-account, cross-region) - Four descriptor types (MCP, A2A, CUSTOM, AGENT_SKILLS) - Automatic sync with stale record cleanup - Granular add/remove of individual registries from the UI - Cascade cleanup when a registry is removed ## Architecture ### Component Overview ``` +-------------------------------+ | MCP Gateway Registry | | | | +-------------------------+ | +---------------------------+ | | Federation Config (Mongo)| <---->| Settings UI | | | aws_registry: | | | (ExternalRegistries.tsx) | | | enabled: true | | +---------------------------+ | | registries: [...] | | | +-------------------------+ | | | | | v | | +-------------------------+ | +---------------------------+ | | AgentCoreFederation | ----->| AWS Bedrock AgentCore | | | Client (boto3) | | | bedrock-agentcore-control | | +-------------------------+ | +---------------------------+ | | | | | | v | +---------+ +---------+ | +-------------------------+ | | Registry| | Registry| | | Server/Agent/Skill | | | Acct A | | Acct B | | | Repositories (Mongo) | | +---------+ +---------+ | +-------------------------+ | +-------------------------------+ ``` ### Data Flow ``` 1. Startup / Manual Sync / Scheduled Sync | v 2. Load FederationConfig from MongoDB | v 3. For each registry in config.aws_registry.registries: | +---> 3a. Create boto3 client (optionally STS AssumeRole for cross-account) | +---> 3b. Paginate ListRegistryRecords (filtered by descriptor_types, sync_status_filter) | +---> 3c. Transform each record into MCP Gateway format | v 4. Register/update assets in MongoDB | v 5. Reconcile stale records (remove assets no longer in source) ``` ## Data Model ### Federation Config (MongoDB: `mcp_federation_config_default`) The federation config is a single document with `_id: "default"`. The `aws_registry` section stores all AgentCore federation settings: ```json { "_id": "default", "aws_registry": { "enabled": true, "aws_region": "us-east-1", "sync_on_startup": true, "sync_interval_minutes": 60, "sync_timeout_seconds": 300, "max_concurrent_fetches": 5, "registries": [ { "registry_id": "arn:aws:bedrock-agentcore:us-east-1:123456789012:registry/rXXX", "aws_account_id": "123456789012", "aws_region": "us-east-1", "assume_role_arn": null, "descriptor_types": ["MCP", "A2A", "CUSTOM", "AGENT_SKILLS"], "sync_status_filter": "APPROVED" } ] }, "anthropic": { ... }, "asor": { ... }, "created_at": "2026-04-10T...", "updated_at": "2026-04-11T..." } ``` ### Synced Asset Tracking Each synced asset carries metadata that links it back to its source registry. This enables cascade cleanup and prevents orphaned records. **MCP Servers** (`mcp_servers_default`): ```json { "path": "/agentcore-my-server", "source": "agentcore", "tags": ["agentcore", "bedrock", "federated", "mcp"], "metadata": { "agentcore_registry_id": "arn:aws:bedrock-agentcore:us-east-1:123456789012:registry/rXXX", "agentcore_record_id": "record-abc123", "agentcore_descriptor_type": "MCP" } } ``` **A2A Agents** (`mcp_agents_default`): ```json { "path": "/agents/agentcore-my-agent", "tags": ["agentcore", "bedrock", "federated", "a2a"], "metadata": { "agentcore_registry_id": "arn:aws:bedrock-agentcore:us-east-1:123456789012:registry/rXXX", "agentcore_record_id": "record-def456", "agentcore_descriptor_type": "A2A" } } ``` **Agent Skills** (`agent_skills_default`): ```json { "path": "/skills/agentcore-my-skill", "tags": ["agentcore", "bedrock", "federated", "skill"], "metadata": { "agentcore_registry_id": "arn:aws:bedrock-agentcore:us-east-1:123456789012:registry/rXXX", "agentcore_record_id": "record-ghi789", "agentcore_descriptor_type": "AGENT_SKILLS" } } ``` ## Key Design Decisions ### 1. Single Enable Flag via Environment Variable **Decision**: Only `AWS_REGISTRY_FEDERATION_ENABLED` is an environment variable. All other settings (region, registries, sync behavior) are managed via the API/UI and stored in MongoDB. **Rationale**: - The enable flag is a deployment-level concern (should this instance support AgentCore federation at all?) - Registry IDs, regions, and descriptor types are operational concerns that change at runtime - Reduces env var sprawl -- previous design had 7 env vars, most of which were unused by the application **Implementation**: `_apply_aws_registry_env_vars()` in `registry/main.py` reads the env var on startup and updates the MongoDB federation config before any sync runs. ### 2. Path-Based Naming Convention **Decision**: Synced assets use a `agentcore-` prefix in their path. | Asset Type | Path Pattern | Example | |-----------|-------------|---------| | MCP Server | `/agentcore-{name}` | `/agentcore-my-mcp-server` | | A2A Agent | `/agents/agentcore-{name}` | `/agents/agentcore-travel-bot` | | Agent Skill | `/skills/agentcore-{name}` | `/skills/agentcore-booking-skill` | **Rationale**: - Makes federated assets visually distinct in the UI and API - Enables tag-based fallback matching for cascade cleanup (older records without metadata) - Avoids path collisions with locally registered assets ### 3. Dual Matching Strategy for Cascade Cleanup **Decision**: When a registry is removed, the cleanup logic uses two matching strategies: 1. **Primary**: Match by `metadata.agentcore_registry_id` (exact, reliable) 2. **Fallback**: Match by `"agentcore" in tags AND path.startswith("/type/agentcore-")` (for older records) **Rationale**: Records synced before metadata tracking was added only have tags and path conventions. The fallback ensures these are cleaned up too. The fallback is conservative -- it requires both the tag and the path prefix to match. ### 4. Conditional IAM Policy Creation **Decision**: The `bedrock_agentcore_access` IAM policy is only created when `aws_registry_federation_enabled = true` in Terraform. ```hcl resource "aws_iam_policy" "bedrock_agentcore_access" { count = var.aws_registry_federation_enabled ? 1 : 0 ... } ``` **Rationale**: Follows the principle of least privilege. Deployments that don't use AgentCore federation don't get AgentCore IAM permissions. The policy uses `bedrock-agentcore:*` for simplicity since AgentCore is a new service with a limited action set, and all actions may be needed as the feature evolves. ### 5. Cross-Account Access via Per-Registry Role Assumption **Decision**: Cross-account access is configured per-registry via `assume_role_arn`, not globally. **Rationale**: Different registries may be in different accounts, each requiring a different IAM role. The STS AssumeRole call is scoped by a condition requiring `Purpose: agentcore-federation` tag on the target role, preventing the gateway from assuming arbitrary roles. ### 6. Backward Compatibility via Model Validator **Decision**: A Pydantic `model_validator` transparently renames the old `agentcore` key to `aws_registry` when loading federation config from MongoDB. ```python @model_validator(mode="before") @classmethod def _migrate_agentcore_key(cls, data: Any) -> Any: if isinstance(data, dict) and "agentcore" in data and "aws_registry" not in data: data["aws_registry"] = data.pop("agentcore") return data ``` **Rationale**: Avoids requiring a MongoDB migration script. Existing documents with the old key name deserialize correctly. New saves use the new key name, so documents are gradually migrated. ## API Endpoints ### Federation Config Management | Method | Endpoint | Description | |--------|----------|-------------| | GET | `/api/federation/config/{config_id}` | Get full federation config | | PUT | `/api/federation/config/{config_id}` | Update full federation config | | POST | `/api/federation/config/{config_id}/aws_registry/registries` | Add a single registry | | DELETE | `/api/federation/config/{config_id}/aws_registry/registries/{registry_id}` | Remove a registry (with cascade cleanup) | ### Sync | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/api/federation/sync` | Sync all enabled sources | | POST | `/api/federation/sync?source=aws_registry` | Sync only AWS Agent Registry | ### Add Registry Request Body ```json { "registry_id": "arn:aws:bedrock-agentcore:us-east-1:123456789012:registry/rXXX", "aws_account_id": "123456789012", "aws_region": "us-east-1", "assume_role_arn": "arn:aws:iam::999888777666:role/FederationReadOnly", "descriptor_types": ["MCP", "A2A", "CUSTOM", "AGENT_SKILLS"], "sync_status_filter": "APPROVED" } ``` Only `registry_id` is required. All other fields are optional. ### Delete Registry Response ```json { "message": "Registry removed and 3 server(s), 2 agent(s), 1 skill(s) deregistered", "deregistered": { "servers": ["/agentcore-my-server"], "agents": ["/agents/agentcore-my-agent"], "skills": ["/skills/agentcore-my-skill"] } } ``` ## Frontend Design ### External Registries Page The External Registries settings page shows a card for each federation source (AWS Agent Registry, Anthropic, ASOR). Each card has: - **Header**: Source name, enabled/disabled badge, Sync button, Add (+) button - **Body**: List of configured entries with remove (X) buttons - **Empty state**: "No registries configured" with an Add button ### Add Registry Modal The `AddRegistryEntryModal` component renders different forms based on `sourceType`: - `aws_registry`: Multi-field form (registry ID, account, region, role ARN, descriptor types, status filter) - `anthropic`: Single field (server name) - `asor`: Single field (agent ID) ARN auto-population: When the user types or pastes a full ARN into the Registry ID field, the region and account ID fields are automatically populated by parsing the ARN structure (`arn:aws:bedrock-agentcore:::registry/...`). ### Confirm Delete Modal A styled `ConfirmModal` replaces the native browser `window.confirm()` dialog. It supports: - Destructive (red) and normal (purple) button styles - Loading state with "Removing..." text - Warning icon with contextual coloring ## Reconciliation ### Stale Record Cleanup After each sync, a reconciliation pass removes records that exist locally but are no longer present in the source registry: 1. Collect all paths synced in this run (`synced_paths`) 2. Query local repos for all agentcore-sourced records 3. Delete any record whose path is not in `synced_paths` This ensures that records deleted from AgentCore are eventually removed from the gateway. ### Timing - **On startup**: Runs after startup sync if `sync_on_startup: true` - **On manual sync**: Runs after each sync triggered via API or UI - **On registry removal**: Cascade cleanup runs immediately (does not wait for sync) ## Security Considerations ### IAM Permissions The minimum IAM permissions for read-only federation: ```json { "Action": [ "bedrock-agentcore:ListRegistries", "bedrock-agentcore:ListRegistryRecords", "bedrock-agentcore:GetRegistryRecord" ], "Resource": "*" } ``` The Terraform module uses `bedrock-agentcore:*` for operational flexibility. ### Cross-Account STS The `sts:AssumeRole` permission is scoped by a condition: ```json { "Condition": { "StringLike": { "iam:ResourceTag/Purpose": "agentcore-federation" } } } ``` This prevents the gateway from assuming arbitrary IAM roles. Remote accounts must explicitly tag their federation role with `Purpose: agentcore-federation`. ### Authentication Chain 1. ECS task role provides base AWS credentials 2. For same-account registries: direct API calls using task role credentials 3. For cross-account registries: STS AssumeRole to get temporary credentials, then API calls ## File Map | File | Purpose | |------|---------| | `registry/schemas/federation_schema.py` | Pydantic models for federation config | | `registry/services/federation/agentcore_client.py` | boto3 client for AgentCore API | | `registry/services/federation_reconciliation.py` | Stale record cleanup | | `registry/api/federation_routes.py` | API endpoints (add/remove/sync) | | `registry/main.py` | Startup sync and env var override | | `frontend/src/components/ExternalRegistries.tsx` | Settings page UI | | `frontend/src/components/AddRegistryEntryModal.tsx` | Add registry modal | | `frontend/src/components/ConfirmModal.tsx` | Styled confirm dialog | | `terraform/aws-ecs/modules/mcp-gateway/iam.tf` | AgentCore IAM policy | | `terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf` | ECS task definition env vars | ================================================ FILE: docs/design/cookie-security-design.md ================================================ # Cookie Security Design ## Overview This document explains the design decisions behind the session cookie security implementation in the MCP Gateway Registry, particularly regarding the use of domain cookies for cross-subdomain authentication. ## Background The MCP Gateway Registry supports authentication through both traditional username/password and OAuth2 providers. In deployments where the auth server and registry are on different subdomains (e.g., `auth.example.com` and `registry.example.com`), session cookies must be shared across these subdomains for seamless authentication. ## Design Decision: Single-Tenant Architecture This implementation is designed for **single-tenant deployments** where: - All subdomains are owned and controlled by a single organization - Cross-subdomain cookie sharing is a desired feature, not a security risk - Users authenticate once and access multiple services on different subdomains ## Cookie Security Configuration ### Environment Variables Two key environment variables control cookie security behavior: 1. **`SESSION_COOKIE_SECURE`** (default: `false`) - Set to `true` in production deployments with HTTPS - When `true`, cookies are only transmitted over HTTPS connections - Prevents man-in-the-middle (MITM) attacks and session hijacking - **Production Requirement:** MUST be set to `true` when deployed with HTTPS 2. **`SESSION_COOKIE_DOMAIN`** (default: `None` or empty string) - **MUST be explicitly configured** - no automatic domain inference - When set (e.g., `.example.com`), enables cross-subdomain cookie sharing - Must start with a dot (`.`) to match all subdomains - When `None` or empty, cookies are scoped to the exact host that sets them - **Format:** `.example.com` (note the leading dot) - **Important:** Set to empty string `""` for single-domain deployments - **Examples:** - Single domain (`mcpgateway.ddns.net`): Leave unset or set to `""` - Cross-subdomain (`auth.example.com`, `registry.example.com`): Set to `.example.com` - Multi-level domains (`registry.region-1.corp.company.internal`): Set to `.corp.company.internal` if cross-subdomain sharing needed ### HTTPS Termination Detection **Critical Implementation Detail**: The auth server intelligently handles HTTPS termination at load balancers (ALB, nginx, etc.): - **Backend sees HTTP** but **load balancer terminates HTTPS** → Common in AWS ALB, nginx reverse proxy - **Solution**: Auth server checks `X-Forwarded-Proto` header to detect original protocol - **Behavior**: Cookie `secure` flag is set based on **original request protocol**, not backend protocol **Code Logic** ([`auth_server/server.py:1797-1803`](../auth_server/server.py)): ```python x_forwarded_proto = request.headers.get("x-forwarded-proto", "") is_https = x_forwarded_proto == "https" or request.url.scheme == "https" # Only set secure=True if the original request was HTTPS cookie_secure_config = OAUTH2_CONFIG.get("session", {}).get("secure", False) cookie_secure = cookie_secure_config and is_https ``` **Important**: - If `SESSION_COOKIE_SECURE=true` but `is_https=False`, the secure flag will NOT be set - This prevents login failures when HTTPS termination is misconfigured - Check server logs for `is_https=True` in production to verify HTTPS detection is working ### Cookie Security Flags The implementation sets the following security flags on all session cookies: | Flag | Value | Purpose | |------|-------|---------| | `httponly` | `True` | Prevents JavaScript access, mitigating XSS attacks | | `samesite` | `"lax"` | Provides CSRF protection while allowing cross-site navigation | | `secure` | Configurable | Ensures HTTPS-only transmission in production | | `path` | `"/"` | Explicitly scopes cookie to entire domain | | `domain` | Configurable | Enables cross-subdomain sharing when needed | ## Security Considerations ### ✅ Safe Deployment Scenarios This design is **SAFE** for: 1. **Single-Tenant Production Deployments** - Example: `auth.company.com` and `registry.company.com` - All subdomains owned by the same organization - Configuration: ```bash SESSION_COOKIE_SECURE=true SESSION_COOKIE_DOMAIN=.company.com ``` 2. **Local Development (localhost)** - Local development on `localhost` via HTTP - Configuration: ```bash SESSION_COOKIE_SECURE=false # MUST be false for HTTP SESSION_COOKIE_DOMAIN= # Leave unset/empty ``` - **Important:** Setting `SESSION_COOKIE_SECURE=true` on localhost will cause login to fail because cookies with `secure=true` are only sent over HTTPS, and localhost typically runs over HTTP. ### ⚠️ Unsafe Deployment Scenarios This design is **NOT SAFE** for: 1. **Multi-Tenant SaaS Deployments** - Example: `customer1.saas-platform.com` and `customer2.saas-platform.com` - **Risk:** Setting `SESSION_COOKIE_DOMAIN=.saas-platform.com` would allow: - Customer A to access Customer B's sessions - Cross-tenant authentication bypass - Serious data breach potential 2. **Shared Hosting Environments** - Multiple organizations sharing the same root domain - **Risk:** Similar to multi-tenant scenario ### Alternative Solutions for Multi-Tenant If you need multi-tenant deployment, consider these alternatives: 1. **Token-Based Authentication** - Use JWT tokens passed via headers instead of cookies - Tokens explicitly scoped to each tenant - No domain-sharing concerns 2. **Separate Auth Domains per Tenant** - `customer1-auth.platform.com` and `customer1-app.platform.com` - Different root domains prevent cookie sharing between tenants 3. **Reverse Proxy with Path-Based Routing** - Single domain with path-based service routing - Example: `platform.com/auth` and `platform.com/registry` - No cross-subdomain cookie requirements 4. **Centralized OAuth Flow** - OAuth server on separate domain - Token exchange instead of session cookies - Better tenant isolation ## Attack Scenarios Mitigated ### 1. Session Hijacking (MITM) - **Threat:** Attacker intercepts session cookies over unencrypted HTTP - **Mitigation:** `secure=True` flag in production - **Status:** ✅ Mitigated when `SESSION_COOKIE_SECURE=true` ### 2. Cross-Site Scripting (XSS) - **Threat:** Malicious JavaScript reads session cookies - **Mitigation:** `httponly=True` flag - **Status:** ✅ Always mitigated ### 3. Cross-Site Request Forgery (CSRF) - **Threat:** Malicious site triggers authenticated requests - **Mitigation:** `samesite="lax"` flag - **Status:** ✅ Always mitigated ### 4. Subdomain Cookie Theft (Single-Tenant) - **Threat:** Attacker controls a subdomain and steals cookies - **Mitigation:** Only valid in trusted single-tenant environments - **Status:** ⚠️ Acceptable risk for single-tenant deployments ## Production Deployment Checklist Before deploying to production: - [ ] Set `SESSION_COOKIE_SECURE=true` in environment (REQUIRED for HTTPS) - [ ] Verify HTTPS is properly configured and enforced - [ ] **IMPORTANT**: If using load balancer with HTTPS termination, ensure `X-Forwarded-Proto` header is set - [ ] Set `SESSION_COOKIE_DOMAIN` appropriately: - **Empty string or unset** for single-domain deployments (RECOMMENDED - safest) - **`.example.com`** only if you need cross-subdomain authentication - [ ] Confirm you are deploying in a single-tenant architecture (NOT multi-tenant SaaS) - [ ] Test cross-subdomain authentication between auth and registry services (if using domain cookies) - [ ] Verify cookies are NOT transmitted over HTTP in production - [ ] Review server logs for cookie configuration at startup: - Check for `Auth server setting session cookie: secure=True` - Verify `domain` setting matches your configuration - Confirm `is_https=True` in production ### Example Production Configurations **Single-Domain Deployment (RECOMMENDED - Most Secure):** ```bash # .env for production - single domain (e.g., mcpgateway.example.com) SESSION_COOKIE_SECURE=true # REQUIRED for HTTPS SESSION_COOKIE_DOMAIN= # Empty = exact host only (safest) SESSION_COOKIE_NAME=mcp_gateway_session SESSION_MAX_AGE_SECONDS=28800 # 8 hours AUTH_SERVER_URL=http://auth-server:8888 # Internal URL AUTH_SERVER_EXTERNAL_URL=https://mcpgateway.example.com # External URL ``` **Cross-Subdomain Deployment:** ```bash # .env for production - cross-subdomain (e.g., auth.example.com + registry.example.com) SESSION_COOKIE_SECURE=true # REQUIRED for HTTPS SESSION_COOKIE_DOMAIN=.example.com # Note the leading dot SESSION_COOKIE_NAME=mcp_gateway_session SESSION_MAX_AGE_SECONDS=28800 # 8 hours AUTH_SERVER_URL=http://auth-server:8888 # Internal URL AUTH_SERVER_EXTERNAL_URL=https://auth.example.com # External URL ``` ## Code Implementation The cookie security implementation is found in: - **Configuration:** [`registry/core/config.py`](../registry/core/config.py) - `session_cookie_secure`: Controls HTTPS-only flag - `session_cookie_domain`: Controls cross-subdomain sharing - **Auth Server Cookie Setting:** [`auth_server/server.py`](../auth_server/server.py) (lines 1800-1831) - X-Forwarded-Proto detection for HTTPS termination at load balancer - Explicit configuration only - no automatic domain inference - Conditional secure flag based on both config AND actual protocol - All security flags properly set - **Registry Cookie Setting:** [`registry/auth/routes.py`](../registry/auth/routes.py) (lines 139-158) - Comprehensive security comments explaining single-tenant model - Conditional domain attribute application - All security flags properly set ## Monitoring and Validation ### Runtime Validation The auth server logs detailed cookie configuration for debugging: ```python logger.info(f"Auth server setting session cookie: secure={cookie_secure} (config={cookie_secure_config}, is_https={is_https}), samesite={cookie_samesite}, domain={cookie_domain or 'not set'}, x-forwarded-proto={x_forwarded_proto}, request_scheme={request.url.scheme}") ``` Key logging details: - **secure**: Final secure flag value (after protocol detection) - **config**: Configured SESSION_COOKIE_SECURE value - **is_https**: Whether the original request was HTTPS (based on X-Forwarded-Proto or request scheme) - **domain**: Configured domain or "not set" - **x-forwarded-proto**: Load balancer protocol header - **request_scheme**: Direct request protocol The registry logs successful login events: ```python logger.info(f"User '{username}' logged in successfully.") ``` ### Security Auditing Periodically review: 1. Cookie flags are properly set in browser developer tools 2. Cookies are NOT transmitted over HTTP in production 3. `secure` flag is enabled in production environments 4. Domain scope matches your deployment architecture ### Browser Developer Tools Verification In your browser's developer tools (Application/Storage → Cookies), verify: | Property | Expected Value | Notes | |----------|---------------|-------| | `Secure` | ✓ (checked) | Production only | | `HttpOnly` | ✓ (checked) | Always | | `SameSite` | `Lax` | Always | | `Domain` | `.example.com` | If configured | | `Path` | `/` | Always | ## References - [OWASP Session Management Cheat Sheet](https://cheatsheetsecurity.org/cheatsheets/session-management-cheat-sheet) - [MDN: Set-Cookie HTTP Header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) - [RFC 6265: HTTP State Management Mechanism](https://datatracker.ietf.org/doc/html/rfc6265) ## Contact For questions or security concerns regarding this implementation, please: - Open an issue in the GitHub repository - Tag the issue with `security` label - Provide details about your deployment scenario ================================================ FILE: docs/design/database-abstraction-layer.md ================================================ # Database Abstraction Layer Design **Status:** Current Implementation **Last Updated:** January 5, 2026 **Target Audience:** Senior/Staff Engineers, Architecture Review ## Table of Contents 1. [Architecture Overview](#architecture-overview) 2. [Design Patterns](#design-patterns) 3. [Abstract Base Classes](#abstract-base-classes) 4. [Implementations](#implementations) 5. [Factory Pattern](#factory-pattern) 6. [Design Rationale](#design-rationale) 7. [Code Organization](#code-organization) ## Architecture Overview The MCP Gateway Registry implements a **repository pattern with abstract base classes** to provide a clean separation between business logic and data storage. This design enables seamless switching between file-based storage (legacy, backwards compatible) and DocumentDB/MongoDB (recommended) without modifying application code. ### System Diagram ``` ┌─────────────────────────────────────────────────────────────────┐ │ Application Layer │ │ (Services, API Endpoints, Business Logic) │ └────────────────────┬────────────────────────────────────────────┘ │ depends on ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Repository Factory Layer │ │ get_server_repository() │ │ get_agent_repository() │ │ get_scope_repository() │ │ get_security_scan_repository() │ │ get_search_repository() │ │ get_federation_config_repository() │ └────────┬──────────────────────────────┬────────────────────────┘ │ │ │ creates (based on │ creates (based on │ STORAGE_BACKEND) │ STORAGE_BACKEND) ▼ ▼ ┌──────────────────────┐ ┌──────────────────────────┐ │ File-Based Impl │ │ DocumentDB Impl │ ├──────────────────────┤ ├──────────────────────────┤ │ FileServerRepo │ │ DocumentDBServerRepo │ │ FileAgentRepo │ │ DocumentDBAgentRepo │ │ FileScopeRepo │ │ DocumentDBScopeRepo │ │ FileSecurityScanRepo │ │ DocumentDBSecurityRepo │ │ FaissSearchRepo │ │ DocumentDBSearchRepo │ │ FileFederationRepo │ │ DocumentDBFederationRepo │ └──────────┬───────────┘ └──────────┬───────────────┘ │ │ ▼ ▼ Local File System DocumentDB/MongoDB Cluster (JSON files, FAISS) (Collections, Vector Search) ``` ### Key Principles 1. **Abstraction**: All repositories implement abstract base classes defining strict contracts 2. **Polymorphism**: Multiple storage backend implementations (file, DocumentDB/MongoDB) 3. **Dependency Injection**: Factory pattern provides single point of repository creation 4. **Consistency**: All implementations provide identical behavior regardless of backend 5. **Testability**: Mock implementations easy to create; reset_repositories() enables test isolation ## Design Patterns ### Repository Pattern The repository pattern provides a **data access abstraction** layer that: - Isolates business logic from storage implementation details - Defines clear, intent-driven interfaces (e.g., `get()`, `create()`, `update()`, `delete()`) - Makes switching storage backends transparent to consuming code - Enables comprehensive testing without actual storage dependencies ### Factory Pattern The factory pattern manages **repository instantiation** by: - Creating repositories lazily on first access (singleton pattern) - Selecting implementation based on environment configuration (`STORAGE_BACKEND` setting) - Centralizing backend selection logic in one place (`factory.py`) - Providing `reset_repositories()` for test isolation ### Strategy Pattern Different storage backends are **strategies** implementing the same interface: - **File Strategy**: YAML/JSON files + FAISS for search - **DocumentDB/MongoDB Strategy**: Distributed document database with vector search and aggregation pipelines ## Abstract Base Classes All repository implementations inherit from abstract base classes defined in [`registry/repositories/interfaces.py`](../../registry/repositories/interfaces.py). These define the contract that ALL implementations must follow. ### ServerRepositoryBase **Location:** [`registry/repositories/interfaces.py` (lines 14-76)](../../registry/repositories/interfaces.py#L14-L76) **Purpose:** Data access for MCP server definitions and lifecycle management **Key Methods:** ```python # Lifecycle operations async def get(path: str) -> Optional[Dict[str, Any]] async def list_all() -> Dict[str, Dict[str, Any]] async def create(server_info: Dict[str, Any]) -> bool async def update(path: str, server_info: Dict[str, Any]) -> bool async def delete(path: str) -> bool # State management (enabled/disabled) async def get_state(path: str) -> bool async def set_state(path: str, enabled: bool) -> bool # Lifecycle async def load_all() -> None # Load/reload from storage at startup ``` **Implementations:** - [`registry/repositories/file/server_repository.py`](../../registry/repositories/file/server_repository.py) - File-based - [`registry/repositories/documentdb/server_repository.py`](../../registry/repositories/documentdb/server_repository.py) - DocumentDB/MongoDB --- ### AgentRepositoryBase **Location:** [`registry/repositories/interfaces.py` (lines 78-140)](../../registry/repositories/interfaces.py#L78-L140) **Purpose:** Data access for A2A (Agent-to-Agent) agent definitions **Key Methods:** ```python async def get(path: str) -> Optional[AgentCard] async def list_all() -> List[AgentCard] async def create(agent: AgentCard) -> AgentCard async def update(path: str, updates: Dict[str, Any]) -> AgentCard async def delete(path: str) -> bool async def get_state(path: str) -> bool async def set_state(path: str, enabled: bool) -> bool async def load_all() -> None # Load/reload from storage ``` **Implementations:** - [`registry/repositories/file/agent_repository.py`](../../registry/repositories/file/agent_repository.py) - File-based - [`registry/repositories/documentdb/agent_repository.py`](../../registry/repositories/documentdb/agent_repository.py) - DocumentDB/MongoDB --- ### ScopeRepositoryBase **Location:** [`registry/repositories/interfaces.py` (lines 142-506)](../../registry/repositories/interfaces.py#L142-L506) **Purpose:** Data access for authorization scopes (RBAC - Role-Based Access Control) **Overview:** The Scope Repository manages a complex authorization model with three primary data structures: 1. **UI-Scopes** - Permissions for UI features grouped by Keycloak group 2. **Server Scopes** - Server access rules (which servers, methods, tools) 3. **Group Mappings** - Mapping between Keycloak groups and scopes **Key Methods:** ```python # UI permission queries async def get_ui_scopes(group_name: str) -> Dict[str, Any] async def get_group_mappings(keycloak_group: str) -> List[str] # Server access rules async def get_server_scopes(scope_name: str) -> List[Dict[str, Any]] # Group management async def get_group(group_name: str) -> Dict[str, Any] async def list_groups() -> Dict[str, Any] async def group_exists(group_name: str) -> bool async def create_group(group_name: str, description: str = "") -> bool async def delete_group(group_name: str, remove_from_mappings: bool = True) -> bool # Server scope assignment async def add_server_scope( server_path: str, scope_name: str, methods: List[str], tools: Optional[List[str]] = None ) -> bool async def remove_server_scope(server_path: str, scope_name: str) -> bool async def remove_server_from_all_scopes(server_path: str) -> bool # UI visibility management async def add_server_to_ui_scopes(group_name: str, server_name: str) -> bool async def remove_server_from_ui_scopes(group_name: str, server_name: str) -> bool # Scope mapping management async def add_group_mapping(group_name: str, scope_name: str) -> bool async def remove_group_mapping(group_name: str, scope_name: str) -> bool async def get_all_group_mappings() -> Dict[str, List[str]] # Bulk operations async def add_server_to_multiple_scopes( server_path: str, scope_names: List[str], methods: List[str], tools: List[str] ) -> bool async def load_all() -> None # Load/reload from storage ``` **Implementations:** - [`registry/repositories/file/scope_repository.py`](../../registry/repositories/file/scope_repository.py) - File-based (YAML) - [`registry/repositories/documentdb/scope_repository.py`](../../registry/repositories/documentdb/scope_repository.py) - DocumentDB/MongoDB --- ### SecurityScanRepositoryBase **Location:** [`registry/repositories/interfaces.py` (lines 508-598)](../../registry/repositories/interfaces.py#L508-L598) **Purpose:** Data access for security scanning results (both MCP servers and agents) **Key Methods:** ```python # CRUD operations async def get(server_path: str) -> Optional[Dict[str, Any]] async def list_all() -> List[Dict[str, Any]] async def create(scan_result: Dict[str, Any]) -> bool # Querying async def get_latest(server_path: str) -> Optional[Dict[str, Any]] async def query_by_status(status: str) -> List[Dict[str, Any]] async def load_all() -> None # Load/reload from storage ``` **Implementations:** - [`registry/repositories/file/security_scan_repository.py`](../../registry/repositories/file/security_scan_repository.py) - File-based - [`registry/repositories/documentdb/security_scan_repository.py`](../../registry/repositories/documentdb/security_scan_repository.py) - DocumentDB/MongoDB --- ### SearchRepositoryBase **Location:** [`registry/repositories/interfaces.py` (lines 600-645)](../../registry/repositories/interfaces.py#L600-L645) **Purpose:** Data access for semantic/hybrid search functionality **Key Methods:** ```python async def initialize() -> None # Initialize search service # Indexing async def index_server( path: str, server_info: Dict[str, Any], is_enabled: bool = False ) -> None async def index_agent( path: str, agent_card: AgentCard, is_enabled: bool = False ) -> None # Query async def search( query: str, entity_types: Optional[List[str]] = None, max_results: int = 10 ) -> Dict[str, List[Dict[str, Any]]] async def remove_entity(path: str) -> None ``` **Implementations:** - [`registry/repositories/file/search_repository.py`](../../registry/repositories/file/search_repository.py) - FAISS (Facebook AI Similarity Search) - [`registry/repositories/documentdb/search_repository.py`](../../registry/repositories/documentdb/search_repository.py) - DocumentDB/MongoDB Hybrid Search (BM25 + k-NN) --- ### FederationConfigRepositoryBase **Location:** [`registry/repositories/interfaces.py` (lines 647-709)](../../registry/repositories/interfaces.py#L647-L709) **Purpose:** Data access for federation configuration (multi-registry federation) **Key Methods:** ```python # CRUD operations async def get_config(config_id: str = "default") -> Optional[FederationConfig] async def save_config( config: FederationConfig, config_id: str = "default" ) -> FederationConfig async def delete_config(config_id: str = "default") -> bool async def list_configs() -> List[Dict[str, Any]] ``` **Implementations:** - [`registry/repositories/file/federation_config_repository.py`](../../registry/repositories/file/federation_config_repository.py) - File-based (JSON) - [`registry/repositories/documentdb/federation_config_repository.py`](../../registry/repositories/documentdb/federation_config_repository.py) - DocumentDB/MongoDB --- ## Implementations ### File-Based Backend (Legacy, Backwards Compatible) **Purpose:** Local file system storage for development, testing, and backwards compatibility **Characteristics:** - **Simplicity**: JSON/YAML files, no external dependencies (except FAISS for search) - **Isolation**: No network calls required - **State Management**: Separate state files for enabled/disabled status - **Search**: FAISS (Facebook AI Similarity Search) for vector similarity **Storage Structure:** ``` registry/ ├── servers/ │ ├── server1.json │ ├── server2.json │ └── server_state.json # Persistence for enabled/disabled state ├── agents/ │ ├── agent1_agent.json │ ├── agent2_agent.json │ └── agent_state.json ├── config/ │ ├── scopes.yml # Authorization scopes configuration │ └── federation/ │ └── default.json # Federation configuration ├── security_scans/ │ ├── scan_result_1.json │ └── scan_result_2.json ├── models/ │ └── all-MiniLM-L6-v2/ # Embedding model weights └── service_index.faiss # FAISS vector index ``` **Implementation Classes:** - [`FileServerRepository`](../../registry/repositories/file/server_repository.py) - [`FileAgentRepository`](../../registry/repositories/file/agent_repository.py) - [`FileScopeRepository`](../../registry/repositories/file/scope_repository.py) - [`FileSecurityScanRepository`](../../registry/repositories/file/security_scan_repository.py) - [`FaissSearchRepository`](../../registry/repositories/file/search_repository.py) - [`FileFederationConfigRepository`](../../registry/repositories/file/federation_config_repository.py) **Advantages:** - No infrastructure setup needed - Good for development and testing - Human-readable file formats - Git-friendly for version control **Limitations:** - Single-node only (no distributed deployment) - Limited query capabilities - File locking issues in concurrent scenarios - Not suitable for production at scale - FAISS requires model file storage --- ### DocumentDB/MongoDB Backend (Recommended for Production) **Purpose:** Distributed document database for production deployments **Characteristics:** - **Scalability**: Clustered deployment for redundancy (DocumentDB) or replica sets (MongoDB) - **Query Capabilities**: Rich aggregation pipelines, complex filtering, projections - **Vector Search**: Native vector search (DocumentDB) or application-level (MongoDB CE) - **Collection Management**: Automatic index creation with proper field mappings - **Async Support**: Full async/await implementation for non-blocking I/O - **Strong Consistency**: ACID transactions and strong consistency guarantees **Collection Structure:** ``` DocumentDB/MongoDB Cluster ├── mcp_servers_{namespace} # Server definitions ├── mcp_agents_{namespace} # Agent definitions ├── mcp_scopes_{namespace} # Authorization scopes ├── mcp_security_scans_{namespace} # Security scan results ├── mcp_embeddings_1536_{namespace} # Vector embeddings └── mcp_federation_config_{namespace} # Federation configurations ``` **Implementation Classes:** - [`DocumentDBServerRepository`](../../registry/repositories/documentdb/server_repository.py) - [`DocumentDBAgentRepository`](../../registry/repositories/documentdb/agent_repository.py) - [`DocumentDBScopeRepository`](../../registry/repositories/documentdb/scope_repository.py) - [`DocumentDBSecurityScanRepository`](../../registry/repositories/documentdb/security_scan_repository.py) - [`DocumentDBSearchRepository`](../../registry/repositories/documentdb/search_repository.py) - [`DocumentDBFederationConfigRepository`](../../registry/repositories/documentdb/federation_config_repository.py) **DocumentDB Client:** [`registry/repositories/documentdb/client.py`](../../registry/repositories/documentdb/client.py) Provides: - Async MongoDB client singleton - Connection pooling and authentication - Index name management with namespace support - Client lifecycle management (initialization and cleanup) **Advantages:** - Distributed, highly available - Rich query capabilities - Hybrid search (BM25 + k-NN) for semantic understanding - Native vector support for embeddings - Scales to millions of documents - Recommended for deployment **Limitations:** - Requires DocumentDB cluster or MongoDB CE instance - Higher operational complexity - More network I/O - Requires proper index tuning for performance **Hybrid Search Explanation:** DocumentDB/MongoDB vector search provides semantic similarity: 1. **BM25 (Best Matching 25)**: Full-text relevance scoring based on term frequency - Good for exact keyword matches - Fast and traditional IR approach - Default web search behavior 2. **k-NN (k-Nearest Neighbors)**: Vector similarity search - Semantic understanding - Captures meaning, not just keywords - Uses neural embeddings (sentence-transformers or Bedrock Titan) **Default Weighting:** ```python # Legacy - removed: float = 0.4 # Legacy - removed: float = 0.6 ``` This gives 60% weight to semantic search (vector similarity) and 40% to keyword matching, optimized for finding semantically relevant servers/agents. --- ## Factory Pattern ### Factory Implementation **Location:** [`registry/repositories/factory.py`](../../registry/repositories/factory.py) The factory provides singleton repository instances with lazy initialization: ```python def get_server_repository() -> ServerRepositoryBase: """Get server repository singleton.""" global _server_repo if _server_repo is not None: return _server_repo backend = settings.storage_backend logger.info(f"Creating server repository with backend: {backend}") if backend == "documentdb": from .documentdb.server_repository import DocumentDBServerRepository _server_repo = DocumentDBServerRepository() else: from .file.server_repository import FileServerRepository _server_repo = FileServerRepository() return _server_repo ``` **Similar factory functions exist for:** - `get_agent_repository()` - `get_scope_repository()` - `get_security_scan_repository()` - `get_search_repository()` - `get_federation_config_repository()` ### Backend Selection Logic Backend selection is controlled by the `storage_backend` setting in [`registry/core/config.py`](../../registry/core/config.py): ```python storage_backend: str = "file" # Options: "file", "documentdb" ``` **How it works:** 1. **At startup**: Application reads `STORAGE_BACKEND` environment variable (or defaults to "file") 2. **On first access**: Factory function checks backend setting 3. **Instance creation**: Creates appropriate implementation (file vs DocumentDB/MongoDB) 4. **Caching**: Returns cached singleton on subsequent calls ### Dependency Injection Pattern Services consume repositories through factory functions: ```python # In any service module from registry.repositories.factory import get_server_repository async def list_servers(): repo = get_server_repository() # Gets file or DocumentDB/MongoDB impl servers = await repo.list_all() return servers ``` **Benefits:** - No hardcoded dependencies - Easy to swap implementations - Test isolation via `reset_repositories()` ### Test Isolation For testing, repositories can be reset: ```python from registry.repositories.factory import reset_repositories async def test_server_creation(): reset_repositories() # Clear all singletons # Now fresh repositories are created repo = get_server_repository() # ... test code ... ``` --- ## Design Rationale ### Why Repository Pattern? The repository pattern addresses several critical concerns: 1. **Separation of Concerns**: Business logic doesn't know about storage details - Controllers/services call `repo.get()`, not filesystem or database directly - Easier to test services in isolation 2. **Multiple Storage Backends**: Single interface, multiple implementations - Same code works with file or DocumentDB/MongoDB backend - Switching backends requires only env var change - No code changes needed 3. **Consistency**: All implementations follow same contract - Same method signatures and behavior - Predictable error handling - Consistent data transformations 4. **Abstraction**: Storage complexity hidden behind simple interface - `repo.get(path)` is simple whether backed by files or DocumentDB/MongoDB - Consumers don't care about implementation details ### Benefits of Repository Abstraction **For Development:** - Develop with file backend (fast, no setup) - Test with mocks (no I/O overhead) - Easy to add new storage backends **For Deployment:** - Production uses DocumentDB/MongoDB (scalable) - Backwards compatible with file storage - Can migrate gradually **For Testing:** - Mock repositories easily - Test services without actual storage - Test data isolation via `reset_repositories()` **For Maintenance:** - Storage logic centralized in repository classes - Changes to storage don't affect business logic - Clear interfaces make code changes safer ### Interface Consistency All repositories implement abstract base classes with: ```python # All repos have these patterns async def load_all() -> None # Startup initialization async def get(id: str) -> Optional # Single entity retrieval async def list_all() -> List/Dict # Bulk retrieval async def create(entity) -> bool/Entity # Create new entity async def update(id, updates) -> bool/Entity # Modify existing async def delete(id) -> bool # Remove entity ``` This consistent interface means: - Easier onboarding for engineers - Less mental overhead switching between repositories - Standardized error handling ### Data Consistency Strategy **File Backend:** - Atomic file operations - Separate state file for enabled/disabled status - No transactions (file-by-file consistency) - Best effort on concurrent writes **DocumentDB/MongoDB Backend:** - Document-level consistency - Index operations with refresh flags - No distributed transactions - Eventual consistency model - Suitable for high concurrency --- ## Code Organization ### Directory Structure ``` registry/repositories/ ├── __init__.py # Package initialization ├── interfaces.py # Abstract base classes (6 repos) ├── factory.py # Repository factory │ ├── file/ # File-based implementations │ ├── __init__.py │ ├── server_repository.py │ ├── agent_repository.py │ ├── scope_repository.py │ ├── security_scan_repository.py │ ├── search_repository.py # FAISS-based │ └── federation_config_repository.py │ └── documentdb/ # DocumentDB/MongoDB implementations ├── __init__.py ├── client.py # MongoDB client management ├── server_repository.py ├── agent_repository.py ├── scope_repository.py ├── security_scan_repository.py ├── search_repository.py # Vector search (native or app-level) └── federation_config_repository.py ``` ### Naming Conventions **Abstract Base Classes** (in `interfaces.py`): - `ServerRepositoryBase` - `AgentRepositoryBase` - `ScopeRepositoryBase` - `SecurityScanRepositoryBase` - `SearchRepositoryBase` - `FederationConfigRepositoryBase` **File Implementations** (in `file/`): - `FileServerRepository` - `FileAgentRepository` - `FileScopeRepository` - `FileSecurityScanRepository` - `FaissSearchRepository` (special: uses FAISS, not files) - `FileFederationConfigRepository` **DocumentDB/MongoDB Implementations** (in `documentdb/`): - `DocumentDBServerRepository` - `DocumentDBAgentRepository` - `DocumentDBScopeRepository` - `DocumentDBSecurityScanRepository` - `DocumentDBSearchRepository` - `DocumentDBFederationConfigRepository` ### Configuration Backend selection and DocumentDB/MongoDB settings in [`registry/core/config.py`](../../registry/core/config.py): ```python # Backend selection storage_backend: str = "file" # "file", "mongodb", or "documentdb" # DocumentDB/MongoDB connection documentdb_endpoint: str = "localhost" documentdb_port: int = 27017 documentdb_username: Optional[str] = None documentdb_password: Optional[str] = None documentdb_database: str = "mcp_gateway" documentdb_use_tls: bool = False documentdb_tls_ca_file: Optional[str] = None # Multi-tenancy support documentdb_namespace: str = "default" # Collection names (with namespace suffix) documentdb_collection_servers: str = "mcp_servers" documentdb_collection_agents: str = "mcp_agents" documentdb_collection_scopes: str = "mcp_scopes" documentdb_collection_embeddings: str = "mcp_embeddings_1536" documentdb_collection_security_scans: str = "mcp_security_scans" documentdb_collection_federation_config: str = "mcp_federation_config" # Vector search configuration documentdb_vector_dimension: int = 1536 # For embeddings ``` --- ## Advanced Topics ### Namespace Support (Multi-Tenancy) DocumentDB/MongoDB implementation supports multi-tenancy via namespaces: ```python # In config.py documentdb_namespace: str = "default" # Collection names are automatically namespaced # mcp_servers_{namespace} # mcp_agents_{namespace} # etc. ``` **Use Cases:** - Multiple registry instances on shared DocumentDB/MongoDB cluster - Isolated test environments - Customer separation in SaaS deployments **Implementation:** [`registry/repositories/documentdb/client.py`](../../registry/repositories/documentdb/client.py) ```python def get_index_name(base_name: str) -> str: """Get full index name with namespace.""" return f"{base_name}-{settings.documentdb_namespace}" ``` ### Error Handling Both implementations handle errors gracefully: **File Backend:** - Missing files → return None or empty list - Parse errors → log and skip - I/O errors → raised to caller **DocumentDB/MongoDB Backend:** - Connection errors → log and attempt retry - Index not found → initialize index - Query errors → log and raise ### Performance Considerations **File Backend:** - Fast for small datasets (<1000 entities) - No network latency - FAISS search: O(n) linear scan (not scalable) - Not suitable for high concurrency **DocumentDB/MongoDB Backend:** - Scalable to millions of entities - Network latency (typically <100ms) - BM25 + k-NN: O(log n) with proper indexing - Built for high concurrency (lock-free reads) **Recommendations:** - Use file backend for development only - Use DocumentDB/MongoDB for production - Monitor DocumentDB/MongoDB query latency - Tune index refresh intervals for throughput vs. latency tradeoff --- ## Testing Strategy ### Unit Tests Mock repositories for unit testing: ```python class MockServerRepository(ServerRepositoryBase): """Mock implementation for testing.""" def __init__(self): self.servers = {} async def get(self, path: str) -> Optional[Dict]: return self.servers.get(path) async def list_all(self) -> Dict: return self.servers.copy() # ... implement other methods ... ``` ### Integration Tests Use file backend for integration tests: ```python @pytest.fixture def reset_repos(): """Reset repositories between tests.""" yield reset_repositories() async def test_server_lifecycle(reset_repos): repo = get_server_repository() # File backend used by default server = {"path": "/test", "name": "test_server"} await repo.create(server) result = await repo.get("/test") assert result is not None ``` ### End-to-End Tests Test against MongoDB CE in Docker: ```yaml # docker-compose.yml services: mongodb: image: mongo:8.2 command: ["--replSet", "rs0", "--bind_ip_all"] ports: - "27017:27017" ``` Set `STORAGE_BACKEND=mongodb` and run full integration tests. --- ## Summary The database abstraction layer provides a **clean, extensible architecture** for data storage in the MCP Gateway Registry: | Aspect | File | DocumentDB/MongoDB | |--------|------|-------------------| | **Use Case** | Development, testing | Production | | **Scalability** | ~1000 entities | Millions | | **Dependencies** | None (+ FAISS) | DocumentDB/MongoDB cluster | | **Query Power** | Basic | Advanced (aggregation pipelines) | | **Concurrency** | Limited | High | | **Setup Complexity** | None | Moderate | | **Cost** | Free | Infrastructure cost | The **repository pattern** with factory ensures: - Clean separation of concerns - Easy backend switching - Comprehensive testability - Consistent interfaces - Future extensibility All implementations maintain **identical behavior**, making backend selection purely an operational decision rather than a code architecture choice. ================================================ FILE: docs/design/federation-architecture.md ================================================ # Federation Architecture Design This document describes the peer-to-peer federation architecture for MCP Gateway Registry instances. ## Overview Federation enables multiple MCP Gateway Registry instances to share servers and agents across organizational boundaries. Unlike external registry integration (Anthropic, ASOR), peer-to-peer federation connects registry instances that run the same codebase, enabling bidirectional synchronization with fine-grained control. ## Architecture Principles ### Peer-to-Peer Symmetric Design The codebase has **no concept of "hub" or "spoke"**. Every registry instance runs identical code with identical capabilities: - Any registry can be both an exporter and an importer simultaneously - There is no role flag, no hierarchy, no hardcoded topology - Terms like "Hub" and "LOB" are purely organizational labels describing deployment choices ``` Symmetric Architecture: Registry A <------> Registry B | | v v Registry C <------> Registry D Any registry can pull from any other registry. Any registry can export to any other registry. ``` ### Common Deployment Patterns **Hub Pulls from LOBs (Centralized Visibility)** A central IT team maintains a Hub Registry. Each Line of Business (LOB) maintains their own registry. The Hub pulls from LOBs to provide centralized visibility. ``` LOB-A Registry LOB-B Registry LOB-C Registry \ | / \ | / Hub Registry (Central IT) "Show me everything across all LOBs" ``` **LOBs Pull from Hub (Inheritance)** LOBs inherit shared tools and agents from a central Hub. ``` Hub Registry (Central IT) / | \ / | \ LOB-A Registry LOB-B Registry LOB-C Registry "Inherit shared tools from central" ``` **Mesh Topology** For organizations with peer relationships between registries. ``` Registry A <------> Registry B ^ ^ | | v v Registry C <------> Registry D ``` ## Data Model ### Peer Registry Configuration Each peer is configured with the following attributes: | Field | Type | Description | |-------|------|-------------| | `peer_id` | string | Unique identifier for the peer (e.g., "lob-a", "hub") | | `name` | string | Human-readable name (e.g., "LOB-A Registry") | | `endpoint` | string | Base URL of the peer registry | | `enabled` | boolean | Whether sync is enabled for this peer | | `sync_mode` | enum | `all`, `whitelist`, or `tag_filter` | | `whitelist_servers` | string[] | Server paths to include (if sync_mode=whitelist) | | `whitelist_agents` | string[] | Agent paths to include (if sync_mode=whitelist) | | `tag_filters` | string[] | Tags to filter by (if sync_mode=tag_filter) | | `sync_interval_minutes` | int | Interval for scheduled sync (0 = manual only) | | `federation_token` | string | Static token for authenticating to this peer (encrypted) | | `expected_client_id` | string | OAuth2 client ID expected from this peer (for peer identification) | ### Sync Modes **All Mode** ```json { "peer_id": "lob-a", "sync_mode": "all" } ``` Imports all public servers and agents from the peer. **Whitelist Mode** ```json { "peer_id": "lob-a", "sync_mode": "whitelist", "whitelist_servers": ["/critical-tool", "/shared-service"], "whitelist_agents": ["/data-analyst-agent"] } ``` Imports only the specified servers and agents. **Tag Filter Mode** ```json { "peer_id": "lob-a", "sync_mode": "tag_filter", "tag_filters": ["production", "shared"] } ``` Imports servers and agents that have any of the specified tags. ### Visibility Control Servers and agents have a `visibility` field that controls federation export: | Visibility | Behavior | |------------|----------| | `internal` | Not exported via federation (default) | | `public` | Exported to all authenticated peers | | `group-restricted` | Exported only to peers in specified groups | ## Authentication ### Static Token Authentication (Recommended) The primary authentication method for federation uses static tokens. This is IdP-agnostic and works regardless of whether registries use Keycloak, Entra ID, Cognito, or no identity provider at all. **On the Exporting Registry:** ```bash # .env on the exporting registry FEDERATION_STATIC_TOKEN_AUTH_ENABLED=true FEDERATION_STATIC_TOKEN= ``` **On the Importing Registry:** ```json { "peer_id": "lob-a", "endpoint": "https://lob-a-registry.corp.com", "federation_token": "" } ``` The token is encrypted using Fernet symmetric encryption before storage in MongoDB/DocumentDB. ### Encryption at Rest Secrets stored in peer configurations (federation tokens, OAuth client secrets) are encrypted using Fernet (AES-128-CBC): ```bash # Generate encryption key (one-time) python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # Set in environment FEDERATION_ENCRYPTION_KEY= ``` This approach is: - Database-agnostic (same code for MongoDB CE and DocumentDB) - Simple (one env var holds the symmetric key) - No extra dependencies on database side ### OAuth2 Client Credentials (Alternative) For organizations requiring short-lived tokens and JWT audit trails, OAuth2 client credentials flow is supported as an alternative: ```json { "peer_id": "lob-external", "endpoint": "https://external-registry.example.com", "auth_config": { "token_endpoint": "https://keycloak.example.com/realms/mcp-gateway/protocol/openid-connect/token", "client_id": "federation-hub-m2m", "client_secret": "", "scope": null } } ``` This works with any OAuth2-compliant provider (Keycloak, Entra ID, Cognito). ## Sync Process ### Sync Triggers Sync can be triggered in three ways: 1. **Manual via API**: `POST /api/peers/{peer_id}/sync` 2. **Manual via UI**: Click "Sync Now" in the Settings UI 3. **Scheduled**: Background scheduler checks every 60 seconds and triggers sync for peers where `sync_interval_minutes > 0` and the interval has elapsed ### Sync Flow ``` 1. Load peer configuration from MongoDB 2. Decrypt authentication credentials 3. Authenticate to peer's federation export API 4. Fetch servers and agents based on sync_mode filters 5. Apply visibility filtering on the exporting side 6. Store synced items with federation metadata: - is_federated: true - source_peer_id: "lob-a" - upstream_path: "/original/path" - is_read_only: true 7. Update sync status (last_sync, generation number) 8. Mark items not present in sync as orphaned ``` ### Federation Metadata Synced servers and agents carry metadata indicating their federated origin: ```json { "name": "External Tool", "path": "/lob-a/external-tool", "sync_metadata": { "is_federated": true, "source_peer_id": "lob-a", "upstream_path": "/external-tool", "last_synced_at": "2026-02-05T10:30:00Z", "is_read_only": true } } ``` ### Path Namespacing Synced items are namespaced under their peer ID to prevent collisions: | Original Path (on peer) | Synced Path (on importer) | |------------------------|---------------------------| | `/my-tool` | `/lob-a/my-tool` | | `/data-agent` | `/lob-a/data-agent` | ### Orphan Detection When a server or agent is removed from the upstream peer, the sync process: 1. Detects the item is no longer present in the export 2. Increments the generation number 3. Items from previous generations are marked as orphaned 4. Admins can review orphaned items before removal ## API Endpoints ### Federation Export API Endpoints exposed by registries for peers to pull data: | Endpoint | Method | Description | |----------|--------|-------------| | `/api/v1/federation/health` | GET | Health check (unauthenticated) | | `/api/v1/federation/servers` | GET | Export servers for sync | | `/api/v1/federation/agents` | GET | Export agents for sync | ### Peer Management API Endpoints for managing peer configurations: | Endpoint | Method | Description | |----------|--------|-------------| | `/api/peers` | GET | List all configured peers | | `/api/peers` | POST | Add a new peer | | `/api/peers/{peer_id}` | GET | Get peer configuration | | `/api/peers/{peer_id}` | PUT | Update peer configuration | | `/api/peers/{peer_id}` | DELETE | Remove a peer | | `/api/peers/{peer_id}/sync` | POST | Trigger sync for a peer | | `/api/peers/{peer_id}/status` | GET | Get sync status | | `/api/peers/{peer_id}/enable` | POST | Enable a peer | | `/api/peers/{peer_id}/disable` | POST | Disable a peer | | `/api/peers/sync` | POST | Sync all enabled peers | ## Security Considerations ### Principle of Least Privilege The `FEDERATION_STATIC_TOKEN` grants access only to federation-scoped endpoints: | Accessible | Not Accessible | |------------|----------------| | `/api/v1/federation/*` | `/api/servers/*` | | `/api/peers/*` | `/api/agents/*` | | | `/api/admin/*` | | | `/v0.1/*` | ### Token Security 1. **Encryption at rest**: Tokens are Fernet-encrypted in MongoDB 2. **Transport security**: All federation traffic uses HTTPS 3. **Token rotation**: Admin API available for rotating tokens without restart 4. **Revocation**: Immediate revocation via admin endpoint ### Read-Only Federated Items Synced servers and agents are marked as read-only: - Cannot be modified through the local registry API - Cannot be deleted (controlled by upstream) - Must be managed at the source registry ## Comparison with External Registry Federation | Aspect | Peer-to-Peer Federation | External Registry (Anthropic, ASOR) | |--------|------------------------|-------------------------------------| | Protocol | Same codebase, symmetric | Third-party APIs | | Direction | Bidirectional | Import only | | Authentication | Static token or OAuth2 | Provider-specific | | Sync control | Fine-grained (whitelist, tags) | Configuration-based | | Visibility | Configurable per item | All-or-nothing | | Path handling | Namespaced by peer_id | Tagged by source | ## Related Documentation - [Federation Operational Guide](../federation-operational-guide.md) - Setup and operations - [Federation Guide](../federation.md) - External registry integration (Anthropic, ASOR) - [Static Token Auth](../static-token-auth.md) - Static token authentication - [Storage Architecture](storage-architecture-mongodb-documentdb.md) - Database design ================================================ FILE: docs/design/hybrid-search-architecture.md ================================================ # Hybrid Search Architecture This document describes the hybrid search design for MCP servers and A2A agents in the registry. ## Overview The registry implements hybrid search that combines semantic (vector) search with lexical (keyword) matching. This approach provides both conceptual understanding of queries and precise matching when users reference entities by name. ## Architecture Diagram ``` +-------------------+ | Search Query | | "context7 docs" | +--------+----------+ | +-----------------+-----------------+ | | v v +------------------+ +-------------------+ | Query Embedding | | Query Tokenizer | | (Vector Model) | | (Keyword Extract)| +--------+---------+ +---------+---------+ | | | [0.12, -0.34, ...] | ["context7", "docs"] | | v v +------------------+ +-------------------+ | Vector Search | | Keyword Match | | (Cosine Sim) | | (Regex on path, | | | | name, desc, | | | | tags, metadata, | | | | tools) | +--------+---------+ +---------+---------+ | | | semantic_score | text_boost | | +----------------+------------------+ | v +---------------------+ | Score Combination | | relevance_score = | | semantic + boost | +----------+----------+ | v +---------------------+ | Result Distribution| | Global ranking | | with competitive | | soft caps (60%) | | up to max_results | +----------+----------+ | v +---------------------+ | Result Grouping | | - servers | | - agents | | - virtual_servers | | - skills | +----------+----------+ | v +---------------------+ | Tool Extraction | | Extract matching | | tools from servers | | -> tools[] | +---------------------+ ``` ## Search Flow ### 1. Query Processing When a search query arrives: 1. **Embedding Generation**: Query is converted to a vector embedding using the configured model (Amazon Bedrock, OpenAI, or local sentence-transformers) 2. **Tokenization**: Query is split into meaningful keywords - Non-word characters are removed - Stopwords filtered (a, the, is, are, etc.) - Tokens shorter than 3 characters removed ### 2. Dual Search Strategy **Vector Search (Semantic)** - Uses HNSW index on DocumentDB (production) or application-level cosine similarity on MongoDB CE - Finds conceptually similar content even with different wording - Returns results sorted by cosine similarity - DocumentDB uses configurable `efSearch` parameter (default 100) for HNSW recall quality - Minimum `k=50` ensures small collections are fully covered **Keyword Search (Lexical)** - Regex matching on path, name, description, tags, metadata_text, and tool names/descriptions - Catches explicit references that semantic search might miss - Runs as separate query due to DocumentDB limitations (no `$unionWith` support) - Each query keyword is matched independently using case-insensitive regex - Keyword matches from both vector results and separate keyword query are merged, with the highest boost per document kept ### 3. Score Combination The final relevance score combines both approaches: ``` normalized_vector_score = (cosine_similarity + 1.0) / 2.0 # Map [-1,1] to [0,1] text_boost_contribution = text_boost * 0.1 # Scale boost down relevance_score = normalized_vector_score + text_boost_contribution relevance_score = clamp(relevance_score, 0.0, 1.0) ``` The multiplier `0.1` is consistent across both DocumentDB and MongoDB CE search paths. Text boost values (cumulative per keyword match): | Match Location | Boost Value | |----------------|-------------| | Path | +5.0 | | Name | +3.0 | | Description | +2.0 | | Tags | +1.5 | | Metadata | +1.0 | | Tool (each) | +1.0 | ### 4. Score-Before-Filter Pattern All candidate results are scored before applying the distribution filter. This ensures the highest-scoring documents are selected: 1. Vector search returns candidates (up to `k` results) 2. Keyword search returns additional matches (merged by highest boost per document) 3. Every candidate receives a hybrid score (vector + text boost) 4. All candidates are sorted by hybrid score descending 5. The `_distribute_results()` function selects up to `max_results` items using global ranking with competitive soft caps (see [Result Distribution](#result-distribution) below) This prevents lower-scoring documents from consuming a slot before higher-scoring documents are evaluated. ### 5. Diagnostic Logging Both search paths emit a `Score for` log line for every candidate, enabling search quality debugging: ``` Score for 'Context7' (type=mcp_server): vector=0.3412, normalized_vector=0.6706, text_boost=8.0, boost_contrib=0.8000, final=1.0000 ``` ### 6. Result Distribution The `max_results` parameter (range 1-50, default 10) controls how many total results are returned. Results are distributed across entity types using **global ranking with competitive soft caps**. #### Algorithm The `_distribute_results()` function in `search_repository.py` implements a two-pass approach: **Pass 1 -- Pick with soft caps:** 1. Sort all scored candidates by `relevance_score` descending (all entity types on the same 0-1 scale) 2. Walk the sorted list, picking items up to `max_results` 3. If a type reaches its soft cap (`ceil(max_results * 0.6)`), check whether other entity types still have results remaining below in the ranking 4. If other types are waiting: skip this item (enforce cap for diversity) 5. If no other types remain: lift the cap (no point leaving slots empty) **Pass 2 -- Backfill:** 6. If pass 1 didn't fill all `max_results` slots (because some items were skipped), backfill from the skipped items in score order #### Constants | Constant | Value | Description | |----------|-------|-------------| | `SOFT_CAP_RATIO` | `0.6` | No single entity type can claim more than 60% of slots when other types are competing | | Tool extraction limit | `max(3, ceil(max_results * 0.6))` | Scales tool extraction with `max_results`, minimum 3 for backward compatibility | | Pipeline candidate limit | `max(max_results * 3, 50)` | Fetch enough candidates for global ranking | #### Examples **Example 1: Only servers exist (max_results=10)** A registry with 20 MCP servers and no agents, tools, or skills. ``` Candidates (sorted by relevance_score): S(0.95), S(0.93), S(0.91), S(0.89), S(0.87), S(0.85), S(0.83), S(0.81), S(0.79), S(0.77), S(0.75), ... soft_cap = ceil(10 * 0.6) = 6 Pass 1: Pick S(0.95) ... S(0.85) -> 6 servers (cap reached) S(0.83): cap hit, check remaining types -> only mcp_server left -> no competition, cap lifted Pick S(0.83) ... S(0.77) -> 4 more servers Result: 10 servers (no artificial limit when only one type exists) ``` **Example 2: Mixed types (max_results=10)** A registry with servers, agents, and tools. ``` Candidates (sorted by relevance_score): S(0.95), S(0.93), S(0.91), A(0.88), S(0.87), T(0.85), S(0.83), A(0.80), S(0.78), T(0.75), A(0.72), S(0.70) soft_cap = ceil(10 * 0.6) = 6 Pass 1: Pick S(0.95), S(0.93), S(0.91) -> 3 servers Pick A(0.88) -> 1 agent Pick S(0.87), T(0.85), S(0.83), A(0.80) -> 2 more servers, 1 tool, 1 agent Pick S(0.78) -> 6th server (cap reached) T(0.75): pick -> 2nd tool A(0.72): pick -> 10th total (done) Result: 6 servers, 3 agents, 1 tool = 10 total (diverse results, highest relevance wins, cap prevents server dominance) ``` **Example 3: Small max_results (max_results=5)** ``` soft_cap = ceil(5 * 0.6) = 3 With mixed types, the dominant type gets at most 3 slots, leaving 2 for other types. Similar diversity to the previous default behavior of 3 per type. ``` **Example 4: Large max_results with one dominant type (max_results=50)** A registry with 40 servers, 3 agents, and 2 tools. ``` soft_cap = ceil(50 * 0.6) = 30 Pass 1: Servers fill 30 slots (cap reached while agents/tools still available) 3 agents and 2 tools fill 5 slots Cap lifted for servers (no more agents/tools) 12 more servers fill remaining slots Result: 42 servers, 3 agents, 2 tools = 47 total (all available entities returned, servers got the rest) ``` #### Backward Compatibility With the default `max_results=10`, the soft cap is 6. In a typical registry with multiple entity types, results look similar to the previous 3-per-type behavior: the dominant type gets 5-6 results, others share the rest. The key difference is that `max_results=50` now actually returns up to 50 results instead of being capped at 15 (3 per type * 5 types). #### Applies to All Search Paths The same `_distribute_results()` function is used by all three search code paths: | Search Path | When Used | Integration | |-------------|-----------|-------------| | Hybrid (DocumentDB) | Production with vector index | Scored tuples fed directly to `_distribute_results()` | | Client-side (MongoDB CE) | Local dev without vector search | Dict results converted to tuples, then distributed | | Lexical-only | When embedding model unavailable | Scores computed from `text_boost / MAX_LEXICAL_BOOST`, then distributed | ### 7. Result Structure Search returns grouped results (up to `max_results` total, distributed across entity types): ```json { "servers": [ { "path": "/context7", "server_name": "Context7 MCP Server", "relevance_score": 1.0, "matching_tools": [ {"tool_name": "query-docs", "description": "..."} ] } ], "tools": [ { "server_path": "/context7", "tool_name": "query-docs", "inputSchema": {...} } ], "agents": [...], "virtual_servers": [ { "path": "/virtual/dev-tools", "server_name": "Dev Tools", "relevance_score": 0.85, "backend_paths": ["/github", "/jira"], "tool_count": 5 } ], "skills": [...] } ``` ## Entity Types ### MCP Servers **What's included in the embedding:** - Server name - Server description - Tags (prefixed with "Tags: ") - Metadata text (flattened key-value pairs from server metadata) - Tool names (each tool's name) - Tool descriptions (each tool's description) **What's NOT included in the embedding:** - Tool inputSchema (JSON schema is stored but not embedded) - Server path **Stored document fields:** - `path`, `name`, `description`, `tags`, `is_enabled` - `metadata_text` (flattened metadata for keyword search) - `tools[]` array with `name`, `description`, `inputSchema` per tool - `embedding` vector - `metadata` (full server info for reference) ### A2A Agents **What's included in the embedding:** - Agent name - Agent description - Tags (prefixed with "Tags: ") - Capabilities (prefixed with "Capabilities: ") - Metadata text (flattened key-value pairs from agent card metadata) - Skill names (each skill's name) - Skill descriptions (each skill's description) **What's NOT included in the embedding:** - Agent path - Skill IDs, tags, and examples **Stored document fields:** - `path`, `name`, `description`, `tags`, `is_enabled` - `metadata_text` (flattened metadata for keyword search) - `capabilities[]` array - `embedding` vector - `metadata` (full agent card for reference) ### Agent Skills **What's included in the embedding:** - Skill name - Skill description - Tags (prefixed with "Tags: ") - Metadata text (author, version, custom extra key-value pairs) **Stored document fields:** - `path`, `name`, `description`, `tags`, `is_enabled` - `metadata_text` (author, version, flattened `extra` dict, registry_name for keyword search) - `embedding` vector - `metadata` (skill metadata for reference) ### Tools - Not indexed separately - extracted from parent server documents - When a server matches, its tools are checked for keyword matches - Top-level `tools[]` array contains full schema (inputSchema) - `matching_tools` in server results is a lightweight reference (no schema) ### Virtual MCP Servers Virtual MCP Servers are indexed in the unified `mcp_embeddings_{dimensions}` collection (e.g., `mcp_embeddings_384` for 384-dimension models) alongside regular servers and agents, distinguished by `entity_type: "virtual_server"`. **What's included in the embedding:** - Server name - Server description - Tags (prefixed with "Tags: ") - Tool names (alias or original name from each tool mapping) - Tool description overrides (if specified in mappings) **What's NOT included in the embedding:** - Virtual server path - Backend server paths - Required scopes - Tool input schemas **Stored document fields:** - `path`, `name`, `description`, `tags`, `is_enabled` - `entity_type`: `"virtual_server"` - `metadata_text` (created_by for keyword search) - `tools[]` array with `name` (alias or original) per tool mapping - `embedding` vector - `metadata` object containing: - `server_name`, `num_tools`, `backend_count` - `backend_paths[]` (list of backend server paths) - `required_scopes[]`, `supported_transports[]` - `created_by` **Search result structure:** ```json { "virtual_servers": [ { "entity_type": "virtual_server", "path": "/virtual/dev-tools", "server_name": "Dev Tools", "description": "Aggregated development tools", "relevance_score": 0.85, "tags": ["development", "tools"], "backend_paths": ["/github", "/jira"], "tool_count": 5, "matching_tools": [ {"tool_name": "github_search"} ] } ] } ``` ## Metadata in Search Custom metadata from servers, agents, skills, and virtual servers is included in semantic embeddings, hybrid/keyword search, and the REST API list endpoint keyword filters. Metadata is flattened to a text string using `flatten_metadata_to_text()` (defined in `registry/utils/metadata.py`): - Each key name is included as a token - Scalar values are converted to strings - List values have each item converted to a string - Nested dict values have each value converted to a string For example, a server with metadata `{"source": "agentcore-sync", "region": "us-east-1"}` produces the metadata text: `source agentcore-sync region us-east-1`. ### Hybrid / DocumentDB Search The flattened metadata text is: 1. Appended to `text_for_embedding` so semantic search captures metadata meaning 2. Stored in `metadata_text` field for keyword/regex matching 3. Matched in the `$or` keyword filter alongside path, name, description, tags, and tools 4. Scored with +1.0 text boost when matched in the `_build_text_boost_stage` pipeline ### REST API List Endpoint Keyword Search (Pure Lexical, No Vectors) The REST API list endpoints below are **pure lexical search**. They do not use embeddings, vector similarity, or the DocumentDB search index. They load all items from storage, build a searchable text string per item in Python, and perform a case-insensitive substring match. No hybrid or semantic search is involved. The same `flatten_metadata_to_text()` utility is used to include metadata in these filters: | Endpoint | Parameter | Search Type | Metadata Handling | |----------|-----------|-------------|-------------------| | `GET /api/agents?query=` | `query` | Substring match (lexical only) | Metadata appended to `searchable_text` | | `GET /api/servers?query=` | `query` | Substring match (lexical only) | Metadata appended to `searchable_text` | | `GET /api/skills/search?q=` | `q` | Scored substring match (lexical only) | Metadata matched with +0.1 relevance score (author, version, extra) | For hybrid (vector + keyword) search, use `POST /api/search/semantic` instead. ### Metadata Sources | Entity Type | Metadata Source | |----------------|-----------------| | MCP Server | `server_info.get("metadata", {})` | | A2A Agent | `agent_card.metadata` | | Agent Skill | Author, version, `extra` dict (custom key-value pairs), registry_name | | Virtual Server | `created_by` field | ## Backend Implementations ### DocumentDB (Production) - Native HNSW vector index with `$search` aggregation pipeline - Keyword query runs separately and merges results (no `$unionWith` support) - Text boost calculated in aggregation pipeline using `$regexMatch` ### MongoDB CE (Development/Local) - No native vector search support (`$vectorSearch` not available) - Falls back to application-level search (in Python backend, not the calling agent): 1. Fetch all documents with embeddings from collection 2. Calculate cosine similarity in Python code 3. Apply keyword matching and text boost in application 4. Sort and limit results - Same API contract as DocumentDB implementation ## Lexical Fallback Mode When the embedding model is unavailable (misconfigured, network issues, API key expired, model not found), the search system automatically degrades to **lexical-only mode** instead of failing entirely. ### How It Works 1. **Detection**: On the first search request, if the embedding model fails to generate a query vector, the `_embedding_unavailable` flag is set in `DocumentDBSearchRepository` 2. **Fallback**: All subsequent searches skip embedding generation and use `_lexical_only_search()` instead 3. **Error Caching**: The `SentenceTransformersClient` caches load errors in `_load_error` to avoid repeated download attempts (e.g., hitting HuggingFace on every call) 4. **Indexing**: When the model is unavailable during startup, servers and agents are indexed without embeddings. Documents are stored with empty embedding vectors 5. **Response**: The API response includes a `search_mode` field set to `"lexical-only"` (instead of the normal `"hybrid"`) so callers know the search quality is reduced ### Lexical-Only Search Flow ``` +-------------------+ | Search Query | | "context7 docs" | +--------+----------+ | v +-----------------------+ | Embedding Model Check | | _embedding_unavailable| | == True? | +-----------+-----------+ | Yes (fallback) | v +-----------------------+ | Keyword Tokenization | | ["context7", "docs"] | +-----------+-----------+ | v +-----------------------+ | MongoDB Aggregation | | $regexMatch on path, | | name, description, | | tags, metadata, | | tools | +-----------+-----------+ | v +-----------------------+ | Text Boost Scoring | | Normalized by | | MAX_LEXICAL_BOOST | | (12.5) | +-----------+-----------+ | v +-----------------------+ | Result Grouping | | search_mode: | | "lexical-only" | +-----------------------+ ``` ### Scoring in Lexical-Only Mode In lexical-only mode, the text boost score is normalized to a 0-1 range using a fixed denominator (`MAX_LEXICAL_BOOST = 13.5`): ``` relevance_score = text_boost / MAX_LEXICAL_BOOST ``` The same boost weights from hybrid mode apply: | Match Location | Boost Value | |----------------|-------------| | Path | +5.0 | | Name | +3.0 | | Description | +2.0 | | Tags | +1.5 | | Metadata | +1.0 | | Tool (each) | +1.0 | ### Recovery When the embedding model becomes available again (e.g., after a restart with correct configuration), the system automatically returns to full hybrid search mode. The `_embedding_unavailable` flag and `_load_error` cache are per-process and reset on restart. ## HNSW Tuning (DocumentDB) The DocumentDB `$search` pipeline includes two tunable parameters: | Parameter | Default | Description | |-----------|---------|-------------| | `k` | `max(max_results * 3, 50)` | Number of nearest neighbors to retrieve. Minimum 50 ensures small collections are fully covered. | | `efSearch` | `100` (configurable via `VECTOR_SEARCH_EF_SEARCH`) | Controls HNSW recall quality. Higher values improve recall at the cost of query latency. Default DocumentDB value is ~40, which can miss documents in small collections. | The `efSearch` setting is configured in `registry/core/config.py` as `vector_search_ef_search`. ## Lifecycle Status Filtering Search results respect the lifecycle status of assets (servers, agents, skills). By default, **deprecated** and **draft** assets are excluded from search results. Only **active** and **beta** assets appear. ### How It Works 1. **Index-Time**: When an asset is indexed for search, its `status` field is stored in the search document alongside other fields (`path`, `name`, `description`, `tags`, `is_enabled`, etc.) 2. **Query-Time**: The `_build_status_filter()` function constructs a MongoDB `$match` filter that excludes assets by lifecycle status: ```python # Default behavior: exclude deprecated and draft { "$or": [ {"status": {"$nin": ["deprecated", "draft"]}}, {"status": {"$exists": False}} # Treat missing field as active ] } ``` 3. **Opt-In Inclusion**: Callers can include filtered assets using request parameters: - `include_deprecated: true` -- Include deprecated assets in results - `include_draft: true` -- Include draft assets in results - `include_disabled: true` -- Include disabled assets (is_enabled=False) in results ### Search Request Example ```json { "query": "feature flags", "entity_types": ["skill"], "max_results": 10, "include_deprecated": true, "include_draft": false } ``` ### Status Values | Status | Default in Search | Description | |--------|-------------------|-------------| | `active` | Included | Asset is active and ready for use | | `beta` | Included | Asset is in beta testing phase | | `deprecated` | **Excluded** | Asset is deprecated and may be removed | | `draft` | **Excluded** | Asset is in draft mode, not ready for production | ### Indexed Document Fields The `status` field is stored in the search document for all entity types: | Entity Type | Status Source | |-------------|--------------| | MCP Server | `server_info.get("status", "active")` | | A2A Agent | `agent_card.status` (default: `"active"`) | | Agent Skill | `skill.status` (default: `"active"`) | | Virtual Server | Not applicable (always active) | Documents indexed before this feature (without a `status` field) are treated as `active` by the `$exists: False` fallback in the filter. ### Filter Application The status filter is applied consistently across all three search code paths: | Search Path | Filter Location | |-------------|-----------------| | Hybrid (DocumentDB) | Pre-filter in `$search` pipeline via `_build_status_filter()` | | Client-side (MongoDB CE) | Query filter in `collection.find()` | | Lexical-only | Aggregation `$match` stage | ### Re-indexing When an asset's lifecycle status changes (e.g., from `active` to `deprecated`), the asset is re-indexed via the normal update flow. The search document's `status` field is updated, and subsequent searches will respect the new status. ## Performance Considerations 1. **Result Distribution**: Global ranking with competitive soft caps limits results to `max_results` (default 10, max 50). The distribution algorithm is O(n) where n is the candidate set size (at most 150 documents). 2. **Score-Before-Filter**: All candidates scored and sorted before applying the distribution filter 3. **Index Reuse**: HNSW index parameters (m=16, efConstruction=128) optimized for recall 4. **efSearch Tuning**: Set to 100 for near-exact recall in typical deployments 5. **Embedding Caching**: Lazy-loaded model with singleton pattern 6. **Keyword Fallback**: Separate query ensures explicit matches are not missed 7. **Error Caching**: Failed model loads are cached to avoid repeated download/API attempts ## Example: Why Hybrid Matters Query: "context7" - **Vector-only**: Might return documentation servers with similar semantic content - **Keyword-only**: Finds exact match but misses related servers - **Hybrid**: Ranks /context7 at top (keyword boost) while including semantically similar alternatives ================================================ FILE: docs/design/idp-provider-support.md ================================================ # Multi-Provider Identity Provider (IdP) Support **Version:** 1.0 **Last Updated:** 2026-01-18 ## Related Documentation - [Authentication Design](./authentication-design.md) - Auth flows for human users, programmatic access, and M2M workloads - [Authentication & Authorization Guide](../auth.md) - Operational guide with setup instructions - [Microsoft Entra ID Integration](../entra.md) - Entra ID-specific setup and configuration - [Okta Integration](../okta-setup.md) - Okta-specific setup and configuration ## Overview The MCP Gateway Registry supports multiple identity providers (IdPs) through a pluggable architecture. This design enables organizations to use their existing enterprise identity infrastructure (Keycloak, Microsoft Entra ID, Okta) for authentication and authorization. ## Architecture ### High-Level Component Diagram ``` +------------------+ +------------------+ +------------------+ | | | | | | | Registry UI | | CLI Tools | | AI Agents | | (Frontend) | | (registry_mgmt) | | (M2M Clients) | | | | | | | +--------+---------+ +--------+---------+ +--------+---------+ | | | | HTTP + JWT Token | HTTP + JWT Token | | | | v v v +--------+------------------------+------------------------+---------+ | | | NGINX Gateway | | (auth_request /validate) | | | +--------+-----------------------------------------------------------+ | | /validate v +--------+-----------------------------------------------------------+ | | | Auth Server | | | | +----------------+ +------------------+ +----------------+ | | | | | | | | | | | AuthProvider | | AuthProvider | | AuthProvider | | | | Factory +--->+ Protocol +--->+ Implementations| | | | | | (Base Class) | | | | | +----------------+ +------------------+ +----------------+ | | | | | +----------------+---------+--------+ | | | | | | | v v v | | +------+------+ +------+------+ +--------+-+ | | | | | | | | | | | Keycloak | | Entra ID | | Okta | | | | Provider | | Provider | | Provider | | | | | | | | | | | +-------------+ +-------------+ +-----------+ | | | +--------------------------------------------------------------------+ | | Group-to-Scope Mapping v +--------+-----------------------------------------------------------+ | | | MongoDB-CE / Amazon DocumentDB | | | | +---------------------------+ +---------------------------+ | | | mcp_scopes_default | | mcp_servers_default | | | | | | | | | | - scope definitions | | - server configurations | | | | - group_mappings | | - tool definitions | | | | - server_access rules | | | | | | - ui_permissions | | | | | +---------------------------+ +---------------------------+ | | | +--------------------------------------------------------------------+ ``` ## Provider Selection The active identity provider is determined by the `AUTH_PROVIDER` environment variable: ``` AUTH_PROVIDER=keycloak # Use Keycloak AUTH_PROVIDER=entra # Use Microsoft Entra ID AUTH_PROVIDER=okta # Use Okta ``` ### Provider Factory Pattern ``` +-------------------+ +--------------------------+ | | | | | AUTH_PROVIDER env +---->+ AuthProviderFactory | | | | | +-------------------+ +------------+-------------+ | +----------------------+----------------------+ | | | v v v +-------------------+ +-----------------------+ +-----------------------+ | | | | | | | KeycloakProvider | | EntraIdProvider | | OktaProvider | | | | | | | | - OIDC endpoints | | - Microsoft Graph API | | - Okta OAuth2/OIDC | | - JWKS validation | | - JWKS validation | | - JWKS validation | | - Realm-based | | - Tenant-based | | - Domain-based | | | | | | | +-------------------+ +-----------------------+ +-----------------------+ ``` ## IAM Manager Interface For administrative operations (user/group CRUD), the system uses the IAM Manager abstraction: ```python @runtime_checkable class IAMManager(Protocol): """Protocol defining the IAM manager interface.""" async def list_users( self, search: str | None = None, max_results: int = 500, include_groups: bool = True ) -> list[dict[str, Any]]: ... async def create_human_user( self, username: str, email: str, first_name: str, last_name: str, groups: list[str], password: str | None = None, ) -> dict[str, Any]: ... async def delete_user(self, username: str) -> bool: ... async def list_groups(self) -> list[dict[str, Any]]: ... async def create_group( self, group_name: str, description: str = "" ) -> dict[str, Any]: ... async def delete_group(self, group_name: str) -> bool: ... async def create_service_account( self, client_id: str, groups: list[str], description: str | None = None ) -> dict[str, Any]: ... ``` ### Implementation Classes ``` +------------------+ +------------------+ +------------------+ | | | | | | | KeycloakIAM | | EntraIAM | | OktaIAM | | Manager | | Manager | | Manager | | | | | | | +--------+---------+ +--------+---------+ +--------+---------+ | | | | Delegates to | Delegates to | Delegates to v v v +--------+---------+ +--------+---------+ +--------+---------+ | | | | | | | keycloak_manager | | entra_manager | | okta_manager | | .py | | .py | | .py | | | | | | | | - Keycloak Admin | | - Microsoft | | - Okta Admin | | REST API | | Graph API | | REST API | | - Realm mgmt | | - App registr. | | - SSWS auth | | - Client mgmt | | - Service | | - OIDC service | | | | principals | | apps | +------------------+ +------------------+ +------------------+ ``` ## Provider-Specific Details ### Keycloak Provider **Authentication Flow:** - Uses OIDC Authorization Code flow - Tokens issued by Keycloak realm - JWKS endpoint: `{keycloak_url}/realms/{realm}/protocol/openid-connect/certs` **Group Identifier in Tokens:** - Group names (e.g., `registry-admins`, `public-mcp-users`) - Stored in `groups` claim of JWT **IAM Operations:** - Uses Keycloak Admin REST API - Requires admin credentials or service account with realm-admin role ### Microsoft Entra ID Provider **Authentication Flow:** - Uses OAuth2 Authorization Code flow (users) - Uses OAuth2 Client Credentials flow (M2M) - Tokens issued by Microsoft STS - JWKS endpoint: `https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys` **Group Identifier in Tokens:** - Group Object IDs (GUIDs) like `5f605d68-06bc-4208-b992-bb378eee12c5` - Stored in `groups` claim of JWT - Object IDs must be mapped to scope names in MongoDB **IAM Operations:** - Uses Microsoft Graph API - Requires App Registration with appropriate permissions: - `Application.ReadWrite.All` - `Directory.ReadWrite.All` - `Group.ReadWrite.All` - `User.ReadWrite.All` ### Okta Provider **Authentication Flow:** - Uses OAuth2 Authorization Code flow (users) - Uses OAuth2 Client Credentials flow (M2M) - Tokens issued by Okta org authorization server - JWKS endpoint: `https://{okta_domain}/oauth2/v1/keys` **Group Identifier in Tokens:** - Group names (e.g., `mcp-admin`, `mcp-user`) — similar to Keycloak - Stored in `groups` claim of JWT - Requires groups claim to be configured in the Okta Authorization Server **Key Differences from Other Providers:** - Single issuer format: `https://{okta_domain}` (unlike Entra ID's dual v1.0/v2.0) - Uses `scp` claim for scopes in access tokens (fallback to `scope`) - Uses `cid` claim for client ID - Admin API uses a separate API token (`SSWS` scheme), not OAuth2 credentials **IAM Operations:** - Uses Okta Admin REST API (`/api/v1/*`) - Requires dedicated API token (`OKTA_API_TOKEN`) with `SSWS` authorization - User deletion requires deactivate-then-delete two-step flow - See [Okta Setup Guide](../okta-setup.md) for configuration details ## Group-to-Scope Mapping The mapping between IdP groups and registry scopes is stored in MongoDB-CE/Amazon DocumentDB (`mcp_scopes_default` collection): ``` +---------------------------------------------------+ | MongoDB-CE/Amazon DocumentDB: mcp_scopes_default | +---------------------------------------------------+ | Document Structure: | | | | { | | "_id": "registry-admins", | <-- Scope name | "group_mappings": [ | | "registry-admins", | <-- Keycloak group name | "4c46ec66-a4f7-4b62-9095-..." | <-- Entra ID group Object ID | ], | | "server_access": [ ... ], | <-- MCP server permissions | "ui_permissions": { ... } | <-- UI feature access | } | +-------------------------------------------+ ``` ### Mapping Flow ``` +------------------+ +------------------+ +------------------+ | | | | | | | JWT Token | | Scope | | Access | | from IdP +---->+ Repository +---->+ Decision | | | | | | | +------------------+ +------------------+ +------------------+ | | | | groups claim: | Query: | Result: | ["5f605d68-..."] | Find scopes where | scopes=["public- | | group_mappings | mcp-users"] | | contains "5f605d68-" | v v v Keycloak example: Entra ID example: groups: ["public-mcp-users"] groups: ["5f605d68-06bc-4208-b992-bb378eee12c5"] | | +------------------------------+ | v +-------+-------+ | | | Mapped to: | | public-mcp- | | users scope | | | +---------------+ ``` ## Configuration ### Environment Variables ```bash # Provider Selection AUTH_PROVIDER=entra # or "keycloak" or "okta" # Keycloak Configuration KEYCLOAK_URL=https://keycloak.example.com KEYCLOAK_REALM=mcp-gateway KEYCLOAK_CLIENT_ID=mcp-registry KEYCLOAK_CLIENT_SECRET=... # Entra ID Configuration ENTRA_TENANT_ID=6e6ee81b-6bf3-495d-a7fc-d363a551f765 ENTRA_CLIENT_ID=1bd17ba1-aad3-447f-be0b-26f8f9ee859f ENTRA_CLIENT_SECRET=... # Okta Configuration OKTA_DOMAIN=dev-123456.okta.com OKTA_CLIENT_ID=0oa1234567890abcdef OKTA_CLIENT_SECRET=... # OKTA_M2M_CLIENT_ID=... # Optional separate M2M credentials # OKTA_M2M_CLIENT_SECRET=... # OKTA_API_TOKEN=... # Optional, for IAM operations # Token Validation SECRET_KEY=... # For self-signed tokens JWT_ISSUER=mcp-auth-server JWT_AUDIENCE=mcp-registry ``` ### Scopes Configuration (scopes.yml or MongoDB) ```yaml # Group mappings - maps IdP group identifiers to scope names group_mappings: # Entra ID uses Object IDs (GUIDs) "4c46ec66-a4f7-4b62-9095-b7958662f4b6": - registry-admins - mcp-servers-unrestricted/read - mcp-servers-unrestricted/execute "5f605d68-06bc-4208-b992-bb378eee12c5": - public-mcp-users # Keycloak uses group names "registry-admins": - registry-admins "public-mcp-users": - public-mcp-users # Okta also uses group names (same format as Keycloak) "mcp-admin": - registry-admins "mcp-user": - public-mcp-users ``` ## Adding a New Provider To add support for a new identity provider: 1. **Create Provider Class** (`auth_server/providers/new_provider.py`): - Implement `AuthProvider` base class - Handle OIDC/OAuth2 flows - Implement token validation via JWKS 2. **Create IAM Manager** (`registry/utils/new_provider_manager.py`): - Implement user/group CRUD operations - Handle provider-specific API calls 3. **Update Factory** (`registry/utils/iam_manager.py`): - Add new provider case to `get_iam_manager()` 4. **Update Auth Factory** (`auth_server/providers/factory.py`): - Add new provider case to factory function 5. **Configure Group Mappings**: - Add group identifiers to `scopes.yml` or MongoDB-CE/Amazon DocumentDB - Document group identifier format (names vs IDs) ## Security Considerations 1. **Token Validation**: Always validate JWT signatures against provider JWKS 2. **Admin Credentials**: Store IdP admin credentials securely (environment variables, secrets manager) 3. **Principle of Least Privilege**: Request minimal permissions for IAM operations 4. **Eventual Consistency**: Handle Entra ID's eventual consistency with retry logic 5. **Token Expiry**: Respect token expiration times; implement refresh where needed ================================================ FILE: docs/design/server-versioning.md ================================================ # MCP Server Version Routing - Design Document **Date**: 2026-01-29 **Status**: Implemented **Issue**: [#370](https://github.com/agentic-community/mcp-gateway-registry/issues/370) --- ## 1. Overview MCP Server Version Routing enables **multiple versions of the same MCP server** to run simultaneously behind a single gateway endpoint. Traffic routes to the active (default) version unless a client explicitly requests a specific version via the `X-MCP-Server-Version` HTTP header. ### Use Cases - **Canary deployments**: Register a new version as inactive, test it with the version header, then promote it to active - **Version pinning**: Clients that depend on a specific server version can pin to it with a header - **Instant rollback**: Switch the active version back to a previous one without redeployment - **Deprecation lifecycle**: Mark old versions as deprecated with sunset dates before removal ### Example ```bash # Request to active version (default behavior, no header needed) curl -X POST https://gateway.example.com/context7 \ -d '{"method": "tools/list"}' # Routes to v2.0.0 (current active version) # Request to a specific inactive version curl -X POST https://gateway.example.com/context7 \ -H "X-MCP-Server-Version: v1.5.0" \ -d '{"method": "tools/list"}' # Routes to v1.5.0 (legacy version) ``` --- ## 2. Two Version Concepts The registry tracks **two independent version values** for each server. They serve different purposes and are determined differently. | Aspect | User-Provided Version (Routing Label) | MCP Server Version (Software Identity) | |--------|---------------------------------------|----------------------------------------| | **Purpose** | Traffic routing between backend deployments | Identifies the actual software running at the backend | | **Who controls it** | Platform admin / operator | MCP server developer (set in server code) | | **When it is set** | At registration time via API or CLI | Discovered at runtime during health checks | | **How it is determined** | Admin provides it explicitly (e.g., `v1.0.0`, `v2.0.0`) | Read from the MCP `initialize` response `serverInfo.version` field | | **Mutability** | Changes only via explicit admin action (register, switch default) | Changes whenever the upstream server deploys a new build | | **Stored as** | `version` field on the server document | `mcp_server_version` field on the server document | | **Example values** | `v1.0.0`, `v2.0.0`, `beta-3` | `2.14.4`, `1.25.0`, `0.9.1` | | **Multiple can coexist** | Yes, each version is a separate document with its own backend URL | No, only the active version is health-checked | ### Why Two Versions Exist These are fundamentally **different things at different conceptual levels**: - The **user-provided version** is an operational label. It answers: "Which backend deployment should receive traffic for this path?" An admin registers `/context7` with version `v1.0.0` pointing to `https://mcp.context7.com/mcp`, and later registers `v2.0.0` pointing to `https://mcp-v2.context7.com/mcp`. The two versions can run simultaneously with independent backend URLs. - The **MCP server version** is a software fact. It answers: "What version of the code is running at this backend URL right now?" The server at `https://mcp.context7.com/mcp` may report itself as `2.14.4` today and `2.14.5` tomorrow after a deployment. The admin's routing label (`v1.0.0`) does not change. They are **never merged or conflated**. An MCP server version change is an informational event, not a routing change. If the upstream server silently upgrades, the registry detects it during health checks and stores the previous/current values for observability. ### MCP Server Version Change Detection When a health check detects that `mcp_server_version` has changed: | Field | Purpose | |-------|---------| | `mcp_server_version` | Current version reported by the running server | | `mcp_server_version_previous` | The version before the most recent change | | `mcp_server_version_updated_at` | ISO timestamp of when the change was detected | The frontend shows a subtle green dot indicator next to the MCP server version badge when the version changed within the last 24 hours. No acknowledgement workflow is required -- this is informational only. --- ## 3. Storage Design: Separate Documents per Version Each version of a server is stored as a **separate document** in MongoDB/DocumentDB. The active version uses the original path as its `_id` (backward compatible), and inactive versions use a compound `path:version` ID. ### Active Version Document This document appears in all listings, search results, health checks, and the dashboard. ```json { "_id": "/context7", "server_name": "Context7 MCP Server", "version": "v2.0.0", "proxy_pass_url": "https://mcp.context7.com/mcp", "is_active": true, "version_group": "context7", "other_version_ids": ["/context7:v1.5.0"], "description": "Up-to-date Docs for LLMs and AI code editors", "tags": ["documentation", "search", "libraries"], "supported_transports": ["streamable-http"], "num_tools": 12, "num_stars": 4.5, "is_enabled": true, "registered_at": "2026-01-10T00:00:00Z", "updated_at": "2026-01-14T00:00:00Z", "mcp_server_version": "2.14.5", "mcp_server_version_previous": "2.14.4", "mcp_server_version_updated_at": "2026-01-28T15:30:00Z" } ``` ### Inactive Version Document This document is hidden from listings and search. It is accessible only via the version management API. ```json { "_id": "/context7:v1.5.0", "server_name": "Context7 MCP Server", "version": "v1.5.0", "proxy_pass_url": "https://v1.mcp.context7.com/mcp", "is_active": false, "version_group": "context7", "active_version_id": "/context7", "status": "deprecated", "sunset_date": "2026-06-01", "description": "Legacy version for backward compatibility", "tags": ["documentation", "search", "libraries"], "supported_transports": ["streamable-http"], "num_tools": 10, "is_enabled": true, "registered_at": "2025-11-15T00:00:00Z", "updated_at": "2026-01-14T00:00:00Z" } ``` ### Version-Specific Fields These fields are added to the standard server document schema to support versioning: | Field | Type | Present On | Description | |-------|------|-----------|-------------| | `version` | `str` | Both | The user-provided version label (e.g., `v2.0.0`) | | `is_active` | `bool` | Both | `true` for the active version, `false` for inactive | | `version_group` | `str` | Both | Groups all versions of the same server (derived from path) | | `other_version_ids` | `list[str]` | Active only | Array of `_id` values for all inactive versions | | `active_version_id` | `str` | Inactive only | The `_id` of the currently active version document | | `status` | `str` | Inactive | Version lifecycle status: `stable`, `beta`, `deprecated` | | `sunset_date` | `str` | Inactive | ISO date after which this version will be removed | ### Design Decisions | Decision | Rationale | |----------|-----------| | Active version keeps original path as `_id` | Backward compatibility -- existing nginx location blocks, health checks, and API references continue to work unchanged | | Inactive versions use `path:version` compound `_id` | Guarantees uniqueness within the collection and is easy to parse | | `is_active` field for filtering | All listing and dashboard queries add `is_active: true`, keeping inactive versions out of normal views | | `version_group` for linking | Enables efficient queries to populate the version selector modal without scanning the full collection | | Each version is a complete document | Versions can have different descriptions, tool counts, ratings, and backend URLs | ### Why Separate Documents Instead of Embedded Array Two storage approaches were evaluated: | Criteria | Embedded Array | Separate Documents (chosen) | |----------|---------------|----------------------------| | Search pre-filtering | Requires `$elemMatch` or application logic | Simple `is_active: true` filter | | Each version as independent entity | Awkward -- tools, ratings, descriptions nested in array | Natural -- each doc has full metadata | | Document size | Grows with versions | Fixed size per document | | Version swap complexity | Array element update | Document insert/delete (more complex, but infrequent) | | Listing queries | Need to exclude inactive array items | Simple query filter | The separate-documents design was chosen because **search filtering is critical** (see Section 5) and each version is a complete entity with its own tools, ratings, and metadata. --- ## 4. Nginx Version Routing ### Map Directive The nginx configuration uses a `map` directive for O(1) version lookup based on the URI path and the `X-MCP-Server-Version` request header. The map is auto-generated whenever servers are registered, updated, or versions are changed. ```nginx map "$uri:$http_x_mcp_server_version" $versioned_backend { default ""; # context7 versions "~^/context7(/.*)?:$" "https://mcp.context7.com/mcp"; "~^/context7(/.*)?:latest$" "https://mcp.context7.com/mcp"; "~^/context7(/.*)?:v2.0.0$" "https://mcp.context7.com/mcp"; "~^/context7(/.*)?:v1.5.0$" "https://v1.mcp.context7.com/mcp"; } ``` Each entry maps a `path:version` combination to a backend URL. Three entries exist for the active version: empty header (no version specified), `latest` keyword, and the explicit version string. ### Location Block For multi-version servers, the location block uses a variable-based `proxy_pass` instead of a hardcoded URL: ```nginx location /context7 { # ... existing auth_request, headers, transport config ... set $backend_url "https://mcp.context7.com/mcp"; # Default fallback if ($versioned_backend != "") { set $backend_url $versioned_backend; } proxy_pass $backend_url; add_header X-MCP-Version-Routing "enabled" always; } ``` Single-version servers continue to use direct `proxy_pass` with no map entries (fully backward compatible). ### Has-Versions Detection The nginx config generator checks `server_info.get("other_version_ids", [])` to determine whether a server has multiple versions. If the array is non-empty, the location block uses the variable-based pattern. This check uses `other_version_ids` (the actual MongoDB field), not a `versions` field. ### Request Flow ``` Client Request POST /context7 X-MCP-Server-Version: v1.5.0 (optional) | v Nginx Map Lookup Key: "/context7:v1.5.0" Result: "https://v1.mcp.context7.com/mcp" | v Location /context7 $backend_url = map result (or default fallback) proxy_pass $backend_url | v Backend: https://v1.mcp.context7.com/mcp ``` ### Request/Response Headers | Header | Direction | Required | Description | |--------|-----------|----------|-------------| | `X-MCP-Server-Version` | Request | No | Target version (`v1.0.0`, `v2.0.0`, `latest`, or omit for default) | | `X-MCP-Version-Routing` | Response | Auto | Indicates version routing is active for this server (`enabled`) | --- ## 5. Search and Listing Integration ### Dashboard Listings All listing queries filter by `is_active: true`, ensuring only the active version of each server appears in the dashboard: ```python cursor = collection.find({"is_active": True}) ``` Inactive versions are invisible in normal listings and only accessible via the version management API. ### Semantic Search The registry uses hybrid semantic search combining vector similarity with tokenized keyword matching. The search backend is either **MongoDB-CE** (client-side cosine similarity) or **AWS DocumentDB** (native `$vectorSearch` pipeline with HNSW index). Both backends use the same indexing strategy for versioning. #### Search Index Structure Server embeddings are stored in a separate collection (`mcp_embeddings_{dimension}_{namespace}`) with this document structure: ```json { "_id": "/context7", "entity_type": "mcp_server", "path": "/context7", "name": "Context7 MCP Server", "description": "Up-to-date Docs for LLMs...", "tags": ["documentation", "search"], "is_enabled": true, "text_for_embedding": "Context7 MCP Server Up-to-date Docs... Tags: documentation, search...", "embedding": [0.042, -0.018, ...], "metadata": { ... }, "indexed_at": "2026-01-28T12:00:00Z" } ``` The embedding text is built from the server's name, description, tags, and tool names/descriptions. #### How Inactive Versions Are Excluded There is always **exactly one search document per server path** in the embeddings collection. That document always contains the active version's metadata. Inactive versions never get their own search documents. This is enforced by which code paths call `index_server()`: | Operation | Calls `index_server()`? | What happens in the embeddings collection | |-----------|------------------------|-------------------------------------------| | `register_server()` (first registration) | Yes | Creates search document at `_id: /context7` with this version's data | | `register_server()` (new version of existing server) | No (calls `add_server_version()` internally) | No change -- the existing search document stays as-is with the active version | | `update_server()` | Yes | Overwrites the search document with updated metadata | | `set_default_version()` | Yes | Overwrites the search document with the **new** active version's data | | `add_server_version()` | No | No change -- inactive versions are not indexed | Every call to `index_server()` **recomputes the embedding vector from scratch**. It rebuilds the embedding text by concatenating the provided version's `server_name`, `description`, `tags`, and `tool_list` (tool names and descriptions), then generates a fresh embedding vector from that text. The resulting document is written via `replace_one({"_id": path}, doc, upsert=True)`, which overwrites whatever was previously stored at that path. There is no separate removal step -- the old active version's embedding data is simply replaced by the new active version's embedding data. This means that if `v2.0.0` has 15 tools and a different description than `v1.0.0`'s 10 tools, switching the active version causes the search document to reflect `v2.0.0`'s content with a new embedding vector that captures its tools and description. Inactive version documents (stored in the server collection at compound IDs like `/context7:v1.0.0`) have **no corresponding entry** in the embeddings collection. They were never added there. **Example**: Context7 has three versions (`v1.0.0`, `v1.5.0`, `v2.0.0`) with `v2.0.0` active. The embeddings collection contains exactly one document at `_id: /context7` with `v2.0.0`'s name, description, tags, and tools as the embedding text. When `set_default_version()` switches to `v1.5.0`, `index_server()` rebuilds the embedding text from `v1.5.0`'s metadata (which may have different tools, description, and tags), generates a new embedding vector, and overwrites the search document. A search for "documentation tools" can only ever match this single document -- the two inactive versions have no search presence. This means inactive versions never consume search result slots, which is the critical requirement for search quality. #### MongoDB-CE vs AWS DocumentDB Search Behavior | Aspect | MongoDB-CE | AWS DocumentDB | |--------|-----------|----------------| | Vector index | Regular B-tree index (no native vector support) | HNSW vector index (cosine similarity, M=16, efConstruction=128) | | Search method | Client-side: fetches all embeddings, computes cosine similarity in Python | Native: `$vectorSearch` aggregation pipeline | | Keyword matching | Tokenized matching in Python (stopwords removed, tokens > 2 chars) | Aggregation pipeline with `$addFields` for text boost scoring | | Re-ranking | `relevance = normalized_vector_score + (text_boost * 0.05)` | `relevance = normalized_vector_score + (text_boost * 0.1)` | | Pre-filtering of inactive versions | Same -- inactive versions are not in the search collection | Same -- inactive versions are not in the search collection | Both backends produce the same result: only active versions appear in search results. #### Keyword Boost Scoring The hybrid search applies keyword boosts on top of vector similarity scores: | Match Location | Boost Points | |---------------|-------------| | Path match | 5.0 | | Name match | 3.0 | | Description match | 2.0 | | Tags match | 1.5 | | Tool name/description match | 1.0 per tool | The boost is multiplied by a factor (0.05 for MongoDB-CE, 0.1 for DocumentDB) and added to the normalized vector score. This ensures exact name matches rank higher than semantically similar but differently-named servers. ### Summary of Filtering Strategy | Filter | When Applied | Mechanism | |--------|-------------|-----------| | Active vs. inactive version | **Index time** (pre-filter) | Only active version is written to search collection | | Enabled vs. disabled server | **Query time** (post-filter) | `is_enabled` metadata returned with results | | User access control | **Query time** (post-filter) | API layer checks user permissions | This design ensures that inactive versions never waste search result slots, which is the critical requirement for search quality. ### Removal from Search When a server is deleted via `remove_server()`, the search index entry is also removed via `search_repo.remove_entity(path)`. The `delete_with_versions()` repository method handles cascade deletion of all version documents (active + inactive) from MongoDB/DocumentDB. --- ## 6. Health Check Integration Only the **active version** of each server is health-checked. The health check service filters out inactive versions: ```python async def get_enabled_services(self) -> list[str]: for path, server_info in all_servers.items(): if not server_info.get("is_enabled", False): continue # Skip inactive versions if server_info.get("version_group") and not server_info.get("is_active", True): continue enabled_paths.append(path) ``` When the active version is switched via `set_default_version()`, an immediate background health check is triggered for the newly active version: ```python asyncio.create_task(health_service.perform_immediate_health_check(path)) ``` This ensures the dashboard reflects the health status of the new active version promptly after a switch. --- ## 7. API Endpoints All version management endpoints are under `/api/servers/{path}/versions`: | Method | Endpoint | Description | |--------|----------|-------------| | `GET` | `/api/servers/{path}/versions` | List all versions of a server | | `DELETE` | `/api/servers/{path}/versions/{version}` | Remove an inactive version | | `PUT` | `/api/servers/{path}/versions/default` | Switch the active (default) version | New versions are created by registering a server with the same path but a different `version` field. The `register_server()` method detects this and creates an inactive version document automatically. ### Version Creation via Registration ```bash # First registration creates the server POST /api/servers/register { "server_name": "Context7", "path": "/context7", "version": "v1.0.0", "proxy_pass_url": "https://mcp.context7.com/mcp" } # Second registration with same path but different version creates an inactive version POST /api/servers/register { "server_name": "Context7", "path": "/context7", "version": "v2.0.0", "proxy_pass_url": "https://mcp-v2.context7.com/mcp" } ``` The second call returns `is_new_version: true` to indicate a new version was added rather than a new server being created. --- ## 8. Version Swap Operation Switching the active version (`set_default_version`) is the most complex operation in the versioning system. It performs a document swap: 1. Read the current active document at path `_id` (e.g., `/context7`) 2. Read the target inactive version document (e.g., `/context7:v2.0.0`) 3. Build a new active document from the target, assigning it the original path `_id` 4. Build a new inactive document from the current active, assigning it a compound `_id` 5. Delete the old active and target inactive documents 6. Insert the new active and new inactive documents 7. Update the `other_version_ids` array (remove target, add old active) 8. Re-index the FAISS search entry with the new active version's data 9. Regenerate nginx configuration and reload 10. Trigger an immediate background health check for the newly active version This is an infrequent admin operation. The trade-off of complexity here versus simpler listing/search queries is acceptable. --- ## 9. Cascade Deletion When a server is deleted via `remove_server()`, all version documents are deleted together using `delete_with_versions()`: ```python filter_query = { "$or": [ {"_id": path}, # Active document {"_id": {"$regex": f"^{path}:"}}, # All inactive version documents ] } result = await collection.delete_many(filter_query) ``` This prevents orphaned version documents from remaining in the database after a server is removed. --- ## 10. Frontend Components ### Version Badge A clickable badge on the ServerCard that shows the current active version (e.g., `v2.0.0`). Only visible when the server has multiple versions (`versions.length > 1`). Single-version servers show no badge. ### Version Selector Modal Opened by clicking the version badge. Displays all versions as individual cards with: - Version number and status badge (`ACTIVE`, `stable`, `beta`, `deprecated`) - Backend URL - Release and sunset dates - "Set Active" button (disabled for the already-active version) An info footer explains the `X-MCP-Server-Version` header usage for clients that want to pin to a specific version. ### MCP Server Version Display A separate, smaller badge below the routing version badge shows the MCP server-reported version (e.g., `srv 2.14.5`). If the version changed within the last 24 hours, a small green dot indicator appears. Hovering shows the previous version in a tooltip. --- ## 11. Backward Compatibility | Scenario | Behavior | |----------|----------| | Existing single-version servers | Work unchanged. No `version_group`, no map entries, direct `proxy_pass` | | No `X-MCP-Server-Version` header | Routes to active version (same as before versioning existed) | | `version` field missing on legacy document | Defaults to `v1.0.0` | | Client sends header for single-version server | Map returns empty string, falls back to default `proxy_pass` | --- ## 12. Index Strategy ```javascript // Primary filter for all listing operations db.mcp_servers.createIndex({ "is_active": 1 }) // For version group lookups (modal population) db.mcp_servers.createIndex({ "version_group": 1 }) // Compound index for dashboard queries db.mcp_servers.createIndex({ "is_active": 1, "is_enabled": 1 }) ``` --- ## 13. Future: Traffic Splitting (Phase 2) Not yet implemented. Phase 2 will use nginx `split_clients` directive to route a percentage of traffic to different versions for gradual rollouts: ```nginx split_clients "${remote_addr}${request_uri}" $canary_backend { 10% "http://server-v2:8000/"; * "http://server-v1:8000/"; } ``` | Condition | Routing | |-----------|---------| | `X-MCP-Server-Version: v2.0.0` | Force v2.0.0 (explicit header takes precedence) | | No header + traffic split enabled | Percentage-based routing | | No header + no traffic split | Route to active version | ================================================ FILE: docs/design/storage-architecture-mongodb-documentdb.md ================================================ # Storage Architecture: MongoDB CE & AWS DocumentDB **Status:** Current Implementation **Last Updated:** January 30, 2026 **Target Audience:** Developers, DevOps Engineers, System Architects ## Table of Contents 1. [Overview](#overview) 2. [Storage Backend Options](#storage-backend-options) 3. [MongoDB CE Local Development](#mongodb-ce-local-development) 4. [AWS DocumentDB Production](#aws-documentdb-production) 5. [Vector Search Implementation](#vector-search-implementation) 6. [Build and Run Process](#build-and-run-process) 7. [Repository Architecture](#repository-architecture) 8. [Configuration](#configuration) 9. [Migration Strategy](#migration-strategy) ## Overview The MCP Gateway Registry supports three storage backends for data persistence: 1. **File-Based Backend** (Legacy) - JSON/YAML files with FAISS 2. **MongoDB CE** (Local Development) - MongoDB Community Edition 8.2 with application-level vector search 3. **AWS DocumentDB** (Production) - MongoDB-compatible service with native vector search This document focuses on the MongoDB and DocumentDB backends, which provide distributed storage with semantic search capabilities. ### Architecture Diagram ``` ┌─────────────────────────────────────────────────────────────┐ │ Application Layer │ │ (Services, API Endpoints) │ └────────────────────┬────────────────────────────────────────┘ │ depends on ▼ ┌─────────────────────────────────────────────────────────────┐ │ Repository Factory Layer │ │ get_server_repository() │ │ get_search_repository() │ │ etc. │ └────────┬──────────────────────┬──────────────┬─────────────┘ │ │ │ │ STORAGE_BACKEND= │ │ │ file / mongodb-ce / │ │ │ documentdb │ │ ▼ ▼ ▼ ┌──────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │ File Backend │ │ MongoDB CE │ │ AWS DocumentDB │ ├──────────────────┤ ├─────────────────┤ ├──────────────────┤ │ FileServerRepo │ │ DocumentDBRepo │ │ DocumentDBRepo │ │ FaissSearch │ │ + App-level │ │ + Native │ │ │ │ vector search │ │ vector search │ └──────────────────┘ └─────────────────┘ └──────────────────┘ │ │ │ ▼ ▼ ▼ Local Files Local MongoDB CE AWS DocumentDB (JSON + FAISS) (Docker) (Managed Service) ``` --- ## Storage Backend Options ### Comparison Matrix | Aspect | File | MongoDB CE | AWS DocumentDB | |--------|------|------------|----------------| | **Use Case** | Dev/Testing | Local Development | Production | | **Scalability** | ~1000 entities | 10,000s | Millions | | **Vector Search** | FAISS (app-level) | App-level (Python) | Native (HNSW) | | **Setup Complexity** | None | Docker Compose | Terraform/AWS | | **Concurrency** | Limited | Good | Excellent | | **HA/Clustering** | No | Manual | Automatic | | **Cost** | Free | Free | AWS Pricing | | **Best For** | Quick start | Feature development | Production deployments | --- ## MongoDB CE Local Development ### Architecture MongoDB Community Edition 8.2 provides a local development environment that mimics production DocumentDB behavior without requiring AWS infrastructure. #### Key Components 1. **MongoDB 8.2 Container** (`mongo:8.2`) - Runs in Docker Compose - Configured as replica set (`rs0`) for transaction support - No authentication for local development simplicity - Bind address: `127.0.0.1,mongodb` 2. **Application-Level Vector Search** - Python-based cosine similarity computation - Embeddings stored in `mcp_embeddings_1536_default` collection - Full scan with in-memory ranking for search queries - Isolated in `DocumentDBSearchRepository` class 3. **Collections** - `mcp_servers_{namespace}` - Server definitions - `mcp_agents_{namespace}` - Agent cards - `mcp_scopes_{namespace}` - Authorization scopes - `mcp_embeddings_1536_{namespace}` - Vector embeddings - `mcp_security_scans_{namespace}` - Security scan results - `mcp_federation_config_{namespace}` - Federation settings ### Vector Search Implementation (MongoDB CE) Since MongoDB CE 8.2 doesn't include the separate `mongot` search component needed for native vector search, we implement semantic search at the application layer. #### Search Flow ```python # 1. Query arrives query = "financial analysis tools" # 2. Generate query embedding model = create_embeddings_client(...) query_embedding = model.encode([query])[0] # [1536 dimensions] # 3. Retrieve all embeddings from MongoDB docs = await collection.find({"entity_type": "mcp_server"}).to_list(length=1000) # 4. Calculate cosine similarity in Python for doc in docs: doc_embedding = doc["embedding"] # [1536 dimensions] score = cosine_similarity(query_embedding, doc_embedding) doc["relevance_score"] = score # 5. Rank results by similarity results = sorted(docs, key=lambda x: x["relevance_score"], reverse=True) # 6. Apply text-based boosting (hybrid search) # If query appears in name or description, add bonus points for result in results: if query.lower() in result["name"].lower(): result["relevance_score"] += 0.1 # Name match bonus if query.lower() in result["description"].lower(): result["relevance_score"] += 0.05 # Description match bonus # 7. Return top-k results return results[:max_results] ``` #### Code Location **File:** `registry/repositories/documentdb/search_repository.py` **Key Methods:** ```python class DocumentDBSearchRepository: async def search( self, query: str, entity_types: Optional[List[str]] = None, max_results: int = 10, ) -> Dict[str, List[Dict[str, Any]]]: """Hybrid search with application-level vector similarity.""" # Lines 240-385: Full implementation def _calculate_cosine_similarity( self, vec1: List[float], vec2: List[float] ) -> float: """Calculate cosine similarity between two vectors.""" # Lines 199-220: Pure Python implementation # Returns value between 0 and 1 (1 = identical) ``` #### Performance Characteristics - **Pros:** - No dependency on external search services - Works identically to production DocumentDB (same code path) - Full control over ranking algorithm - No additional containers needed - **Cons:** - O(n) full collection scan for every search query - All embeddings loaded into memory for comparison - Not suitable for >10,000 entities - No index optimization (brute force) - **Optimization:** - For local dev with <1,000 entities, performance is acceptable (<100ms) - Production workloads should use AWS DocumentDB with native vector search ### Docker Compose Configuration **File:** `docker-compose.yml` (lines 59-77) ```yaml mongodb: image: mongo:8.2 container_name: mcp-mongodb command: mongod --replSet rs0 --bind_ip 127.0.0.1,mongodb ports: - "27017:27017" volumes: - mongodb-data:/data/db - mongodb-config:/data/configdb healthcheck: test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] interval: 10s timeout: 5s retries: 5 start_period: 20s restart: unless-stopped ``` **Key Settings:** - **Replica Set:** `--replSet rs0` enables transactions (required for DocumentDB compatibility) - **Bind Address:** `127.0.0.1,mongodb` - listens on localhost and container network - **No Authentication:** Simplifies local development (not for production!) - **Healthcheck:** Ensures container is ready before dependent services start ### Initialization **Script:** `scripts/init-mongodb-ce.py` **What It Does:** 1. **Initializes Replica Set** ```python # Initialize rs0 replica set (required for transactions) config = { "_id": "rs0", "members": [{"_id": 0, "host": "mongodb:27017"}] } client.admin.command("replSetInitiate", config) ``` 2. **Creates Collections** - Server registry (`mcp_servers_default`) - Agent registry (`mcp_agents_default`) - Authorization scopes (`mcp_scopes_default`) - Vector embeddings (`mcp_embeddings_1536_default` - 1536 dimensions for OpenAI/Titan) - Security scans (`mcp_security_scans_default`) - Federation config (`mcp_federation_config_default`) 3. **Creates Indexes** for query performance ```python # Server indexes await collection.create_index([("path", ASCENDING)], unique=True) await collection.create_index([("enabled", ASCENDING)]) await collection.create_index([("tags", ASCENDING)]) # Embeddings indexes await collection.create_index([("path", ASCENDING)], unique=True) await collection.create_index([("entity_type", ASCENDING)]) ``` 4. **Loads OAuth Scopes** from `auth_server/scopes.yml` ```python # Reads scopes.yml and populates mcp_scopes_default collection # Includes server scopes and group mappings await _load_scopes_from_yaml(db, namespace, scopes_file) ``` 5. **Note on Vector Index** - MongoDB CE 8.2 does not include native vector search (requires `mongot` component) - Vector search is implemented at application level using Python cosine similarity - See `registry/repositories/documentdb/search_repository.py` for implementation ### Environment Variables **File:** `.env.example` (lines 360-386) ```bash # Storage backend selection STORAGE_BACKEND=mongodb-ce # Use MongoDB CE local instance # DocumentDB Configuration (reused for MongoDB CE) DOCUMENTDB_HOST=mongodb # Docker Compose service name DOCUMENTDB_PORT=27017 DOCUMENTDB_DATABASE=mcp_registry DOCUMENTDB_NAMESPACE=default # No authentication needed for local MongoDB CE # DOCUMENTDB_USERNAME and DOCUMENTDB_PASSWORD not required # DOCUMENTDB_USE_TLS=false (disabled for local) ``` --- ## AWS DocumentDB Production ### Architecture AWS DocumentDB is a MongoDB-compatible managed database service optimized for cloud deployments with native vector search support. #### Key Components 1. **DocumentDB Cluster** - Managed by AWS - Multi-AZ deployment for redundancy - Auto-scaling read replicas - Automated backups and point-in-time recovery 2. **Native Vector Search** - HNSW (Hierarchical Navigable Small World) algorithm - Index-based approximate nearest neighbor (ANN) - Sub-100ms query times even with millions of vectors - Cosine similarity metric 3. **Network Architecture** - Private VPC deployment - TLS encryption in transit - VPC security groups for access control ### Vector Search Implementation (DocumentDB) AWS DocumentDB provides native vector search using optimized indexes, eliminating the need for application-level computation. #### Search Flow ```python # 1. Query arrives query = "financial analysis tools" # 2. Generate query embedding (same as MongoDB CE) query_embedding = model.encode([query])[0] # 3. DocumentDB performs indexed vector search with tuned parameters ef_search = settings.vector_search_ef_search # Default: 100 k_value = max(max_results * 3, 50) # At least 50 for small collections pipeline = [ { "$search": { "vectorSearch": { "vector": query_embedding, "path": "embedding", "similarity": "cosine", "k": k_value, "efSearch": ef_search, } } } ] # 4. DocumentDB returns sorted results (FAST - uses HNSW index) # Results are already ranked by vector similarity # 5. Apply text-based boosting in aggregation pipeline # Each query keyword is matched independently against path, name, # description, tags, and tool names/descriptions pipeline.append({ "$addFields": { "text_boost": { "$add": [ # Per keyword: path(+5), name(+3), description(+2), tags(+1.5) {"$cond": [{"$regexMatch": {"input": "$path", "regex": keyword, "options": "i"}}, 5.0, 0.0]}, {"$cond": [{"$regexMatch": {"input": "$name", "regex": keyword, "options": "i"}}, 3.0, 0.0]}, {"$cond": [{"$regexMatch": {"input": "$description", "regex": keyword, "options": "i"}}, 2.0, 0.0]}, # ... plus tags (+1.5) and each tool (+1.0) ] } } }) # 6. Score combination: normalized_vector + (text_boost * 0.1) # All candidates scored, sorted by hybrid score, then top-3 per entity type # 7. Execute aggregation pipeline results = await collection.aggregate(pipeline).to_list(k_value) ``` #### Code Location **File:** `registry/repositories/documentdb/search_repository.py` **The same code works for both MongoDB CE and AWS DocumentDB!** The key difference: - **MongoDB CE:** No vector index → slow full scan - **DocumentDB:** HNSW vector index → fast indexed search The application code is identical, but DocumentDB executes it much faster. #### Performance Characteristics - **Pros:** - O(log n) indexed search via HNSW - Handles millions of vectors efficiently - Sub-100ms latency even with large datasets - Native database operation (no network round trips) - Automatic index optimization - **Cons:** - Requires AWS infrastructure - Additional cost for managed service - Network latency to AWS region - **Optimization:** - HNSW parameters tuned for accuracy vs. speed tradeoff - `m=16, efConstruction=128` provides good balance for index construction - `efSearch=100` (configurable) provides near-exact recall for typical deployments - Minimum `k=50` ensures small collections are fully covered - Can adjust via `VECTOR_SEARCH_EF_SEARCH` environment variable ### Terraform Configuration **Directory:** `terraform/aws-ecs/` **Key Resources:** 1. **DocumentDB Cluster** (`modules/documentdb/main.tf`) ```hcl resource "aws_docdb_cluster" "main" { cluster_identifier = "mcp-registry-${var.environment}" engine = "docdb" master_username = var.master_username master_password = var.master_password # Redundancy backup_retention_period = 7 preferred_backup_window = "03:00-04:00" # Security storage_encrypted = true kms_key_id = aws_kms_key.docdb.arn # Network db_subnet_group_name = aws_docdb_subnet_group.main.name vpc_security_group_ids = [aws_security_group.docdb.id] } ``` 2. **DocumentDB Instance(s)** (read/write nodes) ```hcl resource "aws_docdb_cluster_instance" "main" { count = var.instance_count identifier = "mcp-registry-${var.environment}-${count.index}" cluster_identifier = aws_docdb_cluster.main.id instance_class = var.instance_class # db.r5.large, db.r6g.xlarge, etc. } ``` 3. **Vector Search Configuration** - Automatically enabled on DocumentDB 5.0+ - No additional configuration needed - HNSW index created via application init script ### Environment Variables (Production) **File:** `.env` (not in git) ```bash # Storage backend STORAGE_BACKEND=documentdb # Use AWS DocumentDB # DocumentDB connection DOCUMENTDB_HOST=mcp-registry-prod.cluster-xxxxx.us-east-1.docdb.amazonaws.com DOCUMENTDB_PORT=27017 DOCUMENTDB_DATABASE=mcp_registry DOCUMENTDB_USERNAME=mcp_admin DOCUMENTDB_PASSWORD= # Security settings DOCUMENTDB_USE_TLS=true DOCUMENTDB_TLS_CA_FILE=/app/global-bundle.pem # AWS DocumentDB CA bundle DOCUMENTDB_USE_IAM=false # Set to true for IAM authentication # Replica set configuration DOCUMENTDB_REPLICA_SET=rs0 DOCUMENTDB_READ_PREFERENCE=secondaryPreferred # Distribute read load # Namespace for multi-tenancy DOCUMENTDB_NAMESPACE=production ``` --- ## Vector Search Implementation ### Overview Vector search enables semantic search - finding conceptually similar servers and agents even when exact keywords don't match. **Example:** ``` Query: "financial analytics" Matches: ✓ "Stock market analysis tools" (85% similarity) ✓ "Portfolio management assistant" (78% similarity) ✗ "Weather forecast service" (12% similarity) ``` ### Embedding Generation **Module:** `registry/embeddings/` **Providers:** 1. **Sentence Transformers** (Default, Local) - Model: `all-MiniLM-L6-v2` - Dimensions: 384 - Runs locally, no API costs 2. **LiteLLM** (Cloud, via API) - OpenAI: `text-embedding-ada-002` (1536 dims) - Amazon Bedrock Titan: `amazon.titan-embed-text-v1` (1536 dims) - Cohere: `embed-english-v3.0` (1024 dims) **Configuration:** ```bash # Local embeddings EMBEDDINGS_PROVIDER=sentence-transformers EMBEDDINGS_MODEL_NAME=all-MiniLM-L6-v2 EMBEDDINGS_MODEL_DIMENSIONS=384 # OR Cloud embeddings (OpenAI) EMBEDDINGS_PROVIDER=litellm EMBEDDINGS_MODEL_NAME=openai/text-embedding-ada-002 EMBEDDINGS_MODEL_DIMENSIONS=1536 EMBEDDINGS_API_KEY=sk-... # OR Amazon Bedrock EMBEDDINGS_PROVIDER=litellm EMBEDDINGS_MODEL_NAME=bedrock/amazon.titan-embed-text-v1 EMBEDDINGS_MODEL_DIMENSIONS=1536 EMBEDDINGS_AWS_REGION=us-east-1 ``` ### Embedding Storage **Collection:** `mcp_embeddings_{dimensions}_{namespace}` **Document Structure:** ```json { "_id": "/servers/financial-data", "entity_type": "mcp_server", "path": "/servers/financial-data", "name": "Financial Data Server", "description": "Provides stock market data and analysis tools", "tags": ["finance", "data", "stocks"], "is_enabled": true, "text_for_embedding": "Financial Data Server. Provides stock market data and analysis tools. Tools: get_stock_price, analyze_portfolio, market_trends", "embedding": [0.125, -0.342, 0.098, ...], // 1536 floats "embedding_metadata": { "model": "amazon.titan-embed-text-v1", "provider": "litellm", "dimensions": 1536, "created_at": "2026-01-03T10:30:00Z" }, "tools": [ {"name": "get_stock_price", "description": "Get current stock price"}, {"name": "analyze_portfolio", "description": "Analyze investment portfolio"} ], "metadata": { /* full server info */ }, "indexed_at": "2026-01-03T10:30:00Z" } ``` ### Search Algorithm Comparison #### MongoDB CE (Application-Level) ```python # File: registry/repositories/documentdb/search_repository.py async def search(self, query: str, max_results: int = 10): # 1. Generate query embedding query_embedding = model.encode([query])[0] # 2. Fetch ALL documents (full scan) docs = await collection.find({}).to_list(length=10000) # 3. Calculate similarity for each document for doc in docs: doc["score"] = cosine_similarity(query_embedding, doc["embedding"]) # 4. Sort by score (in Python) ranked = sorted(docs, key=lambda x: x["score"], reverse=True) # 5. Return top-k return ranked[:max_results] ``` **Time Complexity:** O(n) - must compare against every document **Latency:** ~50-200ms for 1,000 documents **Scalability:** Limited to ~10,000 documents #### DocumentDB (Native Vector Search) ```python # File: registry/repositories/documentdb/search_repository.py async def search(self, query: str, max_results: int = 10): # 1. Generate query embedding query_embedding = model.encode([query])[0] # 2. DocumentDB HNSW index search with tuned parameters ef_search = settings.vector_search_ef_search # Default: 100 k_value = max(max_results * 3, 50) pipeline = [{ "$search": { "vectorSearch": { "vector": query_embedding, "path": "embedding", "similarity": "cosine", "k": k_value, "efSearch": ef_search, } } }] # 3. DocumentDB returns sorted results (FAST!) results = await collection.aggregate(pipeline).to_list(k_value) # 4. Score all results, sort by hybrid score, pick top-3 per entity type return results ``` **Time Complexity:** O(log n) - HNSW index lookup **Latency:** ~10-50ms for millions of documents **Scalability:** Millions of documents ### Search Resilience: Lexical Fallback Both backends support automatic fallback to lexical-only search when the embedding model is unavailable. This ensures search remains operational even during embedding provider outages, misconfiguration, or API key expiration. **Behavior when embeddings are unavailable:** - Servers and agents are indexed without embeddings (empty vectors) - DocumentDB rejects 0-dimension vectors, so documents are stored without vector data - Search uses MongoDB aggregation with `$regexMatch` for keyword matching on path, name, description, tags, and tools - The `_load_error` cache in `SentenceTransformersClient` prevents repeated model download attempts - API response includes `"search_mode": "lexical-only"` to indicate degraded mode **Recovery:** Restart the service with correct embedding configuration. The error cache resets on restart and search returns to full hybrid mode. See [Hybrid Search Architecture](hybrid-search-architecture.md) for detailed fallback flow and scoring. ### Hybrid Search (Text + Vector) Both backends support hybrid search combining: - **Vector similarity** (semantic matching) - **Text matching** (keyword boosting) **Example:** ```python # Query: "stock market" results = [ { "name": "Financial Analysis Server", "vector_score": 0.85, # Cosine similarity "normalized_vector": 0.925, # (0.85 + 1.0) / 2.0 "text_boost": 3.0, # "market" found in name "boost_contrib": 0.30, # 3.0 * 0.1 "final_score": 1.0 # clamped to 1.0 }, { "name": "Investment Tools", "vector_score": 0.75, # Cosine similarity "normalized_vector": 0.875, # (0.75 + 1.0) / 2.0 "text_boost": 0.0, # No keyword match "boost_contrib": 0.0, "final_score": 0.875 } ] ``` **Formula:** ``` normalized_vector = (cosine_similarity + 1.0) / 2.0 # Map [-1,1] to [0,1] boost_contribution = text_boost * 0.1 # Scale boost down final_score = clamp(normalized_vector + boost_contribution, 0.0, 1.0) ``` The `0.1` multiplier is consistent across both DocumentDB and MongoDB CE search paths. Semantic relevance is primary (normalized vector score dominates) while keyword matches provide a meaningful boost for exact references. --- ## Build and Run Process ### Local Development with MongoDB CE **Script:** `build_and_run.sh` #### Flow ``` 1. Load .env file └─> Check STORAGE_BACKEND variable 2. If STORAGE_BACKEND = mongodb-ce or documentdb: └─> Create empty directories for Docker mounts └─> (Data stored in MongoDB, not local files) 3. Start Docker Compose ├─> Start MongoDB container │ └─> Wait for healthcheck to pass ├─> Run mongodb-init container │ ├─> Initialize replica set │ ├─> Create collections │ └─> Create indexes └─> Start application services 4. Application startup ├─> Repository factory creates DocumentDBRepository instances ├─> Search repository initializes │ └─> Creates vector index if not exists (DocumentDB only) └─> Services load data from MongoDB ``` #### Key Script Logic **File:** `build_and_run.sh` (lines 180-298) ```bash # Build and run script always creates mount directories # and copies JSON files for all backends # (MongoDB/DocumentDB stores data in database, files are for initial seeding) # Create mount directories mkdir -p "$MCPGATEWAY_SERVERS_DIR" mkdir -p "${HOME}/mcp-gateway/agents" mkdir -p "${HOME}/mcp-gateway/auth_server" mkdir -p "${HOME}/mcp-gateway/security_scans" touch "${HOME}/mcp-gateway/federation.json" # Copy server definitions from registry/servers/*.json # Copy agent cards from cli/examples/*agent*.json # Copy scopes.yml # (These provide initial data that can be imported via API) ``` ### Starting the Stack ```bash # 1. Ensure .env is configured cat .env | grep STORAGE_BACKEND # Should show: STORAGE_BACKEND=mongodb-ce # 2. Run build and run script ./build_and_run.sh # What happens: # - MongoDB container starts # - Waits for healthcheck (ping succeeds) # - Runs init script (replica set + collections) # - Starts application services # - Registry connects to MongoDB # - Search repository creates vector index (if DocumentDB) ``` ### Verifying MongoDB CE Setup ```bash # 1. Check MongoDB is running docker compose ps mongodb # Should show: Status = healthy # 2. Check collections were created docker exec -it mcp-mongodb mongosh --eval "use mcp_registry; show collections" # Expected output: # mcp_agents_default # mcp_embeddings_1536_default # mcp_federation_config_default # mcp_scopes_default # mcp_security_scans_default # mcp_servers_default # 3. Check replica set status docker exec -it mcp-mongodb mongosh --eval "rs.status()" # Should show: rs0 with 1 member (primary) # 4. Verify application can connect curl http://localhost:7860/health # Should return: {"status": "healthy"} ``` ### Data Flow ``` User Action → API Endpoint → Service Layer → Repository ─────────────────────────────────────────────────────────────────────── Register Server → POST /servers → server_service → DocumentDBServerRepository └─> MongoDB: mcp_servers_default Search "finance" → GET /search → search_service → DocumentDBSearchRepository └─> MongoDB: mcp_embeddings_1536_default └─> Vector search (app-level) List Agents → GET /agents → agent_service → DocumentDBAgentRepository └─> MongoDB: mcp_agents_default ``` --- ## Repository Architecture ### Abstract Base Classes **File:** `registry/repositories/interfaces.py` All storage backends implement the same interfaces: ```python class ServerRepositoryBase(ABC): @abstractmethod async def get(self, path: str) -> Optional[Dict[str, Any]]: ... @abstractmethod async def list_all(self) -> Dict[str, Dict[str, Any]]: ... @abstractmethod async def create(self, server_info: Dict[str, Any]) -> bool: ... ``` ### Factory Pattern **File:** `registry/repositories/factory.py` ```python def get_server_repository() -> ServerRepositoryBase: backend = settings.storage_backend if backend in ["documentdb", "mongodb-ce"]: from .documentdb.server_repository import DocumentDBServerRepository return DocumentDBServerRepository() else: from .file.server_repository import FileServerRepository return FileServerRepository() ``` **Key Point:** `mongodb-ce` and `documentdb` use the **same repository implementation**. The only difference is: - **mongodb-ce:** Connects to local MongoDB container - **documentdb:** Connects to AWS DocumentDB cluster The repository code is identical - only the connection string changes! ### Implementation Files **Directory:** `registry/repositories/documentdb/` ``` documentdb/ ├── __init__.py ├── client.py # MongoDB/DocumentDB client management ├── server_repository.py # Server CRUD operations ├── agent_repository.py # Agent CRUD operations ├── scope_repository.py # Authorization scopes ├── search_repository.py # Vector search (app-level OR native) ├── security_scan_repository.py # Security scan results └── federation_config_repository.py # Federation configuration ``` ### Client Management **File:** `registry/repositories/documentdb/client.py` ```python async def get_documentdb_client() -> AsyncIOMotorDatabase: """Get DocumentDB/MongoDB database client. Works with both: - MongoDB CE (local Docker) - AWS DocumentDB (production) Configuration via environment variables. """ if _client is None: connection_string = _build_connection_string() # Example (MongoDB CE): # mongodb://mongodb:27017/mcp_registry?replicaSet=rs0 # Example (DocumentDB): # mongodb://user:pass@docdb-cluster.us-east-1.docdb.amazonaws.com:27017/ # ?tls=true&tlsCAFile=/app/global-bundle.pem&replicaSet=rs0 motor_client = AsyncIOMotorClient(connection_string) _client = motor_client[settings.documentdb_database] return _client def get_collection_name(base_name: str) -> str: """Add namespace suffix to collection name.""" return f"{base_name}_{settings.documentdb_namespace}" ``` --- ## Configuration ### Environment Variables **File:** `.env` ```bash # ============================================================================ # STORAGE BACKEND CONFIGURATION # ============================================================================ # Backend selection STORAGE_BACKEND=mongodb-ce # Options: file, mongodb-ce, documentdb # MongoDB/DocumentDB connection DOCUMENTDB_HOST=mongodb # MongoDB CE: "mongodb" # DocumentDB: "cluster.us-east-1.docdb.amazonaws.com" DOCUMENTDB_PORT=27017 DOCUMENTDB_DATABASE=mcp_registry DOCUMENTDB_NAMESPACE=default # Multi-tenancy: dev, staging, production # Authentication (not needed for MongoDB CE local) DOCUMENTDB_USERNAME=admin # DocumentDB: actual username DOCUMENTDB_PASSWORD=secure_password # DocumentDB: from Secrets Manager # TLS/Security (MongoDB CE: disabled, DocumentDB: enabled) DOCUMENTDB_USE_TLS=false # MongoDB CE: false # DocumentDB: true DOCUMENTDB_TLS_CA_FILE=global-bundle.pem # DocumentDB CA bundle DOCUMENTDB_USE_IAM=false # DocumentDB: set true for IAM auth # Replica set configuration DOCUMENTDB_REPLICA_SET=rs0 DOCUMENTDB_READ_PREFERENCE=secondaryPreferred # Load balance reads # ============================================================================ # EMBEDDINGS CONFIGURATION # ============================================================================ # Embedding provider EMBEDDINGS_PROVIDER=sentence-transformers # Options: sentence-transformers, litellm EMBEDDINGS_MODEL_NAME=all-MiniLM-L6-v2 # Or: openai/text-embedding-ada-002 EMBEDDINGS_MODEL_DIMENSIONS=384 # Or: 1536 for OpenAI/Titan # For cloud embeddings EMBEDDINGS_API_KEY= # OpenAI: sk-... # Bedrock: uses IAM (leave empty) EMBEDDINGS_AWS_REGION=us-east-1 # For Amazon Bedrock ``` ### Docker Compose **File:** `docker-compose.yml` ```yaml services: # MongoDB CE 8.2 mongodb: image: mongo:8.2 container_name: mcp-mongodb command: mongod --replSet rs0 --bind_ip 127.0.0.1,mongodb ports: - "27017:27017" volumes: - mongodb-data:/data/db - mongodb-config:/data/configdb healthcheck: test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] interval: 10s timeout: 5s retries: 5 start_period: 20s restart: unless-stopped # MongoDB initialization (runs once) mongodb-init: image: mongo:8.2 container_name: mcp-mongodb-init depends_on: mongodb: condition: service_healthy environment: - DOCUMENTDB_HOST=mongodb - DOCUMENTDB_PORT=27017 - DOCUMENTDB_DATABASE=${DOCUMENTDB_DATABASE:-mcp_registry} - DOCUMENTDB_NAMESPACE=${DOCUMENTDB_NAMESPACE:-default} volumes: - ./scripts/init-mongodb.sh:/init-mongodb.sh:ro entrypoint: ["/bin/bash", "/init-mongodb.sh"] restart: "no" volumes: mongodb-data: mongodb-config: ``` ### Terraform (DocumentDB) **File:** `terraform/aws-ecs/modules/documentdb/variables.tf` ```hcl variable "cluster_name" { description = "DocumentDB cluster name" type = string } variable "instance_class" { description = "Instance class (e.g., db.r5.large)" type = string default = "db.r5.large" } variable "instance_count" { description = "Number of instances (1 writer + N readers)" type = number default = 3 # 1 writer + 2 readers } variable "master_username" { description = "Master username" type = string sensitive = true } variable "master_password" { description = "Master password" type = string sensitive = true } variable "backup_retention_period" { description = "Backup retention in days" type = number default = 7 } ``` --- ## Migration Strategy ### From File Backend to MongoDB CE **Scenario:** Moving from JSON files to MongoDB for local development **Steps:** 1. **Export existing data** ```bash # Servers cp ~/mcp-gateway/servers/*.json /tmp/servers-backup/ # Agents cp ~/mcp-gateway/agents/*.json /tmp/agents-backup/ ``` 2. **Update configuration** ```bash # In .env sed -i 's/STORAGE_BACKEND=file/STORAGE_BACKEND=mongodb-ce/' .env ``` 3. **Start MongoDB** ```bash docker compose up -d mongodb docker compose up mongodb-init ``` 4. **Import data via API** ```bash # Re-register servers for file in /tmp/servers-backup/*.json; do curl -X POST http://localhost:7860/servers \ -H "Content-Type: application/json" \ -d @"$file" done # Re-register agents for file in /tmp/agents-backup/*.json; do curl -X POST http://localhost:7860/agents \ -H "Content-Type: application/json" \ -d @"$file" done ``` 5. **Verify** ```bash # Check server count curl http://localhost:7860/servers | jq 'length' # Test search curl "http://localhost:7860/search?q=financial" | jq '.servers | length' ``` ### From MongoDB CE to AWS DocumentDB **Scenario:** Moving from local development to production **Steps:** 1. **Export from MongoDB CE** ```bash # Dump all collections docker exec mcp-mongodb mongodump \ --db=mcp_registry \ --out=/tmp/mongodb-backup # Copy backup from container docker cp mcp-mongodb:/tmp/mongodb-backup ./mongodb-backup ``` 2. **Deploy DocumentDB with Terraform** ```bash cd terraform/aws-ecs terraform apply # Get DocumentDB endpoint terraform output documentdb_endpoint ``` 3. **Import to DocumentDB** ```bash # From bastion host or ECS task mongorestore \ --host=mcp-registry.cluster-xxxxx.us-east-1.docdb.amazonaws.com:27017 \ --ssl \ --sslCAFile=/app/global-bundle.pem \ --username=mcp_admin \ --password= \ --db=mcp_registry \ ./mongodb-backup/mcp_registry ``` 4. **Update application configuration** ```bash # In .env STORAGE_BACKEND=documentdb DOCUMENTDB_HOST=mcp-registry.cluster-xxxxx.us-east-1.docdb.amazonaws.com DOCUMENTDB_USERNAME=mcp_admin DOCUMENTDB_PASSWORD= DOCUMENTDB_USE_TLS=true DOCUMENTDB_TLS_CA_FILE=/app/global-bundle.pem ``` 5. **Deploy application** ```bash terraform apply ``` 6. **Verify vector search** ```bash # Test search endpoint curl "https://api.example.com/search?q=financial" | jq '.servers' # Should return results with relevance_score # DocumentDB will use native HNSW index (faster!) ``` --- ## Summary ### Architecture Highlights | Component | MongoDB CE | AWS DocumentDB | |-----------|------------|----------------| | **Container** | mongo:8.2 | Managed Service | | **Connection** | mongodb://mongodb:27017 | mongodb://cluster.docdb.amazonaws.com:27017 | | **Authentication** | None (local) | Username/Password or IAM | | **TLS** | Disabled | Required | | **Vector Search** | App-level (Python) | Native (HNSW) | | **Latency** | 50-200ms | 10-50ms | | **Max Scale** | ~10,000 docs | Millions | | **Cost** | Free | AWS pricing | ### Key Takeaways 1. **Same Code, Different Backends** - Identical repository implementation - Only connection configuration differs - Seamless migration path 2. **Vector Search Strategy** - MongoDB CE: Application-level for dev simplicity - DocumentDB: Native HNSW for production performance - Both use cosine similarity metric 3. **Development Workflow** - Local dev: `STORAGE_BACKEND=mongodb-ce` - Production: `STORAGE_BACKEND=documentdb` - Terraform handles infrastructure 4. **No Terraform Changes** - AWS DocumentDB infrastructure deployed via Terraform - Local MongoDB CE runs in Docker Compose - Terraform only manages AWS resources --- ## See Also - [Database Abstraction Layer Design](./database-abstraction-layer.md) - [Embeddings Configuration](../embeddings.md) - [Configuration Guide](../configuration.md) - [MongoDB Documentation](https://www.mongodb.com/docs/manual/) - [AWS DocumentDB Documentation](https://docs.aws.amazon.com/documentdb/) ================================================ FILE: docs/design/virtual-mcp-server-explained.md ================================================ # Virtual MCP Server - How It Works This document explains how Virtual MCP Servers work using diagrams and examples. For detailed implementation specifics, see [virtual-mcp-server.md](virtual-mcp-server.md). --- ## What Problem Are We Solving? Consider a typical development setup: you have separate MCP servers for GitHub (code search, PRs), Slack (messaging), and Jira (issue tracking). Your AI agent needs tools from all three, which means: - Managing three separate connections - Handling three different sessions - Dealing with tool name conflicts (both GitHub and Jira have a `search` tool) A Virtual MCP Server solves this by providing a **single endpoint** that aggregates tools from multiple backends. Your agent connects once and gets access to all the tools it needs. ``` WITHOUT Virtual Server: WITH Virtual Server: You You | | +---> GitHub Server | | |-> search v | |-> create_pr +------------+ | | Virtual | +---> Slack Server | Server | | |-> send_message +-----+------+ | |-> list_channels | | +-----+-----+-----+ +---> Jira Server | | | |-> create_issue v v v |-> search_issues GitHub Slack Jira Server Server Server ``` **Benefits:** - Your app only connects to ONE server instead of many - You can pick exactly which tools you want from each backend - You can rename tools to avoid confusion (like "github_search" vs "jira_search") - You can control who has access to which tools --- ## The Big Picture Request flow when a client connects to a Virtual MCP Server: ``` +----------------+ +------------------+ | Your App | | MCP Gateway | | | | | | "I want to | ---(1) Request --> | Nginx receives | | search on | | your request | | GitHub" | +--------+---------+ | | | | | v | | +------------------+ | | | Lua Router | | | | (the brain) | | | | | | | | "Ah, this tool | | | | belongs to | | | | GitHub backend"| | | +--------+---------+ | | | | | v | | +------------------+ | | | GitHub Backend | | | | | | | <--(4) Response -- | (does the | | | | actual work) | +----------------+ +------------------+ ``` Each component is described below. --- ## The Three Key Players ### 1. Nginx (Reverse Proxy) Nginx receives incoming requests and handles: - JWT authentication via `auth_request` subrequest - Path-based routing to determine which virtual server - Invoking the Lua content handler for MCP protocol processing ``` Request arrives at /virtual/dev-tools | v +---------------+ | Nginx | | | | 1. Check JWT | <-- "Is this token valid?" | 2. Read path | <-- "Which virtual server?" | 3. Call Lua | <-- "Hand off to the router" +---------------+ ``` ### 2. Lua Router (Content Handler) The Lua router (`virtual_router.lua`) runs as an nginx content handler. It: - Reads tool-to-backend mappings from JSON config files - Translates tool aliases back to original names - Manages session multiplexing across backends - Issues concurrent subrequests for aggregation methods ### 3. Backend Servers The actual MCP servers (GitHub, Slack, Jira, etc.) that execute tool calls. The virtual server coordinates requests but delegates all execution to backends. --- ## How Tool Mapping Works This is the core mechanism. The process works as follows: ### Step 1: Configuration is Created When someone creates a virtual server, they specify which tools to include: ``` Virtual Server: "dev-tools" Path: /virtual/dev-tools Tool Mappings: +------------------+------------------+------------------+ | Tool Name | Backend Server | Alias | +------------------+------------------+------------------+ | search | /github | github_search | | search | /jira | jira_search | | send_message | /slack | (none - use as-is)| +------------------+------------------+------------------+ ``` Both GitHub and Jira have a tool called "search". Aliases resolve this naming conflict. ### Step 2: Mapping File is Generated The system writes a JSON file that the Lua router will read: ``` File: /etc/nginx/lua/virtual_mappings/dev-tools.json { "tool_backend_map": { "github_search": { "original_name": "search", "backend_location": "/_backend/github" }, "jira_search": { "original_name": "search", "backend_location": "/_backend/jira" }, "send_message": { "original_name": "send_message", "backend_location": "/_backend/slack" } } } ``` This file is a lookup table mapping tool names to their backend locations. ### Step 3: Request Comes In When your app calls a tool: ``` Your app sends: { "method": "tools/call", "params": { "name": "github_search", <-- The alias you see "arguments": { "query": "bug fixes" } } } ``` ### Step 4: Lua Router Translates The Lua router: 1. Reads the mapping file 2. Looks up "github_search" 3. Finds: backend is "/_backend/github", original name is "search" 4. Rewrites the request: ``` Forwarded to /_backend/github: { "method": "tools/call", "params": { "name": "search", <-- Original name the backend knows "arguments": { "query": "bug fixes" } } } ``` ### Step 5: Response Goes Back The GitHub backend responds. The Lua router passes it back to your app unchanged. --- ## The Complete Request Flow (Sequence Diagram) Sequence diagram for a `tools/call` request: ``` Your App Nginx Lua Router Backend | | | | | POST /virtual/dev-tools | | | tools/call: github_search | | |--------------->| | | | | | | | | auth_request | | | | (check JWT) | | | |----------------->| | | | OK + scopes | | | |<-----------------| | | | | | | | content_by_lua | | | |----------------->| | | | | | | | Read mapping file | | | "github_search" -> | | | backend: /_backend/github | | | original: search | | | | | | | Check session cache | | | (do we have a session | | | with this backend?) | | | | | | | Rewrite tool name | | | github_search -> search | | | | | | | | POST to | | | | /_backend/github | | |-------------->| | | | | | | | Response | | | |<--------------| | | | | |<----------------------------------| | | Response | | ``` --- ## Session Management (The Tricky Part) Each backend server requires its own session. When your app connects to the virtual server, it gets ONE session ID: ``` Your app <---> Virtual Server (session: vs-abc123) ``` But behind the scenes, the virtual server maintains SEPARATE sessions with each backend: ``` Virtual Server: +-- Session with GitHub: sess-gh-001 +-- Session with Slack: sess-sl-002 +-- Session with Jira: sess-jr-003 ``` The Lua router keeps track of this mapping so you don't have to. ### The Two-Tier Cache Looking up sessions from the database on every request would be slow. So we use two levels of caching: ``` Request: "What's the GitHub session for client vs-abc123?" +-------------------+ | Level 1 Cache | <-- Super fast (in nginx memory) | (Shared Dict) | TTL: 30 seconds | | | "Do I have it?" | +--------+----------+ | MISS | v +-------------------+ | Level 2 Cache | <-- Fast (MongoDB lookup) | (MongoDB) | TTL: 1 hour | | | "Check database" | +--------+----------+ | MISS | v +-------------------+ | Create New | <-- Send "initialize" to backend | Session | Store in both caches +-------------------+ ``` **Why two levels?** - Level 1 is in memory - no network call, extremely fast - Level 2 is in MongoDB - survives server restarts - If the server restarts, we lose Level 1 but Level 2 still has sessions --- ## Listing Tools (Aggregation) When your app asks "what tools do you have?", the virtual server needs to ask ALL backends: ``` Your app asks: tools/list Lua Router: +-- Ask GitHub: "What tools do you have?" | Response: [search, create_pr, list_repos] | +-- Ask Slack: "What tools do you have?" | Response: [send_message, list_channels] | +-- Ask Jira: "What tools do you have?" Response: [create_issue, search_issues] Lua Router combines them: [github_search, create_pr, list_repos, <-- Applied aliases send_message, list_channels, jira_search, create_issue] <-- Renamed "search" to "jira_search" ``` **Important optimization:** These backend calls happen IN PARALLEL, not one after another. This makes the aggregation fast. ``` Time ------> Sequential (slow): [GitHub call]---[Slack call]---[Jira call]---Done Parallel (fast): [GitHub call]----- [Slack call]------+---Done [Jira call]------- ``` --- ## What the Nginx Config Looks Like When a virtual server is enabled, the system generates two things: ### 1. A Location Block (for routing) ```nginx location /virtual/dev-tools { # Tell Lua which virtual server this is set $virtual_server_id "dev-tools"; # Check authentication first auth_request /validate; # Run the Lua router content_by_lua_file /etc/nginx/lua/virtual_router.lua; } ``` ### 2. Internal Backend Locations ```nginx # These are marked "internal" - only Lua can use them # Regular users can't access them directly location /_backend/github { internal; proxy_pass https://github-mcp.example.com/mcp; } location /_backend/slack { internal; proxy_pass https://slack-mcp.example.com/mcp; } ``` The Lua router uses these internal locations to talk to backends. --- ## Error Handling What happens when things go wrong? ### Backend is Down ``` Lua Router tries to call /_backend/github | v Connection fails or returns error | v Lua Router returns error to your app: { "error": { "code": -32000, "message": "Backend server unreachable: /github" } } ``` ### Session Expired ``` Lua Router uses cached session sess-gh-001 | v GitHub returns: "400 Bad Request - Invalid session" | v Lua Router: 1. Delete sess-gh-001 from both caches 2. Send new "initialize" to GitHub 3. Get new session: sess-gh-002 4. Cache it in both levels 5. Retry the original request with new session ``` ### User Lacks Permission ``` User has scopes: ["mcp-access"] Tool "create_pr" requires: ["github-write"] | v Lua Router checks scopes... DENIED | v Response: 403 Forbidden { "error": "Missing required scope: github-write" } ``` --- ## Access Control in Simple Terms Access control works at two levels: ### Level 1: Server Access To use the virtual server at all, you need certain scopes: ``` Virtual Server: /virtual/dev-tools Required Scopes: ["mcp-access"] User with scopes ["mcp-access"] -> Allowed in User with scopes ["other-stuff"] -> Blocked at the door ``` ### Level 2: Tool Access Individual tools can require additional scopes: ``` Tool: create_pr Required Scopes: ["github-write"] User with ["mcp-access", "github-read"] -> Can't use this tool User with ["mcp-access", "github-write"] -> Can use this tool ``` When listing tools, the Lua router hides tools the user can't access: ``` Full tool list: [search, create_pr, delete_repo] User scopes: ["mcp-access", "github-read"] Filtered list: [search] <-- Only shows tools user can actually use ``` --- ## How Changes Are Applied When you create or update a virtual server: ``` 1. You call the API: POST /api/virtual-servers | v 2. Service validates the configuration - Does each backend server exist? - Does each tool exist on its backend? - Are all alias names unique? | v 3. Configuration saved to MongoDB | v 4. Nginx config regenerated - New location block written - New mapping JSON file written | v 5. Nginx reloaded - nginx -s reload - New config takes effect immediately | v 6. Virtual server is live! ``` --- ## Quick Reference ### Files You Should Know | File | What It Does | |------|-------------| | `virtual_router.lua` | The Lua brain that routes requests | | `nginx_service.py` | Generates nginx config + mapping files | | `virtual_server_service.py` | Business logic and validation | | `virtual_server_routes.py` | REST API endpoints | | `/etc/nginx/lua/virtual_mappings/*.json` | Tool mapping files read by Lua | ### Key Concepts | Term | Plain English | |------|--------------| | Virtual Server | A fake server that coordinates real servers | | Tool Mapping | "This tool comes from that backend" | | Alias | A renamed tool to avoid confusion | | Backend Location | Where to forward requests (internal nginx path) | | Session Multiplexing | One client session, many backend sessions | | Scope | A permission string that controls access | ### Common Operations | What You Want | What Happens | |---------------|--------------| | List tools | Asks all backends in parallel, combines results | | Call a tool | Looks up backend, translates name, forwards request | | Initialize | Creates client session, backend sessions are lazy | | Ping | Responds immediately, no backend calls | --- ## Summary 1. **Virtual servers aggregate tools** from multiple backends into one endpoint 2. **Nginx routes requests** to the Lua router based on path 3. **Lua router reads mapping files** to know which tool goes where 4. **Aliases solve naming conflicts** when two backends have same tool names 5. **Sessions are cached in two levels** for speed and reliability 6. **Access control works at server and tool level** using scopes 7. **Backend calls happen in parallel** when listing tools The virtual server acts as a coordinator - all tool execution happens on the backend servers. The virtual server's role is to present a unified endpoint to clients. ================================================ FILE: docs/design/virtual-mcp-server.md ================================================ # Virtual MCP Server - Design Document **Date**: 2026-02-10 **Status**: Implemented **PR**: [#459](https://github.com/agentic-community/mcp-gateway-registry/pull/459) **Issue**: [#129](https://github.com/agentic-community/mcp-gateway-registry/issues/129) --- ## 1. Overview A Virtual MCP Server is a gateway-level construct that aggregates tools, resources, and prompts from multiple backend MCP servers into a single unified endpoint. Instead of connecting to individual MCP servers, clients connect to a virtual server that presents a curated, access-controlled view of capabilities drawn from any combination of registered backends. ### Problem Statement Organizations deploying multiple MCP servers face several operational challenges: - **Client complexity**: Each client must discover, connect to, and manage sessions with every individual MCP server it needs - **Tool sprawl**: Teams cannot curate role-specific or project-specific tool bundles from existing servers - **Naming conflicts**: Two backend servers may expose tools with the same name (e.g., both GitHub and GitLab expose a `search` tool) - **Version drift**: No mechanism to pin a client to a specific backend server version while allowing others to upgrade - **Access control gaps**: Authorization is all-or-nothing per server, with no per-tool granularity ### Solution Virtual MCP Servers solve these problems by introducing a composition layer at the gateway: ``` +-----------------------+ | Virtual MCP Server | | /virtual/dev-tools | +-----------+-----------+ | +-----------------+-----------------+ | | | +-----+-----+ +-----+-----+ +-----+-----+ | /github | | /slack | | /jira | | Backend | | Backend | | Backend | +-----+-----+ +-----+-----+ +-----+-----+ | | | search-repo post-message create-ticket create-pr list-channels search-issues ``` A client connecting to `/virtual/dev-tools` sees `search-repo`, `post-message`, `create-ticket`, `list-channels`, `create-pr`, and `search-issues` as a single flat tool list, regardless of which backend provides each tool. ### Key Capabilities | Capability | Description | |------------|-------------| | Tool aggregation | Merge tools from multiple backends into one endpoint | | Tool aliasing | Rename tools to resolve conflicts or improve clarity | | Version pinning | Lock a tool mapping to a specific backend server version | | Scope-based access control | Server-level and per-tool scope requirements | | Session multiplexing | One client session maps to N backend sessions transparently | | Resource and prompt aggregation | Aggregate `resources/list` and `prompts/list` across backends | --- ## 2. Architecture ### System Context ``` +------------------------------------------------------------+ | MCP Gateway | | | | +------------------+ +---------------------------+ | | | FastAPI Registry | | Nginx Reverse Proxy | | | | (Port 7860) | | (Port 80/443) | | | | | | | | | | - CRUD API |<----->| - Auth validation | | | | - Tool catalog | | - Location routing | | | | - Session store | | - Lua router execution | | | +------------------+ +-------------+-------------+ | | | | | +------------+------------+ | | | virtual_router.lua | | | | - JSON-RPC dispatch | | | | - Session multiplexing | | | | - Tool aggregation | | | | - Alias translation | | | +------------+------------+ | | | | +-------------------------------------------|----------------+ | +----------+----------+----------+ | | | | Backend Backend Backend Backend Server A Server B Server C Server D ``` ### Request Lifecycle 1. Client sends an MCP JSON-RPC request to `/virtual/{server-slug}` 2. Nginx matches the location block and issues an `auth_request` to validate the JWT 3. The auth subrequest returns user scopes in response headers 4. Nginx invokes `virtual_router.lua` as the content handler 5. Lua loads the virtual server mapping file from disk (`/etc/nginx/lua/virtual_mappings/{id}.json`) 6. Lua validates user scopes against server-level `required_scopes` 7. Lua dispatches the request based on JSON-RPC method: - **`initialize`**: Creates a client session in MongoDB, returns MCP capabilities - **`tools/list`**: Fetches tools from each distinct backend (concurrent subrequests), applies aliases and scope filtering, returns merged list - **`tools/call`**: Looks up the tool in the mapping, translates alias back to original name, routes to the correct backend with the appropriate backend session - **`resources/list`** / **`prompts/list`**: Aggregates from all backends, builds a lookup map for subsequent read/get calls - **`resources/read`** / **`prompts/get`**: Uses the lookup map to route to the owning backend - **`ping`**: Responds directly without contacting backends ### Component Responsibilities | Component | Responsibility | |-----------|---------------| | `virtual_server_models.py` | Pydantic data models for configuration, requests, and responses | | `virtual_server_repository.py` | MongoDB persistence (CRUD on `virtual_servers` collection) | | `virtual_server_service.py` | Business logic: validation, tool resolution, nginx reload coordination | | `tool_catalog_service.py` | Aggregates available tools across all enabled backend servers | | `virtual_server_routes.py` | REST API endpoints for management | | `nginx_service.py` | Generates nginx location blocks, backend proxies, and Lua mapping files | | `virtual_router.lua` | Runtime JSON-RPC routing, session management, tool aggregation | | `backend_session_repository.py` | MongoDB persistence for backend session tracking | | Frontend components | React management UI with multi-step wizard | --- ## 3. Data Model ### Virtual Server Configuration The primary configuration document stored in MongoDB: ```python class VirtualServerConfig: path: str # e.g., "/virtual/dev-tools" server_name: str # e.g., "Dev Tools" description: Optional[str] tool_mappings: List[ToolMapping] # At least one required required_scopes: List[str] # Server-level scope requirements tool_scope_overrides: List[ToolScopeOverride] tags: List[str] supported_transports: List[str] # Default: ["streamable-http"] is_enabled: bool # Controls nginx routing num_stars: float # Average rating (0.0-5.0) rating_details: List[dict] # Individual ratings [{user, rating}] created_by: Optional[str] created_at: datetime updated_at: datetime ``` ### Tool Mapping Each tool mapping connects a tool from a backend server to the virtual server: ```python class ToolMapping: tool_name: str # Original tool name on backend alias: Optional[str] # Renamed tool in virtual server backend_server_path: str # e.g., "/github" backend_version: Optional[str] # Pin to specific version description_override: Optional[str] ``` The effective tool name exposed to clients is `alias` if set, otherwise `tool_name`. This enables conflict resolution when two backends expose tools with the same name. ### Tool Scope Override Per-tool access control layered on top of server-level scopes: ```python class ToolScopeOverride: tool_alias: str # Matches alias or tool_name required_scopes: List[str] # Additional scopes for this tool ``` ### Backend Session Tracks the session mapping between a client session and each backend: ```python class BackendSession: client_session_id: str backend_location: str # e.g., "/_backend/github" backend_session_id: str created_at: datetime expires_at: datetime # TTL-based expiry ``` ### Storage Design | Collection | `_id` | Purpose | |------------|-------|---------| | `virtual_servers` | path (e.g., `/virtual/dev-tools`) | Virtual server configuration | | `backend_sessions` | `{client_session_id}:{backend_location}` | Session mapping with TTL index | | `client_sessions` | `{session_id}` | Client session metadata for audit | Indexes on `virtual_servers`: - `is_enabled` (for listing active servers) - `tags` (for filtering) - `server_name` (for search) - Compound: `is_enabled` + `tags` --- ## 4. Session Management ### Two-Tier Caching The Lua router uses a two-tier cache to minimize latency for session lookups: ``` Request arrives | v +-------------------+ | L1: Shared Dict | nginx shared memory (lua_shared_dict) | TTL: 30 seconds | Key: "bsess:{client_session}:{backend_location}" +--------+----------+ | miss v +-------------------+ | L2: MongoDB | via internal API subrequest | TTL: 1 hour | GET /_internal/sessions/{client_session}/{backend} +--------+----------+ | miss v +-------------------+ | Initialize | POST to backend with MCP initialize | Backend Session | Store result in L1 + L2 +-------------------+ ``` **L1 Cache (Nginx Shared Dictionary)**: - In-worker memory, no network calls - 30-second TTL keeps sessions warm for burst traffic - 2 MB allocation (`lua_shared_dict virtual_server_map 2m`) **L2 Cache (MongoDB)**: - Survives nginx reloads and worker restarts - 1-hour TTL with MongoDB TTL index on `expires_at` - Accessed via FastAPI internal endpoints (`/_internal/sessions/*`) ### Session Lifecycle 1. Client calls `initialize` on the virtual server endpoint 2. Lua generates a client session ID (`vs-{uuid}`) and stores it in MongoDB 3. Lua returns `Mcp-Session-Id` header to the client 4. On subsequent requests, client includes `Mcp-Session-Id` 5. For each backend involved in the request: - Check L1 cache for existing backend session - On miss, check L2 (MongoDB) - On miss, send `initialize` to the backend, store the returned session ID in both L1 and L2 6. If a backend returns HTTP 400+, Lua invalidates the stale session in both tiers and retries with a fresh session --- ## 5. Nginx Configuration Generation When a virtual server is created, updated, toggled, or deleted, the registry regenerates the nginx configuration. This process is serialized with an `asyncio.Lock` to prevent concurrent reloads. ### Generated Artifacts For each enabled virtual server, three artifacts are produced: **1. Location Block** (in `nginx.conf`): ```nginx # Virtual MCP Server: Dev Tools location /virtual/dev-tools { set $virtual_server_id "dev-tools"; auth_request /validate; auth_request_set $auth_scopes $upstream_http_x_scopes; auth_request_set $auth_user $upstream_http_x_user; rewrite_by_lua_file /etc/nginx/lua/capture_body.lua; content_by_lua_file /etc/nginx/lua/virtual_router.lua; } ``` **2. Internal Backend Locations** (one per unique backend referenced by any virtual server): ```nginx location /_backend/github { internal; proxy_pass https://github-mcp.example.com; proxy_set_header Host github-mcp.example.com; # ... standard proxy headers } ``` **3. JSON Mapping File** (`/etc/nginx/lua/virtual_mappings/dev-tools.json`): ```json { "required_scopes": ["mcp-access"], "tools": [ { "name": "search-repo", "original_name": "search", "backend_location": "/_backend/github", "backend_version": null, "description": "Search repositories", "required_scopes": ["github-access"], "inputSchema": { "type": "object", "properties": { "query": { "type": "string" } } } } ], "tool_backend_map": { "search-repo": { "original_name": "search", "backend_location": "/_backend/github", "backend_version": null, "required_scopes": ["github-access"] } } } ``` The mapping file is read by the Lua router at request time. It provides pre-computed lookup tables so the router does not need to query the registry API for tool metadata on every request. --- ## 6. Tool Aliasing and Version Pinning ### Tool Aliasing Tool aliasing solves naming conflicts and improves tool discoverability: ``` Backend /github exposes: search, create_pr, list_repos Backend /gitlab exposes: search, create_mr, list_projects ``` Without aliasing, both `search` tools would collide. With aliasing: ```json { "tool_mappings": [ { "tool_name": "search", "alias": "github-search", "backend_server_path": "/github" }, { "tool_name": "search", "alias": "gitlab-search", "backend_server_path": "/gitlab" } ] } ``` The client sees `github-search` and `gitlab-search`. When the client calls `github-search`, the Lua router translates it back to `search` before proxying to the `/github` backend. ### Version Pinning Version pinning locks a tool mapping to a specific backend server version: ```json { "tool_name": "search", "alias": "search-repo", "backend_server_path": "/github", "backend_version": "v1.5.0" } ``` When proxying to the backend, the Lua router sets the `X-MCP-Server-Version: v1.5.0` header. The nginx configuration for versioned backends uses separate internal locations: ```nginx location /_backend/github:v1.5.0 { internal; proxy_pass https://github-mcp.example.com; proxy_set_header X-MCP-Server-Version v1.5.0; } ``` This enables scenarios where one virtual server pins to a stable version while another uses the latest. --- ## 7. Access Control ### Scope Validation Flow ``` JWT Token --> auth_request --> Extract scopes --> Lua validation | v +---------------------------+ | 1. Server-level scopes | | required_scopes: [A,B] | | User must have A AND B | +---------------------------+ | v +---------------------------+ | 2. Tool-level scopes | | (on tools/call only) | | tool.required_scopes | +---------------------------+ | v +---------------------------+ | 3. tools/list filtering | | Tools the user cannot | | access are excluded | +---------------------------+ ``` **Server-level scopes** are checked on every request. If the user lacks any required scope, the request is rejected with HTTP 403. **Tool-level scopes** are checked on `tools/call` and used as a filter on `tools/list`. A user who has server-level access but lacks a specific tool scope will not see that tool in listings and cannot invoke it. ### Example ```json { "required_scopes": ["mcp-access"], "tool_scope_overrides": [ { "tool_alias": "search-repo", "required_scopes": ["github-read"] }, { "tool_alias": "create-pr", "required_scopes": ["github-write"] } ] } ``` | User Scopes | Visible Tools | Can Call | |-------------|--------------|---------| | `mcp-access` | (none with extra scopes, any without) | Tools without scope overrides | | `mcp-access`, `github-read` | `search-repo` + unscoped tools | `search-repo` | | `mcp-access`, `github-read`, `github-write` | All tools | All tools | | `github-read` (missing `mcp-access`) | HTTP 403 | Nothing | --- ## 8. JSON-RPC Method Routing The Lua router (`virtual_router.lua`) implements the full MCP protocol for virtual endpoints: ### Method Dispatch Table | Method | Backend Calls | Caching | Session Required | Notes | |--------|--------------|---------|-----------------|-------| | `initialize` | None | No | Creates session | Returns virtual server capabilities | | `ping` | None | No | No | Responds directly | | `notifications/initialized` | None | No | No | Returns HTTP 202 Accepted per MCP spec | | `notifications/cancelled` | None | No | No | Returns HTTP 202 Accepted per MCP spec | | `tools/list` | All distinct backends | 60s TTL | Yes | Aggregated and scope-filtered | | `tools/call` | Single backend | No | Yes | Alias translated, routed to owner backend | | `resources/list` | All distinct backends | 60s TTL | Yes | Aggregated with lookup map | | `resources/read` | Single backend | No | Yes | Routed via lookup map | | `prompts/list` | All distinct backends | 60s TTL | Yes | Aggregated with lookup map | | `prompts/get` | Single backend | No | Yes | Routed via lookup map | **HTTP Method Handling:** - `POST` - JSON-RPC requests and notifications - `GET` - Returns HTTP 405 (server-initiated SSE streams not supported) - `DELETE` - Returns HTTP 405 (client-initiated session termination not supported) ### Concurrent Backend Requests For aggregation methods (`tools/list`, `resources/list`, `prompts/list`), the Lua router issues concurrent subrequests to all distinct backend locations using `ngx.location.capture_multi()`. This parallelizes backend calls and minimizes latency. ```lua -- Pseudocode for concurrent tool aggregation local requests = {} for _, location in ipairs(distinct_backends) do table.insert(requests, { location, { method = ngx.HTTP_POST, body = tools_list_body } }) end local responses = { ngx.location.capture_multi(unpack(requests)) } -- Merge tools from all responses, apply aliases, filter by scope ``` --- ## 9. API Endpoints ### Management API All management endpoints are served by FastAPI on the registry port. | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | `POST` | `/api/virtual-servers` | Admin | Create a new virtual server | | `GET` | `/api/virtual-servers` | User | List all virtual servers | | `GET` | `/api/virtual-servers/{path}` | User | Get a specific virtual server | | `PUT` | `/api/virtual-servers/{path}` | Admin | Update a virtual server | | `DELETE` | `/api/virtual-servers/{path}` | Admin | Delete a virtual server | | `POST` | `/api/virtual-servers/{path}/toggle` | Admin | Enable or disable a virtual server | | `GET` | `/api/virtual-servers/{path}/tools` | User | Get resolved tools with full metadata | | `GET` | `/api/tool-catalog` | User | Browse all available tools across backends | ### Internal API (Lua Router <-> FastAPI) These endpoints are marked `internal` in nginx and are only accessible from Lua subrequests: | Method | Endpoint | Description | |--------|----------|-------------| | `GET` | `/_internal/sessions/{client_id}/{backend}` | Get backend session ID | | `PUT` | `/_internal/sessions/{client_id}/{backend}` | Store backend session ID | | `DELETE` | `/_internal/sessions/{client_id}/{backend}` | Invalidate backend session | | `POST` | `/_internal/sessions` | Create client session record | ### Path Validation Virtual server paths must match the pattern `/virtual/{slug}` where `slug` is lowercase alphanumeric with hyphens. Path traversal attacks are prevented by normalizing and validating paths before any database or filesystem operation. ### Rating Endpoints Virtual servers support user ratings (1-5 stars): | Method | Endpoint | Auth | Description | |--------|----------|------|-------------| | `POST` | `/api/virtual-servers/{path}/rate` | User | Submit or update a rating | | `GET` | `/api/virtual-servers/{path}/rating` | User | Get rating info for a virtual server | **Rating Request:** ```json { "rating": 4 } ``` **Rating Response:** ```json { "average_rating": 4.2, "message": "Rating submitted successfully" } ``` **Get Rating Response:** ```json { "num_stars": 4.2, "rating_details": [ {"user": "alice", "rating": 5}, {"user": "bob", "rating": 4} ] } ``` --- ## 10. Search and Discovery Virtual servers are indexed for semantic search alongside regular MCP servers and A2A agents. ### Indexed Fields The following fields are included in the vector embedding for semantic search: - Server name - Description - Tags (prefixed with "Tags: ") - Tool names (alias or original name from each tool mapping) - Tool description overrides ### Search Collection Virtual servers are stored in the unified `mcp_embeddings_{dimensions}` collection (e.g., `mcp_embeddings_384` for 384-dimension models) with `entity_type: "virtual_server"`. This enables cross-entity search queries that return servers, agents, and virtual servers in a single response. The dimension suffix matches the configured embedding model (384 for sentence-transformers, 1536 for OpenAI/Bedrock Titan). ### Search Document Structure ```json { "_id": "/virtual/dev-tools", "entity_type": "virtual_server", "path": "/virtual/dev-tools", "name": "Dev Tools", "description": "Aggregated development tools", "tags": ["development", "tools"], "is_enabled": true, "tools": [ {"name": "github_search"}, {"name": "jira_search"} ], "embedding": [0.12, -0.34, ...], "metadata": { "server_name": "Dev Tools", "num_tools": 5, "backend_count": 2, "backend_paths": ["/github", "/jira"], "required_scopes": ["mcp-access"], "supported_transports": ["streamable-http"], "created_by": "admin" } } ``` ### Search Result Format When virtual servers match a search query, they appear in the `virtual_servers` array: ```json { "servers": [...], "agents": [...], "virtual_servers": [ { "entity_type": "virtual_server", "path": "/virtual/dev-tools", "server_name": "Dev Tools", "description": "Aggregated development tools", "relevance_score": 0.85, "tags": ["development", "tools"], "backend_paths": ["/github", "/jira"], "tool_count": 5, "matching_tools": [ {"tool_name": "github_search"} ] } ], "tools": [...], "skills": [...] } ``` ### Indexing Lifecycle Virtual servers are indexed/re-indexed when: - Created via `POST /api/virtual-servers` - Updated via `PUT /api/virtual-servers/{path}` - Toggled via `POST /api/virtual-servers/{path}/toggle` - Deleted (removed from search index) --- ## 11. Frontend Management UI The management UI provides a multi-step wizard for creating and editing virtual servers: ### Wizard Steps 1. **Basics**: Server name (auto-generates path slug), description, tags, transport selection 2. **Tool Selection**: Interactive picker showing all available tools grouped by backend server, with search filtering 3. **Configuration**: Per-tool alias assignment, version pinning, scope overrides, description overrides 4. **Review**: Summary of the complete configuration before submission ### Dashboard Integration Virtual servers appear on the main dashboard alongside regular MCP servers. They are visually distinguished with a different color scheme and a "Virtual" badge. A dedicated "Virtual MCP" filter tab in the dashboard allows viewing only virtual servers. ### Key Components | Component | Purpose | |-----------|---------| | `VirtualServerList` | Table view with search, toggle, edit, delete | | `VirtualServerCard` | Dashboard card with status, tool count, backend count | | `VirtualServerForm` | 4-step creation/edit wizard | | `ToolSelector` | Searchable tool picker grouped by backend | | `useVirtualServers` | React hook for CRUD with optimistic updates | --- ## 12. Validation and Error Handling ### Creation-Time Validation When a virtual server is created or updated, the service layer performs the following validations: 1. **Path format**: Must match `/virtual/[a-z0-9-]+` 2. **Path uniqueness**: No existing virtual server with the same path 3. **Backend existence**: Each `backend_server_path` must reference a registered, enabled server 4. **Tool existence**: Each `tool_name` must exist in the referenced backend's tool list 5. **Alias uniqueness**: No two tool mappings may produce the same effective name 6. **Scope override validity**: Each `tool_alias` in scope overrides must match an existing tool mapping ### Runtime Error Handling | Error Condition | Lua Router Behavior | |----------------|---------------------| | Missing `Mcp-Session-Id` header | Returns JSON-RPC error: "Missing session" | | Invalid/expired client session | Returns JSON-RPC error: "Invalid session" | | Backend returns HTTP 400+ | Invalidates cached session, retries with fresh `initialize` | | Backend unreachable | Returns JSON-RPC error with backend details | | User lacks required scope | Returns HTTP 403 with scope details | | Unknown tool name in `tools/call` | Returns JSON-RPC error: "Tool not found" | | Unknown JSON-RPC method | Returns JSON-RPC error: "Method not found" | --- ## 13. Performance Characteristics ### Caching Strategy | Data | Cache Location | TTL | Invalidation | |------|---------------|-----|--------------| | Backend sessions | L1 (shared dict) | 30s | On 400+ response | | Backend sessions | L2 (MongoDB) | 1 hour | On 400+ response | | Enriched tool list | L1 (shared dict) | 60s | On nginx reload | | Resource/prompt lookup maps | L1 (shared dict) | 60s | On nginx reload | | Mapping files | Disk | Until regenerated | On CRUD mutation | ### Stress Test Results Testing with a production-representative configuration: | Scenario | Requests | Throughput | Error Rate | |----------|----------|------------|------------| | Concurrent `tools/list` | 1,000 | 68.9 req/s | 0% | | Concurrent `tools/call` | 1,000 | 57.9 req/s | 0% | | Mixed workload | 1,000 | 5.2 req/s | 0% | | Session storm (100 concurrent inits) | 100 | 43.7 req/s | 0% | ### Latency Overhead Virtual server routing adds overhead compared to direct backend access due to session lookup, tool mapping resolution, and (for aggregation methods) concurrent subrequests. The latency benchmarks measure 20 iterations per method to characterize this overhead under realistic conditions. --- ## 14. Deployment Considerations ### Multi-Instance Behavior - Each nginx worker maintains its own L1 shared dict cache - L2 (MongoDB) provides cross-instance session consistency - Nginx config regeneration is triggered by the registry instance that receives the mutation - In multi-registry deployments, a mechanism for cross-instance nginx reload propagation would be needed (not currently implemented) ### Configuration Reload When a virtual server is mutated: 1. The service acquires a global `asyncio.Lock` to serialize reload operations 2. Full nginx configuration is regenerated (including all virtual and non-virtual servers) 3. Mapping JSON files are written to disk 4. `nginx -s reload` is issued 5. The lock is released This approach is simple and correct but means all virtual server mutations are serialized. For typical management workloads (infrequent CRUD), this is not a bottleneck. ### Resource Sizing | Resource | Sizing Guidance | |----------|----------------| | Shared dict memory | 2 MB covers ~10K cached entries | | MongoDB `backend_sessions` | TTL-indexed, self-cleaning | | Mapping files on disk | ~1-10 KB per virtual server | | Nginx location blocks | One per virtual server + one per unique backend | --- ## 15. Limitations and Future Work ### Current Limitations - **No resource subscriptions**: `listChanged` notifications from backends are not propagated through virtual servers - **No per-backend load balancing**: Each backend location maps to a single upstream; horizontal scaling of a backend requires external load balancing - **No streaming support**: The current Lua router buffers full request/response bodies; SSE streaming through virtual servers is not implemented - **Single-instance nginx reload**: Config regeneration assumes a single nginx instance; multi-instance coordination is not built in ### Future Enhancements - **Dynamic tool routing**: Route a single tool to different backends based on request parameters or user attributes - **Weighted backend selection**: Load balance across multiple instances of the same backend - **SSE pass-through**: Support streaming transports for long-running tool calls - **Cross-instance reload coordination**: Notify peer registry instances when nginx config changes - **Tool usage analytics**: Track per-tool invocation counts, latency, and error rates at the virtual server level - **Template virtual servers**: Pre-defined virtual server templates for common tool bundles --- ## 16. File Reference ``` registry/ schemas/ virtual_server_models.py # Pydantic data models backend_session_models.py # Session tracking models services/ virtual_server_service.py # Business logic and validation tool_catalog_service.py # Cross-backend tool aggregation repositories/ interfaces.py # Repository interfaces documentdb/ virtual_server_repository.py # MongoDB persistence backend_session_repository.py # Session persistence with TTL api/ virtual_server_routes.py # REST API endpoints core/ nginx_service.py # Nginx config + mapping generation docker/ lua/ virtual_router.lua # Lua JSON-RPC router frontend/ src/ types/virtualServer.ts # TypeScript type definitions hooks/useVirtualServers.ts # React data hooks components/ VirtualServerList.tsx # List/table view VirtualServerCard.tsx # Dashboard card VirtualServerForm.tsx # Multi-step wizard ToolSelector.tsx # Interactive tool picker tests/ unit/ test_virtual_server_models.py # Model validation tests test_virtual_server_service.py # Service layer tests test_virtual_server_nginx.py # Nginx generation tests test_backend_session_repository.py # Session repository tests integration/ test_virtual_server_api.py # API endpoint tests e2e/ test_virtual_mcp_protocol.py # MCP protocol E2E tests test_virtual_mcp_latency.py # Latency benchmarks test_virtual_mcp_stress.py # Stress tests ``` ================================================ FILE: docs/dynamic-tool-discovery.md ================================================ # Dynamic Tool Discovery and Invocation The MCP Gateway & Registry provides a powerful **Dynamic Tool Discovery and Invocation** feature that enables AI agents to autonomously discover and execute tools beyond their initial capabilities. This feature uses advanced semantic search with FAISS indexing and sentence transformers to intelligently match natural language queries to the most relevant MCP tools across all registered servers. ## Table of Contents - [Overview](#overview) - [How It Works](#how-it-works) - [Architecture](#architecture) - [Usage Examples](#usage-examples) - [Agent Integration](#agent-integration) - [API Reference](#api-reference) - [Technical Implementation](#technical-implementation) - [Demo](#demo) ## Overview Traditional AI agents are limited to the tools they were initially configured with. The Dynamic Tool Discovery feature breaks this limitation by allowing agents to: 1. **Discover new tools** through natural language queries 2. **Automatically find** the most relevant tools from hundreds of available MCP servers 3. **Dynamically invoke** discovered tools without prior configuration 4. **Expand capabilities** on-demand based on user requests This enables agents to handle tasks they weren't originally designed for, making them truly adaptive and extensible. ## How It Works The dynamic tool discovery process follows these steps: 1. **Natural Language Query**: Agent receives a user request requiring specialized capabilities 2. **Semantic Search**: The `intelligent_tool_finder` tool processes the query using sentence transformers 3. **FAISS Index Search**: Searches through embeddings of all registered MCP tools 4. **Relevance Ranking**: Returns tools ranked by semantic similarity to the query 5. **Tool Invocation**: Agent uses the discovered tool information to invoke the appropriate MCP tool ![Dynamic Tool Discovery Flow](img/dynamic-tool-discovery-demo.gif) ## Architecture ### Components ```mermaid graph TB subgraph "Agent Layer" A[AI Agent] --> B[Natural Language Query] A --> H[invoke_mcp_tool] end subgraph "Discovery Layer" B --> C[intelligent_tool_finder] C --> D[Sentence Transformer] C --> E[FAISS Index] E --> F[Tool Metadata] F --> G[Server Information] G --> K[Tool Discovery Results] K --> A end subgraph "Execution Layer" H --> I[Target MCP Server] I --> J[Tool Result] J --> A end ``` ### Key Technologies - **FAISS (Facebook AI Similarity Search)**: High-performance vector similarity search - **Sentence Transformers**: Neural network models for semantic text understanding - **Cosine Similarity**: Mathematical measure of semantic similarity between queries and tools - **MCP Protocol**: Standardized communication with tool servers ## Usage Examples Dynamic tool discovery can be used in two primary ways: ### 1. Direct Developer Usage Agent developers can directly call the `intelligent_tool_finder` in their code to discover tools, then use the results with the `invoke_mcp_tool` function to call the discovered tool. #### Basic Discovery ```python # Basic usage with session cookie tools = await intelligent_tool_finder( natural_language_query="what time is it in Tokyo", session_cookie="your_session_cookie_here" ) # Returns information about relevant tools: # [ # { # "tool_name": "current_time_by_timezone", # "service_path": "/currenttime", # "service_name": "Current Time Server", # "tool_schema": {...}, # "overall_similarity_score": 0.89 # } # ] ``` #### Advanced Discovery ```python # Advanced usage with multiple results tools = await intelligent_tool_finder( natural_language_query="stock market information and financial data", session_cookie="your_session_cookie", top_k_services=5, top_n_tools=3 ) ``` #### Complete Workflow ```python # 1. Discover tools for weather information weather_tools = await intelligent_tool_finder( natural_language_query="weather forecast for tomorrow", session_cookie="your_session_cookie" ) # 2. Use the discovered tool if weather_tools: tool_info = weather_tools[0] # Get the best match result = await invoke_mcp_tool( mcp_registry_url="https://your-registry.com/mcpgw/sse", server_name=tool_info["service_path"], # e.g., "/weather" tool_name=tool_info["tool_name"], # e.g., "get_forecast" arguments={"location": "New York", "days": 1}, auth_token=auth_token, user_pool_id=user_pool_id, client_id=client_id, region=region, auth_method="m2m" ) ``` ### 2. Agent Integration The more powerful approach is when AI agents themselves use dynamic tool discovery autonomously. The agent has access to both `intelligent_tool_finder` and `invoke_mcp_tool` as available tools, allowing it to discover and execute new capabilities on-demand. **Demo Video**: [Watch the agent integration in action](https://github.com/user-attachments/assets/cee1847d-ecc1-406b-a83e-ebc80768430d) #### System Prompt Configuration Agents are configured with instructions on how to use dynamic tool discovery: ```text When a user requests something that requires a specialized tool you don't have direct access to, use the intelligent_tool_finder tool. How to use intelligent_tool_finder: 1. When you identify that a task requires a specialized tool (e.g., weather forecast, time information, etc.) 2. Call the tool with a description of what you need: `intelligent_tool_finder("description of needed capability")`. 3. The tool will return the most appropriate specialized tool along with usage instructions 4. You can then use the invoke_mcp_tool to invoke this discovered tool by providing the MCP Registry URL, server name, tool name, and required arguments Example workflow: 1. Discover a tool: result = intelligent_tool_finder("current time timezone") 2. The result provides details about a time tool on the "currenttime" MCP server. 3. Always use the "service_path" path field for the server name while creating the arguments for the invoke_mcp_tool in the next step. 4. Use invoke_mcp_tool to call it with ALL required auth parameters ``` #### Agent Implementation The agent implementation in [`agents/agent.py`](../agents/agent.py) shows how to: 1. **Load MCP tools** from the registry 2. **Combine built-in and discovered tools** 3. **Handle authentication** for both session cookie and M2M methods 4. **Process tool discovery results** Key code snippet: ```python # Get available tools from MCP and display them mcp_tools = await client.get_tools() logger.info(f"Available MCP tools: {[tool.name for tool in mcp_tools]}") # Add the calculator and invoke_mcp_tool to the tools array # The invoke_mcp_tool function already supports authentication parameters all_tools = [calculator, invoke_mcp_tool] + mcp_tools logger.info(f"All available tools: {[tool.name if hasattr(tool, 'name') else tool.__name__ for tool in all_tools]}") # Create the agent with the model and all tools agent = create_react_agent(model, all_tools) ``` This integration enables agents to have **limitless capabilities** - they can handle any task for which there's an appropriate MCP tool registered in the system, even if they weren't originally programmed with knowledge of that tool. ## API Reference ### intelligent_tool_finder Finds the most relevant MCP tool(s) across all registered and enabled services based on a natural language query. #### Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `natural_language_query` | `str` | Yes | Your query in natural language describing the task you want to perform | | `username` | `str` | No* | Username for mcpgw server authentication | | `password` | `str` | No* | Password for mcpgw server authentication | | `session_cookie` | `str` | No* | Session cookie for registry authentication | | `top_k_services` | `int` | No | Number of top services to consider from initial FAISS search (default: 3) | | `top_n_tools` | `int` | No | Number of best matching tools to return (default: 1) | *Either `session_cookie` OR (`username` AND `password`) must be provided for authentication. #### Returns ```python List[Dict[str, Any]] ``` A list of dictionaries, each describing a recommended tool: ```python [ { "tool_name": "current_time_by_timezone", "tool_parsed_description": { "main": "Get current time for a specific timezone", "parameters": {...} }, "tool_schema": { "type": "object", "properties": {...} }, "service_path": "/currenttime", "service_name": "Current Time Server", "overall_similarity_score": 0.89 } ] ``` #### Example Usage ```python # Basic usage with session cookie tools = await intelligent_tool_finder( natural_language_query="what time is it in Tokyo", session_cookie="your_session_cookie_here" ) # Advanced usage with multiple results tools = await intelligent_tool_finder( natural_language_query="stock market information and financial data", session_cookie="your_session_cookie", top_k_services=5, top_n_tools=3 ) ``` ## Technical Implementation ### FAISS Index Creation The registry automatically creates and maintains a FAISS index of all registered MCP tools: 1. **Tool Metadata Collection**: Gathers tool descriptions, schemas, and server information 2. **Text Embedding**: Uses sentence transformers to create vector embeddings 3. **Index Building**: Constructs FAISS index for fast similarity search 4. **Automatic Updates**: Refreshes index when servers are added/modified ### Semantic Search Process ```python # 1. Embed the natural language query query_embedding = await asyncio.to_thread(_embedding_model_mcpgw.encode, [natural_language_query]) query_embedding_np = np.array(query_embedding, dtype=np.float32) # 2. Search FAISS for top_k_services distances, faiss_ids = await asyncio.to_thread(_faiss_index_mcpgw.search, query_embedding_np, top_k_services) # 3. Collect tools from top services candidate_tools = [] for service in top_services: for tool in service.tools: tool_text = f"Service: {service.name}. Tool: {tool.name}. Description: {tool.description}" candidate_tools.append({ "text_for_embedding": tool_text, "tool_name": tool.name, "service_path": service.path, # ... other metadata }) # 4. Embed all candidate tool descriptions tool_embeddings = await asyncio.to_thread(_embedding_model_mcpgw.encode, tool_texts) # 5. Calculate cosine similarity and rank similarities = cosine_similarity(query_embedding_np, tool_embeddings_np)[0] ranked_tools = sorted(tools_with_scores, key=lambda x: x["similarity_score"], reverse=True) ``` ### Performance Optimizations - **Lazy Loading**: FAISS index and models are loaded on-demand - **Caching**: Embeddings and metadata are cached and reloaded only when files change - **Async Processing**: All embedding operations run in separate threads - **Memory Efficiency**: Uses float32 precision for embeddings to reduce memory usage ### Model Configuration The system uses configurable sentence transformer models: ```python # Default model (lightweight, fast) EMBEDDINGS_MODEL_NAME = 'all-MiniLM-L6-v2' # 384 dimensions # Model loading with caching model_cache_path = _registry_server_data_path.parent / ".cache" _embedding_model_mcpgw = SentenceTransformer(EMBEDDINGS_MODEL_NAME, cache_folder=model_cache_path) ``` ## Demo **Demo Video**: [Dynamic Tool Discovery and Invocation](https://github.com/user-attachments/assets/cee1847d-ecc1-406b-a83e-ebc80768430d) ### Example Interaction **User Query**: "What's the current time in Tokyo?" **Agent Process**: 1. Agent recognizes need for time information 2. Calls `intelligent_tool_finder("current time in Tokyo")` 3. Discovers `current_time_by_timezone` tool from `/currenttime` server 4. Invokes tool with `{"tz_name": "Asia/Tokyo"}` 5. Returns formatted time result **Result**: "The current time in Tokyo is 2024-01-15 14:30:45 JST" ### Performance Metrics _Coming soon._ ## Best Practices ### For Tool Developers 1. **Descriptive Names**: Use clear, descriptive tool names 2. **Rich Descriptions**: Provide detailed tool descriptions with use cases 3. **Proper Schemas**: Include comprehensive parameter schemas 4. **Consistent Naming**: Follow naming conventions for better discoverability ### For Agent Developers 1. **Specific Queries**: Use specific, descriptive queries for better matches 2. **Fallback Handling**: Implement fallbacks when no suitable tools are found 3. **Authentication**: Always include proper authentication parameters 4. **Error Handling**: Handle tool discovery and invocation errors gracefully ### For System Administrators 1. **Index Maintenance**: Monitor FAISS index updates and performance 2. **Model Updates**: Consider updating sentence transformer models periodically 3. **Server Health**: Ensure registered servers are healthy and responsive 4. **Access Control**: Configure proper authentication and authorization ================================================ FILE: docs/embeddings.md ================================================ # Embeddings Configuration Flexible, vendor-agnostic embeddings generation for MCP Gateway Registry's semantic search functionality. ## Overview The MCP Gateway Registry provides semantic search capabilities across MCP servers, tools, and AI agents. You can choose from three embedding provider options to power this search: 1. **Sentence Transformers** (Default) - Local models 2. **OpenAI** - Cloud embeddings via API 3. **Any LiteLLM-supported provider** - Amazon Bedrock Titan, Cohere, and 100+ other models Switch between providers with simple configuration changes - no code modifications required. ## Features - **Vendor-agnostic**: Switch between embeddings providers with configuration changes - **Local & Cloud Support**: Use local models or cloud APIs (OpenAI, Cohere, Amazon Bedrock, etc.) - **Backward Compatible**: Works seamlessly with existing FAISS indices - **Easy Configuration**: Simple environment variable setup - **Extensible**: Easy to add new providers - **AWS Deployable**: Terraform support for AWS deployments ## Quick Start ### Option 1: Sentence Transformers (Default) Local embedding models that run on your infrastructure. ```bash # In .env EMBEDDINGS_PROVIDER=sentence-transformers EMBEDDINGS_MODEL_NAME=all-MiniLM-L6-v2 EMBEDDINGS_MODEL_DIMENSIONS=384 ``` **Characteristics:** - Runs locally on your infrastructure - No API costs - No external network calls required - Requires CPU/GPU resources - Model files stored locally - Data stays within your infrastructure ### Option 2: OpenAI Cloud-based embedding service via OpenAI API. ```bash # In .env EMBEDDINGS_PROVIDER=litellm EMBEDDINGS_MODEL_NAME=openai/text-embedding-ada-002 EMBEDDINGS_MODEL_DIMENSIONS=1536 EMBEDDINGS_API_KEY=sk-your-openai-api-key ``` **Characteristics:** - Cloud-based service - Requires API key - API costs per 1K tokens - No local compute resources needed - Network dependency - Data sent to OpenAI ### Option 3: Amazon Bedrock Titan Cloud-based embedding service via AWS Bedrock. ```bash # In .env EMBEDDINGS_PROVIDER=litellm EMBEDDINGS_MODEL_NAME=bedrock/amazon.titan-embed-text-v1 EMBEDDINGS_MODEL_DIMENSIONS=1536 EMBEDDINGS_AWS_REGION=us-east-1 # No API key needed - uses IAM ``` **Characteristics:** - Cloud-based service - Uses IAM authentication (no API key required) - Integrates with AWS security model - API costs apply - Requires AWS credentials - Available in select AWS regions ## Configuration ### Environment Variables | Variable | Description | Default | Required | |----------|-------------|---------|----------| | `EMBEDDINGS_PROVIDER` | Provider type: `sentence-transformers` or `litellm` | `sentence-transformers` | No | | `EMBEDDINGS_MODEL_NAME` | Model identifier | `all-MiniLM-L6-v2` | Yes | | `EMBEDDINGS_MODEL_DIMENSIONS` | Embedding dimension | `384` | Yes | | `EMBEDDINGS_API_KEY` | API key for cloud provider (OpenAI, Cohere, etc.) | - | For cloud* | | `EMBEDDINGS_API_BASE` | Custom API endpoint (LiteLLM only) | - | No | | `EMBEDDINGS_AWS_REGION` | AWS region for Bedrock (LiteLLM only) | - | For Bedrock | *Not required for AWS Bedrock - use standard AWS credential chain (IAM roles, environment variables, ~/.aws/credentials) ### Terraform Configuration For AWS ECS deployments, configure embeddings in your `terraform.tfvars`: #### Using Sentence Transformers (Default) ```hcl # Local embeddings - no additional configuration needed # Uses defaults: sentence-transformers with all-MiniLM-L6-v2 ``` #### Using OpenAI ```hcl embeddings_provider = "litellm" embeddings_model_name = "openai/text-embedding-ada-002" embeddings_model_dimensions = 1536 embeddings_api_key = "sk-proj-YOUR-OPENAI-API-KEY" ``` #### Using Amazon Bedrock ```hcl embeddings_provider = "litellm" embeddings_model_name = "bedrock/amazon.titan-embed-text-v1" embeddings_model_dimensions = 1536 embeddings_aws_region = "us-east-1" embeddings_api_key = "" # Empty for Bedrock (uses IAM) ``` See [terraform/aws-ecs/terraform.tfvars.example](../terraform/aws-ecs/terraform.tfvars.example) for complete examples. ## Supported Models ### Sentence Transformers (Local) | Model | Dimensions | Description | |-------|------------|-------------| | `all-MiniLM-L6-v2` | 384 | Fast, lightweight (default) | | `all-mpnet-base-v2` | 768 | High quality | | `paraphrase-multilingual-MiniLM-L12-v2` | 384 | Multilingual | Any model from [Hugging Face sentence-transformers](https://huggingface.co/models?library=sentence-transformers) is supported. ### LiteLLM (Cloud-based) LiteLLM supports 100+ embedding models from various providers: #### OpenAI - `openai/text-embedding-3-small` (1536 dimensions) - `openai/text-embedding-3-large` (3072 dimensions) - `openai/text-embedding-ada-002` (1536 dimensions) #### Cohere - `cohere/embed-english-v3.0` (1024 dimensions) - `cohere/embed-multilingual-v3.0` (1024 dimensions) #### Amazon Bedrock - `bedrock/amazon.titan-embed-text-v1` (1536 dimensions) - `bedrock/cohere.embed-english-v3` (1024 dimensions) - `bedrock/cohere.embed-multilingual-v3` (1024 dimensions) #### Other Providers - Azure OpenAI - Anthropic (Claude) - Google Vertex AI - Hugging Face Inference API - And 100+ more via [LiteLLM](https://docs.litellm.ai/docs/embedding/supported_embedding) ## Migration Between Providers ### Switching Providers When you switch embedding providers or models with different dimensions, the registry automatically: 1. Detects dimension mismatch 2. Rebuilds the FAISS index 3. Regenerates embeddings for all registered items Example logs when switching from sentence-transformers (384) to OpenAI (1536): ``` WARNING: Embedding dimension mismatch detected Expected: 384 (from existing index) Got: 1536 (from current model) Rebuilding FAISS index with new dimensions... Regenerating embeddings for all items... Index rebuild complete ``` ### No Code Changes Required Just update your environment variables or Terraform configuration: ```bash # From EMBEDDINGS_PROVIDER=sentence-transformers EMBEDDINGS_MODEL_NAME=all-MiniLM-L6-v2 EMBEDDINGS_MODEL_DIMENSIONS=384 # To EMBEDDINGS_PROVIDER=litellm EMBEDDINGS_MODEL_NAME=openai/text-embedding-ada-002 EMBEDDINGS_MODEL_DIMENSIONS=1536 EMBEDDINGS_API_KEY=sk-your-key ``` Restart the service and the index will be automatically rebuilt. ## AWS Bedrock Setup ### IAM Permissions For Amazon Bedrock embeddings, ensure your ECS task role has the following permissions: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "bedrock:InvokeModel" ], "Resource": [ "arn:aws:bedrock:*::foundation-model/amazon.titan-embed-text-v1" ] } ] } ``` ### Authentication Methods **IAM Roles (Recommended for ECS/EC2/EKS)** ```bash # No additional configuration needed # ECS task, EC2 instance, or EKS pod automatically uses attached IAM role ``` ## Architecture ### Embeddings Module Design ``` EmbeddingsClient (Abstract Base Class) ├── SentenceTransformersClient (Local models) └── LiteLLMClient (Cloud APIs via LiteLLM) ``` ### Integration with FAISS Search The embeddings module integrates seamlessly with the FAISS search service: ```python # In registry/search/service.py from registry.embeddings import create_embeddings_client class FaissService: async def _load_embedding_model(self): self.embedding_model = create_embeddings_client( provider=settings.embeddings_provider, model_name=settings.embeddings_model_name, api_key=settings.embeddings_api_key, aws_region=settings.embeddings_aws_region, embedding_dimension=settings.embeddings_model_dimensions, ) ``` ## Performance Considerations ### Local Models (Sentence Transformers) - Runs on your infrastructure (CPU/GPU) - No external API calls - No per-request costs - Model files stored locally - Network-independent operation ### Cloud APIs (LiteLLM) - Runs on provider infrastructure - Requires network connectivity - API costs apply (varies by provider) - No local compute requirements - Data transmitted to provider ## Graceful Degradation ### Lexical Fallback When Model Unavailable If the embedding model fails to load or is unreachable (e.g., invalid model name, expired API key, network failure), the search system automatically falls back to **lexical-only search** instead of returning errors. **What happens:** 1. The embeddings client caches the load error (`_load_error`) to avoid repeated download/API attempts 2. The search repository detects the failure and sets `_embedding_unavailable = True` 3. All subsequent searches use keyword matching (regex on path, name, description, tags, tools) instead of vector similarity 4. Servers and agents are still indexed, but without embeddings (stored with empty vectors) 5. The API response includes `"search_mode": "lexical-only"` to indicate reduced search quality **How to detect:** - Check the API response `search_mode` field: `"hybrid"` (normal) vs. `"lexical-only"` (fallback) - Look for log warnings: `"Embedding model unavailable, falling back to lexical-only search"` - During indexing: `"Embedding model unavailable, indexing '' without embeddings"` **How to recover:** Fix the embedding configuration and restart the service. On restart, the error cache is cleared and the system will attempt to load the model again. If successful, search returns to full hybrid mode automatically. See [Hybrid Search Architecture](design/hybrid-search-architecture.md) for details on lexical-only scoring. ## Troubleshooting ### Embedding Model Not Found ``` Failed to load SentenceTransformer model: sentence-transformers/my-model is not a local folder and is not a valid model identifier listed on 'https://huggingface.co/models' ``` **Solution:** Verify the model name in `EMBEDDINGS_MODEL_NAME` is correct. Check the [Hugging Face model hub](https://huggingface.co/models?library=sentence-transformers) for valid names. The system will continue operating with lexical-only search until the model is fixed. ### LiteLLM Not Installed ``` RuntimeError: LiteLLM is not installed. Install it with: uv add litellm ``` **Solution:** ```bash uv add litellm ``` ### Dimension Mismatch ``` WARNING: Embedding dimension mismatch: expected 384, got 1536 ``` **Solution:** Update `EMBEDDINGS_MODEL_DIMENSIONS` to match your model's actual output dimension. The system will automatically rebuild the index. ### API Authentication Errors **OpenAI:** ```bash # Verify API key is set correctly echo $EMBEDDINGS_API_KEY # Should start with sk- ``` **Bedrock:** ```bash # Verify AWS credentials aws sts get-caller-identity # Check Bedrock access aws bedrock list-foundation-models --region us-east-1 ``` ### Missing IAM Permissions If using AWS ECS and Bedrock, ensure the task execution role has access to the embeddings API key secret: ```bash # Check IAM policy in terraform/aws-ecs/modules/mcp-gateway/iam.tf # Should include: aws_secretsmanager_secret.embeddings_api_key.arn ``` ## API Reference ### Factory Function ```python from registry.embeddings import create_embeddings_client client = create_embeddings_client( provider: str, # "sentence-transformers" or "litellm" model_name: str, # Model identifier api_key: Optional[str] = None, # API key (litellm only) aws_region: Optional[str] = None, # AWS region (Bedrock only) embedding_dimension: Optional[int] = None, ) ``` ### Client Methods **Generate Embeddings:** ```python embeddings = client.encode(["text1", "text2"]) # Returns: numpy array of shape (n_texts, embedding_dim) ``` **Get Dimension:** ```python dim = client.get_embedding_dimension() # Returns: int (e.g., 384, 1536) ``` ## Best Practices 1. Choose the provider that matches your deployment requirements 2. Consider IAM authentication if deploying on AWS 3. Monitor costs when using cloud APIs - implement caching if needed 4. Keep dimension consistent - changing models requires index rebuild 5. Test search results after switching providers to ensure they meet your requirements ## Further Reading - [LiteLLM Documentation](https://docs.litellm.ai/docs/) - [OpenAI Embeddings Guide](https://platform.openai.com/docs/guides/embeddings) - [Amazon Bedrock Embeddings](https://docs.aws.amazon.com/bedrock/latest/userguide/embeddings.html) - [Sentence Transformers Models](https://www.sbert.net/docs/pretrained_models.html) - [FAISS Search Implementation](../registry/search/service.py) ## Contributing To add a new embeddings provider: 1. Create a new client class inheriting from `EmbeddingsClient` 2. Implement `encode()` and `get_embedding_dimension()` methods 3. Update `create_embeddings_client()` factory function 4. Add configuration options to `registry/core/config.py` 5. Update this documentation ## License Apache 2.0 - See [LICENSE](../LICENSE) file for details ================================================ FILE: docs/entra-id-setup.md ================================================ # Microsoft Entra ID Setup Guide This guide provides step-by-step instructions for setting up Microsoft Entra ID (formerly Azure Active Directory) authentication for the MCP Gateway Registry. ## Table of Contents 1. [Prerequisites](#prerequisites) 2. [Azure Portal Configuration](#azure-portal-configuration) 3. [Environment Configuration](#environment-configuration) 4. [Group Configuration](#group-configuration) 5. [Testing the Setup](#testing-the-setup) 6. [Troubleshooting](#troubleshooting) 7. [Using the IAM API to Manage Groups, Users, and M2M Accounts](#using-the-iam-api-to-manage-groups-users-and-m2m-accounts) 8. [Generating JWT Tokens for M2M Accounts](#generating-jwt-tokens-for-m2m-accounts) --- ## Prerequisites Before you begin, ensure you have: - Access to an Azure account with permissions to create App Registrations - Azure Active Directory (Entra ID) tenant - Admin rights to configure App Registrations and assign users to groups - The MCP Gateway Registry codebase --- ## Azure Portal Configuration ### Step 1: Create an App Registration 1. Navigate to the [Azure Portal](https://portal.azure.com) 2. Go to **Azure Active Directory** → **App registrations** 3. Click **New registration** 4. Configure the app registration: - **Name**: `mcp-gateway-web` (or your preferred name) - **Supported account types**: Select the appropriate option: - **Single tenant** (recommended): Only users in your organization - **Multi-tenant**: Users from any Azure AD tenant - **Redirect URI**: - Platform: **Web** - URI: `http://localhost/auth/callback` (for local development) - For production, use: `https://your-domain.com/auth/callback` 5. Click **Register** ### Step 2: Note Your Application IDs After creating the app registration, note the following values (you'll need them later): 1. From the app registration **Overview** page: - **Application (client) ID**: This is your `ENTRA_CLIENT_ID` - **Directory (tenant) ID**: This is your `ENTRA_TENANT_ID` ### Step 3: Create a Client Secret 1. In your app registration, click **Certificates & secrets** in the left menu 2. Click **New client secret** 3. Configure the secret: - **Description**: `mcp-gateway-auth` (or your preferred description) - **Expires**: Choose an appropriate expiration period (recommended: 24 months) 4. Click **Add** 5. **IMPORTANT**: Copy the **Value** immediately (not the Secret ID) - This is your `ENTRA_CLIENT_SECRET` - You cannot retrieve this value later - if you lose it, you'll need to create a new secret ### Step 4: Configure Redirect URIs 1. In your app registration, click **Authentication** in the left menu 2. Under **Platform configurations** → **Web**, add redirect URIs: - For local development: `http://localhost/auth/callback` - For production: `https://your-domain.com/auth/callback` 3. Under **Implicit grant and hybrid flows**, ensure nothing is checked (not needed for authorization code flow) 4. Click **Save** ### Step 5: Add API Permissions To get user email and group information, you need to configure API permissions: 1. Click **API permissions** in the left menu 2. Click **Add a permission** 3. Select **Microsoft Graph** 4. Select **Delegated permissions** 5. Search for and add the following permissions: - `User.Read` (should already be present) - `email` - Read user's email address - `profile` - Read user's basic profile - `GroupMember.Read.All` - Read groups user belongs to 6. Click **Add permissions** #### Application Permissions (Required for IAM Management) If you plan to use the IAM management UI (Settings -> Users/Groups) or the IAM API to manage users, groups, and M2M accounts, you must also add **Application permissions**. These are used by the server-side client credentials flow to call the Microsoft Graph API. 1. Click **Add a permission** 2. Select **Microsoft Graph** 3. Select **Application permissions** 4. Search for and add the following permissions: **Read-only access (minimum for IAM UI to list users and groups):** - `User.Read.All` - Read all users' full profiles - `Group.Read.All` - Read all groups - `GroupMember.Read.All` - Read all group memberships **Read-write access (required to create/delete users, groups, and M2M accounts):** - `User.ReadWrite.All` - Create, update, and delete users - `Group.ReadWrite.All` - Create, update, and delete groups - `GroupMember.ReadWrite.All` - Manage group memberships - `Application.ReadWrite.All` - Create and manage M2M service principal accounts 5. Click **Add permissions** **CRITICAL**: Click **Grant admin consent for [Your Tenant]** - This step is required for **both** Delegated and Application permissions to work - You need admin privileges to grant consent - Without admin consent for Application permissions, the IAM management features will return `403 Forbidden` errors ### Step 6: Configure Optional Claims To include email, username, and groups in the ID token: 1. Click **Token configuration** in the left menu 2. Click **Add optional claim** 3. Select **ID** token type 4. Add these claims: - `email` - User's email address - `preferred_username` - User's UPN (User Principal Name) - `groups` - Security group Object IDs 5. Click **Add** 6. When prompted "Turn on the Microsoft Graph email, profile permission", click **Add** ### Step 7: Configure Group Claims 1. Still in **Token configuration** 2. Click **Add groups claim** 3. Select **Security groups** 4. Under "Customize token properties by type": - **ID**: Check "Group ID" - **Access**: Check "Group ID" 5. Click **Add** ### Step 8: Create Security Groups Create Azure AD security groups for authorization: 1. Go to **Azure Active Directory** → **Groups** 2. Click **New group** 3. Create an admin group: - **Group type**: Security - **Group name**: `Mcp-test-admin` (or your preferred name) - **Group description**: MCP Gateway administrators - **Membership type**: Assigned 4. Click **Create** 5. Repeat for a users group: - **Group name**: `mcp-test-users` (or your preferred name) - **Group description**: MCP Gateway users ### Step 9: Note Group Object IDs For each group you created: 1. Click on the group name 2. From the **Overview** page, copy the **Object Id** 3. Note these IDs - you'll need them for `scopes.yml` configuration ### Step 10: Add Users to Groups 1. For each group, click on the group name 2. Click **Members** in the left menu 3. Click **Add members** 4. Search for and select users 5. Click **Select** ### Step 11: Configure App for API Access (Optional) If you plan to use machine-to-machine (M2M) authentication: 1. Click **Expose an API** in the left menu 2. Click **Add** next to "Application ID URI" 3. Accept the default (`api://{client-id}`) or customize it 4. Click **Save** 5. Click **Add a scope** 6. Configure the scope: - **Scope name**: `.default` - **Who can consent**: Admins only - **Admin consent display name**: Access MCP Gateway - **Admin consent description**: Allow the application to access MCP Gateway - **State**: Enabled 7. Click **Add scope** --- ## Environment Configuration ### Step 1: Update .env File 1. Copy `.env.example` to `.env` if you haven't already: ```bash cp .env.example .env ``` 2. Edit the `.env` file and configure Entra ID settings: ```bash # ============================================================================= # AUTHENTICATION PROVIDER CONFIGURATION # ============================================================================= # Choose authentication provider: 'cognito', 'keycloak', or 'entra' AUTH_PROVIDER=entra # ============================================================================= # MICROSOFT ENTRA ID CONFIGURATION # ============================================================================= # Azure AD Tenant ID (from Azure Portal → App registration → Overview) ENTRA_TENANT_ID=12345678-1234-1234-1234-123456789012 # Entra ID Application (client) ID (from Azure Portal → App registration → Overview) ENTRA_CLIENT_ID=87654321-4321-4321-4321-210987654321 # Entra ID Client Secret (from Azure Portal → App registration → Certificates & secrets) ENTRA_CLIENT_SECRET=your-secret-value-here # Enable Entra ID in OAuth2 providers ENTRA_ENABLED=true # Azure AD Group Object IDs (from Azure Portal → Groups → Overview) # IMPORTANT: ENTRA_GROUP_ADMIN_ID is required for admin access to persist across restarts. # This group ID is added to the registry-admins scope in MongoDB during initialization. # Users in this Entra group will have full admin access to the MCP Gateway. ENTRA_GROUP_ADMIN_ID=16c7e67e-e8ae-498c-ba2e-0593c0159e43 ENTRA_GROUP_USERS_ID=62c07ac1-03d0-4924-90c7-a0255f23bd1d ``` 3. Update other required settings: ```bash # ============================================================================= # REGISTRY CONFIGURATION # ============================================================================= # For local development REGISTRY_URL=http://localhost # For production with custom domain # REGISTRY_URL=https://mcpgateway.mycorp.com # ============================================================================= # AUTH SERVER CONFIGURATION # ============================================================================= # For local development AUTH_SERVER_EXTERNAL_URL=http://localhost # For production with custom domain # AUTH_SERVER_EXTERNAL_URL=https://mcpgateway.mycorp.com # ============================================================================= # APPLICATION SECURITY # ============================================================================= # CRITICAL: CHANGE THIS SECRET KEY IMMEDIATELY! SECRET_KEY=your-super-secure-random-64-character-string-here ``` --- ## Group Configuration ### Configure scopes.yml The `auth_server/scopes.yml` file maps Azure AD groups to MCP Gateway scopes and permissions. 1. Open `auth_server/scopes.yml` 2. Update the Entra ID group mappings section with your group Object IDs: ```yaml group_mappings: # Entra ID group mappings (by Azure AD Group Object IDs) # Admin group "object_id": - mcp-registry-admin - registry-admins ``` 3. Replace the group Object IDs with your actual group IDs from Azure Portal ### Understanding Scope Mappings - **mcp-registry-admin**: Full administrative access to the registry - Can list, register, modify, and toggle services - Has unrestricted read and execute access to MCP servers - **mcp-registry-user**: Limited user access - Can list and view specific services - Has restricted read access to MCP servers - **mcp-registry-developer**: Development access - Can list, register, and health check services - Has restricted read and execute access - **mcp-registry-operator**: Operations access - Can list, health check, and toggle services - Has restricted read and execute access --- ## Testing the Setup ### Step 1: Start the Services 1. Build and start the Docker containers: ```bash docker-compose up -d --build ``` 2. Check that services are running: ```bash docker-compose ps ``` ### Step 2: Test User Authentication 1. Open your browser and navigate to: ``` http://localhost ``` 2. You should see the MCP Gateway Registry login page 3. Click the **Sign in with Microsoft Entra ID** button 4. You will be redirected to Microsoft's login page 5. Sign in with a user account that belongs to one of your configured groups 6. After successful authentication, you should be redirected back to the registry ### Step 3: Verify User Information 1. Check the auth server logs to verify user information is being received: ```bash docker-compose logs auth-server | grep "Raw user info" ``` 2. You should see output similar to: ``` Raw user info from entra: { 'sub': 'abc123...', 'email': 'user@yourdomain.onmicrosoft.com', 'preferred_username': 'user@yourdomain.onmicrosoft.com', 'groups': ['16c7e67e-...', '62c07ac1-...'], 'name': 'First Last' } ``` 3. Verify the mapped scopes: ```bash docker-compose logs auth-server | grep "Mapped user info" ``` 4. You should see: ``` Mapped user info: { 'username': 'user@yourdomain.onmicrosoft.com', 'email': 'user@yourdomain.onmicrosoft.com', 'name': 'First Last', 'groups': ['mcp-registry-admin', 'mcp-servers-unrestricted/read', ...] } ``` ### Step 4: Test Authorization 1. Log in with an admin user (member of the admin group) 2. Verify you can access admin functions: - Register new services - Modify service configurations - Toggle services on/off 3. Log in with a regular user (member of the users group) 4. Verify restricted access: - Can view services - Cannot register or modify services ### Step 5: Test Machine-to-Machine (M2M) Authentication If you configured API access for M2M authentication: 1. Create a service principal for your AI agent: ```bash # This is done in Azure Portal → App registrations # Create a new app registration for the AI agent ``` 2. Test M2M token generation: ```bash curl -X POST "https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id={agent-client-id}" \ -d "client_secret={agent-client-secret}" \ -d "scope=api://{mcp-gateway-client-id}/.default" ``` 3. Use the access token to call MCP Gateway APIs --- ## Troubleshooting ### Issue: 403 Forbidden when using IAM management UI **Symptoms:** ``` Client error '403 Forbidden' for url 'https://graph.microsoft.com/v1.0/users?...' ``` **Cause:** The IAM management features (listing users, creating users/groups, managing M2M accounts) use the OAuth2 client credentials flow to call the Microsoft Graph API. This flow requires **Application permissions**, not Delegated permissions. If only Delegated permissions are configured, the Graph API will return 403 Forbidden. **Solution:** 1. Go to Azure Portal -> App registrations -> Your app -> **API permissions** 2. Click **Add a permission** -> **Microsoft Graph** -> **Application permissions** 3. Add the required Application permissions (see [Step 5: Application Permissions](#application-permissions-required-for-iam-management)) 4. Click **Grant admin consent for [Your Tenant]** 5. Wait 5-10 minutes for Azure AD to propagate changes 6. Restart the registry service ### Issue: Missing email and groups claims **Symptoms:** ``` Raw user info from entra: {'sub': '...', 'name': 'User Name', 'family_name': '...', 'given_name': '...'} Mapped user info: {'username': None, 'email': None, 'groups': []} ``` **Solution:** 1. Verify you completed [Step 5: Add API Permissions](#step-5-add-api-permissions) 2. Ensure you clicked **Grant admin consent** 3. Complete [Step 6: Configure Optional Claims](#step-6-configure-optional-claims) 4. Complete [Step 7: Configure Group Claims](#step-7-configure-group-claims) 5. Wait 5-10 minutes for Azure AD to propagate changes 6. Clear browser cookies and try logging in again ### Issue: Token validation fails with "Invalid issuer" **Symptoms:** ``` Token validation failed: Invalid issuer: https://sts.windows.net/{tenant}/ ``` **Solution:** The Entra ID provider supports both v1.0 and v2.0 token formats. This error should not occur with the current implementation. If you see this: 1. Check that `ENTRA_TENANT_ID` in `.env` matches your actual tenant ID 2. Verify the token is being issued by Microsoft Entra ID 3. Check auth server logs for more details ### Issue: User cannot access any resources **Symptoms:** User can log in but sees "Access Denied" or "Insufficient Permissions" **Solution:** 1. Verify the user is added to at least one security group in Azure AD 2. Check that group Object IDs in `scopes.yml` match the groups in Azure Portal 3. Verify the group mappings include the necessary scopes 4. Check auth server logs to see what groups are being received: ```bash docker-compose logs auth-server | grep "groups" ``` ### Issue: Redirect URI mismatch error **Symptoms:** ``` AADSTS50011: The redirect URI 'http://localhost/auth/callback' does not match the redirect URIs configured for the application ``` **Solution:** 1. Go to Azure Portal → App registrations → Your app → Authentication 2. Verify the redirect URI exactly matches what's in the error message 3. Add any missing redirect URIs 4. Ensure `AUTH_SERVER_EXTERNAL_URL` in `.env` matches the base URL ### Issue: "Groups overage" claim **Symptoms:** Groups claim contains `_claim_names` and `_claim_sources` instead of group IDs **Solution:** This occurs when a user is a member of more than 200 groups. You need to: 1. Modify the auth provider to fetch groups via Microsoft Graph API 2. See the alternative implementation in `docs/ENTRA-ID-APP-CONFIGURATION.md` Step 5 ### Issue: Client secret expired **Symptoms:** ``` AADSTS7000215: Invalid client secret provided ``` **Solution:** 1. Go to Azure Portal → App registrations → Your app → Certificates & secrets 2. Create a new client secret 3. Update `ENTRA_CLIENT_SECRET` in `.env` 4. Restart the services: ```bash docker-compose restart auth-server ``` ### Issue: Cannot grant admin consent **Symptoms:** You don't see the "Grant admin consent" button or get an error when clicking it **Solution:** 1. You need Global Administrator, Application Administrator, or Cloud Application Administrator role 2. Contact your Azure AD administrator to grant the permissions 3. Alternatively, users can consent individually (not recommended for production) --- ## Additional Resources - [Microsoft Entra ID Documentation](https://learn.microsoft.com/en-us/entra/) - [OAuth 2.0 Authorization Code Flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow) - [Optional Claims Configuration](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims) - [Configure Group Claims](https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims#configure-groups-optional-claims) - [Microsoft Graph Permissions Reference](https://learn.microsoft.com/en-us/graph/permissions-reference) --- ## Production Deployment ### Update Redirect URIs For production, update redirect URIs: ``` https://your-domain.com/oauth2/callback/entra ``` ### Environment Variables Update production `.env`: ```bash ENTRA_REDIRECT_URI=https://your-domain.com/oauth2/callback/entra AUTH_SERVER_EXTERNAL_URL=https://your-domain.com:8888 ``` ### SSL/TLS Configuration Ensure your production deployment uses HTTPS for all OAuth flows. --- ## Advanced Configuration ### Custom Claims To add custom claims to tokens: 1. Go to **Token configuration** 2. Click **Add optional claim** 3. Select token type and claims 4. Configure claim conditions ### Group Filtering To limit which groups are included in tokens: 1. Go to **Token configuration** 2. Click **Add groups claim** 3. Configure **Groups assigned to the application** ### Enterprise Applications For advanced management: 1. Go to **Enterprise applications** 2. Find your app registration 3. Configure: - User assignment required - Visibility settings - Provisioning (if needed) --- ## Adding New Users ### Option 1: Add User to Existing Group (Recommended) **In Azure Portal:** 1. Go to **Microsoft Entra ID** → **Groups** 2. Click on **MCP Registry Admins** (or appropriate group) 3. Click **Members** → **Add members** 4. Search and select the new user 5. Click **Select** **Access will be immediate** - user can login and see servers/agents. ### Option 2: Create New Group for User **If you need different permissions:** 1. **Create new group in Azure:** - **Group name**: `MCP Registry LOB3 Users` - **Members**: Add the new user 2. **Get the group Object ID** from the group overview page 3. **Add to scopes.yml:** ```yaml group_mappings: # Add new group mapping "new-group-object-id-here": - registry-users-lob1 # or whatever permission level needed ``` 4. **Restart auth server:** ```bash cp auth_server/scopes.yml ~/mcp-gateway/auth_server/scopes.yml docker-compose restart auth-server ``` --- ## API Reference ### Token Endpoint ``` POST https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token ``` ### Authorization Endpoint ``` GET https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize ``` ### User Info Endpoint ``` GET https://graph.microsoft.com/v1.0/me ``` --- ## Security Best Practices 1. **Client Secret Management** - Store client secrets securely (use Azure Key Vault in production) - Rotate secrets regularly (set expiration and create new secrets) - Never commit secrets to version control 2. **Token Configuration** - Keep token expiration times reasonable (default: 1 hour for access tokens) - Use refresh tokens for long-running sessions - Implement proper token revocation 3. **Group Management** - Use security groups (not distribution lists or Microsoft 365 groups) - Apply principle of least privilege - Regularly audit group memberships 4. **HTTPS in Production** - Always use HTTPS in production environments - Configure proper SSL/TLS certificates - Update redirect URIs to use HTTPS 5. **Monitoring and Logging** - Enable Azure AD audit logs - Monitor sign-in logs for suspicious activity - Set up alerts for authentication failures 6. **Multi-Factor Authentication** - Enable MFA for all users (configured in Azure AD) - Use conditional access policies - Enforce MFA for admin accounts --- ## Using the IAM API to Manage Groups, Users, and M2M Accounts The MCP Gateway Registry provides an IAM API for managing groups, human users, and M2M (machine-to-machine) service accounts programmatically. This section covers how to use the `registry_management.py` CLI to perform these operations. ### Prerequisites Before using the IAM API commands, you need: 1. **An admin access token**: Either a self-signed token from the UI sidebar or a Keycloak/Entra ID token for an admin user 2. **Registry URL**: The URL of your MCP Gateway Registry deployment 3. **Admin group membership**: Your user must be in the `registry-admins` group Save your token to a file for CLI usage: ```bash # Save token from UI sidebar to a file echo "eyJhbGci..." > api/.token ``` ### Creating a Group (Scope) Groups define access permissions for users and M2M accounts. Create a group definition JSON file: **Example: `cli/examples/public-mcp-users.json`** ```json { "scope_name": "public-mcp-users", "description": "Users with access to public MCP servers", "server_access": [ { "server": "context7", "methods": ["initialize", "tools/list", "tools/call"], "tools": ["*"] }, { "server": "api", "methods": ["initialize", "GET", "POST", "servers", "agents"], "tools": [] }, { "agents": { "actions": [ {"action": "list_agents", "resources": ["/flight-booking"]}, {"action": "get_agent", "resources": ["/flight-booking"]} ] } } ], "group_mappings": ["public-mcp-users"], "ui_permissions": { "list_service": ["all"], "list_agents": ["/flight-booking"], "get_agent": ["/flight-booking"] }, "create_in_idp": true } ``` **Import the group:** ```bash uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://your-registry-url.example.com \ import-group --file cli/examples/public-mcp-users.json ``` **Key fields in group definition:** | Field | Description | |-------|-------------| | `scope_name` | Unique identifier for the scope/group | | `description` | Human-readable description | | `server_access` | Array of server access rules | | `group_mappings` | List of IdP group names/IDs that map to this scope | | `ui_permissions` | Permissions for the web UI | | `create_in_idp` | If `true`, creates corresponding group in Entra ID | ### Creating a Human User Human users can log in via the web UI using Entra ID authentication. ```bash uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://your-registry-url.example.com \ user-create-human \ --username jsmith \ --email jsmith@example.com \ --first-name John \ --last-name Smith \ --groups public-mcp-users \ --password "SecurePassword123!" ``` **Parameters:** | Parameter | Required | Description | |-----------|----------|-------------| | `--username` | Yes | Username for the account | | `--email` | Yes | Email address | | `--first-name` | Yes | User's first name | | `--last-name` | Yes | User's last name | | `--groups` | Yes | Comma-separated list of groups | | `--password` | No | Initial password (auto-generated if not provided) | ### Creating an M2M Service Account M2M (machine-to-machine) accounts are used for programmatic API access, AI coding assistants, and agent identities. ```bash uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://your-registry-url.example.com \ user-create-m2m \ --name my-ai-agent \ --groups public-mcp-users \ --description "AI coding assistant service account" ``` **Output:** ``` Client ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx Client Secret: xxxxx~xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx Groups: public-mcp-users Service Principal ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx IMPORTANT: Save the client secret securely - it cannot be retrieved later. ``` **Parameters:** | Parameter | Required | Description | |-----------|----------|-------------| | `--name` | Yes | Service account name/client ID | | `--groups` | Yes | Comma-separated list of groups | | `--description` | No | Account description | ### Where to Find Parameter Values | Parameter | Location | |-----------|----------| | **Tenant ID** | Azure Portal -> Microsoft Entra ID -> Overview -> Tenant ID | | **App Client ID** | Azure Portal -> App registrations -> [Your App] -> Application (client) ID | | **App Client Secret** | Azure Portal -> App registrations -> [Your App] -> Certificates & secrets | | **Group Object ID** | Azure Portal -> Microsoft Entra ID -> Groups -> [Group Name] -> Object Id | | **M2M Client ID/Secret** | Output from `user-create-m2m` command | --- ## Generating JWT Tokens for M2M Accounts M2M accounts use OAuth2 client credentials flow to obtain JWT tokens. These tokens can be used for: - Agent identities in A2A (Agent-to-Agent) communication - AI coding assistants (Cursor, VS Code, etc.) - Programmatic API access - Automated scripts and CI/CD pipelines ### Method 1: Direct Token Request (curl) Request a token directly from Microsoft Entra ID: ```bash curl -X POST "https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "client_id={M2M_CLIENT_ID}" \ -d "client_secret={M2M_CLIENT_SECRET}" \ -d "scope=api://{APP_CLIENT_ID}/.default" \ -d "grant_type=client_credentials" ``` **Example with placeholder values:** ```bash curl -X POST "https://login.microsoftonline.com/your-tenant-id/oauth2/v2.0/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "client_id=your-m2m-client-id" \ -d "client_secret=your-m2m-client-secret" \ -d "scope=api://your-app-client-id/.default" \ -d "grant_type=client_credentials" ``` **Response:** ```json { "token_type": "Bearer", "expires_in": 3599, "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi..." } ``` ### Method 2: Using the Credentials Provider Script The `generate_creds.sh` script automates token generation for multiple identities. **Step 1: Configure identities file** Create or edit `.oauth-tokens/entra-identities.json`: ```json [ { "identity_name": "my-ai-agent", "tenant_id": "your-tenant-id", "client_id": "your-m2m-client-id", "client_secret": "your-m2m-client-secret", "scope": "api://your-app-client-id/.default" } ] ``` **Identities File Structure:** | Field | Required | Description | |-------|----------|-------------| | `identity_name` | Yes | Unique name for this identity (used for output file naming) | | `tenant_id` | Yes | Azure AD Tenant ID (from Azure Portal -> Microsoft Entra ID -> Overview) | | `client_id` | Yes | M2M service account Client ID (from `user-create-m2m` output) | | `client_secret` | Yes | M2M service account Client Secret (from `user-create-m2m` output) | | `scope` | Yes | OAuth2 scope in format `api://{APP_CLIENT_ID}/.default` | **Multiple Identities Example:** ```json [ { "identity_name": "cursor-assistant", "tenant_id": "your-tenant-id", "client_id": "cursor-m2m-client-id", "client_secret": "cursor-m2m-client-secret", "scope": "api://your-app-client-id/.default" }, { "identity_name": "ci-pipeline", "tenant_id": "your-tenant-id", "client_id": "cicd-m2m-client-id", "client_secret": "cicd-m2m-client-secret", "scope": "api://your-app-client-id/.default" } ] ``` **Step 2: Set auth provider** Ensure `AUTH_PROVIDER=entra` is set in your `.env` file. **Step 3: Run the script** ```bash ./credentials-provider/generate_creds.sh ``` Or with a custom identities file: ```bash uv run credentials-provider/entra/get_m2m_token.py \ --identities-file /path/to/my-identities.json \ --output-dir .oauth-tokens \ --verbose ``` **Output:** - Tokens are saved to `.oauth-tokens/{identity_name}.json` - Each file contains the access token, expiration time, and metadata ### Token Scope Format The scope for Entra ID M2M tokens follows this format: ``` api://{APP_CLIENT_ID}/.default ``` Where: - `{APP_CLIENT_ID}` is the Application (client) ID of your MCP Gateway app registration - `.default` requests all scopes that admin consent has been granted for ### Using Tokens in AI Coding Assistants Once you have a JWT token, you can use it in AI coding assistants like Cursor or VS Code extensions: 1. **Configure the MCP server connection** with the registry URL 2. **Set the Bearer token** in the authorization header 3. **The token** grants access based on the M2M account's group membership Example configuration for an AI assistant: ```json { "mcp_registry_url": "https://your-registry-url.example.com", "auth_token": "eyJ0eXAiOiJKV1QiLCJhbGciOi..." } ``` ### User-Generated Tokens from the UI Users can also generate personal JWT tokens from the MCP Gateway Registry web UI: 1. Log in to the registry at `https://your-registry-url.example.com` 2. Navigate to the sidebar 3. Click on "Generate Token" or similar option 4. Copy the generated token These self-signed tokens: - Are signed with HS256 using the server's secret key - Include the user's groups and scopes - Can be used for programmatic API access - Work with the same endpoints as M2M tokens --- ## Next Steps After completing the setup: 1. **Configure Additional Services**: Add more MCP servers to the registry 2. **Set Up Custom Domain**: Configure HTTPS and custom domain names 3. **Configure M2M Authentication**: Set up service principals for AI agents 4. **Implement Monitoring**: Set up observability and alerting 5. **Production Deployment**: Deploy to your production environment For more information, see: - [Complete Setup Guide](./complete-setup-guide.md) - [Observability Documentation](./OBSERVABILITY.md) - [FAQ](./faq/index.md) ================================================ FILE: docs/entra.md ================================================ # Microsoft Entra ID Integration for MCP Gateway Registry This document describes the integration between Microsoft Entra ID and the MCP Gateway Registry, including the JWT token generation flow for programmatic API access. ## Overview The MCP Gateway Registry supports Microsoft Entra ID as an OAuth2 identity provider. Users can authenticate via Entra ID and obtain JWT tokens for programmatic access to the gateway APIs (CLI tools, coding assistants, etc.). ## Architecture ### Authentication Flow ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Browser │ │ Registry │ │ Auth Server │ │ Entra ID │ │ (User) │ │ Frontend │ │ │ │ (Microsoft)│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ 1. Click Login │ │ │ │──────────────────>│ │ │ │ │ │ │ │ 2. Redirect to Auth Server │ │ │<──────────────────│ │ │ │ │ │ │ │ 3. /oauth2/login/entra │ │ │──────────────────────────────────────>│ │ │ │ │ │ │ 4. Redirect to Entra ID authorize endpoint │ │<─────────────────────────────────────────────────────────>│ │ │ │ │ │ 5. User authenticates with Microsoft │ │ │<─────────────────────────────────────────────────────────>│ │ │ │ │ │ 6. Redirect with auth code │ │ │──────────────────────────────────────>│ │ │ │ │ │ │ │ 7. Exchange code │ │ │ │ for tokens │ │ │ │ │──────────────────>│ │ │ │<──────────────────│ │ │ │ (ID token + │ │ │ │ access token) │ │ │ │ │ │ 8. Set session cookie + redirect │ │ │<──────────────────────────────────────│ │ │ │ │ │ │ 9. Access Registry with session │ │ │──────────────────>│ │ │ │ │ │ │ ``` ### JWT Token Generation Flow (Get JWT Token Button) When an OAuth-authenticated user clicks "Get JWT Token" in the UI: ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ Browser │ │ Registry │ │ Auth Server │ │ DocumentDB │ │ (User) │ │ Backend │ │ │ │ (Scopes) │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ 1. Click "Get JWT Token" │ │ │──────────────────>│ │ │ │ │ │ │ │ │ 2. POST /api/tokens/generate │ │ │ (with session cookie) │ │ │──────────────────>│ │ │ │ │ │ │ │ │ 3. Validate session │ │ │ Extract: username, │ │ │ groups, provider │ │ │ │ │ │ │ │ 4. Query group │ │ │ │ mappings │ │ │ │──────────────────>│ │ │ │<──────────────────│ │ │ │ (scopes for │ │ │ │ user's groups) │ │ │ │ │ │ │ │ 5. Build JWT claims: │ │ │ - iss: mcp-auth-server │ │ │ - aud: mcp-registry │ │ │ - sub: username │ │ │ - groups: [group IDs] │ │ │ - scope: mapped scopes │ │ │ - exp: 8 hours │ │ │ │ │ │ │ 6. Sign JWT with │ │ │ SECRET_KEY (HS256) │ │ │ │ │ │ 7. Return JWT │ │ │ │<──────────────────│ │ │ │ │ │ │ 8. Display token │ │ │ │<──────────────────│ │ │ │ │ │ │ ``` ### Token Validation Flow (CLI/API Usage) When a user uses the self-signed JWT token with the CLI or API: ``` ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ CLI / │ │ NGINX │ │ Auth Server │ │ MCP Server │ │ Client │ │ Gateway │ │ │ │ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │ │ │ │ 1. API Request │ │ │ │ Authorization: │ │ │ │ Bearer │ │ │ │──────────────────>│ │ │ │ │ │ │ │ │ 2. auth_request │ │ │ │ /validate │ │ │ │──────────────────>│ │ │ │ │ │ │ │ │ 3. Check token issuer │ │ │ iss == "mcp-auth-server"? │ │ │ │ │ │ │ 4. If yes: validate │ │ │ with SECRET_KEY (HS256) │ │ │ │ │ │ │ 5. If no: try Entra │ │ │ JWKS validation (RSA) │ │ │ │ │ │ │ 6. Extract scopes, │ │ │ validate server/tool │ │ │ access permissions │ │ │ │ │ │ 7. 200 OK + │ │ │ │ X-User headers │ │ │ │<──────────────────│ │ │ │ │ │ │ │ 8. Proxy request │ │ │ │──────────────────────────────────────>│ │ │ │ │ │ 9. Response │ │ │ │<──────────────────────────────────────────────────────────│ │ │ │ │ ``` ## Token Types ### 1. Entra ID Tokens (from Microsoft) When users authenticate via Entra ID, Microsoft issues: - **ID Token**: Contains user identity claims (username, email, groups) - **Access Token**: Scoped for Microsoft Graph API (not usable for our gateway) These tokens are: - RSA-signed (RS256) with Microsoft's keys - Validated against Microsoft's JWKS endpoint - Contain group Object IDs (not group names) ### 2. Self-Signed JWT Tokens (from Auth Server) When users click "Get JWT Token", the auth server generates: - **Self-Signed JWT**: Contains user identity + gateway-specific scopes - Signed with HS256 using `SECRET_KEY` - Contains: username, groups, mapped scopes, provider info ## Security Analysis ### Why We Use Self-Signed Tokens (Not IdP Tokens Directly) **The IdP tokens don't work for our use case:** 1. **Entra Access Token is for Microsoft Graph API** - When you authenticate with Entra ID, the access token you receive is scoped for Microsoft's APIs (like Graph API for reading user profiles). It's not meant for your custom gateway. 2. **IdP tokens don't contain your scopes** - Entra doesn't know about your `public-mcp-users` scope or your MCP server permissions. Those mappings exist only in your system (scopes.yml, DocumentDB). 3. **Group-to-scope mapping is custom** - The translation from Entra Group Object ID (`5f605d68-06bc-4208-b992-bb378eee12c5`) to gateway scopes (`public-mcp-users`) happens in your auth server, not in Entra. ### Is the Self-Signed Approach Secure? **Yes, with proper implementation.** Here's why: | Security Aspect | Implementation | |-----------------|----------------| | **Secret Management** | SECRET_KEY from environment variable, not hardcoded | | **Token Validation** | Every request validates signature, expiry, issuer, audience | | **Short Expiry** | 8-hour token lifetime limits exposure window | | **No Credential Storage** | Users don't store passwords; token is derived from OAuth session | | **Auditable Claims** | Token contains username, groups, provider - traceable | | **Rate Limiting** | Token generation rate-limited per user (100/hour default) | ### Comparison: Self-Signed vs Direct IdP Tokens | Aspect | Self-Signed JWT | Direct IdP Token | |--------|-----------------|------------------| | **Signing** | HS256 (symmetric) | RS256 (asymmetric) | | **Key Management** | Single SECRET_KEY | IdP manages key rotation | | **Scope Mapping** | Done at generation time | Would need separate mapping layer | | **Token Revocation** | Expiry-based only | Could use IdP revocation | | **Complexity** | Simple | Requires IdP API registration | | **Audit Trail** | In auth server logs | In IdP audit logs | ### What Would Be "More Secure"? Getting tokens directly from IdP would require: 1. **Registering your gateway as an API in Entra** - Defining your own scopes in Azure AD 2. **Users requesting your API scopes** - During OAuth login 3. **Entra issuing tokens for your API** - Instead of for Graph API This provides: - Tokens signed by Microsoft's keys (asymmetric RSA) - Centralized token revocation through Entra - Entra's audit logs for token issuance **However**, you'd still need to map Entra groups to your MCP permissions somewhere, so the complexity often isn't worth it for internal/enterprise use cases. ### Security Best Practices 1. **Rotate SECRET_KEY periodically** - Update via environment variable 2. **Use HTTPS everywhere** - Tokens in transit must be encrypted 3. **Monitor token usage** - Log and alert on unusual patterns 4. **Short token lifetime** - 8 hours default, configurable 5. **Scope minimization** - Tokens only get scopes user already has ## Configuration ### Environment Variables ```bash # Auth Server SECRET_KEY=your-secure-random-key-here JWT_ISSUER=mcp-auth-server JWT_AUDIENCE=mcp-registry MAX_TOKENS_PER_USER_PER_HOUR=100 # Entra ID ENTRA_ENABLED=true ENTRA_CLIENT_ID=your-client-id ENTRA_CLIENT_SECRET=your-client-secret ENTRA_TENANT_ID=your-tenant-id ``` ### Group Mappings (scopes.yml or DocumentDB) ```yaml group_mappings: # Entra ID Group Object ID -> Gateway Scopes "5f605d68-06bc-4208-b992-bb378eee12c5": - public-mcp-users "4c46ec66-a4f7-4b62-9095-b7958662f4b6": - registry-admins - mcp-servers-unrestricted/read - mcp-servers-unrestricted/execute ``` ### Entra ID App Registration Requirements #### User Authentication App (OAuth Login) 1. **Redirect URIs**: Add your auth server callback URLs - `https://your-domain.com/oauth2/callback/entra` 2. **Token Configuration**: - Enable ID tokens - Add `groups` claim to ID token 3. **API Permissions (Delegated)**: - `openid` (delegated) - `email` (delegated) - `profile` (delegated) 4. **Group Claims**: - Configure "Groups assigned to the application" or "All groups" - Emit groups as Object IDs (not names) #### Admin App (IAM Management - M2M Account Creation) To create M2M service accounts via the Management API, the admin app registration needs additional **Application permissions** (not delegated): 1. **API Permissions (Application - requires admin consent)**: - `Application.ReadWrite.All` - Create/manage app registrations - `Directory.ReadWrite.All` - Create service principals and manage group memberships - `Group.ReadWrite.All` - Create and manage groups - `User.ReadWrite.All` - Create and manage users 2. **Grant Admin Consent**: - After adding permissions, click "Grant admin consent for [Tenant]" - Requires Global Administrator or Privileged Role Administrator 3. **Client Secret**: - Create a client secret under "Certificates & secrets" - Set as `ENTRA_CLIENT_SECRET` environment variable **Note**: The admin app is used by the registry backend for IAM operations. It's separate from the user-facing OAuth app (though they can be the same app registration with both delegated and application permissions). ## JWT Token Structure ### Claims in Self-Signed JWT ```json { "iss": "mcp-auth-server", "aud": "mcp-registry", "sub": "user@example.com", "preferred_username": "user@example.com", "email": "user@example.com", "groups": ["5f605d68-06bc-4208-b992-bb378eee12c5"], "scope": "public-mcp-users", "token_use": "access", "auth_method": "oauth2", "provider": "entra", "iat": 1768685007, "exp": 1768713807, "description": "Generated via sidebar" } ``` ### Token Validation Logic The Entra provider's `validate_token` method: 1. **Check issuer first**: If `iss == "mcp-auth-server"`, validate as self-signed 2. **Self-signed validation**: Use HS256 with SECRET_KEY 3. **Entra validation**: If not self-signed, use RSA with Microsoft JWKS This ensures both token types work seamlessly with the same validation endpoint. ## Usage Examples ### CLI with Self-Signed Token ```bash # Set the token from "Get JWT Token" button export MCP_TOKEN="eyJhbGciOiJIUzI1NiIs..." # Use with mcpgw CLI mcpgw servers list --token "$MCP_TOKEN" mcpgw tools call context7 resolve-library-id --args '{"libraryName": "react"}' ``` ### Python SDK ```python import requests token = "eyJhbGciOiJIUzI1NiIs..." headers = {"Authorization": f"Bearer {token}"} response = requests.get( "https://your-gateway.com/api/servers", headers=headers ) ``` ## Troubleshooting ### Common Issues 1. **"Token missing 'kid' in header"** - Cause: Self-signed tokens don't have `kid`, but validation expected RSA token - Fix: Auth server now checks issuer before attempting JWKS validation 2. **"Invalid token issuer"** - Cause: Token issuer doesn't match expected value - Fix: Ensure `JWT_ISSUER` env var matches on token generation and validation 3. **"Access denied - no scopes configured"** - Cause: User's groups don't map to any scopes - Fix: Add group mapping in scopes.yml or DocumentDB 4. **Groups not appearing in token** - Cause: Entra app not configured to emit groups - Fix: Configure "Token configuration" in Entra app registration ================================================ FILE: docs/faq/agent-autonomous-tool-discovery.md ================================================ # How do I handle tool discovery when I don't know what tools are available? Use the Dynamic Tool Discovery feature: 1. **In your agent code**: ```python # Let your agent discover tools autonomously tools = await intelligent_tool_finder( natural_language_query="I need to get stock market data", session_cookie=session_cookie, top_n_tools=3 ) # Then invoke the discovered tool if tools: result = await invoke_mcp_tool( server_name=tools[0]["service_path"], tool_name=tools[0]["tool_name"], arguments={"symbol": "AAPL"}, # ... auth parameters ) ``` 2. **Configure your agent** with tool discovery capabilities as shown in the [Dynamic Tool Discovery guide](../dynamic-tool-discovery.md). ## Related Documentation - [Dynamic Tool Discovery](../dynamic-tool-discovery.md) -- complete guide with configuration details - [AI Registry Tools](../ai-registry-tools.md) -- available registry tools for agents ================================================ FILE: docs/faq/connecting-multiple-mcp-servers.md ================================================ # How do I connect my agent to multiple MCP servers through the gateway? The gateway provides a single endpoint with path-based routing: ```python # Connect to different servers via the gateway using SSE client from mcp import ClientSession from mcp.client.sse import sse_client async def connect_to_server(server_url): async with sse_client(server_url) as (read, write): async with ClientSession(read, write) as session: await session.initialize() # Use the session for tool calls return session # Example server URLs through the gateway server_url = f"https://your-gateway.com/currenttime/sse" time_session = await connect_to_server(server_url) # Or use the registry's tool discovery registry_url = f"https://your-gateway.com/mcpgw/sse" registry_session = await connect_to_server(registry_url) ``` All requests go through the same gateway with authentication handled centrally. ## Related Documentation - [Authentication](../auth.md) -- authentication modes and headers - [Installation](../installation.md) -- gateway deployment and configuration ================================================ FILE: docs/faq/deploying-and-registering-servers-agents.md ================================================ # How do I deploy and register MCP servers and agents? MCP servers and agents are built and deployed **out of band** -- the MCP Gateway Registry does not host or run them. You build and deploy your servers and agents using whatever framework and infrastructure you prefer, then register them in the registry so they can be discovered and accessed through the gateway. ## Building and Deploying MCP Servers You can build MCP servers using any MCP-compatible framework and deploy them on any infrastructure: **Frameworks:** - [FastMCP](https://github.com/PrefectHQ/fastmcp) -- Python framework for building MCP servers - [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) -- Official TypeScript SDK - Any framework that implements the [MCP specification](https://modelcontextprotocol.io/specification) **Deployment options:** - [Amazon Bedrock AgentCore](https://aws.amazon.com/bedrock/agentcore/) -- managed runtime for MCP servers - Amazon EKS or any Kubernetes cluster - AWS ECS, Azure Container Apps, Google Cloud Run - A standalone Linux instance with Docker or systemd - Any cloud or on-premises infrastructure that can serve HTTP endpoints Your deployed server needs to expose an MCP-compatible endpoint (typically `/mcp` for Streamable HTTP) that the gateway can reach over the network. ## Building and Deploying A2A Agents Similarly, agents are built using any agent framework and deployed independently: **Frameworks:** - [A2A Python SDK](https://github.com/a2aproject/a2a-python) -- reference implementation for A2A protocol - [LangGraph](https://github.com/langchain-ai/langgraph) with A2A adapter - [CrewAI](https://github.com/crewAIInc/crewAI) -- multi-agent orchestration - Any framework that exposes an [A2A-compatible agent card](https://a2a-protocol.org/) at `/.well-known/agent-card.json` **Deployment options:** - [Amazon Bedrock AgentCore](https://aws.amazon.com/bedrock/agentcore/) -- managed runtime for A2A agents - Amazon EKS or any Kubernetes cluster - AWS Lambda behind API Gateway - Any cloud infrastructure that can serve HTTP endpoints Your deployed agent needs to expose its agent card at `/.well-known/agent-card.json` and handle A2A protocol requests. ## Registering in the Registry Once your server or agent is deployed and accessible, register it in the MCP Gateway Registry using one of these methods: ### Option 1: Register through the Web UI 1. Open the registry dashboard 2. Click **Register** on the MCP Servers or Agents tab 3. Fill in the form with your server/agent details (URL, name, description, etc.) 4. Click Submit ### Option 2: Generate JSON cards using Claude Code skills Use the built-in Claude Code skills to generate registration JSON by analyzing your source code: ```bash # For MCP servers -- analyzes source code and generates a server card JSON /generate-server-card # For A2A agents -- analyzes source code and generates an agent card JSON /generate-agent-card ``` These skills produce JSON files that can be uploaded through the UI or used with the API. ### Option 3: Register programmatically via API Use the [Registry Management CLI](../../api/registry_management.py) to register from the command line. To get a token, click the **"Get JWT Token"** button in the top-left corner of the registry UI, then click **"Copy JSON"** and save it to a `.token` file: ```bash # Create .token file with the copied JSON from the registry UI cat > .token << 'EOF' EOF # Register an MCP server from a JSON config file uv run python api/registry_management.py \ --registry-url https://your-registry-url \ --token-file .token \ register --config my-server-card.json # Register an A2A agent from a JSON config file uv run python api/registry_management.py \ --registry-url https://your-registry-url \ --token-file .token \ agent-register --config my-agent-card.json ``` You can also call the REST API directly. See the [OpenAPI specification](../../api/openapi.json) for the full API reference, available at `/openapi.json` on your running registry instance. ### Example JSON files See the [cli/examples/](../../cli/examples/) directory for complete registration examples: **MCP Servers:** - `currenttime.json` -- minimal server example - `cloudflare-docs-server-config.json` -- server with full configuration - `complete-server-example.json` -- all available fields documented **A2A Agents:** - `flight_booking_agent_card.json` -- agent with multiple skills - `code_reviewer_agent.json` -- agent with JWT auth and verified trust level - `complete-agent-example.json` -- all available fields documented ## Related Documentation - [Quick Start Guide](../quickstart.md) -- getting the registry running - [Service Management](../service-management.md) -- managing servers, agents, users, and groups - [API Reference](../api-reference.md) -- REST API endpoints - [AI Coding Assistants Setup](../ai-coding-assistants-setup.md) -- connecting AI tools to registered servers ================================================ FILE: docs/faq/discovering-mcp-tools.md ================================================ # How do I discover available MCP tools for my AI agent? You can discover tools in several ways: 1. **Dynamic Tool Discovery** (Recommended): Use the [`intelligent_tool_finder`](../dynamic-tool-discovery.md) tool with natural language queries: ```python tools = await intelligent_tool_finder( natural_language_query="get current time in different timezones", session_cookie="your_session_cookie" ) ``` 2. **Web Interface**: Browse available tools at `https://your-gateway-url` after authentication. 3. **Direct MCP Connection**: Connect to the registry MCP server at `/mcpgw/sse` and use standard MCP `tools/list` calls. ## Related Documentation - [Dynamic Tool Discovery](../dynamic-tool-discovery.md) -- full guide on autonomous tool discovery - [API Reference](../api-reference.md) -- search and listing endpoints ================================================ FILE: docs/faq/filtering-agents-by-tags-and-fields.md ================================================ # What filtering options are available for agents in the registry? The registry provides several API endpoints for discovering and filtering agents, each with different filtering capabilities. ## Agent List Endpoint `GET /api/agents` | Parameter | Type | Description | |-----------|------|-------------| | `query` | string (optional) | Substring search across agent name, description, tags, and skill names (case-insensitive) | | `visibility` | string (optional) | Exact match filter: `public`, `private`, or `group-restricted` | | `enabled_only` | boolean (optional) | When `true`, returns only enabled agents | Example: ```bash # List agents matching "internal" in name, description, or tags curl "https://your-registry/api/agents?query=internal" \ -H "Authorization: Bearer $TOKEN" # List only public, enabled agents curl "https://your-registry/api/agents?visibility=public&enabled_only=true" \ -H "Authorization: Bearer $TOKEN" ``` ## Semantic Search Endpoint `POST /api/search/semantic` | Parameter | Type | Description | |-----------|------|-------------| | `query` | string (required, can be empty) | Natural language semantic + lexical search (max 512 chars) | | `entity_types` | list (optional) | Filter by type: `a2a_agent`, `mcp_server`, `tool`, `skill`, `virtual_server` | | `tags` | list (optional) | Exact tag filter with AND logic -- all specified tags must be present (case-insensitive) | | `max_results` | integer (optional) | Limit per entity type, 1-50 (default: 10) | Tags can also be specified as `#hashtags` inside the query string. They are extracted and merged with the explicit `tags` list. Example: ```bash # Search for agents tagged "internal" curl -X POST "https://your-registry/api/search/semantic" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "query": "", "entity_types": ["a2a_agent"], "tags": ["internal"] }' # Hashtag syntax works too -- these are equivalent curl -X POST "https://your-registry/api/search/semantic" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "query": "#internal", "entity_types": ["a2a_agent"] }' # Combine semantic search with tag filtering curl -X POST "https://your-registry/api/search/semantic" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "query": "quiz generation", "entity_types": ["a2a_agent"], "tags": ["internal", "hr"] }' ``` ## Discover Agents by Skills `POST /api/agents/discover` | Parameter | Type | Description | |-----------|------|-------------| | `skills` | list (required) | Skill names or IDs to match (partial matching -- returns agents with at least one match) | | `tags` | list (optional) | Tag filter (case-insensitive) | | `max_results` | integer (optional) | Limit, 1-100 (default: 10) | Results are ranked by a weighted score: 60% skill match, 20% tag match, 20% trust level boost. ## Semantic Agent Discovery `POST /api/agents/discover/semantic` | Parameter | Type | Description | |-----------|------|-------------| | `query` | string (required) | Natural language query describing needed capabilities | | `max_results` | integer (optional) | Limit, 1-100 (default: 10) | ## Using Tags for Classification Since the registry does not have a dedicated "agent type" or "classification" field, tags are the recommended way to categorize agents for filtering. For example: - Tag internal agents with `internal` and vendor agents with `vendor` - Filter via the semantic search endpoint: `"tags": ["internal"]` - Filter via the agent list endpoint: `?query=internal` (searches tags among other fields) The semantic search `tags` parameter provides the most precise filtering because it performs exact tag matching (all specified tags must be present). The agent list `query` parameter is a broader substring search that also matches against name, description, and skill names. ## Fields Not Available as Direct Filters The following agent fields exist but are not exposed as filter parameters on any endpoint today: - `trust_level` (used for ranking in discover endpoint, but not as a filter) - `status` (lifecycle: active, deprecated, draft, beta) - `supported_protocol` (a2a, other) - `provider` / `provider_organization` - `registered_by` - `health_status` - `metadata` keys (searchable in full-text, but no field-level filter) If you need filtering by any of these fields, please open a feature request on [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues). ## Related Documentation - [API Reference](../api-reference.md) -- full API documentation - [A2A Agent Management](../a2a-agent-management.md) -- agent registration and management guide - [Custom Metadata](../custom-metadata.md) -- using metadata fields for organization and compliance ================================================ FILE: docs/faq/group-restricted-agent-visibility.md ================================================ # How do I restrict which agents a user can see based on their group? The registry has two layers of access control for agents. Understanding when each layer applies helps you choose the right approach. ## Quick Answer **"I want only specific groups to see my agent."** Set `visibility: "group-restricted"` and `allowedGroups` when registering the agent: ```bash curl -s -X POST "https://your-registry/api/agents/register" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Salary Calculator", "path": "/salary-calculator", "version": "1.0.0", "url": "https://example.com/salary-calculator", "supportedProtocol": "a2a", "description": "Calculate salary projections", "visibility": "group-restricted", "allowedGroups": ["hr-team", "finance-team"], "skills": [ { "id": "calculate-salary", "name": "Calculate Salary", "description": "Calculate salary projections", "tags": ["hr", "finance"], "inputSchema": {} } ] }' ``` Only users whose IdP groups include `hr-team` or `finance-team` will see this agent. Admin users always see all agents. ## When Does This Actually Matter? There are two layers of access control, and `allowed_groups` only adds value depending on how your IAM group scopes are configured: | Your group scope config | Does allowed_groups help? | Why | |------------------------|--------------------------|-----| | **Narrow** (e.g., `"list_agents": ["/flight-booking"]`) | No | IAM already controls per-agent access | | **Broad** (e.g., `"list_agents": ["all"]`) | Yes | Publisher can restrict who sees their agent without an admin | | **Mix of narrow and broad** | Yes, for agents that broad groups should not all see | Narrows access for broad groups | For a full explanation with examples, see [Agent Visibility and Group-Based Access Control](../agent-visibility-and-group-access.md). ## How to Set Up Group-Restricted Access ### Step 1: Make Sure Your Group Has IAM Access Your group scope config must include agent access. If it uses `"list_agents": ["all"]`, you're set. If it lists specific agents, the agent must be in that list. ### Step 2: Register the Agent as Group-Restricted **Via API:** ```bash curl -s -X POST "https://your-registry/api/agents/register" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Internal Finance Agent", "path": "/finance-agent", "version": "1.0.0", "url": "https://finance-agent.internal.example.com", "supportedProtocol": "a2a", "description": "Agent for internal finance operations", "visibility": "group-restricted", "allowedGroups": ["finance-team", "finance-admins"], "skills": [ { "id": "run-report", "name": "Run Report", "description": "Run financial reports", "tags": ["finance"], "inputSchema": {} } ] }' ``` **Via the Web UI:** The agent registration and edit forms include a Visibility dropdown with the "Group Restricted" option. When selected, an input field appears for specifying the allowed groups. ### Step 3: Update an Existing Agent ```bash curl -s -X PUT "https://your-registry/api/agents/finance-agent" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Internal Finance Agent", "path": "/finance-agent", "version": "1.0.0", "url": "https://finance-agent.internal.example.com", "supportedProtocol": "a2a", "description": "Agent for internal finance operations", "visibility": "group-restricted", "allowedGroups": ["finance-team", "finance-admins", "executive-team"], "skills": [ { "id": "run-report", "name": "Run Report", "description": "Run financial reports", "tags": ["finance"], "inputSchema": {} } ] }' ``` ## Filtering Agents by Visibility or Group ```bash # List only group-restricted agents curl -s "https://your-registry/api/agents?visibility=group-restricted" \ -H "Authorization: Bearer $TOKEN" # List only agents shared with hr-team curl -s "https://your-registry/api/agents?allowed_groups=hr-team" \ -H "Authorization: Bearer $TOKEN" # List agents shared with either hr-team or finance-team curl -s "https://your-registry/api/agents?allowed_groups=hr-team,finance-team" \ -H "Authorization: Bearer $TOKEN" ``` The filter still respects the caller's group membership. A non-admin user filtering by `allowed_groups=hr-team` will only see agents they have both IAM access to and group membership for. ## Visibility Options | Value | Behavior | |-------|----------| | `public` | Visible to all users with IAM access (default) | | `group-restricted` | Visible only to users with IAM access whose groups overlap with `allowed_groups`. Admins always see all agents. | | `private` | Visible only to the agent owner and admin users | | `unlisted` | Visible only to users with the direct URL | ## How Group Matching Works When a user calls `GET /api/agents`, two checks run in sequence: 1. **IAM scope check**: The user's group scope config determines their `accessible_agents` list. Agents not in this list are filtered out. 2. **allowed_groups check** (only for `group-restricted` agents): The user's IdP groups (from their JWT token) must intersect with the agent's `allowed_groups`. If not, the agent is filtered out. Admin users bypass both checks and see all agents. ## IdP Independence The `allowed_groups` field works with any IdP (Keycloak, Entra ID, Cognito, Okta, Auth0) because matching is done against the groups present in the user's JWT token claims. The registry does not call any IdP API to verify group membership. For Entra ID, the group value is typically the Group Object ID or the group display name, depending on your claims configuration. ## Related Documentation - [Agent Visibility and Group-Based Access Control](../agent-visibility-and-group-access.md) -- full explanation of the two-layer model with examples - [Filtering Agents by Tags and Fields](filtering-agents-by-tags-and-fields.md) -- all agent filtering options - [Restrict Server Visibility by Entra Group](restrict-server-visibility-by-entra-group.md) -- similar setup for MCP servers - [Registering M2M Clients without IdP Admin Token](registering-m2m-client-without-idp-admin-token.md) -- register M2M client-id-to-group mappings locally ================================================ FILE: docs/faq/index.md ================================================ # Frequently Asked Questions Common questions and answers about the MCP Gateway Registry. ## Getting Started - [What is MCP and why do I need a gateway?](what-is-mcp-and-gateway.md) - [How do I deploy and register MCP servers and agents?](deploying-and-registering-servers-agents.md) ## Tool and Agent Discovery - [How do I discover available MCP tools for my AI agent?](discovering-mcp-tools.md) - [How do I handle tool discovery when I don't know what tools are available?](agent-autonomous-tool-discovery.md) - [What filtering options are available for agents in the registry?](filtering-agents-by-tags-and-fields.md) ## Connecting and Integration - [How do I connect my agent to multiple MCP servers through the gateway?](connecting-multiple-mcp-servers.md) - [How do I test my agent's integration with the MCP Gateway locally?](local-testing-agent-integration.md) ## Operations and Monitoring - [How do I monitor the health of MCP servers?](monitoring-server-health.md) ## Access Control and Visibility - [How do I restrict which agents a user can see based on their group?](group-restricted-agent-visibility.md) - [How do I restrict which MCP servers a user can see based on their Entra ID group?](restrict-server-visibility-by-entra-group.md) ## Authentication and API Access - [How do I register and manage MCP servers that require authentication?](registering-auth-protected-servers.md) - [Can I use an Entra ID token to call the registry API instead of the UI-generated token?](use-entra-token-for-registry-api.md) - [How do I register an M2M client and assign it groups without an IdP Admin API token?](registering-m2m-client-without-idp-admin-token.md) - [Registry API Authentication FAQ (static token, IdP JWT, coexistence)](registry-api-auth-faq.md) ================================================ FILE: docs/faq/local-testing-agent-integration.md ================================================ # How do I test my agent's integration with the MCP Gateway locally? Follow these steps: 1. **Set up local environment**: ```bash git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry cp .env.template .env # Configure your .env file ./build_and_run.sh ``` 2. **Test authentication**: ```bash # For user identity mode cd agents/ python cli_user_auth.py python agent.py --use-session-cookie --message "test message" # For agent identity mode python agent.py --message "test message" ``` 3. **Access the web interface** at `http://localhost` to verify server registration and tool availability. ## Related Documentation - [Quick Start Guide](../quickstart.md) -- getting started with the registry - [Installation Guide](../installation.md) -- detailed deployment instructions - [Authentication](../auth.md) -- authentication modes and configuration ================================================ FILE: docs/faq/monitoring-server-health.md ================================================ # How do I monitor the health of MCP servers? The registry provides built-in health monitoring: 1. **Web Interface**: View server status at `https://your-gateway` - Green: Healthy servers - Red: Servers with issues - Gray: Disabled servers 2. **Manual Health Checks**: Click the refresh icon on any server card in the dashboard 3. **Logs**: Monitor service logs: ```bash # View all service logs docker compose logs -f # View specific service logs docker compose logs -f registry docker compose logs -f auth-server ``` 4. **API Endpoint**: Programmatic health checks via `/health` endpoints ## Related Documentation - [Service Management](../service-management.md) -- managing MCP server lifecycle - [Observability](../OBSERVABILITY.md) -- monitoring and telemetry ================================================ FILE: docs/faq/registering-auth-protected-servers.md ================================================ # How do I register and manage MCP servers that require authentication? The MCP Gateway Registry fully supports registering MCP servers that are behind access control (Bearer token or API key). When a server requires authentication, the registry stores the credential securely (encrypted at rest) and automatically injects it when performing health checks, tool discovery, and proxying requests. ## Registering a Server with Authentication Use the `POST /api/servers/register` endpoint with JWT Bearer authentication. Include the `auth_scheme` and `auth_credential` fields to specify how the registry should authenticate with your backend MCP server. ### Bearer Token Authentication ```bash curl -X POST https://registry.example.com/api/servers/register \ -H "Authorization: Bearer $JWT_TOKEN" \ -F "name=My Protected Server" \ -F "description=An MCP server behind Bearer auth" \ -F "path=/my-protected-server" \ -F "proxy_pass_url=http://my-server:8000" \ -F "auth_scheme=bearer" \ -F "auth_credential=my-backend-server-token" ``` ### API Key Authentication ```bash curl -X POST https://registry.example.com/api/servers/register \ -H "Authorization: Bearer $JWT_TOKEN" \ -F "name=My API Key Server" \ -F "description=An MCP server behind API key auth" \ -F "path=/my-apikey-server" \ -F "proxy_pass_url=http://my-server:8000" \ -F "auth_scheme=api_key" \ -F "auth_credential=my-api-key-value" \ -F "auth_header_name=X-API-Key" ``` ### Supported `auth_scheme` Values | Value | Behavior | |-------|----------| | `none` | No authentication (default) | | `bearer` | Sends `Authorization: Bearer ` header | | `api_key` | Sends credential in a custom header (default: `X-API-Key`) | ### Custom Header Name When using `api_key`, you can specify a custom header name via `auth_header_name`. For example, if your server expects `X-My-Custom-Key`, pass `auth_header_name=X-My-Custom-Key`. ## How Tool Discovery Works with Auth Once registered with credentials, the registry automatically: 1. **Health checks** -- Injects the decrypted credential when checking if the server is reachable 2. **Tool discovery** -- Uses the credential to call the MCP `tools/list` method on the backend server 3. **Request proxying** -- When clients connect through the gateway, the credential is injected into proxied requests This means tool discovery works the same way for protected servers as it does for public ones -- no additional configuration is needed beyond providing the credential at registration time. ## Manually Providing Tools If your server is behind a firewall or tool auto-discovery is not possible, you can provide tools manually at registration time using the `tool_list_json` parameter: ```bash curl -X POST https://registry.example.com/api/servers/register \ -H "Authorization: Bearer $JWT_TOKEN" \ -F "name=My Server" \ -F "description=Server with manually defined tools" \ -F "path=/my-server" \ -F "proxy_pass_url=http://my-server:8000" \ -F 'tool_list_json=[{"name": "get_weather", "description": "Get weather for a city", "inputSchema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}]' ``` The `tool_list_json` field accepts a JSON array of MCP tool definitions. These will be stored in the registry and returned to clients during tool discovery, even if the backend server is unreachable for live tool listing. ## Updating or Rotating Credentials Use the `PATCH /api/servers/{path}/auth-credential` endpoint to update credentials without re-registering the server: ```bash # Rotate a Bearer token curl -X PATCH https://registry.example.com/api/servers/my-protected-server/auth-credential \ -H "Authorization: Bearer $JWT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "auth_scheme": "bearer", "auth_credential": "new-backend-server-token" }' ``` ```bash # Switch from Bearer to API key curl -X PATCH https://registry.example.com/api/servers/my-protected-server/auth-credential \ -H "Authorization: Bearer $JWT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "auth_scheme": "api_key", "auth_credential": "new-api-key", "auth_header_name": "X-API-Key" }' ``` ```bash # Remove authentication (make server public) curl -X PATCH https://registry.example.com/api/servers/my-protected-server/auth-credential \ -H "Authorization: Bearer $JWT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "auth_scheme": "none" }' ``` ## Credential Security - Credentials are **encrypted at rest** using the `SECRET_KEY` configured in your deployment - Credentials are **never returned** in API responses or displayed in the UI - Credentials are **decrypted only in memory** when needed for health checks, tool discovery, or request proxying ## Getting a JWT Token To authenticate with the registry API, you need a JWT token. Click the **Get JWT Token** button in the top-left corner of the registry UI, or use the token generation API: ```bash curl -X POST https://registry.example.com/api/tokens/generate \ -H "Cookie: session=" \ -H "Content-Type: application/json" \ -d '{ "expires_in_hours": 8, "description": "Server registration token" }' ``` ## Troubleshooting ### Tools not discovered for a protected server 1. Verify the credential is correct by testing it directly against your MCP server 2. Check the server health status in the registry UI -- if unhealthy, the credential may be invalid or expired 3. Use the credential update endpoint to provide a fresh credential 4. As a fallback, provide tools manually via `tool_list_json` at registration time ### "Failed to fetch tools" error This typically means: - The backend server is unreachable from the registry - The credential is invalid or expired - The server does not implement the standard MCP `tools/list` method Check the registry logs for detailed error messages about the connection failure. ================================================ FILE: docs/faq/registering-m2m-client-without-idp-admin-token.md ================================================ # How do I register an M2M client and assign it groups without an IdP Admin API token? **Short answer**: use the direct M2M client registration API at `/api/iam/m2m-clients`. Create the M2M client in your IdP (Keycloak, Okta, Entra, Auth0) as you normally would, then register its `client_id` with the registry and assign groups. The registry writes directly to its own `idp_m2m_clients` collection -- no `OKTA_API_TOKEN` or equivalent IdP Admin API credentials required. ## When to use this - Your enterprise gates IdP Admin API tokens (e.g. Okta requires approval for Admin API access) and getting one is disproportionate overhead. - You already know the M2M `client_id` you want to register (it lives in the IdP). - You want to assign groups so the registry's auth server can enrich M2M tokens with those groups during authorization. If `OKTA_API_TOKEN` / `AUTH0_M2M_CLIENT_ID` etc. are available, the existing `/api/iam/okta/m2m/*` or `/api/iam/auth0/m2m/*` sync endpoints cover the same ground. This FAQ is for the case where those credentials are not available. ## Prerequisites - A user JWT (or static API token) with **admin** scope on the registry. - The `client_id` of an M2M client you have already created in your IdP. - `M2M_DIRECT_REGISTRATION_ENABLED=true` on the registry (this is the default). ## Step 1: Create the M2M client in your IdP In Keycloak Admin UI (example; equivalent steps apply in Okta/Entra/Auth0): 1. Navigate to **Clients > Create client**. 2. Client type: **OpenID Connect**. Client ID: e.g. `my-automation-pipeline`. **Save**. 3. Enable **Client authentication** and **Service accounts roles**. Disable standard/direct flows. **Save**. 4. Copy the **Client Secret** from the `Credentials` tab. Your application will use this pair to request tokens from Keycloak. You do **not** need to assign groups inside the IdP. The registry resolves groups from its own `idp_m2m_clients` collection, which you will populate in the next step. ## Step 2: Register the client with the registry Using the bundled CLI (`api/registry_management.py`): ```bash export REGISTRY_URL=http://localhost export TOKEN_FILE=~/repos/mcp-gateway-registry/.token # admin user token uv run python api/registry_management.py \ --registry-url $REGISTRY_URL --token-file $TOKEN_FILE \ m2m-client-create \ --client-id my-automation-pipeline \ --client-name "My Automation Pipeline" \ --groups pipeline-operators,registry-readonly \ --description "CI/CD pipeline service account" ``` Expected output: ``` M2M client registered successfully Client ID: my-automation-pipeline Name: My Automation Pipeline Provider: manual Enabled: True Groups: pipeline-operators, registry-readonly Description: CI/CD pipeline service account Created by: admin Created at: ... Updated at: ... ``` `Provider: manual` means the record was created via this API (rather than synced from an IdP). Manual records are the only ones this API can modify or delete later. ## Step 3: Verify from your application 1. Request an M2M access token from Keycloak using client credentials: ```bash curl -X POST \ "http://localhost:8080/realms/mcp-gateway/protocol/openid-connect/token" \ -d "grant_type=client_credentials" \ -d "client_id=my-automation-pipeline" \ -d "client_secret=" ``` 2. Call the registry with that token. The registry's auth server looks up `client_id=my-automation-pipeline` in `idp_m2m_clients`, enriches the token with groups `["pipeline-operators", "registry-readonly"]`, and authorization proceeds based on those groups. ```bash curl -H "Authorization: Bearer $M2M_TOKEN" "$REGISTRY_URL/api/servers" ``` ## Managing registered clients All commands below use the same `--registry-url`/`--token-file` prefix as above. **List**: ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL --token-file $TOKEN_FILE \ m2m-client-list --provider manual ``` Supports `--limit`, `--skip`, `--json`. **Get one**: ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL --token-file $TOKEN_FILE \ m2m-client-get --client-id my-automation-pipeline ``` **Update** (partial -- fields you omit are left unchanged; pass `--groups ""` to clear groups): ```bash # Change groups only uv run python api/registry_management.py \ --registry-url $REGISTRY_URL --token-file $TOKEN_FILE \ m2m-client-update \ --client-id my-automation-pipeline \ --groups registry-readonly # Disable the client (kill switch) uv run python api/registry_management.py \ --registry-url $REGISTRY_URL --token-file $TOKEN_FILE \ m2m-client-update \ --client-id my-automation-pipeline \ --enabled false ``` **Delete**: ```bash uv run python api/registry_management.py \ --registry-url $REGISTRY_URL --token-file $TOKEN_FILE \ m2m-client-delete --client-id my-automation-pipeline --force ``` ## HTTP endpoints (for reference) | Method | Path | Auth | |--------|------|------| | POST | `/api/iam/m2m-clients` | admin | | GET | `/api/iam/m2m-clients` | any authenticated user (paginated) | | GET | `/api/iam/m2m-clients/{client_id}` | any authenticated user | | PATCH | `/api/iam/m2m-clients/{client_id}` | admin | | DELETE | `/api/iam/m2m-clients/{client_id}` | admin | ## Things to know - **Ownership guard**: records created by this API have `provider: "manual"`. Records written by the existing Okta/Auth0 sync services (`provider: "okta"`, `provider: "auth0"`) are visible via `GET` but return `HTTP 403` on `PATCH` or `DELETE` from this API, to prevent conflicts with IdP sync. - **Duplicate `client_id`**: returns `HTTP 409 Conflict`. One `client_id` can only have one record across all providers. - **Admins grant privilege directly**: any admin calling this API can assign any group to any `client_id`. The audit log records every mutation with the calling admin's identity for accountability. Treat the registry admin role accordingly. - **Feature flag**: `M2M_DIRECT_REGISTRATION_ENABLED` (default `true`) disables the whole router if set to `false`. Surface the flag on the System Config page under **Authentication**. ## Related FAQs - [How do I restrict which MCP servers a user can see based on their Entra ID group?](restrict-server-visibility-by-entra-group.md) - [Can I use an Entra ID token to call the registry API instead of the UI-generated token?](use-entra-token-for-registry-api.md) - [How do I register and manage MCP servers that require authentication?](registering-auth-protected-servers.md) ================================================ FILE: docs/faq/registry-api-auth-faq.md ================================================ # Registry API Authentication FAQ Common questions about authenticating against the Registry API (`/api/*`, `/v0.1/*`). For the full authentication model, see [Registry API Authentication](../registry-api-auth.md). ## Can I use an IdP token and the static token on the same deployment? Yes, as of [#871](https://github.com/agentic-community/mcp-gateway-registry/issues/871). When `REGISTRY_STATIC_TOKEN_AUTH_ENABLED=true`, the static token is accepted as an **additional** credential, not an exclusive gate. Valid Okta / Entra / Cognito / Keycloak JWTs, UI-issued self-signed JWTs, and session cookies all continue to work on `/api/*`. A bearer that doesn't match the static token falls through to the JWT validation pipeline. Before #871, turning on static-token mode silently broke every non-static-token caller on `/api/*` with a 401/403 before JWT validation ran. ## Do I need to seed MongoDB with `mcp-servers-unrestricted/*` scope docs for the static token to work? **After #779:** Yes, the standard scope-resolution path is used for all static-token keys (including the legacy `REGISTRY_API_TOKEN`). The auth server resolves each key's groups to scopes via `group_mappings` in MongoDB. If the group-to-scope mappings are missing, the key will authenticate but carry an empty scope set, which means the registry will treat it as a non-admin caller. **Before #779:** No. The registry hard-coded full admin access when the auth server set `X-Auth-Method == "network-trusted"`. Scopes returned in the `/validate` response were informational only. ## Can I give a static token read-only access? Yes, since [#779](https://github.com/agentic-community/mcp-gateway-registry/issues/779). Define a key in `REGISTRY_API_KEYS` whose `groups` list maps to read-only scopes (e.g., `mcp-servers-unrestricted/read`). The key will authenticate successfully but will not carry mutating scopes, so the registry treats it as a non-admin caller. ```json {"ci-readonly": {"key": "", "groups": ["mcp-readonly"]}} ``` Make sure the group `mcp-readonly` is mapped to the desired read-only scopes in your `group_mappings` collection. ## Why does the static token not work on `//tools/list`? By design. The static token is only accepted on Registry API paths (`/api/*`, `/v0.1/*`). **MCP gateway tool invocations always require full IdP authentication** regardless of static-token settings. This is a deliberate boundary, not a bug: the static token grants admin-level access on registry metadata endpoints, but tool invocations — which can have real-world side effects — stay gated behind per-user identity and scopes from the IdP. A curl with `-H "Authorization: Bearer $REGISTRY_API_TOKEN"` against an MCP gateway path will currently return a 500 wrapping the JWT validation failure. That's a pre-existing error-code bug (separate from #871), not a sign the call should have succeeded. ## What status code does a fully invalid bearer get? Since [#871](https://github.com/agentic-community/mcp-gateway-registry/issues/871): **401** from the JWT block (detail: `"Missing or invalid Authorization header. Expected: Bearer or valid session cookie"`). Before #871: **403** from the static-token block (detail: `"Invalid API token"`). No caller with a valid credential is affected by this change. The status-code shift only applies to bearers that were going to be rejected anyway. ## Is my UI-issued JWT usable against `/api/*`? Yes, since #871. Before the fix, the **Get JWT Token** sidebar in the UI produced valid HS256 JWTs that were nonetheless rejected on `/api/*` when static-token mode was on. After #871 they flow through the same `_validate_self_signed_token` path as any other UI-issued token, regardless of whether static-token mode is on. ## How do I rotate a static token without downtime? **With `REGISTRY_API_KEYS` (recommended):** Zero-downtime rotation is straightforward: 1. Add a new key entry to the JSON array (the old key stays). 2. Deploy the updated config. Both keys are now valid. 3. Migrate clients to the new key at your own pace. 4. Remove the old key entry and redeploy. **With legacy `REGISTRY_API_TOKEN` only:** There is still a cutover window during which old clients are rejected while new clients have yet to pick up the new value. Mitigations: - Roll out the new token value to clients first, then flip the server value. - Or accept a brief 401/403 window and notify callers. - Or migrate to `REGISTRY_API_KEYS` for zero-downtime rotation. ## Where do I see the current values in the UI? The **Settings → Authentication** page shows: | Field | Label | Behavior | |---|---|---| | `registry_static_token_auth_enabled` | Static Token Auth Enabled | Displayed as `true` / `false` | | `registry_api_token` | Registry API Token | Masked | | `registry_api_keys` | Registry API Keys | Masked | | `m2m_direct_registration_enabled` | M2M Direct Registration Enabled | Displayed as `true` / `false` (from [#851](https://github.com/agentic-community/mcp-gateway-registry/issues/851)) | The field registry is defined in [registry/api/config_routes.py](../../registry/api/config_routes.py). ## What's the roadmap? Three improvements, landing in order on top of each other: 1. **[#871](https://github.com/agentic-community/mcp-gateway-registry/issues/871) — coexistence** (shipped): static token and JWT auth work together on `/api/*`. 2. **[#779](https://github.com/agentic-community/mcp-gateway-registry/issues/779) — multi-key static tokens** (shipped): replaces the single `REGISTRY_API_TOKEN` with a `REGISTRY_API_KEYS` JSON object, each key carrying its own groups. Lets operators give scripts the minimum privilege they need. Zero-downtime rotation is built in. 3. **[#826](https://github.com/agentic-community/mcp-gateway-registry/issues/826) — external user access tokens**: lets a frontend application that has its own IdP integration call the Registry API on behalf of a logged-in user, either via `/userinfo` group enrichment (Solution A) or a new token-exchange endpoint (Solution B). See the [full design in Registry API Authentication](../registry-api-auth.md#roadmap-near-term-improvements). ## Related FAQs - [How do I register an M2M client and assign it groups without an IdP Admin API token?](registering-m2m-client-without-idp-admin-token.md) - [Can I use an Entra ID token to call the registry API instead of the UI-generated token?](use-entra-token-for-registry-api.md) - [How do I register and manage MCP servers that require authentication?](registering-auth-protected-servers.md) ================================================ FILE: docs/faq/restrict-server-visibility-by-entra-group.md ================================================ # How do I restrict which MCP servers a user can see based on their Entra ID group? The registry has a built-in IAM system that lets you control which servers/tools each group can access. You create the group in Entra ID first, then map it in the registry with the servers that group should have access to. ## Option A: Via the Web UI 1. **Create the group in Entra ID first** (Azure Portal > Groups) 2. In the registry UI, go to **Settings > IAM > Groups** 3. Click **Create Group** 4. **Uncheck "Create group in IdP"** -- since the group already exists in Entra ID 5. Enter the group name (must match the Entra ID group name or Object ID depending on your claims configuration) 6. Under **Server Access**, select which MCP servers, methods, and tools this group should have access to 7. Under **UI Permissions**, configure what actions group members can perform in the dashboard 8. Save the group ## Option B: Via CLI (registry_management.py) You can import a group definition from a JSON file with the `import-group` command: ```bash python api/registry_management.py \ --registry-url https://your-registry-url \ --token-file .token \ import-group \ --file my-group.json ``` Example JSON file (`my-group.json`) -- adapted from [`cli/examples/public-mcp-users.json`](https://github.com/agentic-community/mcp-gateway-registry/blob/main/cli/examples/public-mcp-users.json): ```json { "scope_name": "restricted-mcp-users", "description": "Users with access to specific MCP servers only", "create_in_idp": false, "group_mappings": ["restricted-mcp-users", "your-entra-group-object-id-guid"], "server_access": [ { "server": "your-server-1", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call"], "tools": ["*"] }, { "server": "/your-server-1", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call"], "tools": ["*"] }, { "server": "api", "methods": ["initialize", "GET", "POST", "servers", "agents", "search"], "tools": [] } ], "ui_permissions": { "list_service": ["all"], "list_agents": [], "get_agent": [] } } ``` Set `"create_in_idp": false` since the group already exists in Entra ID. The `group_mappings` array should include both the group name and the Entra ID Group Object ID (GUID). Example scope JSON files are also available in [`scripts/registry-admins.json`](https://github.com/agentic-community/mcp-gateway-registry/blob/main/scripts/registry-admins.json) and [`cli/examples/public-mcp-users.json`](https://github.com/agentic-community/mcp-gateway-registry/blob/main/cli/examples/public-mcp-users.json). ## Related Documentation - [IAM Settings UI Guide](https://github.com/agentic-community/mcp-gateway-registry/blob/main/docs/iam-settings-ui.md) -- full walkthrough of the Groups UI with server access, tools, and permissions configuration - [Entra ID Setup Guide](https://github.com/agentic-community/mcp-gateway-registry/blob/main/docs/entra-id-setup.md) -- Steps 5-10 cover configuring group claims in Azure and mapping Entra ID Group Object IDs to registry scopes - [Scopes Management](https://github.com/agentic-community/mcp-gateway-registry/blob/main/docs/scopes-mgmt.md) -- detailed field reference for scope/group JSON configuration - [Entra ID Setup - IAM API for Groups](https://github.com/agentic-community/mcp-gateway-registry/blob/main/docs/entra-id-setup.md#using-the-iam-api-to-manage-groups-users-and-m2m-accounts) -- covers `import-group`, `group-create`, `group-delete` commands with full JSON examples ================================================ FILE: docs/faq/use-entra-token-for-registry-api.md ================================================ # Can I use an Entra ID token to call the registry API instead of the UI-generated token? Yes -- you can use Entra ID-based tokens directly for API authorization instead of the tokens from the registry UI. The recommended approach is to create an M2M (Machine-to-Machine) identity in Entra ID and assign it to a registry group to control its access. ## Setup Steps 1. **Register an App Registration** in Entra ID with client credentials (client ID + client secret) 2. In the registry UI, go to **Settings > IAM > M2M Accounts** and create an M2M account linked to this Entra ID app 3. **Assign the M2M account to a group** -- this restricts its access to only the servers/tools that group allows (see [How do I restrict server visibility by Entra group?](restrict-server-visibility-by-entra-group.md)) 4. **Request tokens** directly from Entra ID using the standard OAuth2 client credentials flow: ```bash curl -X POST "https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "client_id={M2M_CLIENT_ID}" \ -d "client_secret={M2M_CLIENT_SECRET}" \ -d "scope=api://{APP_CLIENT_ID}/.default" \ -d "grant_type=client_credentials" ``` Where: - `{TENANT_ID}` is your Azure AD Tenant ID - `{M2M_CLIENT_ID}` is the M2M service account Client ID - `{M2M_CLIENT_SECRET}` is the M2M service account Client Secret - `{APP_CLIENT_ID}` is the Application (client) ID of your MCP Gateway app registration in Entra ID - `.default` requests all scopes that admin consent has been granted for 5. **Use the resulting token** in API calls: ```bash curl -H "Authorization: Bearer {ACCESS_TOKEN}" \ https://your-registry-url/api/servers ``` ## How Token Validation Works The registry validates Entra ID tokens (RS256) by: 1. Fetching the JWKS from your Entra ID tenant 2. Verifying the token signature, issuer, and audience claims 3. Extracting group claims from the token 4. Mapping group claims to registry scopes The M2M identity will only see the servers and tools that its assigned group allows. ## Related Documentation - [Entra ID Setup - M2M Token Generation](https://github.com/agentic-community/mcp-gateway-registry/blob/main/docs/entra-id-setup.md#generating-jwt-tokens-for-m2m-accounts) -- covers direct token requests, credentials provider scripts, and token usage - [Authentication Overview](https://github.com/agentic-community/mcp-gateway-registry/blob/main/docs/auth.md) -- covers all three identity types (Human, Programmatic, M2M) and how group-to-scope mapping works for each - [Auth Management](https://github.com/agentic-community/mcp-gateway-registry/blob/main/docs/auth-mgmt.md) -- M2M account creation and token usage examples ================================================ FILE: docs/faq/what-is-mcp-and-gateway.md ================================================ # What is the Model Context Protocol (MCP) and why do I need a gateway? **Model Context Protocol (MCP)** is an open standard that allows AI models to connect with external systems, tools, and data sources. ## Why You Need a Gateway - **Service Discovery**: Find approved MCP servers in your organization - **Centralized Access Control**: Secure, governed access to tools - **Dynamic Tool Discovery**: Agents can find new tools autonomously - **Simplified Client Configuration**: Single endpoint for multiple servers - **Enterprise Security**: Authentication, authorization, and audit logging **Without Gateway**: Each agent connects directly to individual MCP servers **With Gateway**: All agents connect through a single, secure, managed endpoint ## What's the difference between the Registry and the Gateway? They are complementary components: **Registry**: - **Purpose**: Service discovery and management - **Features**: Web UI, server registration, health monitoring, tool catalog - **Users**: Platform administrators, developers - **Access**: Web browser at port 80 (HTTP) or 443 (HTTPS) via nginx reverse proxy **Gateway**: - **Purpose**: Secure proxy for MCP protocol traffic - **Features**: Authentication, authorization, request routing - **Users**: AI agents, MCP clients - **Access**: MCP protocol at `/server-name/sse` **Together**: Registry manages what's available, Gateway controls access to it. ## Related Documentation - [Quick Start Guide](../quickstart.md) -- getting started - [Installation Guide](../installation.md) -- deployment options - [Architecture](../design/architectural-decision-reverse-proxy-vs-application-layer-gateway.md) -- architectural design decisions ================================================ FILE: docs/federation-operational-guide.md ================================================ # Federation Operational Guide This guide covers setting up and operating peer-to-peer federation between MCP Gateway Registry instances. ## Demo https://github.com/user-attachments/assets/630ce847-b151-4eaa-9cc9-2ec77797f2b5 ## Quick Start ### Prerequisites - Two or more MCP Gateway Registry instances running - Network connectivity between registries (HTTPS) - Admin access to both registries ### Step 1: Generate Encryption Key (One-Time) On the importing registry, generate a Fernet encryption key for storing peer credentials: ```bash python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" ``` Add to your `.env`: ```bash FEDERATION_ENCRYPTION_KEY= ``` ### Step 2: Configure the Exporting Registry On the registry that will export data, enable federation static token auth: ```bash # Generate a static token python -c "import secrets; print(secrets.token_urlsafe(32))" # Add to .env FEDERATION_STATIC_TOKEN_AUTH_ENABLED=true FEDERATION_STATIC_TOKEN= ``` Restart the registry for changes to take effect. ### Step 3: Add Peer Configuration On the importing registry, add the peer using the UI or API: **Using the UI:** 1. Navigate to Settings (gear icon in header) 2. Select Federation > Peers 3. Click "Add Peer" 4. Fill in the peer details: - Peer ID: A unique identifier (e.g., "lob-a") - Name: Human-readable name - Endpoint: Base URL of the peer registry - Federation Token: The token from Step 2 - Sync Mode: Select how to filter synced items **Using the API:** ```bash curl -X POST https://your-registry.com/api/peers \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{ "peer_id": "lob-a", "name": "LOB-A Registry", "endpoint": "https://lob-a-registry.corp.com", "enabled": true, "sync_mode": "all", "sync_interval_minutes": 30, "federation_token": "" }' ``` ### Step 4: Set Visibility on Exportable Items On the exporting registry, mark servers and agents for federation export: ```bash # Mark a server as public (exportable) curl -X PUT https://lob-a-registry.corp.com/api/servers/my-tool \ -H "Authorization: Bearer " \ -d '{"visibility": "public"}' ``` Or use the UI to edit server/agent settings and set visibility to "public". ### Step 5: Trigger Initial Sync **Using the UI:** 1. Navigate to Settings > Federation > Peers 2. Click the sync icon next to the peer 3. View sync status and results **Using the API:** ```bash curl -X POST https://your-registry.com/api/peers/lob-a/sync \ -H "Authorization: Bearer " ``` ## Common Deployment Topologies ### Hub and Spoke Central IT maintains a Hub that pulls from all LOB registries. **Hub Configuration:** ```bash # Hub .env FEDERATION_ENCRYPTION_KEY= ``` Add each LOB as a peer (UI or API). **LOB Configuration:** ```bash # Each LOB .env FEDERATION_STATIC_TOKEN_AUTH_ENABLED=true FEDERATION_STATIC_TOKEN= ``` No peer configuration needed on LOBs (they only export). ### Bidirectional Sync Two registries share items with each other. **Registry A:** ```bash # .env FEDERATION_ENCRYPTION_KEY= FEDERATION_STATIC_TOKEN_AUTH_ENABLED=true FEDERATION_STATIC_TOKEN= ``` Add Registry B as a peer with its token. **Registry B:** ```bash # .env FEDERATION_ENCRYPTION_KEY= FEDERATION_STATIC_TOKEN_AUTH_ENABLED=true FEDERATION_STATIC_TOKEN= ``` Add Registry A as a peer with its token. ### Mesh Topology Multiple registries in a mesh where each can pull from any other. Each registry: 1. Has its own `FEDERATION_STATIC_TOKEN` for others to pull from it 2. Has `FEDERATION_ENCRYPTION_KEY` to store peer tokens 3. Configures each other registry as a peer ## Sync Mode Configuration ### Sync All Import all public servers and agents from the peer: ```json { "peer_id": "lob-a", "sync_mode": "all" } ``` ### Whitelist Mode Import only specific servers and agents: ```json { "peer_id": "lob-a", "sync_mode": "whitelist", "whitelist_servers": ["/production-db", "/shared-api"], "whitelist_agents": ["/analytics-agent"] } ``` ### Tag Filter Mode Import items with specific tags: ```json { "peer_id": "lob-a", "sync_mode": "tag_filter", "tag_filters": ["production", "shared"] } ``` ## Scheduled Sync Configure automatic sync at regular intervals: ```json { "peer_id": "lob-a", "sync_interval_minutes": 30 } ``` Set to `0` for manual-only sync. ### How Scheduled Sync Works The registry runs a background scheduler that: 1. **Checks every 60 seconds** for peers that need syncing 2. **Evaluates each enabled peer** with `sync_interval_minutes > 0` 3. **Triggers sync** when the time since `last_successful_sync` exceeds the configured interval 4. **Skips peers** that are disabled, have sync in progress, or have interval set to 0 The scheduler starts automatically when the registry starts and stops gracefully on shutdown. ### Viewing Scheduled Sync Activity Check the registry logs for scheduled sync activity: ```bash docker-compose logs registry | grep -i "scheduled sync" ``` Example log output: ``` Scheduled sync triggered for peer 'lob-a' (interval: 30m) Scheduled sync completed for peer 'lob-a': 15 servers, 3 agents ``` ## Managing Peers ### Enable/Disable a Peer **UI:** Toggle the enabled switch in the peers list. **API:** ```bash # Enable curl -X POST https://registry.com/api/peers/lob-a/enable \ -H "Authorization: Bearer " # Disable curl -X POST https://registry.com/api/peers/lob-a/disable \ -H "Authorization: Bearer " ``` ### Update Peer Configuration ```bash curl -X PUT https://registry.com/api/peers/lob-a \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "sync_mode": "tag_filter", "tag_filters": ["production"] }' ``` ### Delete a Peer ```bash curl -X DELETE https://registry.com/api/peers/lob-a \ -H "Authorization: Bearer " ``` This removes the peer configuration. Synced items are marked as orphaned. ### View Sync Status **UI:** Click on a peer to view detailed status including: - Last successful sync - Total servers/agents synced - Current generation number - Health status **API:** ```bash curl https://registry.com/api/peers/lob-a/status \ -H "Authorization: Bearer " ``` ## Token Rotation ### Rotating Federation Token (Exporting Registry) 1. Generate a new token: ```bash python -c "import secrets; print(secrets.token_urlsafe(32))" ``` 2. Update the exporting registry's `.env`: ```bash FEDERATION_STATIC_TOKEN= ``` 3. Restart the exporting registry. 4. Update the peer configuration on all importing registries: ```bash curl -X PUT https://hub-registry.com/api/peers/lob-a \ -H "Authorization: Bearer " \ -d '{"federation_token": ""}' ``` ### Rotating Encryption Key (Importing Registry) If you need to rotate the `FEDERATION_ENCRYPTION_KEY`: 1. Export current peer configurations (tokens will be encrypted) 2. Generate new Fernet key 3. Run migration script to re-encrypt tokens with new key 4. Update `.env` with new key 5. Restart registry ## Troubleshooting ### Connection Refused **Symptom:** Sync fails with connection error. **Checks:** - Verify network connectivity: `curl https://peer-registry.com/api/v1/federation/health` - Check firewall rules allow HTTPS traffic - Verify endpoint URL is correct in peer config ### Authentication Failed (401/403) **Symptom:** Sync fails with authentication error. **Checks:** - Verify `FEDERATION_STATIC_TOKEN_AUTH_ENABLED=true` on exporting registry - Verify token in peer config matches `FEDERATION_STATIC_TOKEN` on exporting registry - Check token was copied correctly (no extra whitespace) - Verify `FEDERATION_ENCRYPTION_KEY` is set on importing registry ### No Items Synced **Symptom:** Sync succeeds but no servers/agents appear. **Checks:** - Verify items have `visibility: "public"` on exporting registry - Check sync_mode and filters are not too restrictive - Verify items exist on the exporting registry ### Sync Reports 0 Items After Successful Authentication **Symptom:** Sync completes successfully and authentication passes, but 0 servers/agents are returned even though items exist on the peer registry. **Root Cause:** This can indicate that the federation token was lost or corrupted during a peer configuration update (issue #561, fixed in version XX.XX). **Diagnostic Steps:** 1. Check if the peer had items synced previously: ```bash curl https://registry.com/api/peers/peer-id/status ``` If `total_servers_synced` was > 0 before but is now 0, the token may be lost. 2. Verify the peer registry is actually returning data: ```bash # Direct test to peer registry (replace with actual token) curl -H "Authorization: Bearer " \ https://peer-registry.com/api/v1/federation/servers ``` **Resolution:** If authentication is working but 0 items are returned, update the federation token using the dedicated endpoint: ```bash curl -X PATCH https://registry.com/api/peers/peer-id/token \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{ "federation_token": "" }' ``` Or use the UI: 1. Navigate to Settings > Federation > Peers 2. Click the peer name 3. Update the Federation Token field 4. Save changes 5. Trigger a manual sync to verify ### Federation Token Lost After Peer Update (Fixed in XX.XX) **Symptom:** After updating a peer's configuration (name, endpoint, sync interval, etc.), all subsequent syncs fail with authentication errors or return 0 items. **Root Cause:** Bug in versions prior to XX.XX where the `update_peer()` operation would silently drop the encrypted federation token when updating any peer field. **Who is Affected:** - Anyone who updated peer configurations between version X.X and X.X **How to Identify:** - Sync was working before a peer update - Sync now returns 0 items or authentication errors - No changes were made to the token itself **Recovery:** Update the federation token using the dedicated token update endpoint: ```bash curl -X PATCH https://registry.com/api/peers//token \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{ "federation_token": "" }' ``` **Prevention:** This issue is fixed in version XX.XX. After upgrading, peer updates will preserve the federation token correctly. ### Synced Items Are Read-Only **Expected behavior:** Federated items cannot be modified locally. If you need to modify a synced item: 1. Modify it on the source registry 2. Wait for next sync or trigger manual sync ### Orphaned Items **Symptom:** Items show as orphaned in the UI. This happens when items are removed from the source registry. To resolve: 1. Confirm the items should be removed from the source 2. Delete orphaned items manually, or 3. Re-sync to clear orphaned status if items are restored ## Monitoring ### Health Check Endpoint Each registry exposes a federation health endpoint: ```bash curl https://registry.com/api/v1/federation/health ``` Returns: ```json { "status": "healthy", "federation_enabled": true, "peer_count": 3 } ``` ### Sync Status Metrics Monitor sync status via the API: ```bash curl https://registry.com/api/peers/lob-a/status ``` Returns: ```json { "peer_id": "lob-a", "is_healthy": true, "last_successful_sync": "2026-02-05T10:30:00Z", "total_servers_synced": 15, "total_agents_synced": 3, "sync_in_progress": false, "consecutive_failures": 0 } ``` ### Alerting Recommendations Set up alerts for: - `consecutive_failures > 3` - Sync has failed multiple times - `is_healthy == false` - Peer is unreachable - Time since `last_successful_sync > 2x sync_interval` - Sync is stale ## Security Best Practices 1. **Use strong tokens**: Generate tokens with `secrets.token_urlsafe(32)` or longer 2. **Rotate tokens periodically**: Rotate federation tokens at least annually 3. **Limit visibility**: Only set `visibility: "public"` on items that should be shared 4. **Use tag filters**: Use tag-based filtering to control what gets synced 5. **Monitor sync activity**: Review sync logs for unexpected patterns 6. **Network isolation**: Use private networks or VPNs between registries when possible ## Registry Card Configuration The Registry Card is a discovery document that provides metadata about your registry instance, including its capabilities, authentication endpoints, and contact information. It is essential for federation discovery and is accessed via the `.well-known` endpoint: ``` GET /.well-known/registry-card ``` ### Viewing and Editing the Registry Card Navigate to **Settings > Registry Card** to view and edit your registry's metadata: ![Registry Card Settings](img/reg-card.png) The Registry Card settings page shows: 1. **Registry Information** (read-only): - Registry ID (UUID) - Name - Organization - Registry URL - Federation Endpoint - API Version 2. **Authentication Configuration** (read-only): - Supported authentication schemes - OAuth2 issuer URL - OAuth2 token endpoint - Supported scopes 3. **Editable Information**: - Description (up to 1000 characters) - Contact Email - Contact URL 4. **Capabilities** (configured via feature flags): - Servers, Agents, Skills management - Security scans - Incremental sync (future) - Webhooks (future) ### Auto-Initialization The Registry Card is automatically initialized on first startup using environment variables. Configure these in your `.env` file: ```bash # Registry Identity (Required) REGISTRY_URL=https://registry.example.com REGISTRY_NAME=My Registry REGISTRY_ORGANIZATION_NAME=ACME Corporation # Optional REGISTRY_DESCRIPTION=Enterprise MCP Gateway Registry REGISTRY_CONTACT_EMAIL=mcp-support@example.com REGISTRY_CONTACT_URL=https://example.com/support ``` For a complete list of configuration options, see the [Configuration Reference](configuration.md). ### Authentication Provider Examples The Registry Card automatically configures authentication endpoints based on your `AUTH_PROVIDER` setting: - **Entra ID**: Uses Microsoft login endpoints - **Keycloak**: Uses your Keycloak realm endpoints - **Okta**: Uses your Okta domain endpoints - **Cognito**: Uses AWS Cognito endpoints The authentication configuration is read-only and updates automatically when you change authentication providers. ## Related Documentation - [Federation Architecture](design/federation-architecture.md) - Technical architecture - [Federation Guide](federation.md) - External registry integration (Anthropic, ASOR) - [Static Token Auth](static-token-auth.md) - Static token authentication details - [Configuration Reference](configuration.md) - Environment variable reference ================================================ FILE: docs/federation.md ================================================ # Federation Guide - External Registry Integration The MCP Gateway Registry supports federation with external registries, allowing you to import and manage servers/agents from multiple sources through a unified interface. ## Supported Federation Sources | Source | Type | Description | Visual Tag | |--------|------|-------------|------------| | **Anthropic MCP Registry** | MCP Servers | Official Anthropic curated MCP servers | `ANTHROPIC` (purple) | | **Workday ASOR** | AI Agents | Workday Agent System of Record | `ASOR` (orange) |
Federation Demo
--- ## Quick Setup ### 1. Environment Variables Add to your `.env` file: ```bash # Anthropic MCP Registry (no auth required) ANTHROPIC_REGISTRY_ENABLED=true # Workday ASOR (requires OAuth credentials and token) ASOR_CLIENT_ID=your_client_id ASOR_CLIENT_SECRET=your_client_secret ASOR_TENANT_NAME=your_tenant_name ASOR_HOSTNAME=your_host_name ``` ### 2. Federation Configuration Create or update `~/mcp-gateway/federation.json`: ```json { "anthropic": { "enabled": true, "endpoint": "https://registry.modelcontextprotocol.io", "servers": [] }, "asor": { "enabled": true, "endpoint": "https://wcpdev-services1.wd103.myworkday.com/ccx/api/asor/v1/awsasor_wcpdev1", "auth_env_var": "ASOR_ACCESS_TOKEN", "agents": [] } } ``` ### 3. Start Services ```bash ./build_and_run.sh ``` --- ## Anthropic MCP Registry Integration ### Configuration ```json { "anthropic": { "enabled": true, "endpoint": "https://registry.modelcontextprotocol.io", "servers": [ {"name": "io.github.jgador/websharp"}, {"name": "another-server-name"} ] } } ``` ### Configuration Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `enabled` | boolean | `false` | Enable Anthropic federation | | `endpoint` | string | `https://registry.modelcontextprotocol.io` | Anthropic registry API endpoint | | `servers` | array | `[]` | Specific servers to import (empty = all) | ### Import Specific Servers for Anthropic **Option 1: Configuration File** ```json { "anthropic": { "servers": [ {"name": "io.github.jgador/websharp"}, {"name": "modelcontextprotocol/filesystem"}, {"name": "modelcontextprotocol/brave-search"} ] } } ``` ### Import All Available Servers for Asor Set `servers` to empty array: ```json { "asor": { "servers": [] } } ``` --- ## Workday ASOR Integration ### Prerequisites 1. **Workday ASOR Access**: Valid Workday tenant with ASOR enabled 2. **OAuth Credentials**: Client ID and Secret for ASOR API 3. **Access Token**: Valid OAuth token with "Agent System of Record" scope ### Step 1: Get OAuth Token Add to `.env`: ```bash # ASOR OAuth Credentials ASOR_CLIENT_ID=your_client_id ASOR_CLIENT_SECRET=your_client_secret ASOR_TENANT_NAME=your_tenant_name ASOR_HOSTNAME=your_host_name Use the provided token generator: ```bash python3 get_asor_token.py ``` This will: 1. Generate authorization URL 2. Guide you through OAuth flow 3. Provide access token for `.env` file ### Step 2: Environment Configuration # OAuth Access Token (generated by get_asor_token.py) ASOR_ACCESS_TOKEN=your_oauth_token_here ``` ### Step 3: Federation Configuration ```json { "asor": { "enabled": true, "endpoint": "https://wcpdev-services1.wd103.myworkday.com/ccx/api/asor/v1/awsasor_wcpdev1", "auth_env_var": "ASOR_ACCESS_TOKEN", "agents": [] } } ``` ### Configuration Options | Option | Type | Default | Description | |--------|------|---------|-------------| | `enabled` | boolean | `false` | Enable ASOR federation | | `endpoint` | string | Required | ASOR API endpoint URL | | `auth_env_var` | string | `ASOR_ACCESS_TOKEN` | Environment variable containing OAuth token | | `agents` | array | `[]` | Specific agents to import (empty = all) | ### Token Management **Token Expiration**: ASOR tokens expire every 4 hours. You'll need to: 1. **Monitor logs** for authentication errors 2. **Regenerate tokens** using `python3 get_asor_token.py` 3. **Update .env** with new token 4. **Restart services** to apply new token **Automated Token Refresh** (Future Enhancement): ```bash # Set up cron job for token refresh 0 */3 * * * cd /path/to/mcp-gateway && python3 get_asor_token.py --auto-update ``` --- ## Visual Identification Federated servers and agents are visually tagged in the UI: ### Server Cards (MCP Servers Tab) - **ANTHROPIC**: Purple gradient badge for Anthropic MCP Registry servers ### Agent Cards (A2A Agents Tab) - **ASOR**: Orange gradient badge for ASOR-sourced agents --- ## Troubleshooting ### Common Issues **1. Anthropic Servers Not Importing** ```bash # Check logs docker-compose logs registry | grep -i anthropic # Verify connectivity curl https://registry.modelcontextprotocol.io/servers # Check configuration cat ~/mcp-gateway/federation.json ``` **2. ASOR Authentication Errors** ```bash # Check token in logs docker-compose logs registry | grep -i asor # Verify token echo $ASOR_ACCESS_TOKEN # Test token manually curl -H "Authorization: Bearer $ASOR_ACCESS_TOKEN" \ https://wcpdev-services1.wd103.myworkday.com/ccx/api/asor/v1/awsasor_wcpdev1/agentDefinition ``` **3. Duplicate Entries** - ASOR agents should only appear in **A2A Agents** tab - If appearing in both tabs, check federation service logs for duplicate registration ### Debug Mode Enable detailed federation logging: ```bash # Add to .env FEDERATION_DEBUG=true LOG_LEVEL=DEBUG # Restart services docker-compose restart registry ``` ### Log Analysis ```bash # Federation startup docker-compose logs registry | grep -i "federation.*enabled" # Sync operations docker-compose logs registry | grep -i "sync.*servers\|sync.*agents" # Authentication docker-compose logs registry | grep -i "token\|auth" # Errors docker-compose logs registry | grep -i "error\|failed" ``` --- ## Advanced Configuration ### Custom Endpoints For enterprise deployments with custom registry endpoints: ```json { "anthropic": { "endpoint": "https://your-custom-mcp-registry.company.com" }, "asor": { "endpoint": "https://your-workday-tenant.myworkday.com/ccx/api/asor/v1/your_tenant" } } ``` ### Selective Import Import only specific servers/agents: ```json { "anthropic": { "servers": [ {"name": "modelcontextprotocol/filesystem"}, {"name": "modelcontextprotocol/brave-search"} ] }, "asor": { "agents": [ {"id": "aws_assistant"}, {"id": "data_analyst"} ] } } ``` --- ## Security Considerations ### Token Security 1. **Environment Variables**: Store tokens in `.env`, never in code 2. **Token Rotation**: Regularly rotate ASOR tokens 3. **Access Control**: Limit federation access to admin users 4. **Audit Logging**: Monitor federation sync operations ### Network Security 1. **HTTPS Only**: All federation endpoints use HTTPS 2. **Firewall Rules**: Allow outbound HTTPS to federation endpoints 3. **Proxy Support**: Configure HTTP proxy if required ```bash # Proxy configuration in .env HTTP_PROXY=http://proxy.company.com:8080 HTTPS_PROXY=http://proxy.company.com:8080 ``` --- ## API Reference ### Federation Endpoints | Method | Endpoint | Description | |--------|----------|-------------| | `GET` | `/api/federation/status` | Get federation configuration and status | | `POST` | `/api/federation/sync` | Sync all enabled federations | | `POST` | `/api/federation/sync/{source}` | Sync specific federation source | ### Response Examples **Federation Status:** ```json { "enabled_federations": ["anthropic", "asor"], "anthropic": { "enabled": true, "last_sync": "2024-01-15T10:30:00Z" }, "asor": { "enabled": true, "last_sync": "2024-01-15T10:25:00Z" } } ``` **Sync Response:** ```json { "success": true, "results": { "anthropic": { "synced": 25, "errors": 0, "duration_ms": 1250 }, "asor": { "synced": 3, "errors": 0, "duration_ms": 850 } } } ``` ### Contributing 1. **New Federation Sources**: Guidelines for adding new sources 2. **Bug Reports**: How to report federation issues 3. **Feature Requests**: Process for requesting new federation features 4. **Testing**: How to test federation changes --- *Last Updated: November 2024* ## ASOR to Agent Card Field Mapping This section documents how ASOR agent data is mapped to the MCP Gateway Registry Agent Card format. ### Field Mapping Table | ASOR Field | Agent Card Field | Mapping Logic | Status | |------------|------------------|---------------|---------| | **Required A2A Fields** | | N/A | `protocol_version` | Hardcoded to `"1.0"` | ✅ Mapped | | `name` | `name` | Direct mapping, fallback to `"Unknown ASOR Agent"` | ✅ Mapped | | `description` | `description` | Direct mapping, fallback to `f"ASOR agent: {agent_name}"` if `"None"` | ✅ Mapped | | `url` | `url` | Direct mapping, fallback to empty string | ✅ Mapped | | **Optional A2A Fields** | | `version` | `version` | Direct mapping, fallback to `"1.0.0"` | ✅ Mapped | | N/A | `provider` | Hardcoded to `"ASOR"` | ✅ Mapped | | N/A | `security_schemes` | Empty dict (default) | ❌ Missing | | N/A | `security` | None (default) | ❌ Missing | | `skills[]` | `skills` | Array mapping: `{name, description, id}` | ✅ Mapped | | `capabilities.streaming` | `streaming` | Direct mapping from capabilities object | ⚠️ Available but not mapped | | `capabilities`, `workdayConfig`, `supportsAuthenticatedExtendedCard` | `metadata` | Could map additional ASOR fields | ⚠️ Available but not mapped | | **Registry Extensions** | | N/A | `path` | Generated from name: `f"/{agent_name.lower().replace('_', '-')}"` | ✅ Mapped | | N/A | `tags` | Hardcoded to `["asor", "federated", "workday"]` | ✅ Mapped | | N/A | `is_enabled` | False (default) | ✅ Mapped | | N/A | `num_stars` | 0 (default) | ✅ Mapped | | N/A | `license` | Hardcoded to `"Unknown"` | ✅ Mapped | | N/A | `registered_at` | Current timestamp | ✅ Mapped | | N/A | `updated_at` | None (default) | ✅ Mapped | | N/A | `registered_by` | Hardcoded to `"asor-federation"` | ✅ Mapped | ### ASOR Data Structure Based on the actual ASOR API response, the agent data structure is: ```json { "capabilities": { "stateTransitionHistory": false, "pushNotifications": false, "streaming": true }, "url": "https://bedrock-agentcore.us-west-2.amazonaws.com/runtimes/arn%3Aaws%3Abedrock-agentcore%3Aus-west-2%3A218208277580%3Aruntime%2Faws_assistant-XYx9SWFOvW/invocations?qualifier=DEFAULT", "description": "None", "name": "aws_assistant", "supportsAuthenticatedExtendedCard": false, "workdayConfig": [ { "skillId": "skill_extractContent" }, { "skillId": "skill_searchQuery" } ], "skills": [ { "id": "skill_extractContent", "description": "Extract and parse content from up to 20 URLs simultaneously", "name": "extractContent" }, { "id": "skill_searchQuery", "description": "Performs a search query using Tavily Search and returns comprehensive results including answer, images, and search results", "name": "searchQuery" } ], "version": "1" } ``` ### Available but Unmapped ASOR Fields The following ASOR fields are available but not currently mapped: 1. **`capabilities`** - Object with streaming, notifications, state history flags 2. **`workdayConfig`** - Array of skill configurations 3. **`supportsAuthenticatedExtendedCard`** - Boolean flag for extended card support ### Missing Fields from ASOR The following Agent Card fields are not provided by ASOR: 1. **Security Configuration** - `security_schemes` - No authentication schemes provided - `security` - No security requirements specified 2. **Licensing** - `license` - License information not available ### Recommendations To improve ASOR integration: 1. **Map available fields:** ```python streaming=agent_data.get("capabilities", {}).get("streaming", False) metadata={ "capabilities": agent_data.get("capabilities", {}), "workdayConfig": agent_data.get("workdayConfig", []), "supportsAuthenticatedExtendedCard": agent_data.get("supportsAuthenticatedExtendedCard", False) } ``` 2. **Request additional fields from ASOR API:** - License information - Security/authentication schemes ================================================ FILE: docs/iam-settings-ui.md ================================================ # IAM Settings UI This document describes the Identity and Access Management (IAM) Settings UI, which provides a visual interface for managing users, groups, and machine-to-machine (M2M) service accounts directly from the MCP Gateway Registry web interface. ## Overview The IAM Settings UI is accessible to administrators via **Settings > IAM** in the left navigation panel. It provides three management sections: - **Groups** - Create and manage access control groups with fine-grained permissions - **Users** - Create and manage human user accounts - **M2M Accounts** - Create and manage machine-to-machine service accounts for AI agents and automation The IAM UI works with both Keycloak and Microsoft Entra ID identity providers, providing a unified experience regardless of which IdP you use. ![IAM Settings UI](img/iam.gif) ## Prerequisites - Administrator access to the MCP Gateway Registry - A configured identity provider (Keycloak or Entra ID) - For Entra ID: Application configured with User.ReadWrite.All and GroupMember.ReadWrite.All permissions ## Groups Management Groups are the primary unit of access control. Each group maps to an IdP group and defines what servers, tools, agents, and UI features members can access. ### Creating a Group 1. Navigate to **Settings > IAM > Groups** 2. Click the **Create Group** button 3. Fill in the required fields: - **Name**: Group identifier (will be created in the IdP) - **Description**: Human-readable description of the group's purpose ### Configuring Server Access Server access defines which MCP servers and virtual servers the group can connect to via the MCP Gateway. 1. In the **Server Access** section, click **Add Server** 2. Select a server from the dropdown (includes both MCP servers and virtual servers) 3. Configure access: - **Methods**: Select which MCP methods are allowed (initialize, tools/list, tools/call, etc.) - **Tools**: Select specific tools or use `*` for all tools on that server **Tip**: Virtual servers (paths starting with `/virtual/`) are automatically displayed alongside regular MCP servers in the server selector. ### Configuring UI Permissions UI permissions control what users can see and do in the Registry web interface: | Permission | Description | |------------|-------------| | `list_service` | View MCP servers in the dashboard | | `register_service` | Register new MCP servers | | `health_check_service` | Trigger health checks on servers | | `toggle_service` | Enable/disable servers | | `modify_service` | Edit server configuration | | `delete_service` | Delete servers | | `list_agents` | View A2A agents in the dashboard | | `get_agent` | View agent details | | `publish_agent` | Register new agents | | `modify_agent` | Edit agent configuration | | `delete_agent` | Delete agents | | `list_virtual_server` | View virtual MCP servers | For each permission, specify which resources it applies to: - Use `all` to grant access to all resources - Use specific server/agent paths for fine-grained control ### Configuring Agent Access Agent access controls which A2A agents the group can interact with: 1. In the **Agent Access** section, click **Add Agent** 2. Select an agent from the dropdown 3. Select allowed actions (list_agents, get_agent, invoke_agent, etc.) ### Import/Export Scope JSON Groups can be imported and exported as JSON for version control, backup, or migration: **Export**: Click the download icon to export the current group configuration as JSON **Import**: Click the upload icon and paste a scope JSON configuration: ```json { "scope_name": "data-team", "description": "Data team with access to data processing tools", "server_access": [ { "server": "data-processor", "methods": ["initialize", "tools/list", "tools/call"], "tools": ["process_csv", "analyze_data"] } ], "group_mappings": ["data-team"], "ui_permissions": { "list_service": ["data-processor"], "health_check_service": ["data-processor"] }, "create_in_idp": true } ``` ## Users Management The Users section manages human user accounts that can log into the Registry UI and generate JWT tokens for CLI tools. ### Creating a User 1. Navigate to **Settings > IAM > Users** 2. Click **Create User** 3. Fill in the required fields: - **Username**: Unique identifier for the user - **Email**: User's email address - **First Name**: User's first name - **Last Name**: User's last name - **Password**: Initial password (user should change on first login) 4. Select groups to assign the user to 5. Click **Create** ### Managing User Groups To modify a user's group memberships: 1. Find the user in the list 2. Click the edit (pencil) icon in the Groups column 3. Check/uncheck groups as needed 4. Click the checkmark to save ### Deleting a User 1. Find the user in the list 2. Click the delete (trash) icon 3. Confirm the deletion **Note**: Deleting a user removes them from the IdP. This action cannot be undone. ## M2M Accounts (Service Accounts) M2M (Machine-to-Machine) accounts are service accounts for AI agents, automation scripts, and other non-human clients that need to authenticate with the Registry API. ### Creating an M2M Account 1. Navigate to **Settings > IAM > M2M Accounts** 2. Click **Create M2M Account** 3. Fill in the required fields: - **Name**: Identifier for the service account (e.g., `my-ai-agent`) - **Description**: Purpose of the service account 4. Select groups to assign (determines what the account can access) 5. Click **Create** ### Viewing Credentials After creating an M2M account, the client credentials are displayed **once**: - **Client ID**: The OAuth2 client identifier - **Client Secret**: The OAuth2 client secret **Important**: Copy and store these credentials securely. The client secret cannot be retrieved again after you navigate away. ### Using M2M Credentials To obtain a JWT token for API access: ```bash # Get JWT token using client credentials grant # For local testing, use http://localhost for Keycloak TOKEN=$(curl -s -X POST "https://keycloak.example.com/realms/mcp-gateway/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "client_id=my-ai-agent" \ -d "client_secret=YOUR_CLIENT_SECRET" \ -d "grant_type=client_credentials" | jq -r '.access_token') # Use the token to call Registry API # For local testing, use http://localhost curl -H "Authorization: Bearer $TOKEN" "https://registry.example.com/api/servers" ``` Or use the provided helper script: ```bash # Create credentials file mkdir -p .oauth-tokens cat > .oauth-tokens/my-ai-agent.json << EOF { "client_id": "my-ai-agent", "client_secret": "YOUR_CLIENT_SECRET" } EOF # Get token (uses KEYCLOAK_URL env var, defaults to http://localhost) ./scripts/refresh_m2m_token.sh my-ai-agent # Use the token # For local testing, use http://localhost export TOKEN=$(jq -r '.access_token' .oauth-tokens/my-ai-agent-token.json) curl -H "Authorization: Bearer $TOKEN" "https://registry.example.com/api/servers" ``` ### Deleting an M2M Account 1. Find the account in the list 2. Click the delete (trash) icon 3. Confirm the deletion **Note**: Deleting an M2M account immediately invalidates all tokens issued to it. ## Best Practices ### Group Organization - Create groups based on team function or access level (e.g., `data-team`, `ml-engineers`, `read-only-users`) - Use descriptive names and descriptions - Apply the principle of least privilege - grant only the access needed ### Server Access - Start with minimal methods and tools, expand as needed - Use tool-level restrictions rather than granting `*` when possible - For virtual servers, ensure `list_virtual_server` UI permission is granted ### M2M Accounts - Create separate M2M accounts for each service or agent - Rotate credentials periodically - Store secrets in a secure vault, not in code repositories - Use descriptive names that identify the service (e.g., `data-pipeline-bot`, `monitoring-agent`) ## Troubleshooting ### User Cannot See Servers 1. Check the user's group membership 2. Verify the group has `list_service` UI permission for the server 3. For virtual servers, verify `list_virtual_server` UI permission ### User Cannot See Virtual Servers Virtual servers require the `list_virtual_server` UI permission. Add this permission to the group: ```json { "ui_permissions": { "list_virtual_server": ["/virtual/your-server-path"] } } ``` ### M2M Account Token Not Working 1. Verify the client credentials are correct 2. Check that the M2M account is assigned to appropriate groups 3. Ensure the groups have the necessary `server_access` permissions 4. Check Keycloak/Entra ID logs for authentication errors ### Group Changes Not Taking Effect After modifying group permissions: 1. Users may need to log out and log back in 2. M2M accounts need to obtain a new token 3. Changes to IdP group membership may take a few minutes to sync ## Related Documentation - [Scopes Management](scopes-mgmt.md) - Detailed scope configuration format - [Authentication Design](design/authentication-design.md) - Architecture overview - [IdP Provider Architecture](design/idp-provider-support.md) - Multi-provider support details - [Keycloak Integration](keycloak-integration.md) - Keycloak setup guide - [Entra ID Setup](entra-id-setup.md) - Microsoft Entra ID configuration ================================================ FILE: docs/img/MCPGW-Registry.drawio ================================================ ================================================ FILE: docs/img/architecture-with-dataplane.md ================================================ # MCP Gateway & Registry - Architecture with Data Plane ``` USERS & AI AGENTS | v +-----------------------------------------------------------------------------------+ | MCP GATEWAY & REGISTRY INFRASTRUCTURE | +-----------------------------------------------------------------------------------+ | | | +------------------------------------------------------------------------+ | | | NGINX REVERSE PROXY (Gateway) | | | | Entry Point - SSL/TLS Termination | | | +------------------------------------------------------------------------+ | | | | | | | | v v | | | +-----------------------------+ +-------------------+ | | | | Registry | | Auth Server | | | | | (FastAPI) | | (FastAPI) | | | | | | | | | | | | - Server Management | | - OAuth 2.0/OIDC | | | | | - Tool Discovery | | - JWT Validation | | | | | - Agent Registry | | - Scope Enforce | | | | | - Health Monitoring | | - Token Vending | | | | +-----------------------------+ +-------------------+ | | | | | | | v | | | +--------------------+ | | | | Identity Provider | | | | | (IdP) | | | | +--------------------+ | | | | - Keycloak | | | | | - Microsoft Entra | | | | | - Amazon Cognito | | | | | - Other OIDC/SAML | | | | +--------------------+ | | | | | | DATA PLANE | | | ========== | | v | | +------------------------------------------------------------------------+ | | | MCP SERVERS | | | +------------------------------------------------------------------------+ | | | | | | | +---------------+ +---------------+ +---------------+ | | | | | MCP Server | | MCP Server | | MCP Server | . . . | | | | | (context7) | | (github) | | (jira) | | | | | +---------------+ +---------------+ +---------------+ | | | | | | | | +---------------+ +---------------+ +---------------+ | | | | | MCP Server | | MCP Server | | MCP Server | . . . | | | | | (confluence) | | (slack) | | (custom) | | | | | +---------------+ +---------------+ +---------------+ | | | | | | | +------------------------------------------------------------------------+ | | | +-----------------------------------------------------------------------------------+ +-----------------------------------------------------------------------------------+ | DATASTORE | | MongoDB-CE | Amazon DocumentDB | +-----------------------------------------------------------------------------------+ | | | +-------------+ +-------------+ +-------------+ +------------------+ | | | servers | | agents | | scopes | | security_scans | | | | collection | | collection | | collection | | collection | | | +-------------+ +-------------+ +-------------+ +------------------+ | | | | +-------------------------------------------------------------------------+ | | | HYBRID SEARCH SUPPORT | | | | Keyword Text Matching + Vector k-NN (Embeddings) | | | +-------------------------------------------------------------------------+ | | | +-----------------------------------------------------------------------------------+ +-----------------------------------------------------------------------------------+ | DEPLOYMENT INFRASTRUCTURE | +-----------------------------------------------------------------------------------+ | | | +-------------------+ +-------------------+ +-------------------+ | | | Amazon EKS | | Amazon ECS | | Amazon EC2 | | | | (Kubernetes) | | (Fargate) | | (Local Dev) | | | +-------------------+ +-------------------+ +-------------------+ | | | +-----------------------------------------------------------------------------------+ ``` ================================================ FILE: docs/index.md ================================================
MCP Gateway Logo **Gateway for AI Development Tools**
## MCP Server & Registry A comprehensive solution for managing, securing, and accessing Model Context Protocol (MCP) servers at scale. Built for enterprises, development teams, and autonomous AI agents. ### Demo Videos | Feature | Demo | |---------|------| | **Full End-to-End Functionality** | [Watch Full Demo](https://github.com/user-attachments/assets/5ffd8e81-8885-4412-a4d4-3339bbdba4fb) | | **OAuth 3-Legged Authentication** | [Watch 3LO Demo](https://github.com/user-attachments/assets/3c3a570b-29e6-4dd3-b213-4175884396cc) | | **Dynamic Tool Discovery & Invocation** | [Watch Tool Discovery](https://github.com/user-attachments/assets/cee25b31-61e4-4089-918c-c3757f84518c) | ### MCP Tools in Action
MCP Tools Demo
*Experience dynamic tool discovery and intelligent MCP server integration in real-time* --- ## Key Features ### Architecture Features - **Reverse Proxy**: Centralized access point for all MCP servers - **Service Discovery**: Automatic registration and health monitoring - **Load Balancing**: Intelligent request distribution across server instances - **Multi-Instance Support**: Deployment patterns supporting redundancy ### Advanced Security & Authentication - **OAuth 2.0 Integration**: Amazon Cognito, Google, GitHub, and custom providers - **Fine-Grained Access Control**: Role-based permissions with scope management - **JWT Token Vending**: Secure token generation and validation - **Audit Logging**: Comprehensive security event tracking ### AI Agent Optimization - **Dynamic Tool Discovery**: Runtime MCP server and tool enumeration - **Intelligent Tool Finder**: AI-powered tool recommendation and selection - **Autonomous Access Control**: Context-aware permission management - **Multi-Agent Coordination**: Shared resource access with conflict resolution ### Developer Experience - **React Web Interface**: Intuitive server management and monitoring - **REST API**: Programmatic registry management and integration - **AI Coding Assistant Integration**: VS Code, Cursor, Claude Code support - **Real-Time Monitoring**: Live server health and performance metrics --- ## Quick Start !!! tip "Prerequisites" Before proceeding, ensure you have satisfied all [prerequisites](installation.md#prerequisites) including Docker, AWS account setup, and Amazon Cognito configuration. Get up and running in 5 minutes with Docker Compose: ```bash # 1. Clone and setup git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry # 2. Configure environment cp .env.example .env # Edit .env with your Amazon Cognito credentials # 3. Generate authentication credentials ./credentials-provider/generate_creds.sh # 4. Deploy with Docker Compose docker-compose up -d # 5. Access the registry open http://localhost:7860 ``` The registry will be available at `http://localhost:7860` with example MCP servers pre-configured. --- ## Architecture Overview ```mermaid flowchart TB subgraph Human_Users["Human Users"] User1["Human User 1"] User2["Human User 2"] UserN["Human User N"] end subgraph AI_Agents["AI Agents"] Agent1["AI Agent 1"] Agent2["AI Agent 2"] Agent3["AI Agent 3"] AgentN["AI Agent N"] end subgraph EC2_Gateway["MCP Gateway & Registry (Amazon EC2 Instance)"] subgraph NGINX["NGINX Reverse Proxy"] RP["Reverse Proxy Router"] end subgraph AuthRegistry["Authentication & Registry Services"] AuthServer["Auth Server
(Dual Auth)"] Registry["Registry
Web UI"] RegistryMCP["Registry
MCP Server"] end subgraph LocalMCPServers["Local MCP Servers"] MCP_Local1["MCP Server 1"] MCP_Local2["MCP Server 2"] end end %% Identity Provider IdP[Identity Provider
Amazon Cognito] subgraph EKS_Cluster["Amazon EKS/EC2 Cluster"] MCP_EKS1["MCP Server 3"] MCP_EKS2["MCP Server 4"] end subgraph APIGW_Lambda["Amazon API Gateway + AWS Lambda"] API_GW["Amazon API Gateway"] Lambda1["AWS Lambda Function 1"] Lambda2["AWS Lambda Function 2"] end subgraph External_Systems["External Data Sources & APIs"] DB1[(Database 1)] DB2[(Database 2)] API1["External API 1"] API2["External API 2"] API3["External API 3"] end %% Connections from Human Users User1 -->|Web Browser
Authentication| IdP User2 -->|Web Browser
Authentication| IdP UserN -->|Web Browser
Authentication| IdP User1 -->|Web Browser
HTTPS| Registry User2 -->|Web Browser
HTTPS| Registry UserN -->|Web Browser
HTTPS| Registry %% Connections from Agents to Gateway Agent1 -->|MCP Protocol
SSE with Auth| RP Agent2 -->|MCP Protocol
SSE with Auth| RP Agent3 -->|MCP Protocol
Streamable HTTP with Auth| RP AgentN -->|MCP Protocol
Streamable HTTP with Auth| RP %% Auth flow connections RP -->|Auth validation| AuthServer AuthServer -.->|Validate credentials| IdP Registry -.->|User authentication| IdP RP -->|Tool discovery| RegistryMCP RP -->|Web UI access| Registry %% Connections from Gateway to MCP Servers RP -->|SSE| MCP_Local1 RP -->|SSE| MCP_Local2 RP -->|SSE| MCP_EKS1 RP -->|SSE| MCP_EKS2 RP -->|Streamable HTTP| API_GW %% Connections within API GW + Lambda API_GW --> Lambda1 API_GW --> Lambda2 %% Connections to External Systems MCP_Local1 -->|Tool Connection| DB1 MCP_Local2 -->|Tool Connection| DB2 MCP_EKS1 -->|Tool Connection| API1 MCP_EKS2 -->|Tool Connection| API2 Lambda1 -->|Tool Connection| API3 %% Style definitions classDef user fill:#fff9c4,stroke:#f57f17,stroke-width:2px classDef agent fill:#e1f5fe,stroke:#29b6f6,stroke-width:2px classDef gateway fill:#e8f5e9,stroke:#66bb6a,stroke-width:2px classDef nginx fill:#f3e5f5,stroke:#ab47bc,stroke-width:2px classDef mcpServer fill:#fff3e0,stroke:#ffa726,stroke-width:2px classDef eks fill:#ede7f6,stroke:#7e57c2,stroke-width:2px classDef apiGw fill:#fce4ec,stroke:#ec407a,stroke-width:2px classDef lambda fill:#ffebee,stroke:#ef5350,stroke-width:2px classDef dataSource fill:#e3f2fd,stroke:#2196f3,stroke-width:2px %% Apply styles class User1,User2,UserN user class Agent1,Agent2,Agent3,AgentN agent class EC2_Gateway,NGINX gateway class RP nginx class AuthServer,Registry,RegistryMCP gateway class IdP apiGw class MCP_Local1,MCP_Local2 mcpServer class EKS_Cluster,MCP_EKS1,MCP_EKS2 eks class API_GW apiGw class Lambda1,Lambda2 lambda class DB1,DB2,API1,API2,API3 dataSource ``` The MCP Gateway & Registry acts as a centralized hub that: 1. **Authenticates** users and AI agents through OAuth providers 2. **Authorizes** access based on fine-grained scopes and permissions 3. **Routes** requests to appropriate MCP servers 4. **Monitors** server health and performance 5. **Discovers** available tools and capabilities dynamically --- ## Use Cases ### Enterprise Integration Transform how both autonomous AI agents and development teams access enterprise systems: - **Unified Access Point**: Single endpoint for all MCP servers across your organization - **Enterprise SSO**: Integration with existing identity providers (Cognito, SAML, OIDC) - **Compliance & Governance**: Comprehensive audit trails and access control policies - **Scalable Architecture**: Support for hundreds of MCP servers and thousands of concurrent users ### AI Agent Workflows Enable sophisticated AI agent interactions with enterprise systems: - **Dynamic Tool Discovery**: Agents discover and utilize tools based on current context - **Intelligent Tool Selection**: AI-powered recommendations for optimal tool usage - **Multi-Agent Coordination**: Shared access to enterprise resources with conflict resolution - **Context-Aware Permissions**: Dynamic access control based on agent capabilities and current task ### Development Team Productivity Accelerate development workflows with integrated tooling: - **IDE Integration**: Native support for VS Code, Cursor, and Claude Code - **Real-Time Collaboration**: Shared access to development tools and services - **Environment Management**: Consistent tool access across development, staging, and production - **API-First Design**: Programmatic access for custom integrations and automation --- ## Documentation | Getting Started | Authentication & Security | Architecture & Development | |-----------------|---------------------------|----------------------------| | [Complete Setup Guide](complete-setup-guide.md)
Step-by-step from scratch on AWS EC2 | [Security Posture](security-posture.md)
Comprehensive security controls and compliance | [AI Coding Assistants Setup](ai-coding-assistants-setup.md)
VS Code, Cursor, Claude Code integration | | [Installation Guide](installation.md)
Complete setup instructions for EC2 and EKS | [Authentication Guide](auth.md)
OAuth and identity provider integration | [API Reference](registry_api.md)
Programmatic registry management | | [Configuration Reference](configuration.md)
Environment variables and settings | [Amazon Cognito Setup](cognito.md)
Step-by-step IdP configuration | [Dynamic Tool Discovery](dynamic-tool-discovery.md)
Autonomous agent capabilities | | | [Fine-Grained Access Control](scopes.md)
Permission management and security | [Deployment Guide](installation.md)
Complete setup for deployment environments | | | [Security Scanner](security-scanner.md)
MCP server supply chain security | [Troubleshooting Guide](faq/index.md)
Common issues and solutions | --- ## Community & Support **Getting Help** - [FAQ & Troubleshooting](faq/index.md) - Common questions and solutions - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - Bug reports and feature requests - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - Community support and ideas **Resources** - [Demo Videos](https://github.com/agentic-community/mcp-gateway-registry#demo-videos) - See the platform in action **Contributing** - [Contributing Guide](CONTRIBUTING.md) - How to contribute code and documentation - [Code of Conduct](CODE_OF_CONDUCT.md) - Community guidelines and expectations --- ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. --- *Part of the [Agentic Community](https://github.com/agentic-community) ecosystem - building the future of AI-driven development.* ================================================ FILE: docs/installation.md ================================================ # Installation Guide Complete installation instructions for the MCP Gateway & Registry on various platforms. ## Prerequisites - **Node.js 16+**: Required for building the React frontend (not needed with `--prebuilt` flag) - **Container Runtime**: Choose one: - **Docker & Docker Compose**: Standard container runtime - **Podman & Podman Compose**: Rootless alternative (recommended for macOS) - **Amazon Cognito or Keycloak**: Identity provider for authentication (see [Cognito Setup Guide](cognito.md) or [Keycloak Integration](keycloak-integration.md)) - **SSL Certificate**: Optional for HTTPS deployment in production ## Quick Start ### Docker Installation (Default) ```bash # 1. Clone and setup git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry cp .env.example .env # 2. Setup Python virtual environment uv sync source .venv/bin/activate # 3. Download embeddings model uv pip install -U huggingface_hub hf download sentence-transformers/all-MiniLM-L6-v2 --local-dir ${HOME}/mcp-gateway/models/all-MiniLM-L6-v2 # 4. Configure environment - edit .env with your passwords nano .env # Set: KEYCLOAK_ADMIN_PASSWORD, INITIAL_ADMIN_PASSWORD (must match), KEYCLOAK_DB_PASSWORD # Set: SESSION_COOKIE_SECURE=false (for HTTP localhost) # Generate SECRET_KEY SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(64))") sed -i "s/^#*\s*SECRET_KEY=.*/SECRET_KEY=$SECRET_KEY/" .env # 5. Deploy with pre-built images export DOCKERHUB_ORG=mcpgateway source .env export KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}" ./build_and_run.sh --prebuilt # Press Ctrl+C when logs are streaming - containers continue running # 6. Initialize MongoDB docker compose up mongodb-init docker compose restart auth-server # 7. Initialize Keycloak (wait for Keycloak to start first) # Disable SSL for master realm ADMIN_TOKEN=$(curl -s -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=${KEYCLOAK_ADMIN}" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli" | jq -r '.access_token') && \ curl -X PUT "http://localhost:8080/admin/realms/master" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"sslRequired": "none"}' # Initialize realm and clients chmod +x keycloak/setup/init-keycloak.sh ./keycloak/setup/init-keycloak.sh # Disable SSL for application realm ADMIN_TOKEN=$(curl -s -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=${KEYCLOAK_ADMIN}" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli" | jq -r '.access_token') && \ curl -X PUT "http://localhost:8080/admin/realms/mcp-gateway" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"sslRequired": "none"}' # Get client credentials chmod +x keycloak/setup/get-all-client-credentials.sh ./keycloak/setup/get-all-client-credentials.sh # Update .env with client secrets from .oauth-tokens/keycloak-client-secrets.txt cat .oauth-tokens/keycloak-client-secrets.txt nano .env # Update KEYCLOAK_CLIENT_SECRET and KEYCLOAK_M2M_CLIENT_SECRET # Recreate containers with new credentials ./build_and_run.sh --prebuilt # 8. Setup users and service accounts chmod +x ./cli/bootstrap_user_and_m2m_setup.sh ./cli/bootstrap_user_and_m2m_setup.sh # 9. Access registry open http://localhost:7860 # macOS # xdg-open http://localhost:7860 # Linux # Login: admin / ``` For the complete step-by-step guide with detailed explanations, see the [Quick Start Guide](quickstart.md). ### Podman Installation (Rootless Alternative) **Recommended for macOS and rootless Linux environments** ```bash # 1. Clone and setup git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry cp .env.example .env # 2. Install Podman (macOS) brew install podman-desktop # OR download from: https://podman-desktop.io/ # 3. Initialize Podman machine (macOS) podman machine init --cpus 4 --memory 8192 --disk-size 50 podman machine start # 4. Setup Python virtual environment uv sync source .venv/bin/activate # 5. Download embeddings model uv pip install -U huggingface_hub hf download sentence-transformers/all-MiniLM-L6-v2 --local-dir ${HOME}/mcp-gateway/models/all-MiniLM-L6-v2 # 6. Configure environment - edit .env with your passwords nano .env # Set: KEYCLOAK_ADMIN_PASSWORD, INITIAL_ADMIN_PASSWORD (must match), KEYCLOAK_DB_PASSWORD # Set: SESSION_COOKIE_SECURE=false (for HTTP localhost) # For Podman: Set KEYCLOAK_URL=http://localhost:18080 # Generate SECRET_KEY SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(64))") sed -i "s/^#*\s*SECRET_KEY=.*/SECRET_KEY=$SECRET_KEY/" .env # 7. Deploy with Podman export DOCKERHUB_ORG=mcpgateway source .env export KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}" ./build_and_run.sh --prebuilt --podman # Apple Silicon: Use ./build_and_run.sh --podman (without --prebuilt) # Press Ctrl+C when logs are streaming - containers continue running # 8. Initialize MongoDB podman compose up mongodb-init podman compose restart auth-server # 9. Initialize Keycloak (wait for Keycloak to start first) # Note: Podman uses port 18080 for Keycloak ADMIN_TOKEN=$(curl -s -X POST "http://localhost:18080/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=${KEYCLOAK_ADMIN}" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli" | jq -r '.access_token') && \ curl -X PUT "http://localhost:18080/admin/realms/master" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"sslRequired": "none"}' # Initialize realm and clients chmod +x keycloak/setup/init-keycloak.sh KEYCLOAK_URL=http://localhost:18080 ./keycloak/setup/init-keycloak.sh # Disable SSL for application realm ADMIN_TOKEN=$(curl -s -X POST "http://localhost:18080/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=${KEYCLOAK_ADMIN}" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli" | jq -r '.access_token') && \ curl -X PUT "http://localhost:18080/admin/realms/mcp-gateway" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"sslRequired": "none"}' # Get client credentials chmod +x keycloak/setup/get-all-client-credentials.sh KEYCLOAK_URL=http://localhost:18080 ./keycloak/setup/get-all-client-credentials.sh # Update .env with client secrets cat .oauth-tokens/keycloak-client-secrets.txt nano .env # Update KEYCLOAK_CLIENT_SECRET and KEYCLOAK_M2M_CLIENT_SECRET # Recreate containers with new credentials ./build_and_run.sh --prebuilt --podman # 10. Setup users and service accounts chmod +x ./cli/bootstrap_user_and_m2m_setup.sh KEYCLOAK_URL=http://localhost:18080 ./cli/bootstrap_user_and_m2m_setup.sh # 11. Access registry (note the different port for Podman) open http://localhost:8080 # macOS # xdg-open http://localhost:8080 # Linux # Login: admin / ``` > **Note for Apple Silicon:** Don't use `--prebuilt` with Podman on ARM64. Use `./build_and_run.sh --podman` instead. See [Podman on Apple Silicon Guide](podman-apple-silicon.md). **Podman Port Mapping:** - Main interface: `http://localhost:8080` (HTTP) or `https://localhost:8443` (HTTPS) - Registry API: `http://localhost:7860` (unchanged) - Keycloak: `http://localhost:18080` (instead of 8080) - All other internal services: unchanged ports ## Installation on Amazon EC2 ### System Requirements **Minimum (Development)**: - EC2 Instance: `t3.large` (2 vCPU, 8GB RAM) - Storage: 20GB SSD - Network: Ports 80, 443, 7860, 8080 accessible **Recommended (Production)**: - EC2 Instance: `t3.2xlarge` (8 vCPU, 32GB RAM) - Storage: 50GB+ SSD - Network: Multi-AZ with load balancer ### Detailed Setup Steps 1. **Create Local Directories** ```bash mkdir -p ${HOME}/mcp-gateway/{servers,auth_server,secrets,logs} cp -r registry/servers ${HOME}/mcp-gateway/ cp auth_server/scopes.yml ${HOME}/mcp-gateway/auth_server/ ``` 2. **Configure Environment Variables** ```bash cp .env.example .env nano .env # Configure required values ``` **Required Configuration:** - `KEYCLOAK_ADMIN_PASSWORD`: Keycloak admin password (if using Keycloak) - `COGNITO_USER_POOL_ID`: Amazon Cognito User Pool ID - `COGNITO_CLIENT_ID`: Cognito App Client ID - `COGNITO_CLIENT_SECRET`: Cognito App Client Secret - `AWS_REGION`: AWS region for Cognito 3. **Generate Authentication Credentials** ```bash # Configure OAuth credentials cp credentials-provider/oauth/.env.example credentials-provider/oauth/.env nano credentials-provider/oauth/.env # Generate tokens and client configurations ./credentials-provider/generate_creds.sh ``` 4. **Install Dependencies** ```bash # Install uv (Python package manager) curl -LsSf https://astral.sh/uv/install.sh | sh source $HOME/.local/bin/env uv venv --python 3.14 && source .venv/bin/activate # Install Docker sudo apt-get update sudo apt-get install --reinstall docker.io -y sudo apt-get install -y docker-compose sudo usermod -a -G docker $USER newgrp docker ``` 5. **Deploy Services** ```bash ./build_and_run.sh ``` ## Podman Installation (Rootless Containers) Podman is a daemonless container engine that provides rootless container execution, making it ideal for macOS and environments where Docker requires privileged access. ### Benefits of Podman - ✅ **Rootless Execution**: No sudo or privileged ports required - ✅ **macOS Native**: Works seamlessly with Podman Desktop on macOS - ✅ **Security**: Enhanced container isolation without root privileges - ✅ **Compatibility**: Drop-in replacement for Docker with similar CLI commands ### Installation on macOS **Option 1: Podman Desktop (Recommended)** ```bash # Install via Homebrew brew install podman-desktop # Or download directly from: # https://podman-desktop.io/ ``` **Option 2: Podman CLI Only** ```bash # Install Podman brew install podman # Initialize Podman machine podman machine init --cpus 4 --memory 8192 --disk-size 50 podman machine start # Verify installation podman --version podman compose version ``` ### Installation on Linux ```bash # Ubuntu/Debian sudo apt-get update sudo apt-get install -y podman podman-compose # Fedora/RHEL sudo dnf install -y podman podman-compose # Arch Linux sudo pacman -S podman podman-compose # Verify installation podman --version podman compose version ``` ### Deploying with Podman ```bash # Navigate to repository cd mcp-gateway-registry # Configure environment cp .env.example .env nano .env # Configure required values # Important: Set KEYCLOAK_URL=http://localhost:18080 for Podman # Deploy with Podman (explicit) ./build_and_run.sh --prebuilt --podman # Apple Silicon: Use without --prebuilt # ./build_and_run.sh --podman # Or let the script auto-detect (will use Podman if Docker not available) ./build_and_run.sh --prebuilt ``` After initial deployment, you must complete the MongoDB and Keycloak initialization steps. See the [Podman Installation Quick Start](#podman-installation-rootless-alternative) above for the complete sequence including: - MongoDB initialization (`podman compose up mongodb-init`) - Keycloak realm setup (using port 18080) - Client credential retrieval and .env update - Container recreation to apply credentials - User and service account setup > **Apple Silicon Warning:** Don't use `--prebuilt` with Podman on Apple Silicon Macs. Use `./build_and_run.sh --podman` instead. See [Podman on Apple Silicon Guide](podman-apple-silicon.md). ### Accessing Services with Podman **Important Port Differences:** Podman uses non-privileged host ports to avoid requiring root access: | Service | Docker Port | Podman Port | Description | |---------|-------------|-------------|-------------| | Main UI (HTTP) | `http://localhost` | `http://localhost:8080` | Web interface | | Main UI (HTTPS) | `https://localhost` | `https://localhost:8443` | Secure web interface | | Registry API | `http://localhost:7860` | `http://localhost:7860` | API endpoint (unchanged) | | Auth Server | `http://localhost:8888` | `http://localhost:8888` | Auth service (unchanged) | | Keycloak | `http://localhost:8080` | `http://localhost:18080` | IdP (Podman uses 18080 because 8080 is used by the Registry UI) | | Prometheus | `http://localhost:9090` | `http://localhost:9090` | Metrics (unchanged) | | Grafana | `http://localhost:3000` | `http://localhost:3000` | Dashboards (unchanged) | **Access the registry:** ```bash # With Podman open http://localhost:8080 # With Docker open http://localhost ``` ### Podman-Specific Configuration The deployment uses `docker-compose.podman.yml` when using Podman, which: 1. **Remaps privileged ports**: Maps container ports 80→8080 and 443→8443 on the host 2. **Adds SELinux labels**: Adds `:z` mount options for SELinux compatibility (Linux) 3. **Maintains compatibility**: All internal service-to-service communication unchanged ### Troubleshooting Podman **Issue: Permission denied on volume mounts** ```bash # Ensure directories exist with proper permissions mkdir -p ${HOME}/mcp-gateway/{servers,agents,models,logs,security_scans,auth_server,ssl} chmod -R 755 ${HOME}/mcp-gateway ``` **Issue: Podman machine not starting (macOS)** ```bash # Reset Podman machine podman machine stop podman machine rm podman machine init --cpus 4 --memory 8192 --disk-size 50 podman machine start ``` **Issue: Port conflicts** ```bash # Check what's using ports 8080 or 8443 lsof -i :8080 lsof -i :8443 # Stop conflicting services or use different ports by editing docker-compose.podman.yml ``` **Issue: Podman compose command not found** ```bash # Install podman-compose separately pip install podman-compose # Or use podman-compose wrapper brew install podman-compose ``` ### HTTPS Configuration By default, MCP Gateway runs on HTTP (port 80). To enable HTTPS for production deployments: #### 1. Obtain SSL Certificates **Option A: Let's Encrypt (Recommended)** ```bash # Install certbot sudo apt-get update sudo apt-get install -y certbot # Get certificate (requires domain and port 80 accessible) sudo certbot certonly --standalone -d your-domain.com ``` **Option B: Commercial CA** Purchase SSL certificate from a trusted Certificate Authority. #### 2. Copy Certificates to Expected Location MCP Gateway expects SSL certificates at `${HOME}/mcp-gateway/ssl/`. The `build_and_run.sh` script will automatically set up the proper directory structure. ```bash # Create the ssl directory structure mkdir -p ${HOME}/mcp-gateway/ssl/certs mkdir -p ${HOME}/mcp-gateway/ssl/private # Copy your certificates to the expected location # Replace paths below with your actual certificate locations cp /etc/letsencrypt/live/your-domain/fullchain.pem ${HOME}/mcp-gateway/ssl/certs/fullchain.pem cp /etc/letsencrypt/live/your-domain/privkey.pem ${HOME}/mcp-gateway/ssl/private/privkey.pem # Set proper permissions chmod 644 ${HOME}/mcp-gateway/ssl/certs/fullchain.pem chmod 600 ${HOME}/mcp-gateway/ssl/private/privkey.pem ``` **Note**: If SSL certificates are not present at `${HOME}/mcp-gateway/ssl/certs/fullchain.pem` and `${HOME}/mcp-gateway/ssl/private/privkey.pem`, the MCP Gateway will automatically run in HTTP-only mode. #### 3. Configure Security Group - Enable TCP port 443 for HTTPS access - Restrict access to authorized IP ranges - Keep port 80 open for HTTP and Let's Encrypt renewals #### 4. Deploy and Verify ```bash # Start/restart the services ./build_and_run.sh # Check logs for SSL certificate detection docker compose logs registry | grep -i ssl # Expected output: # "SSL certificates found - HTTPS enabled" # "HTTPS server will be available on port 443" # Test HTTPS access curl https://your-domain.com ``` #### Certificate Renewal (Let's Encrypt) Let's Encrypt certificates expire after 90 days. Set up automatic renewal: ```bash # Add to crontab sudo crontab -e # Add this line (checks twice daily, renews if needed) 0 0,12 * * * certbot renew --quiet && cp /etc/letsencrypt/live/your-domain/fullchain.pem ${HOME}/mcp-gateway/ssl/certs/fullchain.pem && cp /etc/letsencrypt/live/your-domain/privkey.pem ${HOME}/mcp-gateway/ssl/private/privkey.pem && docker compose restart registry ``` #### Troubleshooting **HTTPS not working?** - Check certificate files exist: `ls -la ${HOME}/mcp-gateway/ssl/certs/ ${HOME}/mcp-gateway/ssl/private/` - Verify certificates are present: `${HOME}/mcp-gateway/ssl/certs/fullchain.pem` and `${HOME}/mcp-gateway/ssl/private/privkey.pem` - Check container logs: `docker compose logs registry | grep -i ssl` - Verify port 443 is accessible: `sudo netstat -tlnp | grep 443` - Ensure certificates are from a trusted CA ## Installation on Amazon EKS For production Kubernetes deployments, see the [EKS deployment guide](https://github.com/aws-samples/amazon-eks-machine-learning-with-terraform-and-kubeflow/tree/master/examples/agentic/mcp-gateway-microservices). ### Architecture Overview ```mermaid graph TB subgraph "EKS Cluster" subgraph "Ingress" ALB[Application Load Balancer] IC[Ingress Controller] end subgraph "Application Pods" RP[Registry Pod] AS[Auth Server Pod] NG[Nginx Pod] end subgraph "MCP Servers" MS1[MCP Server 1] MS2[MCP Server 2] MSN[MCP Server N] end end subgraph "AWS Services" COG[Amazon Cognito] CW[CloudWatch] ECR[Amazon ECR] end ALB --> IC IC --> RP IC --> AS IC --> NG NG --> MS1 NG --> MS2 NG --> MSN AS --> COG RP --> CW ``` ### Key Benefits of EKS Deployment - **Multi-AZ Support**: Pod distribution across availability zones - **Auto Scaling**: Horizontal pod autoscaling based on metrics - **Service Mesh**: Istio integration for advanced traffic management - **Observability**: Native integration with CloudWatch and Prometheus - **Security**: Pod security policies and network policies ## Post-Installation ### Verify Installation 1. **Check Service Status** ```bash docker-compose ps docker-compose logs -f ``` 2. **Test Web Interface** - Navigate to `http://localhost:7860` - Login with admin credentials - Verify MCP server health status 3. **Test Authentication** ```bash cd tests ./mcp_cmds.sh ping ``` ### Configure AI Coding Assistants 1. **Generate Client Configurations** ```bash ./credentials-provider/generate_creds.sh ls .oauth-tokens/ # View generated configurations ``` 2. **Setup VS Code** ```bash cp .oauth-tokens/vscode-mcp.json ~/.vscode/settings.json ``` 3. **Setup Roo Code** ```bash cp .oauth-tokens/mcp.json ~/.vscode/mcp-settings.json ``` For detailed AI assistant setup, see [AI Coding Assistants Setup Guide](ai-coding-assistants-setup.md). ## Troubleshooting ### Common Issues **Services won't start:** ```bash # Check Docker daemon sudo systemctl status docker # Check environment variables cat .env | grep -v SECRET # View detailed logs docker-compose logs --tail=50 ``` **Authentication failures:** ```bash # Verify Cognito configuration aws cognito-idp describe-user-pool --user-pool-id YOUR_POOL_ID # Test credential generation cd credentials-provider && ./generate_creds.sh --verbose ``` **Network connectivity issues:** ```bash # Check port availability sudo netstat -tlnp | grep -E ':(80|443|7860|8080)' # Test internal services curl -v http://localhost:7860/health ``` For more troubleshooting help, see [Troubleshooting Guide](troubleshooting.md). ## Next Steps - [Authentication Setup](auth.md) - Configure identity providers - [AI Assistant Integration](ai-coding-assistants-setup.md) - Setup development tools - [AWS ECS Deployment](../terraform/aws-ecs/README.md) - Multi-instance configuration - [API Reference](registry_api.md) - Programmatic management ================================================ FILE: docs/jwt-token-vending.md ================================================ # JWT Token Vending Service for MCP Gateway The JWT Token Vending Service provides a user-friendly mechanism for generating personal access tokens _without the use of an external IdP_ that can be used for programmatic access to MCP servers. This service bridges the gap between human authentication (web UI sessions) and machine authentication (JWT tokens), enabling users to create tokens with scoped permissions for automation, scripting, and agent access. ## The Challenge with Token Management in Enterprise MCP Deployments In enterprise scenarios, users often need to provide programmatic access to MCP servers for various automation tasks, CI/CD pipelines, and AI agents. Traditional approaches present several challenges: - **Manual Token Management**: Requiring users to manually generate M2M credentials through Amazon Cognito or other IdPs creates friction and security risks - **Scope Complexity**: Users need to understand complex scope configurations and may accidentally grant excessive permissions - **Token Lifecycle**: No centralized way to manage token expiration, renewal, or revocation - **Audit Trail**: Difficulty tracking which tokens were generated by whom and for what purpose ## A Solution with Integrated Token Vending The JWT Token Vending Service integrates directly with the existing MCP Gateway authentication infrastructure, allowing users to generate scoped JWT tokens through a familiar web interface. Here is an architecture diagram showing how the token vending service integrates with the existing system: ```mermaid graph TB %% Users and Token Generation Flow subgraph UserFlow["User Token Generation Flow"] direction TB User[User
Web UI Session] TokenUI[Token Generation
Web Interface] User -->|Authenticated Session| TokenUI end %% Core Infrastructure subgraph Infrastructure["MCP Gateway & Registry Infrastructure"] direction TB Nginx["Nginx
Reverse Proxy"] AuthServer["Auth Server
(Enhanced with Token Vending)"] Registry["Registry
Web UI + Token Generation"] RegistryMCP["Registry
MCP Server"] end %% Generated Token Usage subgraph TokenUsage["Token Usage"] direction TB Agent[AI Agent
with Generated Token] Script[Automation Script
with Generated Token] Pipeline[CI/CD Pipeline
with Generated Token] end %% Identity Provider IdP[Identity Provider
Amazon Cognito] %% MCP Server Farm subgraph MCPFarm["MCP Server Farm"] direction TB MCP1[MCP Server 1
CurrentTime] MCP2[MCP Server 2
FinInfo] MCP3[MCP Server 3
Custom] MCPn[MCP Server n
...] end %% Token Generation Flow TokenUI -->|POST /api/tokens/generate
with user context| Registry Registry -->|POST /internal/tokens
with user scopes| AuthServer AuthServer -->|Self-signed JWT
with HMAC-SHA256| Registry Registry -->|Display token
to user| TokenUI %% Token Usage Flow Agent -->|MCP requests
with Bearer token| Nginx Script -->|API calls
with Bearer token| Nginx Pipeline -->|Automated access
with Bearer token| Nginx %% Internal routing and validation Nginx -->|Route /mcpgw/*
Auth validation| AuthServer Nginx -->|Route /mcpgw/*
Tool discovery| RegistryMCP Nginx -->|Route /tokens
Token UI| Registry Nginx -->|Route /server1/*
Proxy to MCP servers| MCP1 Nginx -->|Route /server2/*
Proxy to MCP servers| MCP2 Nginx -->|Route /serverN/*
Proxy to MCP servers| MCP3 Nginx -->|Route /serverN/*
Proxy to MCP servers| MCPn %% Auth flows IdP -.->|User session validation
Group/scope mapping| AuthServer AuthServer -.->|Self-signed JWT validation
Scope enforcement| AuthServer %% Styling classDef userStyle fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px classDef tokenStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px classDef agentStyle fill:#e1f5fe,stroke:#01579b,stroke-width:2px classDef idpStyle fill:#fff3e0,stroke:#e65100,stroke-width:2px classDef nginxStyle fill:#f3e5f5,stroke:#4a148c,stroke-width:2px classDef authStyle fill:#ffebee,stroke:#c62828,stroke-width:2px classDef registryStyle fill:#fff8e1,stroke:#f57f17,stroke-width:2px classDef mcpStyle fill:#e3f2fd,stroke:#1976d2,stroke-width:2px class UserFlow userStyle class User userStyle class TokenUI tokenStyle class TokenUsage agentStyle class Agent,Script,Pipeline agentStyle class IdP idpStyle class Nginx nginxStyle class AuthServer authStyle class Registry,RegistryMCP registryStyle class MCP1,MCP2,MCP3,MCPn mcpStyle ``` ### Architecture Components for Token Vending The JWT Token Vending Service extends the existing MCP Gateway infrastructure with new capabilities: #### Enhanced Registry Web UI - **Token Generation Interface**: User-friendly form for creating JWT tokens with custom scopes and expiration - **Scope Validation**: Real-time validation ensuring requested scopes are subset of user's current permissions - **Token Display**: Secure, one-time display of generated tokens with copy functionality and usage instructions #### Enhanced Auth Server - **Internal Token Endpoint**: New `/internal/tokens` endpoint for generating self-signed JWT tokens - **Scope Validation Logic**: Ensures generated tokens cannot exceed user's current permissions - **Rate Limiting**: Prevents token generation abuse with configurable limits per user - **Self-Signed JWT Support**: Validates both Cognito tokens and internally generated tokens #### Token Security Features - **HMAC-SHA256 Signing**: Uses shared secret key for token signing and validation - **Scope Inheritance**: Generated tokens can have same or fewer permissions than user's current scopes - **Configurable Expiration**: Token lifetime from 1-24 hours with 8-hour default - **Unique Token IDs**: Each token has a unique identifier for potential tracking and revocation At a high-level the token generation and usage flow works as follows: ```mermaid sequenceDiagram participant User participant Browser participant Registry as Registry
Web UI participant AuthServer as Auth Server participant Agent participant Gateway as Gateway
(Nginx) participant MCP as MCP Server %% Token Generation Flow Note over User,AuthServer: Token Generation Flow User->>Browser: Navigate to /tokens Browser->>Registry: GET /tokens with session cookie Registry->>Registry: Validate user session + extract scopes Registry->>Browser: Token generation form User->>Browser: Configure token (scopes, expiration, description) Browser->>Registry: POST /api/tokens/generate Registry->>Registry: Validate requested scopes ⊆ user scopes Registry->>AuthServer: POST /internal/tokens with user context AuthServer->>AuthServer: Rate limit check (10/hour/user) AuthServer->>AuthServer: Generate JWT with HMAC-SHA256 AuthServer->>Registry: Return signed JWT token Registry->>Browser: Display token with copy functionality Browser->>User: Show token + usage instructions %% Token Usage Flow Note over Agent,MCP: Token Usage Flow User->>Agent: Provide generated JWT token Agent->>Gateway: MCP request with Bearer token Gateway->>AuthServer: Validate token + extract scopes alt Self-Signed Token AuthServer->>AuthServer: Detect issuer: "mcp-auth-server" AuthServer->>AuthServer: Validate HMAC-SHA256 signature AuthServer->>AuthServer: Extract scopes + enforce access else Cognito Token (Fallback) AuthServer->>AuthServer: Standard Cognito validation end alt Sufficient Permissions AuthServer->>Gateway: 200 OK + allowed scopes Gateway->>MCP: Forward MCP request MCP->>Gateway: MCP response Gateway->>Agent: MCP response else Insufficient Permissions AuthServer->>Gateway: 403 Access Denied Gateway->>Agent: 403 Access Denied end ``` 1. A **User** authenticates to the Registry web UI using their existing session (derived from Cognito OAuth or M2M flow) which contains their current scopes and permissions. 2. The **User** navigates to the token generation interface at `/tokens` and configures their desired token parameters including optional custom scopes (must be subset of current scopes), expiration time (1-24 hours), and description. 3. The **Registry** validates the user's session, ensures requested scopes are a subset of the user's current permissions, and calls the Auth Server's internal token generation endpoint with the user context and token parameters. 4. The **Auth Server** performs security checks (rate limiting, scope validation, expiration limits) and generates a self-signed JWT token using HMAC-SHA256 with the shared secret key. The token contains standard JWT claims plus MCP-specific metadata. 5. The **Registry** displays the generated token to the user with copy functionality, usage instructions, and security warnings. The token is shown only once for security. 6. The **User** saves the token securely and provides it to their **Agent** or automation script for programmatic access to MCP servers. 7. When the **Agent** makes MCP requests, it includes the generated JWT token in the Authorization header. The **Gateway** forwards the request to the **Auth Server** for validation. 8. The **Auth Server** detects self-signed tokens by the issuer claim, validates the HMAC-SHA256 signature using the shared secret, and enforces scope-based access control using the same logic as Cognito tokens. 9. If the token is valid and the requested operation is within the token's scope, the **Gateway** forwards the request to the appropriate **MCP Server**. Otherwise, it returns a 403 Access Denied response. The above implementation provides a seamless way for users to generate programmatic access tokens without requiring direct interaction with the Identity Provider, while maintaining the same security guarantees and scope enforcement as the existing authentication system. ## Agent Integration The JWT Token Vending Service integrates seamlessly with existing agent authentication patterns through enhanced command-line support: ### Enhanced Agent Command Line Interface The agent now supports direct JWT token usage through a new `--jwt-token` parameter: ```bash # Method 1: Direct token usage python agent.py \ --jwt-token "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \ --message "What is the current time in New York?" # Method 2: Environment variable export JWT_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." python agent.py --jwt-token "$JWT_TOKEN" --message "List available servers" # Method 3: Token from file echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." > ~/.mcp/jwt_token python agent.py --jwt-token "$(cat ~/.mcp/jwt_token)" --message "Help me analyze data" ``` ### Token Storage Best Practices #### Secure Storage Options ```bash # Option 1: Encrypted environment file echo "JWT_TOKEN=your_token_here" | gpg --encrypt > ~/.mcp/token.gpg # Option 2: System keyring (macOS) security add-generic-password -a "$USER" -s "mcp-jwt-token" -w "your_token_here" # Option 3: Secure file with restricted permissions echo "your_token_here" > ~/.mcp/jwt_token chmod 600 ~/.mcp/jwt_token ``` #### CI/CD Integration ```yaml # GitHub Actions example name: MCP Agent Workflow on: [push] jobs: run-agent: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Run MCP Agent env: JWT_TOKEN: ${{ secrets.MCP_JWT_TOKEN }} run: | python agent.py --jwt-token "$JWT_TOKEN" --message "Deploy analysis" ``` ## Security Features and Considerations ### Token Security Implementation #### Rate Limiting - **Per-User Limits**: Maximum 10 tokens per user per hour - **Sliding Window**: Uses hourly time slots for rate calculation - **Memory-Based**: Simple in-memory counter with automatic cleanup - **Configurable**: Limits adjustable via environment variables #### Scope Inheritance and Validation - **Subset Validation**: Generated tokens cannot exceed user's current permissions - **Real-Time Validation**: Scope checks performed at generation time - **Default Behavior**: Empty scope request defaults to user's full scope set - **JSON Validation**: Custom scope uploads validated for proper JSON format #### Token Lifecycle Management - **Configurable Expiration**: 1-24 hour range with 8-hour default - **No Refresh**: Tokens cannot be refreshed, must be regenerated - **Unique Identifiers**: Each token has a unique `jti` claim for tracking - **Self-Contained**: All authorization data embedded in token ### Cryptographic Implementation #### HMAC-SHA256 Signing ```python # Token generation process payload = { "iss": "mcp-auth-server", "aud": "mcp-registry", "sub": username, "scope": " ".join(requested_scopes), "exp": current_time + (expires_in_hours * 3600), "iat": current_time, "jti": str(uuid.uuid4()), "token_use": "access", "client_id": "user-generated", "token_type": "user_generated" } # Sign with shared secret access_token = jwt.encode(payload, SECRET_KEY, algorithm='HS256') ``` #### Token Validation Process ```python # Validation with issuer detection try: # Quick check for self-signed tokens unverified_claims = jwt.decode(token, options={"verify_signature": False}) if unverified_claims.get('iss') == 'mcp-auth-server': # Validate self-signed token claims = jwt.decode(token, SECRET_KEY, algorithms=['HS256'], issuer='mcp-auth-server', audience='mcp-registry') scopes = claims.get('scope', '').split() return {'valid': True, 'scopes': scopes, 'method': 'self_signed'} else: # Fall back to Cognito validation return validate_cognito_token(token) except jwt.InvalidTokenError: return {'valid': False, 'error': 'Invalid token'} ``` ### Security Best Practices #### For Users 1. **Minimal Scopes**: Generate tokens with only required permissions 2. **Short Expiration**: Use shortest practical token lifetime 3. **Secure Storage**: Store tokens in encrypted or protected locations 4. **Regular Rotation**: Regenerate tokens periodically 5. **Monitor Usage**: Track token usage in application logs #### For Administrators 1. **Audit Logging**: Monitor token generation patterns and frequency 2. **Scope Configuration**: Regularly review and update scope definitions 3. **Rate Limit Tuning**: Adjust rate limits based on usage patterns 4. **Key Management**: Protect the shared SECRET_KEY used for signing 5. **Access Reviews**: Periodically review user permissions and group memberships ### Threat Model and Mitigations #### Token Theft - **Risk**: Stolen tokens provide unauthorized access - **Mitigation**: Short expiration times, scope limitations, audit logging #### Scope Escalation - **Risk**: Users attempt to generate tokens with excessive permissions - **Mitigation**: Strict subset validation, real-time scope checking #### Rate Limit Bypass - **Risk**: Automated token generation for abuse - **Mitigation**: Per-user rate limiting, monitoring, account lockout policies #### Replay Attacks - **Risk**: Intercepted tokens used maliciously - **Mitigation**: HTTPS enforcement, short token lifetimes, unique token IDs ## Implementation Configuration ### Environment Variables ```bash # Token generation settings MAX_TOKEN_LIFETIME_HOURS=24 # Maximum token lifetime DEFAULT_TOKEN_LIFETIME_HOURS=8 # Default token lifetime MAX_TOKENS_PER_USER_PER_HOUR=10 # Rate limiting # JWT settings JWT_ISSUER="mcp-auth-server" # Token issuer JWT_AUDIENCE="mcp-registry" # Token audience SECRET_KEY="your-shared-secret" # HMAC signing key (must be shared) ``` ### Scope Configuration Generated tokens inherit scope validation from the existing `scopes.yml` configuration: ```yaml # Example scope allowing read access to time servers mcp-servers-time/read: - server: "currenttime" methods: ["initialize", "tools/list", "tools/call"] tools: ["current_time_by_timezone", "current_time_utc"] # Example scope for financial data access mcp-servers-finance/read: - server: "fininfo" methods: ["initialize", "tools/list", "tools/call"] tools: ["get_stock_price", "get_market_data"] # Admin scope with full access mcp-registry-admin: - server: "*" methods: ["*"] tools: ["*"] ``` ### User Interface Configuration The token generation interface provides: #### Token Configuration Options - **Description**: Optional human-readable token description - **Expiration**: Dropdown with 1, 8, and 24-hour options - **Scope Method**: Radio buttons for "Use current scopes" or "Custom JSON" - **Custom Scopes**: JSON textarea for advanced users #### User Experience Features - **Current Permissions Display**: Shows user's active scopes as badges - **Real-time Validation**: Client-side validation of JSON scope format - **Copy Functionality**: Multiple copy methods with fallbacks for different browsers - **Usage Instructions**: Clear examples of how to use the generated token - **Security Warnings**: Prominent warnings about token storage and sharing By implementing the JWT Token Vending Service, organizations can provide their users with a secure, user-friendly way to generate programmatic access tokens while maintaining security controls and comprehensive audit capabilities. The service seamlessly integrates with existing MCP Gateway infrastructure and provides a foundation for advanced token management features. ## Integration with Token Refresh Service The JWT Token Vending Service works seamlessly with the [Automated Token Refresh Service](token-refresh-service.md) to provide comprehensive token lifecycle management: ### Automatic Token Monitoring Once tokens are generated through the vending service, the token refresh service automatically: - **Monitors expiration times** for all generated tokens - **Proactively refreshes** tokens before they expire (configurable buffer time) - **Updates MCP client configurations** with fresh tokens - **Maintains continuous authentication** without user intervention ### MCP Client Configuration The token refresh service automatically generates MCP client configurations that include tokens from the vending service: - **VS Code Extensions** - Automatically configured with refreshed tokens - **Claude Code/Roocode** - Real-time token updates for coding assistants - **Custom MCP Clients** - Standard configuration format for any MCP client ### Enhanced Security Model The combination of both services provides: - **Short-lived primary tokens** from the vending service (1-24 hours) - **Automatic refresh capability** using secure refresh tokens - **Zero-downtime token rotation** for continuous service availability - **Centralized token lifecycle management** with comprehensive audit trails ### Usage Pattern 1. **Generate Initial Token** - Use the JWT Token Vending Service web interface 2. **Automatic Refresh** - Token refresh service monitors and refreshes tokens 3. **Client Integration** - MCP clients automatically use refreshed tokens 4. **Continuous Operation** - No manual intervention required for token management For detailed setup and configuration of the token refresh service, see the [Token Refresh Service Documentation](token-refresh-service.md). ================================================ FILE: docs/keycloak-integration.md ================================================ # Keycloak Integration Documentation ## Overview This document provides comprehensive guidance for implementing Keycloak authentication in the MCP Gateway, including design aspects, operational procedures, configuration parameters, and management scripts. ## Table of Contents 1. [Architecture & Design](#architecture--design) 2. [Environment Configuration](#environment-configuration) 3. [Setup & Installation](#setup--installation) 4. [Operational Procedures](#operational-procedures) 5. [Agent Management](#agent-management) 6. [Monitoring & Troubleshooting](#monitoring--troubleshooting) 7. [Security Considerations](#security-considerations) 8. [Cleanup Procedures](#cleanup-procedures) ## Architecture & Design ### Authentication Flow ```mermaid sequenceDiagram participant Agent as AI Agent participant Gateway as MCP Gateway participant Auth as Auth Server participant KC as Keycloak participant MCP as MCP Server Agent->>Gateway: Request with JWT Token Gateway->>Auth: Validate Token Auth->>KC: Verify JWT Signature & Claims KC-->>Auth: Token Valid + Groups Auth->>Auth: Map Groups to Scopes Auth-->>Gateway: Authorization Success Gateway->>MCP: Forward Request MCP-->>Gateway: Response Gateway-->>Agent: Response ``` ### Service Account Architecture #### Production Architecture (Recommended) ``` AI Agent A → Service Account A (agent-{agent-id}-m2m) → Group: mcp-servers-restricted/unrestricted AI Agent B → Service Account B (agent-{agent-id}-m2m) → Group: mcp-servers-restricted/unrestricted AI Agent C → Service Account C (agent-{agent-id}-m2m) → Group: mcp-servers-restricted/unrestricted ↓ Individual JWT Tokens per Agent ↓ Group-based Authorization + Individual Tracking ``` **Benefits:** - ✅ Individual audit trails per AI agent - ✅ Security isolation between agents - ✅ Granular access control - ✅ Compliance ready (SOC2, ISO27001) - ✅ Per-agent metrics and monitoring ### Keycloak Components #### Realm Configuration - **Realm Name**: `mcp-gateway` - **Purpose**: Isolated authentication domain for MCP Gateway - **Settings**: JWT tokens, group mappings, client configurations #### Client Configuration - **Client ID**: `mcp-gateway-m2m` - **Client Type**: Confidential (with secret) - **Grant Types**: `client_credentials` (Machine-to-Machine) - **Service Accounts**: Enabled - **Standard/Implicit Flow**: Disabled (security best practice) #### Group Structure ``` mcp-gateway (realm) ├── mcp-servers-unrestricted (group) │ ├── Scopes: mcp-servers-unrestricted/read, mcp-servers-unrestricted/execute │ └── Access: Full access to all MCP servers └── mcp-servers-restricted (group) ├── Scopes: mcp-servers-restricted/read, mcp-servers-restricted/execute └── Access: Limited access to approved MCP servers ``` ## Environment Configuration ### Required Environment Variables #### 1. Docker Compose (.env) ```bash # Keycloak Database Configuration KEYCLOAK_DB_VENDOR=postgres KEYCLOAK_DB_ADDR=postgres KEYCLOAK_DB_DATABASE=keycloak KEYCLOAK_DB_USER=keycloak KEYCLOAK_DB_PASSWORD= # Keycloak Admin Configuration KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD= # Keycloak Runtime Configuration KEYCLOAK_HOSTNAME=mcpgateway.ddns.net KEYCLOAK_HOSTNAME_STRICT=false KEYCLOAK_HOSTNAME_STRICT_HTTPS=false KC_PROXY=edge KC_HTTP_ENABLED=true # PostgreSQL Database Configuration POSTGRES_DB=keycloak POSTGRES_USER=keycloak POSTGRES_PASSWORD= ``` #### 2. Auth Server Configuration (.env or docker-compose) ```bash # Authentication Provider Selection AUTH_PROVIDER=keycloak # Keycloak Connection Details KEYCLOAK_URL=https://mcpgateway.ddns.net KEYCLOAK_REALM=mcp-gateway KEYCLOAK_CLIENT_ID=mcp-gateway-m2m KEYCLOAK_CLIENT_SECRET= # M2M Client Configuration (optional, defaults to main client) KEYCLOAK_M2M_CLIENT_ID=mcp-gateway-m2m KEYCLOAK_M2M_CLIENT_SECRET= ``` #### 3. Credentials Provider Configuration ```bash # Token Storage Configuration OAUTH_TOKENS_DIR=.oauth-tokens # Keycloak M2M Token Configuration KEYCLOAK_URL=https://mcpgateway.ddns.net/keycloak KEYCLOAK_REALM=mcp-gateway KEYCLOAK_CLIENT_ID=mcp-gateway-m2m KEYCLOAK_CLIENT_SECRET= # Token Refresh Settings TOKEN_REFRESH_THRESHOLD=60 # Refresh when less than 60 seconds remaining TOKEN_CACHE_TTL=300 # Cache tokens for 300 seconds (5 minutes) ``` #### 4. Agent-Specific Configuration (per agent) ```bash # Agent Identification AGENT_ID=sre-agent AGENT_TYPE=claude AGENT_VERSION=1.0.0 # Keycloak Agent Configuration KEYCLOAK_AGENT_CLIENT_ID=mcp-gateway-m2m KEYCLOAK_AGENT_SERVICE_ACCOUNT=agent-sre-agent-m2m KEYCLOAK_AGENT_GROUP=mcp-servers-unrestricted # Token File Location AGENT_TOKEN_FILE=.oauth-tokens/agent-sre-agent.json ``` ### Configuration File Templates #### .env.keycloak (Main Configuration) ```bash # Keycloak Service Configuration KEYCLOAK_URL=https://mcpgateway.ddns.net KEYCLOAK_REALM=mcp-gateway KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD= # Database Configuration KEYCLOAK_DB_VENDOR=postgres KEYCLOAK_DB_ADDR=postgres KEYCLOAK_DB_DATABASE=keycloak KEYCLOAK_DB_USER=keycloak KEYCLOAK_DB_PASSWORD= # M2M Client Configuration KEYCLOAK_CLIENT_ID=mcp-gateway-m2m KEYCLOAK_CLIENT_SECRET= KEYCLOAK_M2M_CLIENT_ID=mcp-gateway-m2m KEYCLOAK_M2M_CLIENT_SECRET= # Proxy Configuration KC_PROXY=edge KC_HTTP_ENABLED=true KEYCLOAK_HOSTNAME_STRICT=false KEYCLOAK_HOSTNAME_STRICT_HTTPS=false ``` #### .env.auth-server (Auth Server Configuration) ```bash # Authentication Provider AUTH_PROVIDER=keycloak # Keycloak Integration KEYCLOAK_URL=https://mcpgateway.ddns.net KEYCLOAK_REALM=mcp-gateway KEYCLOAK_CLIENT_ID=mcp-gateway-m2m KEYCLOAK_CLIENT_SECRET= # Scopes Configuration SCOPES_CONFIG_PATH=scopes.yml # Logging Configuration LOG_LEVEL=INFO AUTH_LOG_FORMAT=%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s ``` ## Setup & Installation ### Prerequisites 1. **Docker & Docker Compose** ```bash docker --version docker-compose --version ``` 2. **Required Ports Available** - 8080: Keycloak HTTP - 8443: Keycloak HTTPS - 5432: PostgreSQL (internal) 3. **External Dependencies** - Domain name with SSL certificate - PostgreSQL database access ### Installation Steps #### 1. Initial Setup ```bash # Clone repository and navigate to project cd /path/to/mcp-gateway-registry # Start prerequisite services docker-compose up -d postgres # Wait for PostgreSQL to be ready sleep 10 # Start Keycloak docker-compose up -d keycloak # Wait for Keycloak to initialize (may take 2-3 minutes) sleep 120 ``` #### 2. Environment Variables Setup ```bash # MANDATORY: Set secure passwords before running any scripts export KEYCLOAK_ADMIN_PASSWORD="$(openssl rand -base64 32)" export KEYCLOAK_DB_PASSWORD="$(openssl rand -base64 32)" # Verify variables are set echo "Admin password set: ${KEYCLOAK_ADMIN_PASSWORD:+YES}" echo "DB password set: ${KEYCLOAK_DB_PASSWORD:+YES}" ``` #### 3. Keycloak Initialization ```bash # Run the main initialization script ./keycloak/setup/init-keycloak.sh # Expected output: # ✓ Realm 'mcp-gateway' created successfully # ✓ M2M client 'mcp-gateway-m2m' created successfully # ✓ Groups created successfully # ✓ Admin user setup complete ``` #### 4. Service Account Setup ##### Production Setup (Individual Agents) ```bash # Ensure environment variables are still set export KEYCLOAK_ADMIN_PASSWORD="your-secure-password" # Create service account for SRE agent with full access ./keycloak/setup/setup-agent-service-account.sh \ --agent-id sre-agent \ --group mcp-servers-unrestricted # Create service account for travel assistant with restricted access ./keycloak/setup/setup-agent-service-account.sh \ --agent-id travel-assistant \ --group mcp-servers-restricted # Create service account for developer productivity agent with full access ./keycloak/setup/setup-agent-service-account.sh \ --agent-id dev-productivity \ --group mcp-servers-unrestricted ``` ##### Development Setup (Single Account) ```bash # Create single shared service account ./keycloak/setup/setup-m2m-service-account.sh ``` #### 4. Start Complete Stack ```bash # Start all services docker-compose up -d # Verify all services are running docker-compose ps # Check service health curl -f http://localhost:8080/health/ready ``` #### 5. Generate Tokens ##### Agent-Specific Tokens (Production) ```bash # Generate token for SRE agent uv run python credentials-provider/keycloak/get_m2m_token.py --agent-id sre-agent # Generate token for Travel Assistant agent uv run python credentials-provider/keycloak/get_m2m_token.py --agent-id travel-assistant # Generate tokens for all agents uv run python credentials-provider/keycloak/get_m2m_token.py --all-agents # Verify token files created ls -la .oauth-tokens/agent-*-m2m-token.json ``` ##### Complete Credential Generation (Recommended) ```bash # Generate all authentication tokens and MCP configurations ./credentials-provider/generate_creds.sh # Start automatic token refresh service ./start_token_refresher.sh # Verify token refresh is working tail -f token_refresher.log ``` #### 6. Validation & Testing ```bash # Test agent-specific authentication ./test-keycloak-mcp.sh --agent-id sre-agent # Test legacy authentication ./test-keycloak-mcp.sh # Expected output: # ✓ Authentication successful # ✓ Session established with ID: xxx # ✓ Handshake completed # ✓ Ping successful # ✓ Tools list retrieved ``` ## Operational Procedures ### Starting Services #### Complete Stack Startup ```bash # 1. Start database first docker-compose up -d postgres # 2. Wait for database ready sleep 10 # 3. Start Keycloak docker-compose up -d keycloak # 4. Wait for Keycloak initialization sleep 120 # 5. Start remaining services docker-compose up -d # 6. Verify all services docker-compose ps docker-compose logs --tail=20 ``` #### Service Health Checks ```bash # Keycloak health curl -f http://localhost:8080/health/ready # Auth server health curl -f http://localhost:8000/health # PostgreSQL connection docker-compose exec postgres pg_isready -U keycloak # Complete service status docker-compose ps --format table ``` ### Token Management #### Token Generation ```bash # Generate new agent token uv run python credentials-provider/keycloak/get_m2m_token.py --agent-id # Generate tokens for all agents uv run python credentials-provider/keycloak/get_m2m_token.py --all-agents # Use complete credential generation workflow ./credentials-provider/generate_creds.sh ``` #### Token Validation ```bash # Check token expiration cat .oauth-tokens/agent--m2m-token.json | jq '.expires_at_human' # Verify token claims (decode JWT) cat .oauth-tokens/agent--m2m-token.json | jq -r '.access_token' | cut -d. -f2 | base64 -d | jq '.' # Test token authentication ./test-keycloak-mcp.sh --agent-id # Check automatic token refresh status tail -20 token_refresher.log ``` #### Token Rotation Strategy ```bash # Automatic token refresh service (recommended) ./start_token_refresher.sh # The service will automatically: # - Refresh tokens every 5 minutes # - Regenerate MCP configuration files # - Handle both ingress and egress tokens # Manual token refresh if needed uv run python credentials-provider/keycloak/get_m2m_token.py --all-agents # Hourly health check 0 * * * * /path/to/project/test-keycloak-mcp.sh --agent-id sre-agent --silent ``` ### Configuration Updates #### Adding New Agents ```bash # 1. Create new service account ./keycloak/setup/setup-agent-service-account.sh \ --agent-id new-agent-001 \ --group mcp-servers-restricted # 2. Generate initial token uv run python credentials-provider/keycloak/get_m2m_token.py --agent-id new-agent-001 # 3. Test authentication ./test-keycloak-mcp.sh --agent-id new-agent-001 # 4. Update monitoring and rotation scripts ``` #### Modifying Agent Permissions ```bash # Access Keycloak admin console open https://mcpgateway.ddns.net/admin # Navigate to: # Realm: mcp-gateway → Users → agent--m2m → Groups # Add/remove group memberships: # - mcp-servers-unrestricted (full access) # - mcp-servers-restricted (limited access) # Generate new token to reflect changes uv run uv run python credentials-provider/token_refresher.py --agent-id ``` #### Updating Scopes Configuration ```bash # 1. Edit scopes configuration nano auth_server/scopes.yml # 2. Restart auth server to pick up changes docker-compose restart auth-server # 3. Verify changes took effect docker-compose logs auth-server | grep -i scope # 4. Test authorization with updated scopes ./test-keycloak-mcp.sh --agent-id ``` ## Agent Management ### Agent Service Account Lifecycle #### Creating New Agent ```bash # Step 1: Create service account with appropriate permissions ./keycloak/setup/setup-agent-service-account.sh \ --agent-id \ --group # Step 2: Generate initial token uv run uv run python credentials-provider/token_refresher.py --agent-id # Step 3: Validate setup ./test-keycloak-mcp.sh --agent-id # Step 4: Document agent in inventory echo ",,," >> docs/agent-inventory.csv ``` #### Agent Permission Updates ```bash # Via Keycloak Admin Console: # 1. Navigate to Users → agent--m2m → Groups # 2. Leave current group # 3. Join new group # 4. Generate new token uv run uv run python credentials-provider/token_refresher.py --agent-id ``` #### Agent Decommissioning ```bash # 1. Disable service account in Keycloak # (Admin Console → Users → agent--m2m → Enabled: OFF) # 2. Remove token files rm .oauth-tokens/agent-.json # 3. Update documentation sed -i '//d' docs/agent-inventory.csv # 4. Optional: Delete service account entirely # (Admin Console → Users → agent--m2m → Delete) ``` ### Bulk Agent Operations #### Creating Multiple Agents ```bash #!/bin/bash # bulk-create-agents.sh AGENTS=( "sre-agent:mcp-servers-unrestricted" "travel-assistant:mcp-servers-restricted" "dev-productivity:mcp-servers-restricted" "data-analyst:mcp-servers-restricted" "code-reviewer:mcp-servers-unrestricted" ) for agent_config in "${AGENTS[@]}"; do IFS=':' read -r agent_id group <<< "$agent_config" echo "Creating agent: $agent_id with group: $group" ./keycloak/setup/setup-agent-service-account.sh \ --agent-id "$agent_id" \ --group "$group" echo "Generating token for: $agent_id" uv run python credentials-provider/keycloak/get_m2m_token.py --agent-id "$agent_id" done ``` #### Bulk Token Refresh ```bash #!/bin/bash # bulk-refresh-tokens.sh # Use the built-in all-agents option (recommended) uv run python credentials-provider/keycloak/get_m2m_token.py --all-agents # Or manually refresh individual agents for token_file in .oauth-tokens/agent-*-m2m-token.json; do if [ -f "$token_file" ]; then agent_id=$(basename "$token_file" -m2m-token.json | sed 's/agent-//') echo "Refreshing token for agent: $agent_id" uv run python credentials-provider/keycloak/get_m2m_token.py --agent-id "$agent_id" fi done ``` ## Monitoring & Troubleshooting ### Log Monitoring #### Service Logs ```bash # Keycloak logs docker-compose logs -f keycloak # Auth server logs docker-compose logs -f auth-server # PostgreSQL logs docker-compose logs -f postgres # All services docker-compose logs -f ``` #### Authentication Debugging ```bash # Enable debug logging in auth server # Edit docker-compose.yml: # environment: # - LOG_LEVEL=DEBUG # Restart auth server docker-compose restart auth-server # Monitor authentication attempts docker-compose logs -f auth-server | grep -i "keycloak\|token\|auth" ``` #### Token Validation Logs ```bash # Watch token validation in real-time docker-compose logs -f auth-server | grep -E "Token validation|Groups.*mapped|Access.*denied" # Sample output: # ✓ Token validation successful using KeycloakProvider # ✓ Mapped Keycloak groups ['mcp-servers-unrestricted'] to scopes: ['mcp-servers-unrestricted/read', 'mcp-servers-unrestricted/execute'] # ✓ Access granted for server currenttime.tools/list ``` ### Common Issues & Solutions #### Issue: Token Expired ```bash # Symptoms: # - HTTP 500 errors # - "Token has expired" in logs # Solution: uv run uv run python credentials-provider/token_refresher.py --agent-id ``` #### Issue: Service Account Missing ```bash # Symptoms: # - "Service account not found" errors # - Token generation fails # Solution: ./keycloak/setup/setup-agent-service-account.sh --agent-id --group ``` #### Issue: Groups Not in JWT ```bash # Symptoms: # - "Access forbidden" errors # - Groups claim missing from token # Check groups mapper exists: # Admin Console → Clients → mcp-gateway-m2m → Mappers → groups # Fix: ./keycloak/setup/setup-agent-service-account.sh --agent-id --group ``` #### Issue: Database Connection Failed ```bash # Symptoms: # - Keycloak fails to start # - Database connection errors # Check PostgreSQL: docker-compose ps postgres docker-compose logs postgres # Restart database: docker-compose restart postgres sleep 10 docker-compose restart keycloak ``` ### Performance Monitoring #### Token Metrics ```bash # Token expiration monitoring find .oauth-tokens -name "*.json" -exec jq -r '.expires_at_human' {} \; # Token age monitoring find .oauth-tokens -name "*.json" -exec stat -c '%Y %n' {} \; | sort -n ``` #### Service Health Dashboard ```bash #!/bin/bash # health-dashboard.sh echo "=== MCP Gateway Keycloak Health Dashboard ===" echo "Timestamp: $(date)" echo "" echo "--- Service Status ---" docker-compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}" echo "" echo "--- Token Status ---" for token_file in .oauth-tokens/*.json; do if [ -f "$token_file" ]; then agent=$(basename "$token_file" .json) expires=$(jq -r '.expires_at_human' "$token_file") echo "$agent: expires $expires" fi done echo "" echo "--- Service Health ---" curl -s -f http://localhost:8080/health/ready && echo "Keycloak: ✓ Healthy" || echo "Keycloak: ✗ Unhealthy" curl -s -f http://localhost:8000/health && echo "Auth Server: ✓ Healthy" || echo "Auth Server: ✗ Unhealthy" ``` ## Security Considerations ### ⚠️ Critical Security Requirements **MANDATORY**: All setup scripts require environment variables to be set. Scripts will exit with an error if passwords are not provided: ```bash # ✅ Required environment variables - scripts will fail without these export KEYCLOAK_ADMIN_PASSWORD="$(openssl rand -base64 32)" export KEYCLOAK_DB_PASSWORD="$(openssl rand -base64 32)" ``` **Security Features:** - ✅ No hardcoded passwords in scripts - ✅ Scripts exit with clear error if environment variables not set - ✅ Forces explicit password configuration - ✅ Prevents accidental use of default passwords **Before running any setup scripts:** 1. **REQUIRED**: Set `KEYCLOAK_ADMIN_PASSWORD` environment variable 2. **REQUIRED**: Set `KEYCLOAK_DB_PASSWORD` environment variable 3. Never commit these to version control 4. Use a proper secrets management system ### Secret Management ```bash # Environment Variables (Recommended) export KEYCLOAK_CLIENT_SECRET="" export KEYCLOAK_ADMIN_PASSWORD="" # .env Files (Development Only) # Ensure .env files are in .gitignore echo "*.env" >> .gitignore echo ".oauth-tokens/" >> .gitignore # Kubernetes Secrets (Production) kubectl create secret generic keycloak-secrets \ --from-literal=client-secret="" \ --from-literal=admin-password="" ``` ### Network Security ```bash # Firewall Rules (Example) # Allow only necessary ports: ufw allow 80/tcp # HTTP ufw allow 443/tcp # HTTPS ufw deny 8080/tcp # Block direct Keycloak access ufw deny 5432/tcp # Block direct database access # Use reverse proxy (nginx) for SSL termination # Block direct access to Keycloak admin console from external networks ``` ### Token Security ```bash # Token File Permissions chmod 600 .oauth-tokens/*.json chown app:app .oauth-tokens/*.json # Token Rotation Policy # - Refresh tokens every 4 hours (max 5 minutes lifetime) # - Rotate client secrets monthly # - Monitor for token abuse/unusual patterns # Audit Trail # - All token usage logged with agent ID # - Failed authentication attempts monitored # - Suspicious activity alerts configured ``` ### Access Control ```bash # Service Account Principle of Least Privilege # - mcp-servers-restricted: Limited to approved servers only # - mcp-servers-unrestricted: Full access (use sparingly) # Regular Access Review # - Monthly review of agent permissions # - Quarterly audit of service accounts # - Annual security assessment ``` ## Cleanup Procedures ### Graceful Shutdown ```bash # 1. Stop accepting new requests docker-compose stop nginx # 2. Allow current requests to complete sleep 30 # 3. Stop application services docker-compose stop auth-server # 4. Stop Keycloak docker-compose stop keycloak # 5. Stop database last docker-compose stop postgres # 6. Verify all stopped docker-compose ps ``` ### Complete Removal ```bash # Stop and remove containers docker-compose down # Remove volumes (WARNING: This deletes all data) docker-compose down -v # Remove images docker-compose down --rmi all # Remove networks docker network prune -f # Clean up token files rm -rf .oauth-tokens/ # Remove logs docker system prune -f ``` ### Data Backup & Restore #### Backup Procedure ```bash #!/bin/bash # backup-keycloak.sh BACKUP_DIR="backups/$(date +%Y%m%d_%H%M%S)" mkdir -p "$BACKUP_DIR" # Backup database docker-compose exec postgres pg_dump -U keycloak keycloak > "$BACKUP_DIR/keycloak.sql" # Backup configuration cp -r keycloak/setup "$BACKUP_DIR/" cp auth_server/scopes.yml "$BACKUP_DIR/" cp docker-compose.yml "$BACKUP_DIR/" # Backup tokens (optional) cp -r .oauth-tokens "$BACKUP_DIR/" echo "Backup completed: $BACKUP_DIR" ``` #### Restore Procedure ```bash #!/bin/bash # restore-keycloak.sh BACKUP_DIR="$1" if [ -z "$BACKUP_DIR" ]; then echo "Usage: $0 " exit 1 fi # Stop services docker-compose down # Restore database docker-compose up -d postgres sleep 10 docker-compose exec -T postgres psql -U keycloak -d keycloak < "$BACKUP_DIR/keycloak.sql" # Restore configuration cp -r "$BACKUP_DIR/setup" keycloak/ cp "$BACKUP_DIR/scopes.yml" auth_server/ cp "$BACKUP_DIR/docker-compose.yml" . # Start services docker-compose up -d echo "Restore completed from: $BACKUP_DIR" ``` ### Agent Cleanup ```bash #!/bin/bash # cleanup-agent.sh AGENT_ID="$1" if [ -z "$AGENT_ID" ]; then echo "Usage: $0 " exit 1 fi # Remove token file rm -f ".oauth-tokens/agent-${AGENT_ID}.json" # Disable service account in Keycloak # (Manual step via Admin Console) echo "Manual step: Disable service account 'agent-${AGENT_ID}-m2m' in Keycloak Admin Console" # Remove from monitoring sed -i "/agent-${AGENT_ID}/d" docs/agent-inventory.csv echo "Agent cleanup completed for: $AGENT_ID" ``` --- ## Quick Reference ### Key Commands ```bash # Setup ./keycloak/setup/init-keycloak.sh ./keycloak/setup/setup-agent-service-account.sh --agent-id --group # Operations uv run python credentials-provider/token_refresher.py --agent-id ./test-keycloak-mcp.sh --agent-id docker-compose logs -f auth-server # Health Checks curl -f http://localhost:8080/health/ready curl -f http://localhost:8000/health # Troubleshooting docker-compose ps docker-compose logs cat .oauth-tokens/agent-.json | jq '.expires_at_human' ``` ### Important Files ``` keycloak/setup/ # Setup scripts auth_server/scopes.yml # Authorization configuration .oauth-tokens/ # Token storage docs/keycloak-integration.md # This documentation docker-compose.yml # Service orchestration .env # Environment configuration ``` ### Service URLs - **Keycloak Admin**: https://mcpgateway.ddns.net/admin - **Keycloak API**: https://mcpgateway.ddns.net/realms/mcp-gateway - **Auth Server**: http://localhost:8000 - **Health Checks**: http://localhost:8080/health/ready --- *This documentation is maintained as part of the MCP Gateway project. For updates and issues, please refer to the project repository.* ================================================ FILE: docs/llms.txt ================================================ # MCP Gateway & Registry - High-Level Summary This project provides an enterprise-ready gateway and registry for Model Context Protocol (MCP) servers, enabling centralized management, secure access, and dynamic tool discovery for AI agents and development teams. The core goal is to transform the chaos of managing hundreds of individual MCP server connections into a unified, governed platform with comprehensive authentication, fine-grained access control, and intelligent tool discovery capabilities. The repository provides: 1. **Centralized Gateway & Registry**: A unified platform for managing and accessing MCP servers across an organization 2. **Enterprise Authentication**: Multi-provider OAuth 2.0 support with Keycloak, Amazon Cognito, Microsoft Entra ID, and custom identity providers 3. **Fine-Grained Access Control**: Scope-based authorization at server, method, and individual tool levels 4. **Dynamic Tool Discovery**: AI-powered hybrid search (BM25 + vector k-NN) with flexible embedding providers (local, OpenAI, LiteLLM, Bedrock) for autonomous tool discovery 5. **Comprehensive Observability**: Dual-path metrics collection with SQLite and OpenTelemetry for detailed analytics 6. **Production-Ready Deployment**: Docker-based deployment with support for EC2, EKS, and container orchestration Key features include: centralized server management, OAuth 2.0/3.0 authentication flows, token vending service, automated token refresh, dynamic tool discovery and invocation, real-time health monitoring, Grafana dashboards, security scanning integration, and Anthropic MCP Registry compatibility. --- # MCP Gateway & Registry ## 1. Overview - **Project Name:** MCP Gateway & Registry - **Purpose:** Enterprise-ready platform for managing, securing, and accessing Model Context Protocol (MCP) servers at scale - **Core Goal:** Transform scattered MCP server connections into a centralized, governed platform with unified authentication and intelligent tool discovery - **Communication:** Uses MCP protocol over SSE (Server-Sent Events) and Streamable HTTP - **Key Components:** Gateway (Nginx reverse proxy), Registry (Web UI & API), Auth Server (OAuth/JWT), MCP Servers, Metrics Service, Token Refresh Service ### 1.5. Repository Structure **Top-Level Directories:** | Directory | Purpose | Key Files | |-----------|---------|-----------| | `registry/` | **Core Registry Application** - FastAPI backend, repositories, services | `main.py` (FastAPI app), `core/config.py` (settings) | | `auth_server/` | **OAuth Authentication Server** - Keycloak/Cognito/Entra ID integration | `app.py` (Flask auth server), `scopes.yml` (group mappings) | | `frontend/` | **Web UI** - React/TypeScript admin dashboard | `src/App.tsx` (main app), `src/pages/` (UI pages) | | `servers/` | **Example MCP Servers** - Reference implementations | `currenttime/`, `mcpgw/` (MCP Gateway server) | | `terraform/` | **Infrastructure as Code** - AWS deployment automation | `aws-ecs/` (ECS Fargate), `modules/` (reusable modules) | | `tests/` | **Test Suite** - pytest unit, integration, E2E tests | `conftest.py`, `unit/`, `integration/` | | `docs/` | **Documentation** - Architecture, guides, design docs | `llms.txt` (this file), `design/` (architecture) | | `cli/` | **Command-Line Tools** - MCP client, service management | `mcp_client.py`, `service_mgmt.sh` | | `keycloak/` | **Keycloak Setup** - Docker configs, initialization scripts | `docker-compose.yml`, `setup/` (init scripts) | | `docker/` | **Docker Configurations** - Dockerfiles for all services | `Dockerfile.registry`, `Dockerfile.auth` | | `scripts/` | **Automation Scripts** - Deployment, testing, utilities | `test.py` (test runner), `publish-containers.sh` | | `config/` | **Configuration Templates** - Nginx, environment examples | `nginx.conf.template`, `.env.example` | | `metrics-service/` | **Metrics Collection** - OpenTelemetry metrics service | `app.py` (FastAPI metrics API) | | `charts/` | **Helm Charts** - Kubernetes deployment manifests | `mcp-gateway/` (Helm chart) | | `credentials-provider/` | **Credential Management** - OAuth token handling | `app.py` (credentials service) | | `agents/` | **A2A Agent Cards** - Agent-to-Agent protocol agent cards | JSON files for registered agents | | `api/` | **Legacy API** - Deprecated standalone API (use `registry/api/`) | - | | `release-notes/` | **Release History** - Version release notes | Markdown files per version | **Registry Application Structure (`registry/`):** | Subdirectory | Purpose | Key Files | |--------------|---------|-----------| | `api/` | **API Routes** - FastAPI endpoint definitions | `server_routes.py`, `agent_routes.py`, `search_routes.py`, `auth_routes.py` | | `services/` | **Business Logic** - Service layer between routes and repositories | `server_service.py`, `agent_service.py`, `search_service.py`, `rating_service.py` | | `repositories/` | **Data Access Layer** - Abstract repositories with multiple backends | `interfaces.py` (abstract base classes), `factory.py`, `documentdb/`, `file/` | | `repositories/documentdb/` | **DocumentDB Implementation** - Production storage backend | `server_repository.py`, `agent_repository.py`, `scope_repository.py`, `search_repository.py`, `client.py` | | `repositories/file/` | **File Implementation** - Legacy storage backend (DEPRECATED) | `server_repository.py`, `agent_repository.py` | | `schemas/` | **Pydantic Models** - Request/response validation | `server.py`, `agent.py`, `auth.py`, `search.py`, `security.py`, `rating.py` | | `auth/` | **Authentication Logic** - JWT validation, session management | `dependencies.py` (FastAPI auth dependencies), `session.py` | | `core/` | **Core Infrastructure** - Configuration, startup logic | `config.py` (Settings class), `logging.py` | | `embeddings/` | **Embedding Providers** - Multiple embedding model support | `factory.py`, `sentence_transformers.py`, `litellm_embeddings.py` | | `search/` | **Search Implementation** - Hybrid search (BM25 + vector) | `faiss_service.py` (legacy), `hybrid_search.py` | | `health/` | **Health Monitoring** - Server health checks, status tracking | `health_service.py`, `health_routes.py` | | `utils/` | **Utilities** - Helper functions, logging, validation | `scopes_manager.py` (scope CRUD), `nginx_service.py` | | `services/federation/` | **Federation** - External registry synchronization | `anthropic.py`, `asor.py` (Workday ASOR) | | `static/` | **Static Assets** - CSS, JavaScript, images for web UI | - | | `templates/` | **Jinja2 Templates** - HTML templates for web UI | `pages/`, `components/` | | `scripts/` | **Python Scripts** - Data migration, initialization | `migrate_to_documentdb.py`, `init_opensearch.py` | **Important Root Files:** | File | Purpose | |------|---------| | `pyproject.toml` | Python package configuration, dependencies, pytest settings | | `docker-compose.yml` | Local development Docker Compose configuration | | `docker-compose.prod.yml` | Production Docker Compose configuration | | `.env.example` | Environment variable template with all settings | | `README.md` | Project overview, quick start guide | | `CLAUDE.md` | Coding standards and best practices | | `TEAM.md` | Team roles and personas for development | | `MAINTENANCE.md` | Maintenance procedures and troubleshooting | | `WRITING_TESTS.md` | Test writing guidelines and patterns | **Test Structure (`tests/`):** | Subdirectory | Purpose | Key Files | |--------------|---------|-----------| | `unit/` | **Unit Tests** - Fast, isolated component tests | `api/`, `services/`, `repositories/`, `auth/` | | `integration/` | **Integration Tests** - Multi-component workflow tests | `test_server_lifecycle.py`, `test_search_integration.py`, `conftest.py` | | `fixtures/` | **Test Fixtures** - Mock data, factories | `factories.py` (Factory Boy), `mocks/` | | `reporting/` | **Test Reports** - HTML coverage reports, test results | - | **Terraform Structure (`terraform/`):** | Subdirectory | Purpose | Key Files | |--------------|---------|-----------| | `aws-ecs/` | **AWS ECS Fargate Deployment** - Production-ready IaC | `main.tf`, `variables.tf`, `outputs.tf`, `ecs.tf` | | `modules/` | **Reusable Terraform Modules** - Shared infrastructure components | `ecs-service/`, `alb/`, `networking/` | **Important Configuration Files:** | File/Directory | Purpose | Location | |----------------|---------|----------| | `oauth2_providers.yml` | OAuth provider configurations (Keycloak, Cognito, Entra ID) | `auth_server/` | | `scopes.yml` | Group-to-scope mappings, UI permissions | `auth_server/` | | `nginx.conf.template` | Nginx reverse proxy configuration | `config/` | | `global-bundle.pem` | AWS DocumentDB TLS certificate | Root directory | | `.env.example` | Environment variables for all services | Root directory | **Key Entry Points:** | Component | Entry Point | Purpose | |-----------|-------------|---------| | Registry API | `registry/main.py` | FastAPI application for registry and MCP gateway | | Auth Server | `auth_server/app.py` | Flask OAuth server for authentication | | Frontend | `frontend/src/App.tsx` | React web UI for administration | | MCP Client | `cli/mcp_client.py` | CLI tool for calling MCP servers | | Test Runner | `scripts/test.py` | Unified test execution script | | Metrics Service | `metrics-service/app.py` | OpenTelemetry metrics collection | ## 2. Core Problem Solved **Transform this chaos:** - AI agents require separate connections to each MCP server - Each developer configures VS Code, Cursor, Claude Code individually - Developers must install and manage MCP servers locally - No standard authentication flow for enterprise tools - Scattered API keys and credentials across tools - No visibility into what tools teams are using - Security risks from unmanaged tool sprawl - No dynamic tool discovery for autonomous agents **Into this organized approach:** - AI agents connect to one gateway, access multiple MCP servers - Single configuration point for VS Code, Cursor, Claude Code - Central IT manages cloud-hosted MCP infrastructure - Developers use standard OAuth 2LO/3LO flows - Centralized credential management with secure vault integration - Complete visibility and audit trail for all tool usage - Enterprise-grade security with governed tool access - Dynamic tool discovery and invocation for autonomous workflows ## 3. Architecture Overview ### 3.1. Core Architectural Decision: Reverse Proxy Pattern The MCP Gateway uses a **reverse proxy architecture** (Nginx-based) rather than an application-layer gateway: **Key Benefits:** - **Performance**: Direct proxy routing with minimal overhead (~1-2ms) - **Protocol Independence**: Can proxy any protocol (HTTP, WebSocket, SSE, gRPC) - **Scalability**: Each MCP server scales independently - **Implementation**: Allows Python development while Nginx handles message routing - **Future-Proof**: Supports A2A (Agent-to-Agent) and other protocols without gateway changes **Architecture Flow:** ``` AI Agent/Coding Assistant ↓ Multiple Endpoints ┌─────────────────┐ │ Nginx Gateway │ │ /fininfo/ │ ──auth_request──> Auth Server │ /mcpgw/ │ │ │ /currenttime/ │ <──auth_headers───────┘ └─────────────────┘ │ │ │ │ │ └─── localhost:8003 (currenttime) │ └───── localhost:8002 (mcpgw) └─────── localhost:8001 (fininfo) ``` **Alternative Considered:** - Tools Gateway Pattern: Single endpoint with tool aggregation - Trade-offs: Better developer experience but requires Go/Rust for performance and adds complexity ### 3.2. High-Level Component Architecture ``` ┌─────────────────────────────────────┐ │ Human Users / AI Agents │ └──────────────┬──────────────────────┘ │ ↓ ┌──────────────────────────────────────┐ │ Identity Provider (Keycloak/ │ │ Cognito/Entra ID) - OAuth 2.0 │ └──────────────┬───────────────────────┘ │ ↓ ┌──────────────────────────────────────┐ │ MCP Gateway & Registry (EC2/EKS) │ │ ┌────────────────────────────────┐ │ │ │ NGINX Reverse Proxy Router │ │ │ └──────┬─────────────────────────┘ │ │ │ │ │ ┌──────┴─────────┬────────────┐ │ │ │ Auth Server │ Registry │ │ │ │ (Dual Auth) │ Web UI │ │ │ └────────────────┴────────────┘ │ │ │ │ ┌──────────────────────────────┐ │ │ │ Local MCP Servers │ │ │ │ - MCP Server 1, 2, ...N │ │ │ └──────────────────────────────┘ │ └──────────────┬───────────────────────┘ │ ↓ ┌──────────────────────────────────────┐ │ External Systems & Data Sources │ │ - EKS/EC2 Cluster MCP Servers │ │ - API Gateway + Lambda Functions │ │ - Databases, External APIs │ └──────────────────────────────────────┘ ``` ### 3.3. Key Architectural Components **Gateway Layer:** - **Nginx Reverse Proxy**: Path-based routing, SSL termination, load balancing - **Auth Server**: Dual authentication (Keycloak/Cognito), token validation, scope enforcement - **Registry Web UI**: Server management, health monitoring, user administration - **Registry MCP Server**: Dynamic tool discovery, intelligent tool finder **Identity & Access:** - **Keycloak/Cognito/Entra ID**: Primary identity provider (choose one or multi-provider) - **OAuth 2.0/3.0**: User authentication and authorization - **JWT Tokens**: Secure, stateless authentication - **Fine-Grained Access Control**: Scope-based permissions at server, method, and tool levels - **Enterprise SSO**: SAML/OIDC integration with Microsoft Entra ID for Microsoft 365 environments **MCP Server Layer:** - **Local MCP Servers**: Co-located with gateway (SSE transport) - **Remote MCP Servers**: EKS/EC2 clusters (SSE/Streamable HTTP) - **Serverless MCP**: API Gateway + Lambda functions **Observability:** - **Metrics Service**: Dual-path collection (SQLite + OpenTelemetry) - **Prometheus**: Time-series metrics storage - **Grafana**: Real-time dashboards and alerting - **CloudWatch/Datadog**: Cloud-native monitoring integration ### 3.4. Storage Backend Architecture **IMPORTANT:** The MCP Gateway & Registry uses a **repository pattern** with multiple storage backends. File-based storage is **LEGACY and DEPRECATED** - use DocumentDB or MongoDB for production deployments. **Repository Pattern:** ``` Routes → Services → Repositories → Storage Backends ``` **Three Storage Backends:** 1. **File-Based Storage (LEGACY - DEPRECATED)** - Status: Maintained for backward compatibility only, will be removed - Use Case: Local development and testing ONLY - Vector Search: FAISS with in-memory indexing - Limitations: Not suitable for production, no high availability, file corruption risks - Location: `registry/repositories/file/` 2. **MongoDB Community Edition (Development)** - Status: Recommended for local development and testing - Use Case: Local Docker development, CI/CD testing - Vector Search: Application-level k-NN with BM25 hybrid search - Configuration: `STORAGE_BACKEND=mongodb-ce` - Connection: `DOCUMENTDB_HOST=localhost:27017` - Location: `registry/repositories/mongodb/` 3. **Amazon DocumentDB (Production - RECOMMENDED)** - Status: Production-ready, enterprise-grade - Use Case: AWS production deployments with HA requirements - Vector Search: Native HNSW vector search with BM25 hybrid search - Configuration: `STORAGE_BACKEND=documentdb` - Features: Multi-AZ replication, automatic failover, point-in-time recovery - Namespace Support: Multi-tenancy via `DOCUMENTDB_NAMESPACE` - Location: `registry/repositories/documentdb/` **Repository Interfaces (Abstract Base Classes):** - `ServerRepository`: Server registration, listing, metadata management - `AgentRepository`: A2A agent card management - `ScopeRepository`: Group and scope CRUD operations - `SearchRepository`: Hybrid search (BM25 + vector k-NN) **Factory Pattern:** ```python from registry.repositories.factory import ( get_server_repository, get_agent_repository, get_scope_repository, get_search_repository ) # Automatically selects backend based on STORAGE_BACKEND env var server_repo = await get_server_repository() ``` **Key Architectural Principles:** 1. **Never access repositories directly from API routes** - Always use service layer 2. **All backends provide identical behavior** - Polymorphism via abstract base classes 3. **Backend switching is transparent** - Factory pattern handles instantiation 4. **Use DocumentDB for production** - File-based storage is deprecated **Configuration:** ```bash # Production (DocumentDB) STORAGE_BACKEND=documentdb DOCUMENTDB_HOST=docdb-cluster.cluster-xxx.us-east-1.docdb.amazonaws.com DOCUMENTDB_PORT=27017 DOCUMENTDB_DATABASE=mcp_registry DOCUMENTDB_NAMESPACE=prod # For multi-tenancy DOCUMENTDB_USE_TLS=true DOCUMENTDB_USE_IAM=true # Development (MongoDB CE) STORAGE_BACKEND=mongodb-ce DOCUMENTDB_HOST=localhost DOCUMENTDB_PORT=27017 # Legacy (File - DEPRECATED) STORAGE_BACKEND=file # NOT RECOMMENDED ``` **Vector Search Comparison:** - **File Backend (Legacy)**: FAISS IndexFlatIP (cosine similarity) - **MongoDB CE**: Application-level k-NN with score normalization - **DocumentDB**: Native HNSW with optimized indexing **Hybrid Search Strategy:** All backends support hybrid search combining: 1. **BM25 Text Search**: Keyword matching on server/tool names and descriptions 2. **Vector k-NN Search**: Semantic similarity using embeddings (384-1536 dimensions) 3. **Score Fusion**: Weighted combination (configurable weights) **References:** - Design Document: `docs/design/database-abstraction-layer.md` - Storage Architecture: `docs/design/storage-architecture-mongodb-documentdb.md` - Repository Interfaces: `registry/repositories/interfaces.py` - Factory Implementation: `registry/repositories/factory.py` ## 4. Authentication & Authorization ### 4.1. Three-Layer Authentication System **Layer 1: Ingress Authentication (2LO/M2M)** - Purpose: Controls who can access the MCP Gateway - Providers: Keycloak (M2M service accounts), Amazon Cognito (M2M/2LO), Microsoft Entra ID (Azure AD) - Headers: `X-Authorization`, `X-Client-Id`, `X-Keycloak-Realm`, `X-User-Pool-Id`, `X-Tenant-Id` (Entra ID) - Methods: Machine-to-Machine (JWT tokens), User sessions (OAuth PKCE), Enterprise SSO (SAML/OIDC) **Layer 2: Fine-Grained Access Control (FGAC)** - Purpose: Controls which tools/methods within MCP servers can be accessed - Based on: User/agent scopes and group memberships - Validation: Applied at gateway level after ingress auth - Granularity: Server-level, method-level, individual tool-level **Layer 3: Egress Authentication (3LO)** - Purpose: Allows MCP servers to act on user's behalf with external services - Providers: Atlassian, Google, GitHub, Microsoft, custom OAuth providers - Headers: `Authorization`, provider-specific headers (e.g., `X-Atlassian-Cloud-Id`) - Validation: MCP server validates with its IdP ### 4.2. Dual Token System AI agents carry BOTH ingress and egress tokens: ```json { "headers": { // Ingress Authentication (for Gateway) - Keycloak "X-Authorization": "Bearer {keycloak_jwt_token}", "X-Client-Id": "{agent_client_id}", "X-Keycloak-Realm": "mcp-gateway", "X-Keycloak-URL": "http://localhost:8080", // OR Cognito "X-Authorization": "Bearer {cognito_jwt_token}", "X-User-Pool-Id": "{cognito_user_pool_id}", "X-Client-Id": "{cognito_client_id}", "X-Region": "{aws_region}", // Egress Authentication (for MCP Server) - Example: Atlassian "Authorization": "Bearer {atlassian_oauth_token}", "X-Atlassian-Cloud-Id": "{atlassian_cloud_id}" } } ``` ### 4.3. Complete Authentication Flow ``` 1. One-Time Setup: User → Keycloak/Cognito (2LO) → Ingress Token User → External IdP (3LO, consent) → Egress Token User → Agent Configuration (both tokens) 2. Runtime (Every Request): Agent → Gateway (dual tokens) Gateway → Keycloak/Cognito (validate ingress) Gateway → Apply FGAC (check permissions) Gateway → MCP Server (forward egress token) MCP Server → External IdP (validate egress) MCP Server → Response (via Gateway) ``` ### 4.4. Fine-Grained Access Control (FGAC) **Scope Types:** - **UI Scopes**: Registry management permissions - `mcp-registry-admin`: Full administrative access - `mcp-registry-user`: Limited user access - `mcp-registry-developer`: Service registration and management - `mcp-registry-operator`: Operational access without registration - **Server Scopes**: MCP server access - `mcp-servers-unrestricted/read`: Read all servers - `mcp-servers-unrestricted/execute`: Execute all tools - `mcp-servers-restricted/read`: Limited read access - `mcp-servers-restricted/execute`: Limited execute access **Methods vs Tools:** - **MCP Methods**: Protocol operations (`initialize`, `tools/list`, `tools/call`) - **Individual Tools**: Specific functions within servers **Example Access Control:** ```yaml # User can list tools but only execute specific ones mcp-servers-restricted/execute: - server: fininfo methods: - tools/list # Can list all tools - tools/call # Can call tools tools: - get_stock_aggregates # But only these specific tools - print_stock_data ``` **Validation Logic:** 1. Input Validation: Validate server name, method, tool name, user scopes 2. Scope Iteration: Check each user scope for matching permissions 3. Server Matching: Find server configurations that match the requested server 4. Method Validation: Check if the requested method is allowed 5. Tool Validation: For `tools/call`, validate specific tool permissions 6. Access Decision: Grant access if any scope allows the operation **Group Mappings:** ```yaml group_mappings: mcp-registry-admin: - mcp-registry-admin # UI permissions - mcp-servers-unrestricted/read # Server read access - mcp-servers-unrestricted/execute # Server execute access mcp-registry-user: - mcp-registry-user # Limited UI permissions - mcp-servers-restricted/read # Limited server access ``` **Note**: All group names and scope names are completely customizable by administrators. Names must be configured consistently in both the Identity Provider (IdP) and `scopes.yml` configuration file. ## 4.5. Agent-to-Agent (A2A) Protocol Integration The MCP Gateway supports Agent-to-Agent (A2A) communication, enabling AI agents to securely register themselves and their capabilities with the central registry, creating a self-managed agent ecosystem. ### 4.5.1. A2A Agent Architecture ``` Agent Application (AI Code) ↓ M2M Token (Keycloak Service Account) ┌─────────────────────────────────────┐ │ Agent Registry API (/api/agents) │ │ - POST /api/agents/register │ │ - GET /api/agents │ │ - GET /api/agents/{path} │ │ - PUT /api/agents/{path} │ │ - DELETE /api/agents/{path} │ │ - POST /api/agents/{path}/toggle │ └─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ Agent State Management │ │ - registry/agents/agent_state.json │ │ - registry/agents/{name}.json │ └─────────────────────────────────────┘ ``` ### 4.5.2. Agent Registration Flow **Step 1: Agent Authentication** - Agent obtains M2M token from Keycloak service account - Tokens expire in 5 minutes and must be regenerated before use - Token validation includes expiration checks via JWT payload decoding **Step 2: Agent Registration** - Agent calls POST `/api/agents/register` with: - Agent metadata (name, description, version) - Protocol version (e.g., "1.0") - Agent skills/capabilities (MCP tools provided by agent) - Security configuration (bearer tokens, oauth) - Visibility settings (public/private) - Trust level (verified/unverified) **Step 3: Agent Access Control** - Agent permissions defined in `auth_server/scopes.yml` - Three-tier structure: 1. **UI-Scopes**: Agent registry permissions (list_agents, get_agent, publish_agent, modify_agent, delete_agent) 2. **Group Mappings**: Maps Keycloak groups to scope names 3. **Individual group scopes**: Detailed agent and MCP server access **Step 4: Agent CRUD Operations** - CREATE: Register new agent with skills - READ: Retrieve agent metadata and capabilities - UPDATE: Modify agent description, tags, skills - DELETE: Remove agent from registry - TOGGLE: Enable/disable agent availability ### 4.5.3. Agent Access Control Example **Scopes Configuration (auth_server/scopes.yml):** ```yaml UI-Scopes: mcp-registry-admin: list_agents: - all # Admin sees all agents get_agent: - all publish_agent: - all modify_agent: - all delete_agent: - all registry-users-lob1: list_agents: - /code-reviewer # LOB1 sees specific agents - /test-automation get_agent: - /code-reviewer - /test-automation group_mappings: mcp-registry-admin: - mcp-registry-admin registry-users-lob1: - registry-users-lob1 ``` **Agent Permissions Table:** ``` Agent | Group | Can List | Can Get | Can Publish | Can Modify | Can Delete ------|-------|----------|---------|-------------|------------|---------- admin | admin | all | all | all | all | all lob1 | lob1 | 2 agents | 2 | own agents | own | own agents lob2 | lob2 | 2 agents | 2 | own agents | own | own agents ``` ### 4.5.4. Agent State Management **Agent State File (registry/agents/agent_state.json):** ```json { "agents": { "/code-reviewer": { "path": "/code-reviewer", "name": "Code Reviewer Agent", "enabled": true, "registered_at": "2024-11-09T14:45:00Z", "last_modified": "2024-11-09T14:50:00Z" }, "/data-analysis": { "path": "/data-analysis", "name": "Data Analysis Agent", "enabled": true, "registered_at": "2024-11-09T15:00:00Z" } } } ``` **Individual Agent File (registry/agents/code-reviewer.json):** ```json { "protocol_version": "1.0", "name": "Code Reviewer Agent", "description": "Reviews code for quality and best practices", "path": "/code-reviewer", "url": "https://agent.example.com", "skills": [ { "id": "review-python", "name": "Python Code Review", "description": "Reviews Python code", "parameters": { "code_snippet": {"type": "string"} } } ], "security": ["bearer"], "tags": ["code-review", "qa"], "visibility": "public", "trust_level": "verified" } ``` ### 4.5.5. Agent CLI & Testing **Agent CRUD Test Script (tests/agent_crud_test.sh):** - Demonstrates all CRUD operations (create, read, update, delete, toggle) - Includes token validation with JWT expiration checking - Tests agent state persistence - Verifies agent re-registration after deletion - Supports custom token paths and environment variables **Usage:** ```bash # Generate fresh credentials ./credentials-provider/generate_creds.sh # Run CRUD tests with default token bash tests/agent_crud_test.sh # Run with custom token path bash tests/agent_crud_test.sh /path/to/token.json # Run with environment variable TOKEN_FILE=/path/to/token.json bash tests/agent_crud_test.sh ``` ### 4.5.6. Access Control Testing **LOB Bot Access Control Tests (tests/run-lob-bot-tests.sh):** - Tests MCP service access permissions (Tests 1-6) - Tests agent registry API permissions (Tests 7-14) - Validates bot-specific agent visibility - Ensures agents can only access permitted agents - Confirms admin sees all agents **Test Coverage:** ``` Part 1: MCP Service Access (6 tests) - Tests 1-6: Verify bots can only call permitted MCP services Part 2: Agent Registry API (8 tests) - Tests 7-9: LOB1 agent access control - Tests 10-12: LOB2 agent access control - Tests 13-14: Admin agent access (see all) ``` **Running Access Control Tests:** ```bash # Generate tokens for all bots ./keycloak/setup/generate-agent-token.sh admin-bot ./keycloak/setup/generate-agent-token.sh lob1-bot ./keycloak/setup/generate-agent-token.sh lob2-bot # Run 14 comprehensive tests bash tests/run-lob-bot-tests.sh ``` ### 4.5.7. Code Structure for A2A Agent Management **CLI Module (cli/agent_mgmt.py):** - Agent registration and lifecycle management - CRUD operations on agent metadata - Argument validation and error handling - Structured logging and status reporting **Key Functions:** - `register_agent()`: Register new agent in registry - `get_agent()`: Retrieve agent metadata - `update_agent()`: Modify agent settings - `delete_agent()`: Remove agent from registry - `toggle_agent()`: Enable/disable agent - `list_agents()`: Get agents filtered by permissions **API Routes (registry/api/agent_routes.py):** - Implements Agent Registry REST API endpoints - Access control enforcement via scopes - Token validation and authentication - Agent state persistence and management **Data Models (registry/models/):** - Agent schema validation - Skill/capability definitions - Security configuration models - State tracking models **Implementation Notes:** - JWT token validation with expiration checks (5-minute TTL) - Base64 padding for JWT payload decoding - Proper HTTP status codes (200, 201, 204, 400, 403, 404) - Comprehensive error messages for debugging - Agent state file updates on registration/deletion - File-based persistence for agent metadata ## 5. Dynamic Tool Discovery ### 5.1. Overview Traditional AI agents are limited to pre-configured tools. Dynamic Tool Discovery enables agents to: 1. Discover new tools through natural language queries 2. Automatically find relevant tools from hundreds of MCP servers 3. Dynamically invoke discovered tools without prior configuration 4. Expand capabilities on-demand based on user requests ### 5.2. How It Works ``` 1. Natural Language Query → Agent receives user request 2. Semantic Search → intelligent_tool_finder uses sentence transformers 3. FAISS Index Search → Searches embeddings of all registered tools 4. Relevance Ranking → Returns tools ranked by semantic similarity 5. Tool Invocation → Agent uses invoke_mcp_tool with discovered info ``` ### 5.3. Architecture Components **IMPORTANT:** Vector search architecture depends on storage backend: - **File Backend (LEGACY - DEPRECATED)**: Uses FAISS IndexFlatIP - **MongoDB CE/DocumentDB (PRODUCTION)**: Uses hybrid search (BM25 + native vector k-NN) **Discovery Layer (Modern - DocumentDB/MongoDB):** - **Embedding Providers**: Flexible provider selection (sentence-transformers, OpenAI, LiteLLM with 100+ models) - **BM25 Text Search**: Keyword matching on server/tool names and descriptions - **Vector k-NN Search**: Semantic similarity using embeddings (384-1536 dimensions) - **Hybrid Search**: Weighted combination of BM25 and vector search results - **Native Vector Indexing**: DocumentDB HNSW or MongoDB application-level k-NN - **Tool Metadata**: Server information, tool schemas, descriptions, embeddings **Discovery Layer (Legacy - File Backend):** - **FAISS Index**: In-memory vector similarity search (DEPRECATED) - **Sentence Transformer**: all-MiniLM-L6-v2 model (384 dimensions) only - **Cosine Similarity**: IndexFlatIP for vector search - **Limitations**: File-based, no hybrid search, single embedding provider **Embedding Provider Options:** 1. **Sentence Transformers (Local)**: Default all-MiniLM-L6-v2 (384 dimensions), runs locally 2. **OpenAI Embeddings**: text-embedding-ada-002 (1536 dimensions), requires API key 3. **LiteLLM**: 100+ embedding models via unified interface (OpenAI, Cohere, Bedrock, etc.) 4. **Amazon Bedrock Titan**: titan-embed-text-v2:0 (1024 dimensions), native AWS integration **Hybrid Search Strategy (DocumentDB/MongoDB):** ``` Final Score = (BM25_Weight × BM25_Score) + (Vector_Weight × Vector_Score) Default weights: BM25_Weight=0.3, Vector_Weight=0.7 ``` **Key Technologies:** - DocumentDB/MongoDB (native vector search with HNSW indexing) - BM25 algorithm for text matching - Multiple embedding providers (sentence-transformers, OpenAI, LiteLLM, Bedrock) - Hybrid scoring with configurable weights - MCP Protocol - FAISS (legacy file backend only - DEPRECATED) ### 5.4. Usage Patterns **Pattern 1: Direct Developer Usage** ```python # Discover tools tools = await intelligent_tool_finder( natural_language_query="what time is it in Tokyo", session_cookie="your_session_cookie_here" ) # Use discovered tool result = await invoke_mcp_tool( mcp_registry_url="https://registry.com/mcpgw/sse", server_name=tools[0]["service_path"], tool_name=tools[0]["tool_name"], arguments={"tz_name": "Asia/Tokyo"}, auth_token=auth_token, ... ) ``` **Pattern 2: Agent Integration (Autonomous)** ```python # Agent has access to both tools as available capabilities # 1. intelligent_tool_finder - discovers tools # 2. invoke_mcp_tool - executes discovered tools # Agent autonomously: # - Identifies need for specialized tool # - Calls intelligent_tool_finder with description # - Receives tool information and usage instructions # - Calls invoke_mcp_tool with discovered tool details ``` ### 5.5. API Reference **intelligent_tool_finder** Parameters: - `natural_language_query` (str, required): Query describing the task - `username` (str, optional): Username for authentication - `password` (str, optional): Password for authentication - `session_cookie` (str, optional): Session cookie for authentication - `top_k_services` (int, optional): Number of top services to consider (default: 3) - `top_n_tools` (int, optional): Number of best matching tools to return (default: 1) Returns: ```python [ { "tool_name": "current_time_by_timezone", "tool_parsed_description": { "main": "Get current time for a specific timezone", "parameters": {...} }, "tool_schema": {...}, "service_path": "/currenttime", "service_name": "Current Time Server", "overall_similarity_score": 0.89 } ] ``` ### 5.6. Implementation Details **FAISS Index Creation:** 1. Tool Metadata Collection: Gathers descriptions, schemas, server info 2. Text Embedding: Creates vector embeddings using sentence transformers 3. Index Building: Constructs FAISS index for fast similarity search 4. Automatic Updates: Refreshes index when servers are added/modified **Semantic Search Process:** 1. Embed the natural language query 2. Search FAISS for top_k_services 3. Collect tools from top services 4. Embed all candidate tool descriptions 5. Calculate cosine similarity and rank **Performance Optimizations:** - Lazy Loading: FAISS index and models loaded on-demand - Caching: Embeddings and metadata cached - Async Processing: Embedding operations in separate threads - Memory Efficiency: Float32 precision for embeddings ## 6. Registry API & Management ### 6.1. Registry REST API **Authentication Required**: Session cookie obtained via `/login` endpoint **Core Endpoints:** - `GET /login` - Display login form - `POST /login` - Authenticate user, create session cookie (required first step) - `POST /logout` - Invalidate session - `POST /register` - Register new MCP service - `POST /toggle/{service_path}` - Enable/disable service - `POST /edit/{service_path}` - Update service details - `GET /api/server_details/{service_path}` - Get service details - `GET /api/tools/{service_path}` - Get service tools - `POST /api/refresh/{service_path}` - Trigger health check/tool discovery - `WebSocket /ws/health_status` - Real-time health status updates **Registration Parameters:** - `name`: Display name - `description`: Service description - `path`: URL path (e.g., `/weather`) - `proxy_pass_url`: Backend URL - `tags`: Comma-separated tags - `num_tools`: Number of tools - `num_stars`: Star rating - `is_python`: Python-based flag - `license`: License information ### 6.2. Anthropic MCP Registry API Compatibility **Full compatibility** with Anthropic's MCP Registry REST API specification (v0.1): **Endpoints:** - `GET /v0.1/servers` - List all servers (with pagination) - `GET /v0.1/servers/{server_name}/versions` - List server versions - `GET /v0.1/servers/{server_name}/versions/{version}` - Get version details **Authentication**: JWT Bearer token (short-lived, typically 5-15 minutes) **Token Generation:** 1. Login to Registry Web Interface 2. Generate JWT Token from UI 3. Tokens stored in `.oauth-tokens/mcp-registry-api-tokens-YYYY-MM-DD.json` 4. Use Bearer token in Authorization header **Example Usage:** ```bash ACCESS_TOKEN=$(cat token-file.json | jq -r '.tokens.access_token') # List servers curl -X GET "http://localhost/v0.1/servers?limit=10" \ -H "Authorization: Bearer $ACCESS_TOKEN" # Get server versions curl -X GET "http://localhost/v0.1/servers/io.mcpgateway%2Fatlassian/versions" \ -H "Authorization: Bearer $ACCESS_TOKEN" # Get server details curl -X GET "http://localhost/v0.1/servers/io.mcpgateway%2Fatlassian/versions/latest" \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` **Import from Anthropic Registry:** ```bash # Import curated servers from Anthropic's registry ./cli/import_anthropic_servers.py --select ``` ### 6.3. Service Management CLI **Location**: `cli/mcp_services.py` **Key Commands:** ```bash # List all servers uv run python cli/mcp_services.py list-servers # Health check uv run python cli/mcp_services.py health-check --server-name atlassian # Create group uv run python cli/mcp_services.py create-group \ --group-name mcp-servers-finance \ --scope-suffix read # Assign server to group uv run python cli/mcp_services.py assign-server-to-group \ --server-name fininfo \ --group-name mcp-servers-finance # User management uv run python cli/mcp_services.py create-user \ --username john.doe \ --groups mcp-servers-finance # List groups uv run python cli/mcp_services.py list-groups ``` ### 6.4. Rating System **Community-driven quality assessment** for both MCP servers and AI agents using a 5-star rating system. **Key Features:** - **5-Star Rating Scale**: Users rate servers/agents from 1 to 5 stars - **Interactive UI Widget**: Visual star rating interface in the web dashboard - **CLI Support**: Submit ratings via command-line tools - **Aggregate Ratings**: Weighted average with individual rating details - **One Rating Per User**: Users can update their rating, but only one rating per entity - **Rotating Buffer**: Maximum 100 ratings per entity (FIFO replacement) - **Anonymous Tracking**: Ratings linked to username but not publicly displayed - **Real-time Updates**: Aggregate rating updates immediately after submission **Rating API Endpoints:** ```bash # Submit a rating for a server POST /api/v2/servers/{server_path}/rating { "rating": 5, # 1-5 stars "username": "john.doe" } # Submit a rating for an agent POST /api/v2/agents/{agent_path}/rating { "rating": 4, "username": "jane.smith" } # Get server with rating details GET /api/v2/servers/{server_path} # Returns: { "server_path": "/example-server", "name": "Example Server", "aggregate_rating": 4.5, # Weighted average "rating_count": 42, "rating_details": [ {"user": "john.doe", "rating": 5}, {"user": "jane.smith", "rating": 4} # ... up to 100 ratings ] } # Get agent with rating details GET /api/v2/agents/{agent_path} # Similar structure with aggregate_rating, rating_count, rating_details ``` **CLI Rating Submission:** ```bash # Rate a server uv run python cli/mcp_services.py rate-server \ --server-path /example-server \ --rating 5 \ --username john.doe # Rate an agent uv run python cli/mcp_services.py rate-agent \ --agent-path /code-reviewer \ --rating 4 \ --username jane.smith ``` **Rating Logic:** - **Validation**: Ratings must be integers between 1 and 5 (inclusive) - **Update Behavior**: Submitting a new rating for the same user updates their existing rating - **Rotating Buffer**: Once 100 ratings are reached, oldest ratings are removed (FIFO) - **Aggregate Calculation**: Simple arithmetic mean of all ratings - **Service Location**: `registry/services/rating_service.py` **Web UI Integration:** - Interactive 5-star widget on server/agent detail pages - Visual feedback showing aggregate rating and count - Click to submit rating (requires authentication) - Real-time update on submission **Use Cases:** - **Quality Discovery**: Find highly-rated servers/agents for specific tasks - **Community Feedback**: Share experiences with tools and agents - **Filtering**: Sort search results by rating - **Reputation**: Build trust in community-contributed servers/agents ## 7. Configuration & Setup ### 7.1. Main Environment Configuration **File**: `.env` (Project root) **Core Variables:** - `REGISTRY_URL`: Public URL of registry - `AUTH_PROVIDER`: `keycloak` or `cognito` - `AWS_REGION`: AWS region for services **Keycloak Configuration (if AUTH_PROVIDER=keycloak):** - `KEYCLOAK_URL`: Internal URL (`http://keycloak:8080`) - `KEYCLOAK_EXTERNAL_URL`: External URL for browser access - `KEYCLOAK_REALM`: Realm name (`mcp-gateway`) - `KEYCLOAK_ADMIN`, `KEYCLOAK_ADMIN_PASSWORD`: Admin credentials - `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET`: Web client credentials (auto-generated) - `KEYCLOAK_M2M_CLIENT_ID`, `KEYCLOAK_M2M_CLIENT_SECRET`: M2M credentials (auto-generated) **Cognito Configuration (if AUTH_PROVIDER=cognito):** - `COGNITO_USER_POOL_ID`: User Pool ID - `COGNITO_CLIENT_ID`: App Client ID - `COGNITO_CLIENT_SECRET`: App Client Secret - `COGNITO_DOMAIN`: Cognito domain (optional) **Getting Keycloak Credentials:** ```bash # Initialize Keycloak and generate credentials cd keycloak/setup ./init-keycloak.sh # Retrieve existing credentials ./get-all-client-credentials.sh ``` ### 7.2. OAuth Environment Configuration **File**: `credentials-provider/oauth/.env` **Ingress Authentication:** ```bash # Keycloak KEYCLOAK_URL=https://mcpgateway.ddns.net KEYCLOAK_REALM=mcp-gateway KEYCLOAK_M2M_CLIENT_ID=mcp-gateway-m2m KEYCLOAK_M2M_CLIENT_SECRET=ZJqbsamnQs79hbUbkJLB... # OR Cognito INGRESS_OAUTH_USER_POOL_ID=us-east-1_vm1115QSU INGRESS_OAUTH_CLIENT_ID=5v2rav1v93... INGRESS_OAUTH_CLIENT_SECRET=1i888fnolv6k5sa1b8s5k839pdm... ``` **Egress Authentication (Multiple Providers):** ```bash # Pattern: EGRESS_OAUTH_CLIENT_ID_N, EGRESS_OAUTH_CLIENT_SECRET_N EGRESS_OAUTH_CLIENT_ID_1=your_atlassian_client_id EGRESS_OAUTH_CLIENT_SECRET_1=your_atlassian_client_secret EGRESS_OAUTH_REDIRECT_URI_1=http://localhost:8080/callback EGRESS_PROVIDER_NAME_1=atlassian EGRESS_MCP_SERVER_NAME_1=atlassian ``` ### 7.3. OAuth Providers Configuration **File**: `auth_server/oauth2_providers.yml` **Keycloak Provider:** ```yaml keycloak: display_name: "Keycloak" client_id: "${KEYCLOAK_CLIENT_ID}" client_secret: "${KEYCLOAK_CLIENT_SECRET}" auth_url: "${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/auth" token_url: "${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token" user_info_url: "${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/userinfo" logout_url: "${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/logout" scopes: ["openid", "email", "profile"] groups_claim: "groups" enabled: true ``` **Amazon Cognito Provider:** ```yaml cognito: display_name: "Amazon Cognito" client_id: "${COGNITO_CLIENT_ID}" client_secret: "${COGNITO_CLIENT_SECRET}" auth_url: "https://${COGNITO_DOMAIN}.auth.${AWS_REGION}.amazoncognito.com/oauth2/authorize" token_url: "https://${COGNITO_DOMAIN}.auth.${AWS_REGION}.amazoncognito.com/oauth2/token" user_info_url: "https://${COGNITO_DOMAIN}.auth.${AWS_REGION}.amazoncognito.com/oauth2/userInfo" logout_url: "https://${COGNITO_DOMAIN}.auth.${AWS_REGION}.amazoncognito.com/logout" scopes: ["openid", "email", "profile"] groups_claim: "cognito:groups" enabled: true ``` **Microsoft Entra ID (Azure AD) Provider:** ```yaml entra_id: display_name: "Microsoft Entra ID" client_id: "${ENTRA_CLIENT_ID}" client_secret: "${ENTRA_CLIENT_SECRET}" tenant_id: "${ENTRA_TENANT_ID}" auth_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/authorize" token_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/token" user_info_url: "https://graph.microsoft.com/v1.0/me" logout_url: "https://login.microsoftonline.com/${ENTRA_TENANT_ID}/oauth2/v2.0/logout" scopes: ["openid", "email", "profile", "User.Read"] groups_claim: "groups" enabled: true # Enterprise features conditional_access: true # Support for conditional access policies mfa_enabled: true # Multi-factor authentication microsoft_365_integration: true # Integration with M365 environments ``` **References:** - Entra ID Setup Guide: `docs/entra-id-setup.md` - Keycloak Setup Guide: `docs/keycloak-setup.md` - Cognito Setup Guide: `docs/cognito-setup.md` ### 7.4. Scopes Configuration **File**: `auth_server/scopes.yml` **Group Mappings:** ```yaml group_mappings: mcp-registry-admin: - mcp-registry-admin - mcp-servers-unrestricted/read - mcp-servers-unrestricted/execute mcp-registry-user: - mcp-registry-user - mcp-servers-restricted/read ``` **UI Scopes:** ```yaml UI-Scopes: mcp-registry-admin: list_service: [all] register_service: [all] health_check_service: [all] toggle_service: [all] modify_service: [all] ``` **Server Scopes:** ```yaml mcp-servers-restricted/execute: - server: fininfo methods: - initialize - tools/list - tools/call tools: - get_stock_aggregates - print_stock_data ``` ### 7.5. Credential Generation **Quick Start:** ```bash # Configure environment cp .env.example .env cp credentials-provider/oauth/.env.example credentials-provider/oauth/.env # Edit both .env files with your credentials # Generate all credentials ./credentials-provider/generate_creds.sh # Available options: # --all # Run all authentication flows (default) # --ingress-only # Only MCP Gateway authentication # --egress-only # Only external provider authentication # --agentcore-only # Only AgentCore token generation # --keycloak-only # Only Keycloak token generation # --provider google # Specify provider for egress auth # --verbose # Enable debug logging ``` **Generated Configuration Files:** - `.oauth-tokens/vscode_mcp.json` - VS Code MCP configuration - `.oauth-tokens/mcp.json` - Roocode/Claude Code configuration - `.oauth-tokens/ingress.json` - Ingress tokens - `.oauth-tokens/egress.json` - Egress tokens - `.oauth-tokens/agent-{name}-m2m-token.json` - Agent-specific tokens ### 7.6. Keycloak Setup **Initial Setup:** ```bash cd keycloak/setup ./init-keycloak.sh ``` **This creates:** - `mcp-gateway` realm - Web and M2M clients with configurations - Required groups (`mcp-servers-unrestricted`, `mcp-servers-restricted`) - Group mappers for JWT token claims - Initial admin and test users **Service Account Management:** ```bash # Create individual agent service account ./setup-agent-service-account.sh --agent-id sre-agent --group mcp-servers-unrestricted # Create shared M2M service account ./setup-m2m-service-account.sh ``` **Token Generation:** ```bash # Generate M2M token for ingress uv run python credentials-provider/token_refresher.py # Generate agent-specific token uv run python credentials-provider/token_refresher.py --agent-id sre-agent ``` ## 8. Observability & Monitoring ### 8.1. Dual-Path Metrics System **Architecture:** ``` Auth Server Middleware → Metrics Service API → Dual Path: ├─> SQLite Database (detailed storage) └─> OpenTelemetry (Prometheus/Grafana) ``` **Database Tables:** - `auth_metrics`: Authentication requests and validation - `tool_metrics`: Tool execution details (calls, methods, client info) - `discovery_metrics`: Tool discovery/search queries - `metrics`: Raw metrics data (all types) - `api_keys`: API key management for metrics service ### 8.2. Accessing SQLite Metrics **Connect to Database:** ```bash # Via container docker compose exec metrics-db sh sqlite3 /var/lib/sqlite/metrics.db # Or copy locally docker compose cp metrics-db:/var/lib/sqlite/metrics.db ./metrics.db sqlite3 ./metrics.db ``` **Sample Queries:** **Authentication Success Rate:** ```sql SELECT server, COUNT(*) as total, SUM(success) as successful, ROUND(100.0 * SUM(success) / COUNT(*), 2) as success_pct, ROUND(AVG(duration_ms), 2) as avg_ms FROM auth_metrics GROUP BY server ORDER BY total DESC; ``` **Tool Usage Summary:** ```sql SELECT tool_name, COUNT(*) as calls, SUM(success) as successful, ROUND(AVG(duration_ms), 2) as avg_ms, COUNT(DISTINCT client_name) as unique_clients FROM tool_metrics GROUP BY tool_name ORDER BY calls DESC; ``` **Slowest Tool Executions:** ```sql SELECT tool_name, server_name, ROUND(duration_ms, 2) as duration_ms, datetime(timestamp) as time, success FROM tool_metrics ORDER BY duration_ms DESC LIMIT 20; ``` ### 8.3. OpenTelemetry Metrics **Prometheus Endpoint**: `http://localhost:9465/metrics` **Available Metrics:** - `mcp_auth_requests_total` - Counter of authentication requests - `mcp_auth_request_duration_seconds` - Histogram of auth request durations - `mcp_tool_executions_total` - Counter of tool executions - `mcp_tool_execution_duration_seconds` - Histogram of tool execution durations - `mcp_tool_discovery_total` - Counter of discovery requests - `mcp_tool_discovery_duration_seconds` - Histogram of discovery durations - `mcp_protocol_latency_seconds` - Histogram of protocol flow latencies - `mcp_health_checks_total` - Counter of health checks **OTLP Export Configuration:** ```bash # In .env OTEL_OTLP_ENDPOINT=http://otel-collector:4318 ``` ### 8.4. OpenTelemetry Collector Setup **Add to docker-compose.yml:** ```yaml otel-collector: image: otel/opentelemetry-collector-contrib:latest command: ["--config=/etc/otel-collector-config.yaml"] volumes: - ./config/otel-collector-config.yaml:/etc/otel-collector-config.yaml ports: - "4318:4318" # OTLP HTTP receiver - "4317:4317" # OTLP gRPC receiver - "8889:8889" # Prometheus exporter metrics restart: unless-stopped ``` **Basic Configuration (config/otel-collector-config.yaml):** ```yaml receivers: otlp: protocols: http: endpoint: 0.0.0.0:4318 processors: batch: timeout: 10s send_batch_size: 1024 exporters: prometheus: endpoint: "0.0.0.0:8889" namespace: mcp_gateway logging: loglevel: info service: pipelines: metrics: receivers: [otlp] processors: [batch] exporters: [prometheus, logging] ``` **Cloud Backends:** - **AWS CloudWatch**: `awscloudwatch` exporter - **Datadog**: `datadog` exporter with API key - **New Relic**: `otlphttp/newrelic` with license key - **Grafana Cloud**: `otlphttp/grafanacloud` with auth - **Honeycomb**: `otlphttp/honeycomb` with API key ### 8.5. Grafana Dashboards **Access**: `http://localhost:3000` (admin/admin) **Pre-configured Dashboards:** 1. **Authentication Metrics**: Success rates, request volume, error codes, response times 2. **Tool Execution Metrics**: Most used tools, client distribution, success rates, performance trends 3. **Discovery Metrics**: Search query volume, result counts, performance breakdown 4. **System Health**: Overall request volume, error rates, performance percentiles (p50, p95, p99) **Sample PromQL Queries:** ```promql # Authentication success rate rate(mcp_auth_requests_total{success="true"}[5m]) / rate(mcp_auth_requests_total[5m]) # Average tool execution duration by server rate(mcp_tool_execution_duration_seconds_sum[5m]) / rate(mcp_tool_execution_duration_seconds_count[5m]) # Top 5 most used tools topk(5, sum by (tool_name) (rate(mcp_tool_executions_total[5m]))) # 95th percentile request duration histogram_quantile(0.95, rate(mcp_auth_request_duration_seconds_bucket[5m])) ``` ### 8.6. Monitoring Best Practices **Key Metrics to Monitor:** - Authentication Success Rate: >95% - Tool Execution Success Rate: >90% - Average Response Time: <100ms (auth), <500ms (tools) - Error Rate: <5% - Discovery Query Performance: <50ms (embedding time) **Alert Configuration:** - Authentication failure rate >10% - Tool execution errors >5% - Response time p95 >1000ms - Discovery query failures **Data Retention:** - SQLite database: 90 days (configurable via `METRICS_RETENTION_DAYS`) - Prometheus: 200 hours (configurable in `prometheus.yml`) ## 9. Installation & Deployment ### 9.1. Quick Start (5 Minutes) ```bash # 1. Clone and setup git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry # 2. Configure environment cp .env.example .env # Edit .env with your credentials # 3. Generate authentication credentials ./credentials-provider/generate_creds.sh # 4. Install prerequisites curl -LsSf https://astral.sh/uv/install.sh | sh sudo apt-get update && sudo apt-get install -y docker.io docker-compose # 5. Deploy ./build_and_run.sh # 6. Access registry open http://localhost:7860 ``` ### 9.2. Pre-built Images (Instant Setup) **Benefits:** No build time, no Node.js required, no frontend compilation, consistent tested images ```bash # Step 1: Clone and setup git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry cp .env.example .env # Step 2: Download embeddings model hf download sentence-transformers/all-MiniLM-L6-v2 --local-dir ${HOME}/mcp-gateway/models/all-MiniLM-L6-v2 # Step 3: Configure environment # Complete: Initial Environment Configuration guide export DOCKERHUB_ORG=mcpgateway # Step 4: Deploy with pre-built images ./build_and_run.sh --prebuilt # Step 5: Initialize Keycloak # Complete: Initialize Keycloak Configuration guide # Step 6: Access registry open http://localhost:7860 # Step 7: Create first agent account # Complete: Create Your First AI Agent Account guide # Step 8: Restart auth server docker-compose down auth-server && docker-compose rm -f auth-server && docker-compose up -d auth-server # Step 9: Test the setup # Complete: Testing with mcp_client.py and agent.py guide ``` ### 9.3. Amazon EC2 Deployment **System Requirements:** - **Minimum (Development)**: t3.large (2 vCPU, 8GB RAM), 20GB SSD - **Recommended (Production)**: t3.2xlarge (8 vCPU, 32GB RAM), 50GB+ SSD **Detailed Setup:** ```bash # 1. Create directories mkdir -p ${HOME}/mcp-gateway/{servers,auth_server,secrets,logs} cp -r registry/servers ${HOME}/mcp-gateway/ cp auth_server/scopes.yml ${HOME}/mcp-gateway/auth_server/ # 2. Configure environment cp .env.example .env nano .env # Configure required values # 3. Generate credentials cp credentials-provider/oauth/.env.example credentials-provider/oauth/.env nano credentials-provider/oauth/.env ./credentials-provider/generate_creds.sh # 4. Install dependencies curl -LsSf https://astral.sh/uv/install.sh | sh source $HOME/.local/bin/env uv venv --python 3.14 && source .venv/bin/activate sudo apt-get update sudo apt-get install --reinstall docker.io -y sudo apt-get install -y docker-compose sudo usermod -a -G docker $USER newgrp docker # 5. Deploy services ./build_and_run.sh ``` ### 9.4. HTTPS Configuration **Option A: Let's Encrypt** ```bash # Install certbot sudo apt-get install -y certbot # Get certificate sudo certbot certonly --standalone -d your-domain.com # Copy certificates mkdir -p ${HOME}/mcp-gateway/ssl/{certs,private} cp /etc/letsencrypt/live/your-domain/fullchain.pem ${HOME}/mcp-gateway/ssl/certs/ cp /etc/letsencrypt/live/your-domain/privkey.pem ${HOME}/mcp-gateway/ssl/private/ chmod 644 ${HOME}/mcp-gateway/ssl/certs/fullchain.pem chmod 600 ${HOME}/mcp-gateway/ssl/private/privkey.pem # Deploy ./build_and_run.sh ``` **Certificate Renewal (Cron):** ```bash sudo crontab -e # Add: 0 0,12 * * * certbot renew --quiet && cp /etc/letsencrypt/live/your-domain/fullchain.pem ${HOME}/mcp-gateway/ssl/certs/fullchain.pem && cp /etc/letsencrypt/live/your-domain/privkey.pem ${HOME}/mcp-gateway/ssl/private/privkey.pem && docker compose restart registry ``` ### 9.5. Amazon EKS Deployment For production Kubernetes deployments, see [EKS deployment guide](https://github.com/aws-samples/amazon-eks-machine-learning-with-terraform-and-kubeflow/tree/master/examples/agentic/mcp-gateway-microservices). **Key Benefits:** - High Availability: Multi-AZ pod distribution - Auto Scaling: Horizontal pod autoscaling based on metrics - Service Mesh: Istio integration for advanced traffic management - Observability: Native CloudWatch and Prometheus integration - Security: Pod security policies and network policies ### 9.6. AWS ECS Deployment (RECOMMENDED FOR PRODUCTION) **Production-grade infrastructure** using AWS ECS Fargate with complete Terraform automation. This is the **most mature deployment option** for AWS production environments. **Key Benefits:** - **Fully Managed Compute**: ECS Fargate eliminates server management - **Multi-AZ High Availability**: Services deployed across multiple availability zones - **Auto-Scaling**: Task-level autoscaling based on CPU/memory metrics - **Native AWS Integration**: CloudWatch, Secrets Manager, DocumentDB, Aurora - **Infrastructure as Code**: Complete Terraform configuration in `terraform/aws-ecs/` - **SSL/TLS**: Automatic certificate provisioning via AWS Certificate Manager - **Cost Optimized**: Aurora Serverless v2 auto-scales from 0.5 to 2 ACUs **Architecture Components:** - **Compute**: ECS Fargate tasks (serverless containers) - **Load Balancers**: - Main ALB (internet-facing) for Registry and Auth Server - Keycloak ALB for identity management - **Data Layer**: - **Amazon DocumentDB**: Primary storage with native HNSW vector search - **Amazon Aurora PostgreSQL Serverless v2**: User data and sessions - **Networking**: VPC with public/private subnets across 2 AZs - **Security**: AWS Secrets Manager, SSL/TLS, security groups - **Observability**: CloudWatch Logs, CloudWatch Alarms, SNS notifications **Quick Start:** ```bash # Prerequisites: Domain with Route53 hosted zone, AWS credentials cd terraform/aws-ecs # Step 1: Configure variables cp terraform.tfvars.example terraform.tfvars nano terraform.tfvars # Set domain_name, aws_region, etc. # Step 2: Initialize Terraform terraform init # Step 3: Review deployment plan terraform plan # Step 4: Deploy infrastructure (~60-90 minutes) terraform apply # Step 5: Get outputs terraform output -json > terraform-outputs.json ``` **Post-Deployment:** ```bash # View service URLs terraform output registry_url terraform output keycloak_url # Monitor logs ./scripts/view-cloudwatch-logs.sh registry ./scripts/view-cloudwatch-logs.sh auth-server # Check service health aws ecs describe-services --cluster mcp-gateway-ecs-cluster \ --services mcp-gateway-v2-registry # Scale services aws ecs update-service --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry --desired-count 3 ``` **Configuration:** - **Deployment Time**: 60-90 minutes for initial deployment - **Prerequisites**: - Domain with Route53 hosted zone (any registrar supported) - AWS account with AdministratorAccess or specific IAM permissions - Terraform >= 1.5.0, AWS CLI >= 2.0, Docker >= 20.10 - **Region Support**: All commercial AWS regions - **Regional Domains**: Automatic subdomain creation (e.g., `registry.us-east-1.your.domain`) **Documentation:** - Complete guide: `terraform/aws-ecs/README.md` - Architecture diagrams: `terraform/aws-ecs/img/` - Troubleshooting: `terraform/aws-ecs/README.md#troubleshooting` - Cost optimization: `terraform/aws-ecs/README.md#cost-optimization` **Comparison: ECS vs EKS vs EC2** | Feature | ECS Fargate | EKS | EC2 | |---------|-------------|-----|-----| | **Maturity** | ✅ Production-ready | ⚠️ Preview/Experimental | ✅ Stable | | **Management** | Fully managed | Managed control plane | Self-managed | | **IaC** | Complete Terraform | Partial | Docker Compose | | **Scaling** | Auto-scaling tasks | Horizontal pod autoscaling | Manual/script-based | | **Cost** | Pay-per-task | Higher (node costs) | Fixed instance costs | | **Setup Time** | 60-90 min | 2-4 hours | 30-60 min | | **Best For** | Production deployments | K8s-native workloads | Development/testing | ### 9.7. Post-Installation Verification ```bash # Check service status docker-compose ps docker-compose logs -f # Test web interface open http://localhost:7860 # Test authentication cd tests ./mcp_cmds.sh ping # Configure AI assistants ./credentials-provider/generate_creds.sh cp .oauth-tokens/vscode-mcp.json ~/.vscode/settings.json ``` ## 10. Testing & Integration ### 10.1. MCP Testing Tools **Test Script**: `tests/mcp_cmds.sh` ```bash # Test basic connectivity ./tests/mcp_cmds.sh basic # Test MCP connectivity with authentication ./tests/mcp_cmds.sh ping # List available tools (filtered by permissions) ./tests/mcp_cmds.sh list # Call specific tools ./tests/mcp_cmds.sh call debug_auth_context '{}' ./tests/mcp_cmds.sh call intelligent_tool_finder '{"natural_language_query": "quantum"}' ./tests/mcp_cmds.sh call currenttime current_time_by_timezone '{"tz_name": "America/New_York"}' # Test against different gateway URLs GATEWAY_URL=https://your-domain.com/mcp ./tests/mcp_cmds.sh ping ./tests/mcp_cmds.sh --url https://your-domain.com/mcp list ``` **Python MCP Client**: `cli/mcp_client.py` ```bash # Core operations uv run python cli/mcp_client.py --operation ping uv run python cli/mcp_client.py --operation list uv run python cli/mcp_client.py --operation call --tool-name get_stock_aggregates --arguments '{"ticker": "AAPL"}' ``` **Python Agent**: `agents/agent.py` ```bash # Full-featured agent with AI capabilities uv run python agents/agent.py --user-query "What time is it in Tokyo?" ``` ### 10.2. Anthropic API Testing **Test Script**: `cli/test_anthropic_api.py` ```bash # Run all tests uv run python cli/test_anthropic_api.py --token-file /path/to/token-file.json # Test specific endpoint uv run python cli/test_anthropic_api.py \ --token-file /path/to/token-file.json \ --test list-servers \ --limit 10 # Get server details uv run python cli/test_anthropic_api.py \ --token-file /path/to/token-file.json \ --test get-server \ --server-name io.mcpgateway/atlassian ``` ### 10.3. Credential Validation ```bash # Validate all OAuth configurations cd credentials-provider ./generate_creds.sh --verbose # Test specific authentication flows ./generate_creds.sh --ingress-only --verbose # MCP Gateway auth ./generate_creds.sh --egress-only --verbose # External provider auth ./generate_creds.sh --agentcore-only --verbose # AgentCore auth ``` ### 10.4. Testing Architecture **IMPORTANT:** The project uses **pytest as the primary testing framework**. Shell script tests in `tests/mcp_cmds.sh` are **DEPRECATED** and maintained only for backward compatibility. **Test Categories:** - **Unit Tests** (`tests/unit/`): Test individual functions and classes in isolation - **Integration Tests** (`tests/integration/`): Test multiple components working together - **E2E Tests** (`tests/e2e/`): End-to-end workflow tests **Running Tests:** ```bash # Run all tests with parallel execution (8 workers) uv run pytest tests/ -n 8 # Expected results (as of 2026-01-06): # - 701 passed # - 57 skipped # - Coverage: ~39.50% # - Execution time: ~30 seconds # Run tests serially (slower, less memory) uv run pytest tests/ # Run specific test categories uv run pytest tests/unit/ # Unit tests only uv run pytest tests/integration/ # Integration tests only uv run pytest tests/e2e/ # E2E tests only # Run with coverage report uv run pytest tests/ -n 8 --cov=registry --cov-report=term-missing # Run specific test file uv run pytest tests/unit/test_server_service.py -v # Stop at first failure uv run pytest tests/ -n 8 -x ``` **Test Configuration:** - **Location**: `pyproject.toml` lines 78-114 - **Minimum Coverage**: 35% (configured in pyproject.toml) - **Test Markers**: unit, integration, e2e, auth, servers, search, health, core, repositories, slow, requires_models - **Async Mode**: Auto-detected for async tests - **Reports**: HTML report at `tests/reports/report.html`, JSON at `tests/reports/report.json` **Test Prerequisites:** ```bash # MongoDB must be running for integration tests docker ps | grep mongo # Should show: mcp-mongodb running on 0.0.0.0:27017 # Environment is auto-configured: # - DOCUMENTDB_HOST=localhost # - STORAGE_BACKEND=mongodb-ce # - directConnection=true (single-node MongoDB) ``` **Test Best Practices:** 1. **Repository Reset Pattern** (for test isolation): ```python @pytest.fixture(autouse=True) async def reset_repository(): """Reset repository state before each test.""" repo = await get_server_repository() await repo.reset() # Clear all data yield # Cleanup handled by TestClient teardown ``` 2. **Memory Management** (avoid OOM on EC2): ```python # Use -n 8 for parallel tests only if you have enough memory # Otherwise run serially: uv run pytest tests/ # For CI/CD pipelines, use moderate parallelism: pytest tests/ -n 2 ``` 3. **Fixture Cleanup**: ```python # Always cleanup resources in fixtures @pytest.fixture async def test_client(): async with AsyncClient(app=app, base_url="http://test") as client: yield client # Automatic cleanup via async context manager ``` 4. **Mock External Dependencies**: ```python # Mock security scanner, embeddings, external APIs @pytest.fixture(autouse=True) def mock_security_scanner(): mock_service = MagicMock() mock_service.get_scan_config.return_value = SecurityScanConfig(enabled=False) with patch("registry.api.server_routes.security_scanner_service", mock_service): yield mock_service ``` **Shell Script Tests (DEPRECATED):** ```bash # tests/mcp_cmds.sh - Use pytest instead # These are maintained for backward compatibility only ./tests/mcp_cmds.sh basic ./tests/mcp_cmds.sh ping ./tests/mcp_cmds.sh list ``` **Continuous Integration:** - Tests run automatically via GitHub Actions - Triggered on PR creation and pushes to main/develop - Configuration: `.github/workflows/registry-test.yml` - All unit tests must pass (no failures allowed) **Test Documentation:** - Comprehensive guide: `docs/testing/README.md` - Writing tests: `docs/testing/WRITING_TESTS.md` - Test maintenance: `docs/testing/MAINTENANCE.md` - Memory management: `docs/testing/memory-management.md` **Coverage Requirements:** - Minimum: 35% overall coverage (enforced) - Target: 80% coverage for new features - Coverage report: `htmlcov/index.html` (generated after test run) ## 11. Security Features ### 11.1. Security Scanning **IMPORTANT:** The MCP Gateway & Registry provides **TWO SEPARATE security scanning systems** - one for MCP servers and one for A2A agents. #### MCP Server Security Scanning **Integrated Vulnerability Detection** with [Cisco AI Defence MCP Scanner](https://github.com/cisco-ai-defense/mcp-scanner): - Automated security scanning during server registration - Periodic registry-wide scans - YARA pattern matching for malicious code detection - Detailed security reports with vulnerability details, severity assessments, and remediation recommendations - Automatic protection: Servers with security issues automatically disabled - Compliance ready: Security audit trails and vulnerability tracking **Configuration:** ```bash # Enable MCP server scanning SECURITY_SCAN_ENABLED=true SECURITY_SCAN_ON_REGISTRATION=true BLOCK_UNSAFE_SERVERS=true ``` **Service Location:** `registry/services/security_scanner.py` **Scanner Integration:** `registry/api/server_routes.py` (automatic during registration) #### A2A Agent Security Scanning **Integrated Agent Vulnerability Detection** with [Cisco AI Defense A2A Scanner](https://github.com/cisco-ai-defense/a2a-scanner): - Automated security scanning during agent registration - Multi-analyzer support: YARA pattern matching, LLM-based analysis, static analysis - Agent card validation and security assessment - Configurable blocking policies for unsafe agents - Detailed scan reports with security findings and recommendations - Optional "security-pending" tagging for agents awaiting scan results **Configuration:** ```bash # Enable A2A agent scanning AGENT_SECURITY_SCAN_ENABLED=true AGENT_SECURITY_SCAN_ON_REGISTRATION=true AGENT_SECURITY_BLOCK_UNSAFE_AGENTS=true AGENT_SECURITY_ANALYZERS=yara,llm # Comma-separated list AGENT_SECURITY_SCAN_TIMEOUT=300 A2A_SCANNER_LLM_API_KEY=your-api-key # For LLM-based analysis AGENT_SECURITY_ADD_PENDING_TAG=true # Add security-pending tag during scan ``` **Service Location:** `registry/services/agent_scanner.py` **Scanner Integration:** `registry/api/agent_routes.py` (automatic during registration) **Scan Storage:** Results stored in security scan repository for audit trails **Analyzers:** - **YARA**: Pattern-based malicious code detection - **LLM**: AI-powered security analysis using Azure OpenAI - **Static**: Code structure and configuration analysis **Security Scan Results API:** ```bash # Get agent scan results GET /api/v2/agents/{agent_path}/security-scan # Get server scan results GET /api/v2/servers/{server_path}/security-scan ``` ### 11.2. Security Best Practices **Token Storage:** - Tokens stored with `600` permissions in `.oauth-tokens/` - Never commit `.env` files to version control - Use secure secret management for production **Network Security:** - HTTPS-only for production - PKCE where supported - SSL/TLS certificate management **Access Control:** - Follow principle of least privilege - Regular group membership reviews - Scope-based authorization at server, method, and tool levels **Token Lifecycle:** - Ingress tokens: 1-hour expiry, auto-refresh via client credentials - Egress tokens: Provider-specific, refresh tokens where available - Automated refresh service for continuous monitoring **Audit & Compliance:** - Complete audit trails (Nginx access logs + auth server logs + IdP logs) - Comprehensive metrics for compliance reporting - Security event tracking and monitoring ### 11.3. Token Refresh Service **Automated Token Refresh Service** provides: - Continuous monitoring of all OAuth tokens for expiration - Proactive refresh before tokens expire (configurable 1-hour buffer) - Automatic MCP config generation for coding assistants - Service discovery for both OAuth and no-auth services - Background operation with comprehensive logging **Start the service:** ```bash ./start_token_refresher.sh ``` **Generated configurations:** - `.oauth-tokens/vscode_mcp.json` - VS Code extensions - `.oauth-tokens/mcp.json` - Claude Code/Roocode - Standard configuration format for custom MCP clients ## 12. Enterprise Features ### 12.1. AI Coding Assistants Integration **Supported Assistants:** - VS Code with MCP extension - Cursor - Claude Code (Roo Code) - Cline **Setup:** ```bash # Generate configurations ./credentials-provider/generate_creds.sh # VS Code cp .oauth-tokens/vscode-mcp.json ~/.vscode/settings.json # Roo Code cp .oauth-tokens/mcp.json ~/.vscode/mcp-settings.json ``` ### 12.2. Federation with External Registries **Federation Architecture** allows you to import and manage servers/agents from multiple external registries through a unified interface with centralized authentication and access control. **Supported Federation Sources:** | Source | Type | Description | Visual Tag | Auth Required | |--------|------|-------------|------------|---------------| | **Anthropic MCP Registry** | MCP Servers | Official Anthropic curated servers | `ANTHROPIC` (purple) | No | | **Workday ASOR** | AI Agents | Agent System of Record | `ASOR` (orange) | Yes (OAuth) | **Key Benefits:** - **Centralized Management**: Single interface for all servers/agents regardless of source - **Unified Authentication**: Consistent auth/authz across all federated entities - **Visual Tagging**: Color-coded tags show federation source (purple for Anthropic, orange for ASOR) - **Automatic Synchronization**: Scheduled sync to keep federation up-to-date - **Selective Import**: Import all or specific entities from each source - **Audit Trail**: Complete tracking of federated entity provenance #### Anthropic MCP Registry Integration **Features:** - Import servers from [Anthropic's official MCP Registry](https://registry.modelcontextprotocol.io) - Full REST API compatibility - No authentication required - Purple `ANTHROPIC` visual tag on federated servers - Unified access through your gateway with centralized auth **Configuration:** ```bash # Enable Anthropic federation in .env ANTHROPIC_REGISTRY_ENABLED=true # Federation config file: ~/mcp-gateway/federation.json { "anthropic": { "enabled": true, "endpoint": "https://registry.modelcontextprotocol.io", "servers": [] # Empty = import all, or specify: [{"name": "server-name"}] } } ``` **Import Servers:** ```bash # Interactive selection ./cli/import_anthropic_servers.py --select # Import all available servers ./cli/import_anthropic_servers.py --all # Import specific servers (via federation.json) { "anthropic": { "servers": [ {"name": "io.github.jgador/websharp"}, {"name": "modelcontextprotocol/filesystem"}, {"name": "modelcontextprotocol/brave-search"} ] } } ``` **Service Location:** `registry/services/federation/anthropic.py` #### Workday ASOR Integration **Features:** - Import AI agents from Workday Agent System of Record - OAuth 2.0 authentication with token refresh - Orange `ASOR` visual tag on federated agents - Enterprise agent lifecycle management - Scheduled synchronization with ASOR backend **Prerequisites:** 1. Valid Workday tenant with ASOR enabled 2. OAuth credentials (Client ID and Secret) 3. Access token with "Agent System of Record" scope **Configuration:** ```bash # Add to .env ASOR_CLIENT_ID=your_client_id ASOR_CLIENT_SECRET=your_client_secret ASOR_TENANT_NAME=your_tenant_name ASOR_HOSTNAME=your_host_name ASOR_ACCESS_TOKEN=your_oauth_token # Generated via get_asor_token.py # Federation config: ~/mcp-gateway/federation.json { "asor": { "enabled": true, "endpoint": "https://wcpdev-services1.wd103.myworkday.com/ccx/api/asor/v1/awsasor_wcpdev1", "auth_env_var": "ASOR_ACCESS_TOKEN", "agents": [] # Empty = import all } } ``` **Get OAuth Token:** ```bash # Run token generator (interactive OAuth flow) python3 get_asor_token.py # Follow prompts to: # 1. Authorize via browser # 2. Complete OAuth flow # 3. Receive access token for .env ``` **Service Location:** `registry/services/federation/asor.py` #### Federation Synchronization **Automatic Sync:** - Periodic synchronization keeps federated entities up-to-date - Configurable sync schedule - Handles entity updates, additions, and removals - Maintains federation metadata (source, sync timestamp) **Manual Sync:** ```bash # Trigger manual federation sync uv run python -m registry.services.federation.sync # Sync specific source uv run python -m registry.services.federation.sync --source anthropic uv run python -m registry.services.federation.sync --source asor ``` **Documentation:** Complete federation guide at `docs/federation.md` ### 12.3. Token Vending Service **Capabilities:** - JWT token generation for M2M authentication - Service account provisioning - Automated token lifecycle management - Integration with identity providers **Usage:** ```bash # Generate token for agent uv run python credentials-provider/token_refresher.py --agent-id sre-agent # Check generated token cat .oauth-tokens/agent-sre-agent-m2m-token.json ``` ## 13. Troubleshooting ### 13.1. Common Issues **Services won't start:** ```bash # Check Docker daemon sudo systemctl status docker # Check environment variables cat .env | grep -v SECRET # View detailed logs docker-compose logs --tail=50 ``` **Authentication failures:** ```bash # Verify Cognito/Keycloak configuration aws cognito-idp describe-user-pool --user-pool-id YOUR_POOL_ID # Test credential generation cd credentials-provider && ./generate_creds.sh --verbose ``` **Network connectivity issues:** ```bash # Check port availability sudo netstat -tlnp | grep -E ':(80|443|7860|8080)' # Test internal services curl -v http://localhost:7860/health ``` **Permission denied errors:** - Check user's Cognito/Keycloak group memberships - Verify scope mappings in `scopes.yml` - Ensure tool names match exactly - Regenerate tokens after group changes **HTTPS not working:** ```bash # Check certificate files ls -la ${HOME}/mcp-gateway/ssl/certs/ ${HOME}/mcp-gateway/ssl/private/ # Check container logs docker compose logs registry | grep -i ssl # Verify port 443 sudo netstat -tlnp | grep 443 ``` ### 13.2. Debugging Tools **Enable Verbose Logging:** ```python # In auth_server/server.py or relevant module logging.basicConfig(level=logging.DEBUG) ``` **Authentication Event Logging:** ```python def log_auth_event(event_type: str, username: str = None, details: dict = None): logger.info(f"AUTH_EVENT: {event_type}", extra={ 'username': username, 'event_type': event_type, 'details': details, 'timestamp': datetime.utcnow().isoformat() }) ``` **Health Check:** ```bash curl http://localhost:7860/health ``` ## 14. Code Organization & Patterns **CRITICAL:** The MCP Gateway & Registry follows strict architectural patterns to ensure maintainability and consistency. Follow these patterns religiously - violations will break the application architecture. ### 14.1. Layered Architecture (MANDATORY) **The application MUST follow this layered architecture:** ``` API Routes → Services → Repositories → Storage Backends ``` **Each layer has specific responsibilities:** 1. **API Routes** (`registry/api/`): - Handle HTTP requests and responses - Validate request parameters - Call service layer methods - Return HTTP status codes and responses - **NEVER access repositories directly** 2. **Service Layer** (`registry/services/`): - Implement business logic - Coordinate between multiple repositories - Handle complex operations and workflows - Validate business rules - **ALWAYS use factory pattern to get repositories** 3. **Repository Layer** (`registry/repositories/`): - Abstract data access via interfaces (`interfaces.py`) - Provide consistent API across all storage backends - Handle data persistence and retrieval - Implement search and querying logic 4. **Storage Backends** (`registry/repositories/{backend}/`): - Implement repository interfaces for specific storage - File backend: `registry/repositories/file/` (DEPRECATED) - MongoDB CE: `registry/repositories/mongodb/` - DocumentDB: `registry/repositories/documentdb/` ### 14.2. Factory Pattern (REQUIRED) **ALWAYS use the factory pattern** to obtain repository instances. NEVER instantiate repositories directly. **Correct Usage:** ```python from registry.repositories.factory import ( get_server_repository, get_agent_repository, get_scope_repository, get_search_repository, get_security_scan_repository ) # In service layer async def some_service_method(): server_repo = await get_server_repository() servers = await server_repo.get_all_servers() return servers ``` **Wrong Usage (ANTIPATTERN):** ```python # ❌ NEVER DO THIS - Direct instantiation from registry.repositories.documentdb.server_repository import DocumentDBServerRepository server_repo = DocumentDBServerRepository() # WRONG! # ❌ NEVER DO THIS - Direct repository access from routes from registry.api.server_routes import router @router.get("/servers") async def list_servers(): server_repo = await get_server_repository() # WRONG! Use service layer return await server_repo.get_all_servers() ``` ### 14.3. Repository Abstraction **All storage backends MUST provide identical behavior** through polymorphism. Code using repositories should work with ANY backend without modification. **Abstract Base Classes:** - `BaseServerRepository` (registry/repositories/interfaces.py) - `BaseAgentRepository` - `BaseScopeRepository` - `BaseSearchRepository` - `BaseSecurityScanRepository` **Implementation Contract:** ```python # All implementations must provide the same methods with the same signatures class DocumentDBServerRepository(BaseServerRepository): async def get_all_servers(self, namespace: Optional[str] = None) -> List[dict]: # DocumentDB-specific implementation pass class FileServerRepository(BaseServerRepository): async def get_all_servers(self, namespace: Optional[str] = None) -> List[dict]: # File-specific implementation (DEPRECATED) pass ``` ### 14.4. Critical Antipatterns (DO NOT DO THIS) **❌ 1. Direct Repository Access from Routes** ```python # WRONG - Route directly accessing repository @router.get("/servers/{server_path}") async def get_server(server_path: str): repo = await get_server_repository() # ANTIPATTERN! return await repo.get_server(server_path) # CORRECT - Route calls service layer @router.get("/servers/{server_path}") async def get_server(server_path: str): return await server_service.get_server(server_path) ``` **❌ 2. Direct Repository Instantiation** ```python # WRONG - Bypasses factory pattern from registry.repositories.documentdb.server_repository import DocumentDBServerRepository repo = DocumentDBServerRepository() # ANTIPATTERN! # CORRECT - Use factory from registry.repositories.factory import get_server_repository repo = await get_server_repository() ``` **❌ 3. Hardcoding Storage Backend** ```python # WRONG - Hardcoded backend selection if storage_type == "documentdb": repo = DocumentDBServerRepository() elif storage_type == "file": repo = FileServerRepository() # CORRECT - Factory handles backend selection repo = await get_server_repository() # Uses STORAGE_BACKEND env var ``` **❌ 4. Skipping Service Layer** ```python # WRONG - Route contains business logic @router.post("/servers") async def create_server(server: ServerRegistration): repo = await get_server_repository() # Business logic here - ANTIPATTERN! if server.status == "active": await repo.create_server(server) return {"status": "created"} # CORRECT - Business logic in service layer @router.post("/servers") async def create_server(server: ServerRegistration): return await server_service.create_server(server) ``` **❌ 5. Implementing Custom Vector Search** ```python # WRONG - Custom vector search implementation def custom_vector_search(query: str): # Don't implement your own vector search! pass # CORRECT - Use repository abstraction search_repo = await get_search_repository() results = await search_repo.hybrid_search(query) ``` ### 14.5. Code Organization Checklist Before submitting code, verify: - [ ] **No direct repository access from routes** - All routes call service layer - [ ] **Factory pattern used** - No direct repository instantiation - [ ] **Service layer exists** - Business logic in `registry/services/` - [ ] **Repository interfaces** - New repositories extend abstract base classes - [ ] **Backend agnostic** - Code works with any storage backend - [ ] **No hardcoded backends** - Use `STORAGE_BACKEND` environment variable - [ ] **Separation of concerns** - Each layer handles only its responsibility - [ ] **Polymorphism** - All repository implementations provide identical APIs ### 14.6. Design Documentation **Architecture References:** - Database Abstraction Layer: `docs/design/database-abstraction-layer.md` - Storage Architecture: `docs/design/storage-architecture-mongodb-documentdb.md` - Repository Pattern: `registry/repositories/interfaces.py` (docstrings) **When to Read Design Docs:** - Before implementing new repository backend - Before adding new data access patterns - When confused about layering - When reviewing code for architecture compliance ## 15. Additional Resources ### 15.1. Documentation Links - [Complete Setup Guide](docs/complete-setup-guide.md) - Step-by-step from scratch on AWS EC2 - [Installation Guide](docs/installation.md) - Complete setup instructions for EC2 and EKS - [Configuration Reference](docs/configuration.md) - Environment variables and settings - [Authentication Guide](docs/auth.md) - OAuth and identity provider integration - [Keycloak Integration](docs/keycloak-integration.md) - Enterprise identity with agent audit trails - [Amazon Cognito Setup](docs/cognito.md) - Step-by-step IdP configuration - [Fine-Grained Access Control](docs/scopes.md) - Permission management and security - [Dynamic Tool Discovery](docs/dynamic-tool-discovery.md) - Autonomous agent capabilities - [AI Coding Assistants Setup](docs/ai-coding-assistants-setup.md) - VS Code, Cursor, Claude Code integration - [API Reference](docs/registry_api.md) - Programmatic registry management - [Anthropic Registry API](docs/anthropic_registry_api.md) - REST API compatibility - [Service Management](docs/service-management.md) - Server lifecycle and operations - [Token Refresh Service](docs/token-refresh-service.md) - Automated token refresh and lifecycle management - [Observability Guide](docs/OBSERVABILITY.md) - Metrics, monitoring, and OpenTelemetry setup - [Troubleshooting Guide](docs/faq/index.md) - Common issues and solutions - [Architectural Decision](docs/design/architectural-decision-reverse-proxy-vs-application-layer-gateway.md) - Reverse proxy vs application layer gateway - [Registry Auth Architecture](docs/registry-auth-architecture.md) - Internal authentication mechanisms ### 14.2. Community & Support **Getting Help:** - [FAQ & Troubleshooting](docs/faq/index.md) - Common questions and solutions - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - Bug reports and feature requests - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - Community support and ideas **Contributing:** - [Contributing Guide](CONTRIBUTING.md) - How to contribute code and documentation - [Code of Conduct](CODE_OF_CONDUCT.md) - Community guidelines and expectations - [Security Policy](SECURITY.md) - Responsible disclosure process ### 14.3. License This project is licensed under the Apache-2.0 License - see the [LICENSE](LICENSE) file for details. --- *Part of the [Agentic Community](https://github.com/agentic-community) ecosystem - building the future of AI-driven development.* ================================================ FILE: docs/logging.md ================================================ # Centralized Application Logging The MCP Gateway Registry provides centralized application log collection, storage, and retrieval across all service instances. Logs from both the `registry` and `auth-server` services are written to a shared MongoDB/DocumentDB collection, enabling cross-pod log queries through the admin API and the Settings UI. ## Architecture ``` registry / auth-server | v RotatingFileHandler (always active, local file rotation) | v MongoDBLogHandler (optional, buffered writes via background thread) | v MongoDB / DocumentDB (application_logs collection with TTL index) | v Admin REST API (/api/admin/logs) | v Log Viewer UI (Settings > Application Logs) ``` ### Components - **RotatingFileHandler**: Always active. Writes logs to local files with size-based rotation (default 50 MB, 5 backups). No external dependencies. - **MongoDBLogHandler**: Optional. Buffers log records in memory and flushes them to MongoDB periodically (default every 5 seconds or every 50 records). Uses a background daemon thread to avoid blocking the async event loop. - **TTL Index**: MongoDB automatically deletes log documents older than the configured retention period. - **Admin API**: Three endpoints for querying, exporting, and discovering log metadata. All require admin authentication. - **Log Viewer UI**: Filter by service, level, hostname, time range, and message content. Supports pagination and JSONL export. ## Configuration Parameters All parameters use the `APP_LOG_` prefix. The centralized (MongoDB) storage parameters use `APP_LOG_CENTRALIZED_`. | Parameter | Description | Default | |-----------|-------------|---------| | `APP_LOG_CENTRALIZED_ENABLED` | Write application logs to MongoDB/DocumentDB for centralized retrieval | `true` | | `APP_LOG_CENTRALIZED_TTL_DAYS` | Days to retain log entries before automatic deletion | `1` | | `APP_LOG_MAX_BYTES` | Maximum size per log file in bytes before rotation | `52428800` (50 MB) | | `APP_LOG_BACKUP_COUNT` | Number of rotated backup files to keep | `5` | | `APP_LOG_MONGODB_BUFFER_SIZE` | Number of log records to buffer before flushing to MongoDB | `50` | | `APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS` | Seconds between periodic flushes to MongoDB | `5.0` | | `APP_LOG_LEVEL` | Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL | `INFO` | | `APP_LOG_EXCLUDED_LOGGERS` | Comma-separated logger names to exclude from MongoDB writes | `uvicorn.access,httpx,pymongo,motor` | ## Deployment Configuration ### Docker Compose Set parameters in your `.env` file: ```bash # Enable centralized logging (default: true) APP_LOG_CENTRALIZED_ENABLED=true # Retain logs for 1 day (default: 1) APP_LOG_CENTRALIZED_TTL_DAYS=1 # Optional overrides APP_LOG_MAX_BYTES=52428800 APP_LOG_BACKUP_COUNT=5 APP_LOG_MONGODB_BUFFER_SIZE=50 APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS=5.0 APP_LOG_LEVEL=INFO APP_LOG_EXCLUDED_LOGGERS=uvicorn.access,httpx,pymongo,motor ``` All `APP_LOG_*` variables are passed to both the `registry` and `auth-server` services in `docker-compose.yml`, `docker-compose.podman.yml`, and `docker-compose.prebuilt.yml`. ### Terraform / ECS Set parameters in `terraform.tfvars`: ```hcl # Enable centralized logging (default: true) app_log_centralized_enabled = true # Retain logs for 1 day (default: 1) app_log_centralized_ttl_days = 1 # Optional overrides app_log_max_bytes = 52428800 app_log_backup_count = 5 app_log_mongodb_buffer_size = 50 app_log_mongodb_flush_interval_seconds = 5.0 app_log_level = "INFO" app_log_excluded_loggers = "uvicorn.access,httpx,pymongo,motor" ``` Variables are defined in `terraform/aws-ecs/variables.tf` and passed through to the ECS task definitions in `terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf`. Both the registry and auth-server containers receive these environment variables. ### Helm / EKS Set parameters in your values override file: ```yaml registry: app: appLogCentralizedEnabled: "true" appLogCentralizedTtlDays: "1" appLogMaxBytes: "52428800" appLogBackupCount: "5" appLogMongodbBufferSize: "50" appLogMongodbFlushIntervalSeconds: "5.0" appLogLevel: "INFO" appLogExcludedLoggers: "uvicorn.access,httpx,pymongo,motor" ``` Configuration is managed via dedicated ConfigMaps (`registry-app-log-config` and `auth-server-app-log-config`), mounted using `envFrom` in the deployment templates. In the umbrella chart (`mcp-gateway-registry-stack`), a YAML anchor (`&appLogConfig`) defines values once under the `registry.app` section and merges them into `auth-server.app` via `<<: *appLogConfig`, so you only need to set values in one place. ## Admin API Endpoints All endpoints require admin authentication and are rate-limited to 10 requests per 60 seconds per user. ### Query Logs ``` GET /api/admin/logs ``` Query parameters: | Parameter | Type | Description | |-----------|------|-------------| | `service` | string | Filter by service name (e.g., `registry`, `auth-server`) | | `level` | string | Minimum log level (`DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) | | `hostname` | string | Filter by pod/hostname | | `search` | string | Substring search in message (max 200 chars, regex-escaped) | | `start` | datetime | Start of time range (ISO 8601) | | `end` | datetime | End of time range (ISO 8601) | | `limit` | int | Max entries to return (1-10000, default 100) | | `offset` | int | Number of entries to skip (default 0) | ### Export Logs ``` GET /api/admin/logs/export ``` Streams logs as newline-delimited JSON (JSONL) for download. Accepts the same filter parameters as the query endpoint, with a higher limit (up to 50,000 entries). ### Log Metadata ``` GET /api/admin/logs/metadata ``` Returns available filter values: service names, hostnames, and log levels. ## Log Viewer UI Navigate to **Settings > Application Logs > Log Viewer** in the web UI. Features: - Filter by service, level, hostname, time range, and message content - Click any row to expand and view the full log message - Pagination (50 entries per page) - Download filtered results as JSONL ## Observability The `app_log_mongodb_flush_failures_total` Prometheus counter (labeled by `service`) tracks failed flush attempts. Use this metric to alert on write failures. ## Disabling Centralized Logging To disable MongoDB log storage while keeping file-based rotation active: ```bash APP_LOG_CENTRALIZED_ENABLED=false ``` When disabled, the admin API returns `503 Service Unavailable` and the Log Viewer UI shows an informational message. Local file rotation continues regardless of this setting. ## Prerequisites Centralized logging requires: - `STORAGE_BACKEND` set to `documentdb` or `mongodb-ce` - A running MongoDB/DocumentDB instance accessible by both services ================================================ FILE: docs/macos-setup-guide.md ================================================ # Complete macOS Setup Guide: MCP Gateway & Registry This guide provides a comprehensive, step-by-step walkthrough for setting up the MCP Gateway & Registry on macOS. Perfect for local development and testing. > **SECURITY WARNING** > > The examples in this document use placeholder credentials for demonstration purposes only. > **NEVER use these example values in production.** > > Always generate unique, secure credentials and store them in: > - AWS Secrets Manager (production) > - Environment variables (development) > - `.env` files (local only, never commit) ## Table of Contents 1. [Prerequisites](#1-prerequisites) 2. [Container Runtime Choice](#2-container-runtime-choice) 3. [Cloning and Initial Setup](#3-cloning-and-initial-setup) 4. [Environment Configuration](#4-environment-configuration) 5. [Starting Keycloak Services](#5-starting-keycloak-services) 6. [Keycloak Configuration](#6-keycloak-configuration) 7. [Create Test Agent](#7-create-test-agent) 8. [Starting All Services](#8-starting-all-services) 9. [Verification and Testing](#9-verification-and-testing) 10. [Podman Deployment](#10-podman-deployment) 11. [Troubleshooting](#11-troubleshooting) --- ## 1. Prerequisites ### System Requirements - **macOS**: 12.0 (Monterey) or later - **RAM**: At least 8GB (16GB recommended) - **Storage**: At least 10GB free space - **Administrator Access**: Sudo privileges required for Docker volume setup ### Required Software **Container Runtime (choose one):** - **Docker Desktop**: Install from https://www.docker.com/products/docker-desktop/ - Includes Docker Compose - Requires privileged port access - **Important**: Make sure Docker Desktop is running before proceeding! - **Podman Desktop** (Alternative, recommended for rootless): Install from https://podman-desktop.io/ or via Homebrew - Rootless container execution - No privileged port requirements - See [Podman Deployment](#10-podman-deployment) section below **Other Requirements:** - **Node.js**: Version 20.x LTS - Install from https://nodejs.org/ or via Homebrew (not needed with `--prebuilt` flag) - **Python**: Version 3.14+ - Install via Homebrew (`brew install python@3.14`) - **UV Package Manager**: Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` - **Git**: Usually pre-installed on macOS - **jq**: Install via Homebrew (`brew install jq`) --- ## 2. Container Runtime Choice Choose between Docker and Podman based on your needs: ### Docker (Default) ✅ Best for: Standard deployment, familiar workflow ✅ Uses privileged ports (80, 443) ✅ Access at `http://localhost` ⚠️ Requires Docker daemon running ### Podman (Rootless Alternative) ✅ Best for: Rootless deployment, no Docker daemon ✅ Uses non-privileged ports (8080, 8443) ✅ Access at `http://localhost:8080` ✅ More secure, no root access needed **This guide uses Docker by default**. For Podman-specific instructions, see [Section 10: Podman Deployment](#10-podman-deployment). --- ## 3. Cloning and Initial Setup ### Clone the Repository ```bash # Create workspace directory mkdir -p ~/workspace cd ~/workspace # Clone the repository git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry # Verify you're in the right directory ls -la # Should see: docker-compose.yml, .env.example, README.md, etc. ``` ### Setup Python Virtual Environment ```bash # Enable native TLS for enterprise Macs with corporate proxies/custom CA certificates # (harmless on personal Macs -- uses macOS system certificate store) export UV_NATIVE_TLS=true # Create and activate Python virtual environment uv sync source .venv/bin/activate # Verify virtual environment is active which python # Should show: /Users/[username]/workspace/mcp-gateway-registry/.venv/bin/python ``` --- ## 4. Environment Configuration ### Create Environment File ```bash # Copy the example environment file cp .env.example .env # Generate a secure SECRET_KEY SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(64))") echo "Generated SECRET_KEY: $SECRET_KEY" # Open .env file for editing nano .env ``` ### Configure Essential Settings In the `.env` file, make these changes: ```bash # Set authentication provider to Keycloak AUTH_PROVIDER=keycloak # Set auth server URL for local development # This URL must be accessible from your browser for OAuth redirects AUTH_SERVER_EXTERNAL_URL=http://localhost # Set secure passwords (CHANGE THESE!) KEYCLOAK_ADMIN_PASSWORD=your_secure_admin_password_here KEYCLOAK_DB_PASSWORD=your_secure_db_password_here # Set your generated SECRET_KEY SECRET_KEY=[paste-your-generated-key-here] # Leave other Keycloak settings as default for now KEYCLOAK_REALM=mcp-gateway KEYCLOAK_CLIENT_ID=mcp-gateway-web # Note: CLIENT_SECRET will be updated later after Keycloak initialization ``` **Important**: Choose strong, unique passwords and remember them - you'll need the admin password for Keycloak login! ### Download Required Embeddings Model The MCP Gateway requires a sentence-transformers model for intelligent tool discovery. Download it to the shared models directory: ```bash # Download the embeddings model (this may take a few minutes) huggingface-cli download sentence-transformers/all-MiniLM-L6-v2 --local-dir ${HOME}/mcp-gateway/models/all-MiniLM-L6-v2 # Verify the model was downloaded ls -la ${HOME}/mcp-gateway/models/all-MiniLM-L6-v2/ # You should see model files like model.safetensors, config.json, etc. ``` **Note**: This command automatically creates the necessary directory structure and downloads all required model files (~90MB). If you don't have `hf` command installed, install it first with `pip install huggingface_hub[cli]`. --- ## 5. Starting Keycloak Services ### Set Keycloak Passwords **Important**: These environment variables will override the values in your `.env` file. Use the SAME passwords you configured in Step 4! ```bash # Use the SAME passwords you set in the .env file in Step 4! # Replace these with your actual passwords from Step 4 # Note: Use single quotes to prevent issues with special characters export KEYCLOAK_ADMIN_PASSWORD='your-admin-password-here-from-env' export KEYCLOAK_DB_PASSWORD='your-db-password-here-from-env' # Verify they're set correctly echo "Admin Password: $KEYCLOAK_ADMIN_PASSWORD" echo "DB Password: $KEYCLOAK_DB_PASSWORD" ``` **Critical**: These passwords MUST match what you set in the `.env` file in Step 4. If they don't match, Keycloak initialization will fail! ### Start Database and Keycloak **With Docker:** ```bash # Start only the database and Keycloak services first docker compose up -d keycloak-db keycloak # Check if services are starting docker-compose ps # Monitor Keycloak logs until ready docker-compose logs -f keycloak # Wait for: "Keycloak 25.x.x started in xxxms" # Press Ctrl+C when you see this message ``` **Wait Time**: Allow 2-3 minutes for Keycloak to fully initialize. ### Verify Keycloak is Running ```bash # Test basic connectivity curl -s http://localhost:8080/realms/master | jq '.realm' # Should return: "master" # Check health status docker-compose ps keycloak # Should show "Up" status (may show "unhealthy" - this is normal for dev mode) ``` ### Fix macOS SSL Requirement (Critical Step) **Why this is needed on macOS**: Docker on macOS runs in a virtualized environment, which causes Keycloak to treat localhost requests as external network traffic. This triggers Keycloak's default security policy requiring HTTPS for external connections. ```bash # Configure Keycloak admin CLI (use your actual admin password) docker exec mcp-gateway-registry-keycloak-1 /opt/keycloak/bin/kcadm.sh config credentials --server http://localhost:8080 --realm master --user admin --password "${KEYCLOAK_ADMIN_PASSWORD}" # Disable SSL requirement for master realm docker exec mcp-gateway-registry-keycloak-1 /opt/keycloak/bin/kcadm.sh update realms/master -s sslRequired=NONE # Verify the fix worked curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/admin/" # Should return: 302 (redirect to login - this is correct) ``` **Important**: This step MUST be completed before running the init-keycloak.sh script, or the initialization will fail. --- ## 5. Keycloak Configuration ### Initialize Keycloak Configuration **Important**: This is a two-step process. The initialization script creates the realm and clients but does NOT save the credentials to files. ```bash # Make the setup script executable chmod +x keycloak/setup/init-keycloak.sh # Step 1: Run the Keycloak initialization ./keycloak/setup/init-keycloak.sh # Expected output: # ✓ Waiting for Keycloak to be ready... # ✓ Keycloak is ready! # ✓ Logged in to Keycloak # ✓ Created realm: mcp-gateway # ✓ Created clients: mcp-gateway-web and mcp-gateway-m2m # ... more success messages ... # ✓ Client secrets generated! # # IMPORTANT: The script will tell you to run get-all-client-credentials.sh # to retrieve and save the credentials. This is the next required step! # Step 2: Retrieve and save all client credentials (REQUIRED) chmod +x keycloak/setup/get-all-client-credentials.sh ./keycloak/setup/get-all-client-credentials.sh # This will: # - Connect to Keycloak and retrieve all client secrets # - Save credentials to .oauth-tokens/keycloak-client-secrets.txt # - Create individual JSON files: .oauth-tokens/.json # - Create individual env files: .oauth-tokens/.env # - Display a summary of all saved credentials # Expected output: # ✓ Admin token obtained # ✓ Found and saved: mcp-gateway-web # ✓ Found and saved: mcp-gateway-m2m # Files created in: .oauth-tokens/ ``` ### Fix SSL Requirement for mcp-gateway Realm **Important**: Now that the mcp-gateway realm is created, we need to disable SSL for it as well: ```bash # Configure Keycloak admin CLI (if session expired) docker exec mcp-gateway-registry-keycloak-1 /opt/keycloak/bin/kcadm.sh config credentials --server http://localhost:8080 --realm master --user admin --password "${KEYCLOAK_ADMIN_PASSWORD}" # Disable SSL requirement for the mcp-gateway realm docker exec mcp-gateway-registry-keycloak-1 /opt/keycloak/bin/kcadm.sh update realms/mcp-gateway -s sslRequired=NONE # Verify both realms are accessible curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/admin/" # Should return: 302 curl -s http://localhost:8080/realms/mcp-gateway | jq '.realm' # Should return: "mcp-gateway" ``` ### Retrieve Client Credentials ```bash # Make the credentials script executable chmod +x keycloak/setup/get-all-client-credentials.sh # Retrieve all client credentials ./keycloak/setup/get-all-client-credentials.sh ``` **Expected Output:** ``` Admin token obtained Found and saved: mcp-gateway-web (Secret: JyJzW00JeUBaCmH9Z5xtYDhE2MsGqOSv) Found and saved: mcp-gateway-m2m (Secret: iCjPsMLLmet124K8b7FCfcEcRJ9bx4Oo) Files created in: .oauth-tokens/ ``` ### Update Environment with Client Secrets ```bash # View the retrieved client secrets cat .oauth-tokens/keycloak-client-secrets.txt # Copy the secrets and update your .env file nano .env # Update these lines with the actual secret values: # KEYCLOAK_CLIENT_SECRET=[paste-web-client-secret-here] # KEYCLOAK_M2M_CLIENT_SECRET=[paste-m2m-client-secret-here] # Save and exit (Ctrl+X, then Y, then Enter) ``` --- ## 6. Create Your First AI Agent Account ```bash # Make the agent setup script executable chmod +x keycloak/setup/setup-agent-service-account.sh # Create a test agent with full access ./keycloak/setup/setup-agent-service-account.sh \ --agent-id test-agent \ --group mcp-servers-unrestricted # Create an agent for AI coding assistants (VS Code, cursor, etc.) ./keycloak/setup/setup-agent-service-account.sh \ --agent-id ai-coding-assistant \ --group mcp-servers-unrestricted # Create an agent with restricted access for registry operations ./keycloak/setup/setup-agent-service-account.sh \ --agent-id registry-operator \ --group mcp-servers-restricted # Note: The script does not display the credentials at the end. # Your Client ID is: agent-test-agent-m2m # Retrieve and save ALL client credentials (recommended): ./keycloak/setup/get-all-client-credentials.sh # This will: # - Retrieve credentials for ALL clients in the realm # - Save all credentials to .oauth-tokens/keycloak-client-secrets.txt # - Create individual JSON files: .oauth-tokens/.json # - Create individual env files: .oauth-tokens/.env # - Display a summary of all credentials saved # Or to get just one specific client: ./keycloak/setup/get-agent-credentials.sh agent-test-agent-m2m ``` **Important**: Save the Client ID and Client Secret shown in the output. You'll need these to authenticate your AI agents. ### Update .env File with Client Secrets **Critical Step**: After running `get-all-client-credentials.sh`, you MUST update your `.env` file with the retrieved client secrets: ```bash # View the retrieved client secrets cat .oauth-tokens/keycloak-client-secrets.txt # You'll see output like: # KEYCLOAK_CLIENT_ID=mcp-gateway-web # KEYCLOAK_CLIENT_SECRET=JyJzW00JeUBaCmH9Z5xtYDhE2MsGqOSv # # KEYCLOAK_M2M_CLIENT_ID=mcp-gateway-m2m # KEYCLOAK_M2M_CLIENT_SECRET=iCjPsMLLmet124K8b7FCfcEcRJ9bx4Oo # Update your .env file with these exact secret values nano .env # Find and update these lines with the actual secret values from above: # KEYCLOAK_CLIENT_SECRET=JyJzW00JeUBaCmH9Z5xtYDhE2MsGqOSv # KEYCLOAK_M2M_CLIENT_SECRET=iCjPsMLLmet124K8b7FCfcEcRJ9bx4Oo # Save and exit (Ctrl+X, then Y, then Enter) ``` **Note**: These secrets are auto-generated by Keycloak and are different each time you run `init-keycloak.sh`. Always use the latest values from `.oauth-tokens/keycloak-client-secrets.txt`. ### Generate Access Tokens for All Keycloak Users and Agents Generate access tokens for all configured agents and users: ```bash # Generate access tokens for all agents ./credentials-provider/keycloak/get_m2m_token.py --all-agents ``` This will create access token files (both `.json` and `.env` formats) for all Keycloak service accounts in the `.oauth-tokens/` directory. **Note**: If you want tokens to last longer than the default 5 minutes, see [Configure Token Lifetime](#configure-token-lifetime) before generating tokens. ### Verify Keycloak is Running Open a web browser and navigate to: ``` http://localhost:8080 ``` You should see the Keycloak login page. You can log in with: - Username: `admin` - Password: The password you set in KEYCLOAK_ADMIN_PASSWORD --- ## 7. Starting All Services ### Start Services with Pre-built Images **Important macOS Docker Volume Sharing**: On macOS, Docker Desktop only shares certain directories by default (like `/Users`, `/tmp`, `/private`). The `/opt` and `/var/log` directories we need are NOT shared by default, so we must create them with proper ownership for Docker containers to access them. **Note**: If you encounter permission issues, you may need to add `/opt` to Docker Desktop's shared directories: 1. Open Docker Desktop 2. Go to Settings > Resources > Virtual file shares 3. Add `/opt` to the list of shared directories 4. Click "Apply & Restart" ```bash # Create necessary directories # Using ${HOME}/mcp-gateway to avoid needing sudo permissions mkdir -p ${HOME}/mcp-gateway/{servers,models,auth_server,secrets/fininfo,logs,ssl} # Make build script executable chmod +x build_and_run.sh # Start all services using pre-built images (faster, no build required) ./build_and_run.sh --prebuilt # This will: # - Use pre-built container images from Docker registry # - Skip React frontend build (already included in images) # - Create necessary directories # - Start all services # - Much faster than building locally! ``` **Benefits of using `--prebuilt`:** - **Instant deployment**: No build time required - **No Node.js issues**: Pre-built frontend already included - **Consistent experience**: Same tested images for all users - **Bandwidth efficient**: Optimized, compressed images ### Verify All Services are Running ```bash # Check all services status docker-compose ps # Expected services (all should show "Up"): # - keycloak-db # - keycloak # - auth-server # - registry # - nginx (or similar proxy) # - currenttime-server # - fininfo-server # - mcpgw-server # - realserverfaketools-server ``` ### Monitor Service Logs ```bash # View all logs docker-compose logs -f # View specific service logs docker-compose logs -f auth-server docker-compose logs -f registry # Press Ctrl+C to exit log viewing ``` --- ## 8. Verification and Testing ### Test Web Interface 1. **Open your web browser** and navigate to: ``` http://localhost ``` 2. **Login Page**: You should see the MCP Gateway Registry login page 3. **Login with Keycloak**: Click "Login with Keycloak" and use: - Username: `admin` - Password: The password you set in KEYCLOAK_ADMIN_PASSWORD ### Test API Access ```bash # Test registry health curl http://localhost/health # Expected: {"status":"healthy","timestamp":"..."} # Test Keycloak realm curl http://localhost:8080/realms/mcp-gateway | jq '.realm' # Expected: "mcp-gateway" ``` ### Test Python MCP Client ```bash # Activate virtual environment source .venv/bin/activate # Load agent credentials source .oauth-tokens/agent-test-agent-m2m.env # Test connectivity uv run cli/mcp_client.py ping # Expected output: # ✓ M2M authentication successful # Session established: [session-id] # {"jsonrpc": "2.0", "id": 2, "result": {}} # List available tools uv run cli/mcp_client.py list # Test a simple tool uv run cli/mcp_client.py --url http://localhost/currenttime/mcp call --tool current_time_by_timezone --args '{"tz_name":"America/New_York"}' ``` ### Test Admin Console ```bash # Access Keycloak admin console open http://localhost:18080/admin/ # Login with: # Username: admin # Password: The password you set in KEYCLOAK_ADMIN_PASSWORD # You should see the Keycloak admin interface # Navigate to: mcp-gateway realm > Clients # Verify: mcp-gateway-web and mcp-gateway-m2m clients exist ``` --- ## 10. Podman Deployment This section provides complete instructions for deploying MCP Gateway & Registry using **Podman** instead of Docker on macOS. Podman offers rootless container execution without requiring privileged port access. ### Why Podman? - ✅ **Rootless Execution**: No sudo or root access required - ✅ **No Privileged Ports**: Uses ports 8080/8443 instead of 80/443 - ✅ **Enhanced Security**: Better container isolation - ✅ **No Daemon**: Unlike Docker, Podman doesn't require a background daemon - ✅ **Docker-Compatible**: Similar CLI commands and Compose support ### Installation **Option 1: Podman Desktop (Recommended)** ```bash # Install via Homebrew brew install podman-desktop # Launch Podman Desktop from Applications # Or download from: https://podman-desktop.io/ ``` **Option 2: Podman CLI Only** ```bash # Install Podman brew install podman # Install additional tools brew install podman-compose ``` ### Initialize Podman Machine Podman on macOS runs containers in a lightweight Linux VM: ```bash # Initialize Podman machine with adequate resources podman machine init --cpus 4 --memory 8192 --disk-size 50 # Start the machine podman machine start # Verify installation podman --version podman compose version podman machine list ``` **Expected output:** ``` NAME VM TYPE CREATED LAST UP CPUS MEMORY DISK SIZE podman-machine-default* qemu 2 hours ago Currently running 4 8GiB 50GiB ``` ### Complete Setup with Podman Follow the same steps as the Docker guide (Sections 3-8), but use Podman commands: **1. Clone and Configure (same as Section 3-4)** ```bash # Clone repository git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry # Configure environment cp .env.example .env nano .env ``` **2. Start Keycloak with Podman** ```bash # Set passwords (must match .env file) export KEYCLOAK_ADMIN_PASSWORD='your-admin-password' export KEYCLOAK_DB_PASSWORD='your-db-password' # Start Keycloak services podman compose up -d keycloak-db keycloak # Wait for services (takes ~60 seconds) podman compose ps # Follow logs podman compose logs -f keycloak ``` **3. Configure Keycloak (same as Section 5-6)** ```bash # Disable SSL requirement podman exec mcp-gateway-registry-keycloak-1 /opt/keycloak/bin/kcadm.sh config credentials \ --server http://localhost:8080 --realm master \ --user admin --password "${KEYCLOAK_ADMIN_PASSWORD}" podman exec mcp-gateway-registry-keycloak-1 /opt/keycloak/bin/kcadm.sh \ update realms/master -s sslRequired=NONE # Run Keycloak setup scripts cd keycloak/setup ./create-realm-and-clients.sh ./get-all-client-credentials.sh # Create test agent ./setup-agent-service-account.sh test-agent-1 registry-users-lob1 cd ../.. ``` **4. Deploy All Services with Podman** ```bash # Deploy using pre-built images (recommended for Intel Macs) ./build_and_run.sh --prebuilt --podman # For Apple Silicon, build locally instead ./build_and_run.sh --podman ``` > **Apple Silicon Users:** Don't use `--prebuilt` with Podman on ARM64. The pre-built images are amd64 and will cause errors. Use `./build_and_run.sh --podman` to build natively. See [Podman on Apple Silicon Guide](podman-apple-silicon.md). **The script automatically:** - Detects Podman usage - Applies `docker-compose.podman.yml` overlay - Maps ports to non-privileged equivalents (8080/8443) - Configures volume mounts with proper SELinux labels ### Access Services **Important**: With Podman, services use different host ports: | Service | URL (Podman) | |---------|-------------| | **Main UI** | `http://localhost:8080` | | **Main UI (HTTPS)** | `https://localhost:8443` | | Registry API | `http://localhost:7860` | | Keycloak Admin | `http://localhost:18080/admin` | | Auth Server | `http://localhost:8888` | | Prometheus | `http://localhost:9090` | | Grafana | `http://localhost:3000` | **Open in browser:** ```bash # Main interface (note port 8080) open http://localhost:8080 # Registry API (unchanged) open http://localhost:7860 # Keycloak admin console open http://localhost:18080/admin ``` ### Podman-Specific Commands **Container Management:** ```bash # List running containers podman compose ps # or: podman ps # View logs podman compose logs -f podman compose logs -f registry podman logs mcp-gateway-registry-registry-1 # Stop services podman compose down # Restart service podman compose restart registry # Execute commands in container podman exec -it mcp-gateway-registry-registry-1 bash ``` **Resource Management:** ```bash # View resource usage podman stats # Check Podman machine resources podman machine inspect podman-machine-default # Adjust machine resources (requires restart) podman machine stop podman machine rm podman machine init --cpus 8 --memory 16384 --disk-size 100 podman machine start ``` **Volume Management:** ```bash # List volumes podman volume ls # Inspect volume podman volume inspect mcp-gateway-registry_metrics-db-data # Remove unused volumes podman volume prune ``` ### Testing with Podman Update test scripts to use Podman ports: ```bash # Test registry health curl http://localhost:7860/health # Test main interface (note port 8080) curl http://localhost:8080/ # Test with MCP client cd cli python mcp_client.py \ --url http://localhost/mcpgw/mcp \ --token-file ../.oauth-tokens/agent-test-agent-1-m2m.env \ --command ping ``` ### Troubleshooting Podman **Issue: Podman machine won't start** ```bash # Check status podman machine list # View machine logs podman machine ssh systemctl status # Reset machine podman machine stop podman machine rm podman machine init --cpus 4 --memory 8192 podman machine start ``` **Issue: Port 8080 already in use** ```bash # Check what's using the port lsof -i :8080 # Option 1: Stop conflicting service # Option 2: Edit docker-compose.podman.yml to use different ports nano docker-compose.podman.yml # Change "8080:80" to "8081:80" ``` **Issue: Permission denied on volumes** ```bash # Ensure directories exist mkdir -p ${HOME}/mcp-gateway/{servers,agents,models,logs} # Check permissions ls -la ${HOME}/mcp-gateway/ # Fix if needed chmod -R 755 ${HOME}/mcp-gateway/ ``` **Issue: Containers fail to start** ```bash # Check logs podman compose logs # Verify machine has enough resources podman machine inspect | grep -A5 "Resources" # Increase if needed (see Resource Management above) ``` **Issue: podman compose command not found** ```bash # Install podman-compose pip install podman-compose # Or install via Homebrew brew install podman-compose # Verify podman compose version ``` ### Switching Between Docker and Podman You can switch between Docker and Podman without changing configurations: ```bash # Stop Docker services docker compose down # Start with Podman (Intel Mac) ./build_and_run.sh --prebuilt --podman # Start with Podman (Apple Silicon - omit --prebuilt) # ./build_and_run.sh --podman # Or vice versa: podman compose down ./build_and_run.sh --prebuilt ``` > **Apple Silicon Note:** When switching to Podman on Apple Silicon, use `./build_and_run.sh --podman` (without `--prebuilt`). **Note**: Database volumes and configurations are separate between Docker and Podman. You'll need to reconfigure Keycloak when switching. ### Performance Considerations **Podman Machine on macOS:** - Runs in a QEMU VM (like Docker Desktop) - Performance similar to Docker Desktop - Recommended: 4+ CPUs, 8GB+ RAM - SSD recommended for disk operations **Tips for Better Performance:** 1. Allocate sufficient resources to Podman machine 2. Use `--prebuilt` flag to avoid local builds 3. Keep Podman Desktop updated 4. Use SSD for Podman machine storage --- ## 11. Troubleshooting ### Common macOS Issues #### Docker/Podman Not Running **Docker:** ```bash # Check if Docker is running docker ps # If error, start Docker Desktop from Applications # Wait for whale icon to appear in menu bar ``` **Podman:** ```bash # Check if Podman machine is running podman machine list # If not running, start it podman machine start # Verify podman ps ``` #### Port Conflicts ```bash # Check what's using ports lsof -i :80 lsof -i :8080 lsof -i :7860 # Kill conflicting processes if needed sudo lsof -ti :80 | xargs kill ``` #### Permission Issues ```bash # Fix Docker permissions sudo chown -R $(whoami) ~/.docker # Fix file permissions chmod +x keycloak/setup/*.sh chmod +x build_and_run.sh # No additional ownership fixes needed - all directories are in user space ``` #### Keycloak "HTTPS Required" Error ```bash # This was fixed in Section 4, but if it persists: # Re-run SSL disable commands (use your actual admin password) docker exec mcp-gateway-registry-keycloak-1 /opt/keycloak/bin/kcadm.sh config credentials --server http://localhost:8080 --realm master --user admin --password "${KEYCLOAK_ADMIN_PASSWORD}" docker exec mcp-gateway-registry-keycloak-1 /opt/keycloak/bin/kcadm.sh update realms/master -s sslRequired=NONE # Also disable for the mcp-gateway realm after it's created docker exec mcp-gateway-registry-keycloak-1 /opt/keycloak/bin/kcadm.sh update realms/mcp-gateway -s sslRequired=NONE ``` #### Services Won't Start ```bash # Check Docker memory/CPU limits in Docker Desktop preferences # Recommended: 4GB RAM, 2 CPUs minimum # Check disk space df -h # Restart all services docker-compose down docker-compose up -d ``` #### Authentication Failures ```bash # Check client secrets match cat .oauth-tokens/keycloak-client-secrets.txt cat .env | grep KEYCLOAK_CLIENT_SECRET # They should match! If not, update .env file # Restart auth-server after updating secrets docker-compose restart auth-server ``` #### "oauth2_callback_failed" Error ```bash # Check auth-server logs docker-compose logs auth-server | tail -20 # Usually caused by wrong client secret # Regenerate credentials: ./keycloak/setup/get-all-client-credentials.sh # Update .env file with new secrets nano .env # Restart auth-server docker-compose restart auth-server ``` ### Reset Everything If you need to start over completely: ```bash # Stop and remove all containers and data docker-compose down -v # Remove Docker images (optional) docker system prune -a # Remove generated files rm -rf .oauth-tokens/ rm .env # Start fresh from Section 3 cp .env.example .env ``` ### View Service Status ```bash # Check all service status docker-compose ps # Check specific service health docker-compose logs [service-name] --tail 50 # Check resource usage docker stats ``` ### macOS-Specific Logs ```bash # Check Console.app for system logs # Check Docker Desktop logs via Docker Desktop > Troubleshoot > Get support # Check local network issues ping localhost telnet localhost 8080 ``` --- ## Summary You now have a fully functional MCP Gateway & Registry running on macOS! The system provides: - **Authentication**: Keycloak identity provider - **Registry**: Web-based interface for managing MCP servers - **API Gateway**: Centralized access to multiple MCP servers - **Agent Support**: Ready for AI coding assistants and agents - **Container Choice**: Works with both Docker and Podman ### Key URLs: **With Docker:** - **Registry**: http://localhost - **Keycloak Admin**: http://localhost:8080/admin - **API Gateway**: http://localhost/mcpgw/mcp - **Individual Services**: http://localhost/[service-name]/mcp **With Podman:** - **Registry**: http://localhost:8080 - **Keycloak Admin**: http://localhost:18080/admin - **API Gateway**: http://localhost:8080/mcpgw/mcp - **Individual Services**: http://localhost:8080/[service-name]/mcp ### Key Files: - **Configuration**: `.env` - **Client Credentials**: `.oauth-tokens/keycloak-client-secrets.txt` - **Agent Tokens**: `.oauth-tokens/agent-*-m2m.env` - **Podman Overlay**: `docker-compose.podman.yml` (auto-applied with `--podman` flag) ### Next Steps: 1. **Configure your AI coding assistant** with the generated MCP configuration 2. **Create additional agents** using the setup-agent-service-account.sh script 3. **Add custom MCP servers** by editing docker-compose.yml 4. **Explore the web interface** to manage servers and view metrics 5. **Try Podman** if you want rootless container deployment (see Section 10) **Remember**: Save your credentials securely and keep Docker Desktop running when using the system! ### Getting Help - **GitHub Issues**: https://github.com/agentic-community/mcp-gateway-registry/issues - **Documentation**: Check `/docs` folder for additional guides - **Logs**: Always check `docker-compose logs` for troubleshooting ================================================ FILE: docs/mcp-registry-cli.md ================================================ # MCP Registry CLI Guide Interactive terminal interface for chatting with AI models and using MCP (Model Context Protocol) tools. ![MCP Registry CLI Screenshot](img/mcp-registry-cli.png) ## Table of Contents - [Quick Start](#quick-start) - [Setup](#setup) - [Available Commands](#available-commands) - [Provider Selection](#provider-selection) - [Available Models](#available-models) - [Troubleshooting](#troubleshooting) --- ## Quick Start ### Build ```bash cd cli && npm install && npm run build ``` ### Configure AI provider (choose one): 1. Bedrock via AWS profile (by default) 2. Directly configured via execution role 3. Set an anthropic API key ```bash export ANTHROPIC_API_KEY=sk-ant-xxx ``` ### Run (OAuth tokens auto-generated on first start) ```bash npm start ``` or ```bash npm link registry ``` **Default model:** Claude Haiku 4.5 (fastest/cheapest) **Change model:** ```bash export BEDROCK_MODEL_ID=us.anthropic.claude-sonnet-4-5-20250929-v1:0 # Bedrock export ANTHROPIC_MODEL=claude-opus-4-20250514 # Anthropic API ``` ### Status Footer Shows real-time status at the bottom: ``` Token: Valid for 5m 23s | Source: ingress-json | Last refresh: 14:32:15 | Model: us.anthropic.claude-haiku-4-5-20251001-v1:0 | Tokens: In: 1,234 | Out: 567 | Cost: $0.01 ``` - **Token:** Time remaining (green > 60s, yellow < 60s, red when expired) - auto-refreshes at < 10s - **Source:** Token origin (`ingress-json`, `env`, `token-file`) - **Model:** Current AI model - **Tokens:** Input/output usage for session - **Cost:** Estimated session cost --- ## Available Commands | Command | Description | |---------|-------------| | `/help` | Show help message | | `/exit` | Exit CLI (or Ctrl+C) | | `/ping` | Test gateway connectivity | | `/list` | List MCP tools | | `/servers` | List MCP servers | | `/refresh` | Manually refresh OAuth tokens | **Tip:** Type `/` for autocomplete suggestions --- ## Troubleshooting ### OAuth Token Issues **Error:** "Failed to load ingress tokens" or authentication errors **Fix:** 1. **Auto-generate:** Run `npm start` - tokens auto-generate on first run 2. **Manual refresh:** Type `/refresh` in running CLI 3. **Manual generation:** `./credentials-provider/generate_creds.sh --ingress-only` **Note:** Tokens stored in `.oauth-tokens/ingress.json` (project root). Auto-refresh at < 10s remaining. ### Build Errors **Fix:** ```bash cd cli && rm -rf dist/ node_modules/ && npm install && npm run build ``` ### "Agent mode is disabled" **Cause:** No AI credentials found **Fix:** ```bash # Bedrock - verify AWS credentials aws sts get-caller-identity # Bedrock - Execution role (check IAM role attached) curl http://169.254.169.254/latest/meta-data/iam/security-credentials/ # Anthropic API echo $ANTHROPIC_API_KEY # Should show key export ANTHROPIC_API_KEY=sk-ant-your-key ``` ### Anthropic API Errors **Rate limit (429):** Wait and retry, or use Bedrock **Auth failed (401):** Verify `ANTHROPIC_API_KEY` is valid (starts with `sk-ant-`) --- ================================================ FILE: docs/metrics-architecture.md ================================================ # MCP Gateway Metrics Architecture A comprehensive observability system for monitoring authentication, tool discovery, and execution across the MCP Gateway ecosystem. ## Overview The metrics system collects, processes, and visualizes telemetry data from all MCP Gateway components. It provides real-time insights into system performance, user behavior, and service health. ### Key Capabilities - **Real-time Monitoring**: Sub-second metric collection and export - **Flexible Integration**: Native support for Prometheus, Grafana, and OpenTelemetry Collector - **Historical Analysis**: SQLite storage with configurable retention policies - **Secure & Scalable**: API key authentication with rate limiting - **Multiple Export Paths**: Direct Prometheus scraping or OTLP export to any observability platform ## High-Level Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ │ Your MCP Services │ │ │ │ ┌───────────────┐ ┌───────────────┐ ┌──────────────────┐ │ │ │ Auth Server │ │ Registry │ │ MCP Servers │ │ │ │ (middleware) │ │ (middleware) │ │ (client lib) │ │ │ └───────┬───────┘ └───────┬───────┘ └────────┬─────────┘ │ │ │ │ │ │ │ └───────────────────┴────────────────────┘ │ └──────────────────────────────┬──────────────────────────────────┘ │ HTTP POST /metrics X-API-Key: │ ┌─────────────────────▼────────────────────┐ │ Metrics Collection Service │ │ (FastAPI + SQLite + OpenTelemetry) │ │ │ │ • API Key Authentication │ │ • Rate Limiting (1000 req/min) │ │ • Request Validation │ │ • Buffered Processing (5s flush) │ └────────────┬───────────────┬─────────────┘ │ │ ┌────────────▼──┐ ┌────▼─────────────────────────┐ │ SQLite DB │ │ OpenTelemetry Exporters │ │ │ │ │ │ • Raw metrics│ │ ┌────────────────────────┐ │ │ • Specialized│ │ │ Prometheus Exporter │ │ │ tables │ │ │ Port: 9465 │ │ │ • Historical │ │ │ /metrics │ │ │ analysis │ │ └──────────┬─────────────┘ │ │ • 90 day │ │ │ │ │ retention │ │ ┌──────────▼─────────────┐ │ └───────────────┘ │ │ OTLP Exporter │ │ │ │ (Optional) │ │ │ │ http://collector:4318 │ │ │ └──────────┬─────────────┘ │ └─────────────┼───────────────┘ │ ┌────────────────────────┴────────────────────────┐ │ │ ┌───────────▼──────────┐ ┌────────────▼─────────────┐ │ Grafana │ │ OTEL Collector │ │ Port: 3000 │ │ (Optional) │ │ │ │ │ │ • Prometheus queries│ │ Forwards to: │ │ • Pre-built │ │ • Datadog │ │ dashboards │ │ • New Relic │ │ • Real-time alerts │ │ • Honeycomb │ └──────────────────────┘ │ • Jaeger │ │ • Any OTLP-compatible │ └──────────────────────────┘ ``` ## How It Works ### 1. Services Emit Metrics Your services automatically collect metrics using middleware or client libraries: **Example: Auth Server tracks authentication events** ``` When: User authenticates to access a tool Collected: Success/failure, duration, method (JWT/OAuth), user hash, server name Sent to: http://metrics-service:8890/metrics ``` **Example: Registry tracks tool discovery** ``` When: Semantic search for tools Collected: Query text, results count, embedding time, search time Sent to: http://metrics-service:8890/metrics ``` ### 2. Metrics Service Processes Data The centralized service receives, validates, and stores metrics: - **Authentication**: SHA256-hashed API keys per service - **Rate Limiting**: Token bucket algorithm (1000 req/min default) - **Validation**: Schema validation with detailed error reporting - **Buffering**: In-memory buffer with 5-second flush interval - **Storage**: Dual-path to SQLite and OpenTelemetry ### 3. Data Export Options **Option A: Direct Prometheus Scraping (Default)** ``` Prometheus scrapes → metrics-service:9465/metrics Grafana queries → Prometheus ``` **Option B: OpenTelemetry Collector Pipeline** ``` Metrics Service → OTLP export → OTEL Collector → Your observability platform (Datadog, New Relic, etc.) ``` **Option C: Hybrid Approach** ``` Metrics Service → Both Prometheus + OTLP simultaneously (Real-time Grafana + Long-term storage in vendor platform) ``` ## Metric Types ### Authentication Metrics Tracks all authentication requests across services: - **Dimensions**: success, method (jwt/oauth/noauth), server, user_hash - **Measurements**: request count, duration - **Use Cases**: Success rates, auth performance, user activity patterns ### Tool Execution Metrics Tracks MCP protocol method calls: - **Dimensions**: method (initialize/tools/list/tools/call), tool_name, client_name, success - **Measurements**: request count, duration, input/output sizes - **Use Cases**: Tool popularity, client usage, performance analysis ### Discovery Metrics Tracks semantic search operations: - **Dimensions**: query text, results count, top_k/top_n parameters - **Measurements**: embedding time, FAISS search time, total duration - **Use Cases**: Search performance optimization, query pattern analysis ### Protocol Latency Metrics Measures time between protocol steps: - **Flow Steps**: - initialize → tools/list (discovery latency) - tools/list → tools/call (selection latency) - initialize → tools/call (full flow latency) - **Use Cases**: User experience optimization, bottleneck identification ## Database Schema ### Specialized Tables **metrics** - Universal metrics table with JSON dimensions/metadata **auth_metrics** - Fast queries for authentication analysis - Indexed on: timestamp, success, user_hash **tool_metrics** - Tool usage patterns and performance - Indexed on: timestamp, tool_name, client_name, method **discovery_metrics** - Search performance and patterns - Indexed on: timestamp, results_count **api_keys** - Service authentication - SHA256 hashed keys with per-service rate limits All tables include automatic retention cleanup (90 days default). ## OpenTelemetry Integration ### Instruments The service creates standard OTEL instruments: **Counters** (cumulative totals): - `mcp_auth_requests_total` - Authentication events - `mcp_tool_executions_total` - Tool calls - `mcp_tool_discovery_total` - Discovery requests - `mcp_health_checks_total` - Health check operations **Histograms** (duration distributions): - `mcp_auth_request_duration_seconds` - Auth latency - `mcp_tool_execution_duration_seconds` - Tool latency - `mcp_tool_discovery_duration_seconds` - Discovery query latency - `mcp_protocol_latency_seconds` - Protocol flow timing - `mcp_health_check_duration_seconds` - Health check latency ### Export Configuration **Environment Variables:** ```bash # Prometheus export (enabled by default) OTEL_PROMETHEUS_ENABLED=true OTEL_PROMETHEUS_PORT=9465 # OTLP export (optional, for external platforms) OTEL_OTLP_ENDPOINT=http://otel-collector:4318 ``` ### Using OTEL Collector To send metrics to Datadog, New Relic, or other platforms: 1. **Deploy OTEL Collector** with appropriate exporters: ```yaml receivers: otlp: protocols: http: endpoint: 0.0.0.0:4318 exporters: datadog: api: key: ${DD_API_KEY} otlp/newrelic: endpoint: otlp.nr-data.net:4317 headers: api-key: ${NEW_RELIC_LICENSE_KEY} service: pipelines: metrics: receivers: [otlp] exporters: [datadog, otlp/newrelic] ``` 2. **Configure metrics service** to export to collector: ```bash OTEL_OTLP_ENDPOINT=http://otel-collector:4318 ``` 3. **Metrics flow automatically** from service → collector → your platform ### Direct OTLP Push Export (Simplified Setup) For simpler deployments, the metrics service can push OTLP metrics directly to any observability platform that supports OTLP HTTP ingestion **without requiring an intermediate OTEL Collector**. This is the easiest way to integrate with commercial observability platforms. **Supported Platforms:** - Datadog (US1, US3, US5, EU1, AP1, GOV) - New Relic - Honeycomb - Grafana Cloud - Any OTLP-compatible endpoint **Configuration:** Set these environment variables to enable direct OTLP push: ```bash # Required: OTLP endpoint URL OTEL_OTLP_ENDPOINT=https://otlp.datadoghq.com # Required: Authentication headers (API keys, tokens) OTEL_EXPORTER_OTLP_HEADERS=dd-api-key=YOUR_DATADOG_API_KEY # Optional: Export interval (default: 30000ms = 30 seconds) OTEL_OTLP_EXPORT_INTERVAL_MS=30000 # Optional: Metric temporality (Datadog requires 'delta', most others use 'cumulative') OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta ``` **Platform-Specific Examples:** | Platform | Endpoint | Headers | Temporality | |----------|----------|---------|-------------| | Datadog US1 | `https://otlp.datadoghq.com` | `dd-api-key=YOUR_KEY` | `delta` | | Datadog EU1 | `https://otlp.datadoghq.eu` | `dd-api-key=YOUR_KEY` | `delta` | | New Relic | `https://otlp.nr-data.net` | `api-key=YOUR_LICENSE_KEY` | `cumulative` | | Honeycomb | `https://api.honeycomb.io` | `x-honeycomb-team=YOUR_API_KEY` | `cumulative` | | Grafana Cloud | `https://otlp-gateway-{region}.grafana.net/otlp` | `Authorization=Basic {base64}` | `cumulative` | **Docker Compose Setup:** Add to your `.env` file: ```bash # Datadog example OTEL_OTLP_ENDPOINT=https://otlp.datadoghq.com OTEL_EXPORTER_OTLP_HEADERS=dd-api-key=YOUR_DATADOG_API_KEY OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta OTEL_OTLP_EXPORT_INTERVAL_MS=30000 ``` Then start the metrics service: ```bash docker-compose up -d metrics-service ``` The metrics service will automatically start pushing metrics to your configured endpoint every 30 seconds (or your configured interval). **Terraform/ECS Deployment:** For AWS ECS deployments, add these variables to your `terraform.tfvars`: ```hcl # Datadog example otel_otlp_endpoint = "https://otlp.datadoghq.com" otel_exporter_otlp_headers = "dd-api-key=YOUR_DATADOG_API_KEY" # Stored in AWS Secrets Manager otel_exporter_otlp_metrics_temporality_preference = "delta" otel_otlp_export_interval_ms = "30000" ``` For security, `OTEL_EXPORTER_OTLP_HEADERS` is stored in AWS Secrets Manager and not exposed in the ECS task definition plaintext. **Verification:** 1. Check metrics service logs for OTLP export confirmation: ``` INFO: OTLP metrics exporter enabled for https://otlp.datadoghq.com (interval: 30000ms) ``` 2. Within 2 minutes, you should see all 9 MCP Gateway metrics appearing in your observability platform: - 4 counters: `mcp_auth_requests_total`, `mcp_tool_executions_total`, `mcp_tool_discovery_total`, `mcp_health_checks_total` - 5 histograms: `mcp_auth_request_duration_seconds`, `mcp_tool_execution_duration_seconds`, `mcp_tool_discovery_duration_seconds`, `mcp_protocol_latency_seconds`, `mcp_health_check_duration_seconds` 3. All metric dimensions (service, method, tool_name, success, etc.) will appear as tags/labels in your platform **Benefits of Direct Push:** - ✅ **No OTEL Collector required** - Simpler architecture, fewer moving parts - ✅ **Lower latency** - Metrics go directly from service to platform - ✅ **Easier debugging** - Fewer components in the pipeline - ✅ **Lower operational overhead** - No collector to manage, scale, or monitor - ✅ **Secure by default** - API keys stored in Secrets Manager on ECS - ✅ **Works alongside Prometheus** - Both exporters run simultaneously if needed **When to Use Direct Push vs OTEL Collector:** | Use Direct Push When | Use OTEL Collector When | |-----------------------|--------------------------| | Single observability platform | Multiple downstream platforms | | Standard OTLP endpoint | Custom metric transformations needed | | Simplicity is priority | Advanced filtering/sampling required | | Platform-native OTLP support | Legacy/proprietary protocols | **Note:** Direct OTLP push and Prometheus export can run simultaneously. This allows you to use Grafana for real-time monitoring while also sending metrics to a commercial platform for long-term storage and advanced analytics. ## Grafana Dashboards Pre-built dashboard: **MCP Analytics Comprehensive** ### Key Panels **Real-time Protocol Activity** - Shows rate of initialize, tools/list, tools/call operations - Visualizes the MCP protocol flow in real-time **Authentication Flow Analysis** - Success vs failure rates over time - Auth method distribution (JWT, OAuth, NoAuth) **Authentication Success Rate** - Single stat with color thresholds (red < 85%, orange 85-95%, green > 95%) **Tool Execution Latency** - P50, P95, P99 percentiles for performance analysis **Top Tools by Usage** - Most frequently called tools across all servers **Protocol Flow Latency** - Time between protocol steps (initialize → list → call) - Helps identify user experience bottlenecks **Dashboard Features:** - Auto-refresh: 30 seconds - Time range: Last 1 hour (configurable) - Variables: Filter by service, server, method ## Getting Started ### Quick Setup 1. **Start the metrics service:** ```bash docker-compose up -d metrics-service metrics-db grafana ``` 2. **Generate API keys for your services:** ```bash docker-compose exec metrics-service python create_api_key.py ``` 3. **Configure your services with API keys:** ```bash export METRICS_SERVICE_URL=http://metrics-service:8890 export METRICS_API_KEY= ``` 4. **Access Grafana:** ``` http://localhost:3000 Default credentials: admin/admin ``` ### Integrating Your Service **Option 1: Use provided middleware (FastAPI/Python)** ```python from auth_server.metrics_middleware import add_auth_metrics_middleware app = FastAPI() add_auth_metrics_middleware(app, service_name="my-service") ``` **Option 2: Send metrics directly via HTTP:** ```python import httpx await httpx.post( "http://metrics-service:8890/metrics", json={ "service": "my-service", "version": "1.0.0", "metrics": [{ "type": "auth_request", "value": 1.0, "duration_ms": 45.2, "dimensions": {"success": True, "method": "jwt"} }] }, headers={"X-API-Key": api_key} ) ``` ## Configuration ### Metrics Service ```bash SQLITE_DB_PATH=/var/lib/sqlite/metrics.db METRICS_SERVICE_PORT=8890 METRICS_RATE_LIMIT=1000 # Requests per minute per API key METRICS_RETENTION_DAYS=90 # Auto-cleanup after 90 days ``` ### OpenTelemetry ```bash OTEL_SERVICE_NAME=mcp-metrics-service OTEL_PROMETHEUS_ENABLED=true OTEL_PROMETHEUS_PORT=9465 OTEL_OTLP_ENDPOINT= # Optional: http://otel-collector:4318 ``` ### Per-Service API Keys ```bash METRICS_API_KEY_AUTH= METRICS_API_KEY_REGISTRY= METRICS_API_KEY_MYSERVICE= ``` The service automatically discovers `METRICS_API_KEY_*` environment variables and creates corresponding API keys. ## Use Cases ### Performance Monitoring - Track P95/P99 latency for authentication and tool execution - Identify slow tools or services - Monitor protocol flow timing to optimize user experience ### Usage Analytics - Most popular tools across your MCP ecosystem - Client application distribution (Claude Desktop, custom clients) - User activity patterns (hashed for privacy) ### Operational Alerts - Authentication failure spikes - Service availability issues - Rate limit exhaustion - Database growth anomalies ### Capacity Planning - Request rate trends over time - Resource utilization patterns - Growth projection from historical data ## Best Practices ### Security - Never log API keys in plaintext - Use separate API keys per service for isolation - Rotate keys periodically - Monitor for unusual rate limit patterns ### Performance - Services emit metrics asynchronously (fire-and-forget) - Metrics collection adds < 5ms overhead per request - Buffer size and flush interval tunable for high-volume deployments ### Data Retention - Default 90 days for raw metrics - Configure longer retention for aggregated metrics - Use OTLP export for long-term storage in external platforms ### Observability - Start with Prometheus + Grafana for simplicity - Add OTEL Collector when integrating with existing observability stack - Use hybrid approach for best of both worlds ## Troubleshooting **Metrics not appearing in Grafana?** - Check Prometheus is scraping metrics-service:9465 - Verify API key in service configuration - Check metrics service logs for validation errors **Rate limit errors?** - Increase `METRICS_RATE_LIMIT` environment variable - Check rate limit status: `GET /rate-limit` endpoint **High database growth?** - Verify retention policies are active: `GET /admin/retention/policies` - Manually trigger cleanup: `POST /admin/retention/cleanup` - Adjust retention days for high-volume tables ## Additional Resources - **API Reference**: `metrics-service/docs/api-reference.md` - **Data Retention**: `metrics-service/docs/data-retention.md` - **Database Schema**: `metrics-service/docs/database-schema.md` - **Deployment Guide**: `metrics-service/docs/deployment.md` ================================================ FILE: docs/mongodb-m2m-collections.md ================================================ # MongoDB Collections for M2M Accounts ## Overview M2M accounts are stored in **THREE** MongoDB collections with different purposes: ``` ┌─────────────────────────────────────────────────────┐ │ M2M Account Storage Architecture │ └─────────────────────────────────────────────────────┘ 1. idp_m2m_clients ← PRIMARY (used by auth-server) ├─ All providers: Keycloak, Okta, Entra, Auth0 ├─ Purpose: Groups enrichment during authentication └─ Used by: auth_server/mongodb_groups_enrichment.py 2. okta_m2m_clients ← Okta-specific metadata ├─ Only Okta M2M clients ├─ Purpose: Okta sync tracking └─ Used by: registry/services/okta_m2m_sync.py 3. auth0_m2m_clients ← Auth0-specific metadata ├─ Only Auth0 M2M clients ├─ Purpose: Auth0 sync tracking └─ Used by: registry/services/auth0_m2m_sync.py ``` --- ## Collection Details ### 1. `idp_m2m_clients` (PRIMARY - Generic) **Purpose:** Provider-agnostic collection for ALL M2M clients **Used by:** Auth-server for groups enrichment **Scope:** All IdP providers (Keycloak, Okta, Entra, Auth0) **Schema:** ```javascript { "_id": ObjectId("..."), "client_id": "KhZMijfKUcl2TEJqZzrzVJb8rmwk6Qcd", "name": "MCP Gateway M2M", "description": "M2M client for registry access", "groups": ["registry-admins", "developers"], "enabled": true, "provider": "auth0", // or "okta", "keycloak", "entra" "idp_app_id": "KhZMijfKUcl2TEJqZzrzVJb8rmwk6Qcd", "created_at": ISODate("2026-03-29T00:00:00Z"), "updated_at": ISODate("2026-03-29T00:00:00Z") } ``` **How it's used:** 1. M2M token arrives with empty `groups: []` 2. Auth-server validates JWT 3. Queries: `db.idp_m2m_clients.find_one({client_id: "..."})` 4. Returns groups: `["registry-admins"]` 5. Token is enriched with groups for authorization **Created by:** - Manual: `POST /api/iam/users/m2m` (management API) - Auto-sync: `POST /api/iam/okta/m2m/sync` (Okta) - Auto-sync: `POST /api/iam/auth0/m2m/sync` (Auth0) **Updated by:** - `PATCH /api/iam/users/{username}/groups` - `PATCH /api/iam/okta/m2m/clients/{id}/groups` - `PATCH /api/iam/auth0/m2m/clients/{id}/groups` --- ### 2. `okta_m2m_clients` (Okta-specific) **Purpose:** Okta-specific M2M client metadata **Used by:** Okta sync service **Scope:** Only Okta M2M clients **Schema:** ```javascript { "_id": ObjectId("..."), "client_id": "0oa1100req1AzfKaY698", "name": "ai-agent", "description": "AI agent with admin access", "groups": ["registry-admins"], "enabled": true, "okta_app_id": "0oa1100req1AzfKaY698", "last_synced": ISODate("2026-03-29T00:00:00Z"), "created_at": ISODate("2026-03-29T00:00:00Z"), "updated_at": ISODate("2026-03-29T00:00:00Z") } ``` **How it's used:** - Sync service fetches Okta apps with `grant_type: client_credentials` - Stores in `okta_m2m_clients` for tracking - **ALSO** writes to `idp_m2m_clients` for auth enrichment **Operations:** - `GET /api/iam/okta/m2m/clients` - Lists from this collection - `POST /api/iam/okta/m2m/sync` - Syncs to this collection --- ### 3. `auth0_m2m_clients` (Auth0-specific) **Purpose:** Auth0-specific M2M client metadata **Used by:** Auth0 sync service **Scope:** Only Auth0 M2M clients **Schema:** ```javascript { "_id": ObjectId("..."), "client_id": "KhZMijfKUcl2TEJqZzrzVJb8rmwk6Qcd", "name": "MCP Gateway M2M", "description": "M2M client for registry access", "groups": ["registry-admins"], "enabled": true, "auth0_client_id": "KhZMijfKUcl2TEJqZzrzVJb8rmwk6Qcd", "app_type": "non_interactive", "last_synced": ISODate("2026-03-29T00:00:00Z"), "created_at": ISODate("2026-03-29T00:00:00Z"), "updated_at": ISODate("2026-03-29T00:00:00Z") } ``` **How it's used:** - Sync service fetches Auth0 apps with `app_type: non_interactive` - Stores in `auth0_m2m_clients` for tracking - **ALSO** writes to `idp_m2m_clients` for auth enrichment **Operations:** - `GET /api/iam/auth0/m2m/clients` - Lists from this collection - `POST /api/iam/auth0/m2m/sync` - Syncs to this collection --- ## Data Flow ### Creating an M2M Account #### Option 1: Manual Creation (All Providers) ``` POST /api/iam/users/m2m ↓ Creates in IdP (Keycloak/Okta/Entra/Auth0) ↓ Writes to: idp_m2m_clients ✓ ``` #### Option 2: Okta Auto-Sync ``` POST /api/iam/okta/m2m/sync ↓ Fetches from Okta API ↓ Writes to: okta_m2m_clients ✓ ↓ Writes to: idp_m2m_clients ✓ ``` #### Option 3: Auth0 Auto-Sync ``` POST /api/iam/auth0/m2m/sync ↓ Fetches from Auth0 API ↓ Writes to: auth0_m2m_clients ✓ ↓ Writes to: idp_m2m_clients ✓ ``` --- ## Authentication Flow ``` 1. M2M Token arrives (groups: []) ├─ provider: okta/auth0/keycloak/entra ├─ client_id: "abc123..." └─ groups: [] (empty) 2. Auth-server validates JWT └─ auth_server/providers/{provider}.py 3. Groups enrichment triggered └─ mongodb_groups_enrichment.py └─ Queries: db.idp_m2m_clients.find_one({client_id}) 4. Groups found └─ Returns: ["registry-admins"] 5. Authorization succeeds └─ Token enriched with groups ``` --- ## Query Examples ### List ALL M2M accounts (all providers) ```javascript db.idp_m2m_clients.find().pretty() ``` ### List by provider ```javascript // Auth0 M2M clients db.idp_m2m_clients.find({ provider: "auth0" }).pretty() // Okta M2M clients db.idp_m2m_clients.find({ provider: "okta" }).pretty() ``` ### Find specific M2M client ```javascript db.idp_m2m_clients.findOne({ client_id: "KhZMijfKUcl2TEJqZzrzVJb8rmwk6Qcd" }) ``` ### Check groups for client ```javascript db.idp_m2m_clients.findOne( { client_id: "abc123..." }, { groups: 1, name: 1, provider: 1 } ) ``` ### Update groups manually ```javascript db.idp_m2m_clients.updateOne( { client_id: "abc123..." }, { $set: { groups: ["registry-admins", "developers"], updated_at: new Date() } } ) ``` --- ## Key Points ### ✅ Every M2M account MUST be in `idp_m2m_clients` This is the **ONLY** collection that auth-server queries for groups enrichment. ### ✅ Provider-specific collections are optional `okta_m2m_clients` and `auth0_m2m_clients` are for tracking sync metadata. ### ✅ Dual-write pattern When syncing, both collections are updated: - Provider-specific collection (okta/auth0) - Generic `idp_m2m_clients` collection ### ✅ Groups enrichment is automatic Auth-server automatically queries `idp_m2m_clients` when token has empty groups. --- ## Summary Table | Collection | Providers | Used By | Purpose | |------------|-----------|---------|---------| | `idp_m2m_clients` | All (Keycloak, Okta, Entra, Auth0) | Auth-server | Groups enrichment | | `okta_m2m_clients` | Okta only | Okta sync service | Sync tracking | | `auth0_m2m_clients` | Auth0 only | Auth0 sync service | Sync tracking | **Bottom line:** All M2M accounts are listed in `idp_m2m_clients` regardless of provider. ================================================ FILE: docs/okta-setup.md ================================================ # Okta Identity Provider Setup Guide This guide walks through configuring Okta as the identity provider for the MCP Gateway Registry. > **⚠️ IMPORTANT DISCLAIMER** > > This documentation is a **reference guide based on our testing and development experience**, not an official Okta configuration manual. Okta's interface, features, and best practices evolve over time. > > **Always consult the [official Okta documentation](https://developer.okta.com/docs/) for:** > - Current UI layouts and navigation paths > - Latest security recommendations > - Production-grade configuration guidance > - Detailed API references > > **Purpose of this guide:** > - Document the specific configuration steps we used during development > - Provide a working reference for MCP Gateway Registry integration > - Share lessons learned and troubleshooting tips > > If you encounter differences between this guide and your Okta console, refer to Okta's official documentation as the authoritative source. ## Prerequisites - An Okta developer account ([sign up free](https://developer.okta.com/signup/)) - Your Okta domain (e.g., `dev-123456.okta.com`) - Understanding of OAuth2/OIDC flows (see [Okta OAuth2 documentation](https://developer.okta.com/docs/concepts/oauth-openid/)) ## Step 1: Create an OAuth2 Web Application 1. In the Okta Admin Console, go to **Applications** → **Applications** → **Create App Integration** 2. Select **OIDC - OpenID Connect** and **Web Application**, then click **Next** 3. Configure the application: - **Name**: `MCP Gateway Registry` - **Grant types**: Authorization Code, Refresh Token, Client Credentials - **Sign-in redirect URIs**: `http://localhost:8888/oauth2/callback/okta` (dev) or `https://your-auth-server-domain/oauth2/callback/okta` (production) - **Sign-out redirect URIs**: `http://localhost:7860/logout` (dev) or `https://your-registry-domain/logout` (production) - **Controlled access**: Allow everyone in your organization 4. Click **Save** and copy the **Client ID** and **Client Secret** immediately ## Step 2: Configure Groups Claim in ID Tokens The groups claim is configured on the application's Sign On tab using the legacy configuration. This uses the Okta Org Authorization Server (`/oauth2/v1/*`), which has a built-in `groups` scope. 1. Go to **Applications** → your app → **Sign On** tab 2. Scroll to the **Token claims (OIDC)** section and expand **Show legacy configuration** 3. Under **Group Claims**, click **Edit** 4. Set **Groups claim type** to **Filter** 5. Set the name to `groups`, select **Matches regex**, and enter `.*` 6. Click **Save** > **Note:** The Org Authorization Server and the "default" custom authorization server are different. This integration uses the Org Authorization Server, which natively supports the `groups` scope. Custom claims configured under Security → API → Authorization Servers → default will not apply to the Org Authorization Server. ## Step 2a: Custom Authorization Server (Optional - for M2M Tokens) **When to use:** If you need M2M (machine-to-machine) service accounts with custom authorization rules, you may want to create a Custom Authorization Server instead of using the Org Authorization Server. **Key differences:** | Feature | Org Authorization Server | Custom Authorization Server | |---------|-------------------------|----------------------------| | Endpoint pattern | `/oauth2/v1/*` | `/oauth2/{authServerId}/v1/*` | | Built-in groups scope | ✅ Yes | ❌ No (must configure manually) | | Custom claims | ❌ Limited | ✅ Full control | | Custom access policies | ❌ No | ✅ Yes | | Best for | Interactive user login | M2M tokens with custom claims | **Setup steps:** 1. Go to **Security** → **API** → **Authorization Servers** → **Add Authorization Server** 2. Configure: - **Name**: `AI Registry` (or any descriptive name) - **Audience**: `api://ai-registry` (this becomes the `aud` claim in tokens) - **Description**: `Authorization server for MCP Gateway M2M tokens` 3. Click **Save** and copy the **Issuer URI** (e.g., `https://dev-123456.okta.com/oauth2/aus1234567890abcdef`) 4. Extract the authorization server ID from the URI: `aus1234567890abcdef` 5. Configure the `groups` claim: - Go to **Claims** tab → **Add Claim** - **Name**: `groups` - **Include in token type**: Access Token, ID Token - **Value type**: Groups - **Filter**: Matches regex `.*` - **Include in**: Any scope 6. Configure scopes (if needed): - Go to **Scopes** tab - The default scopes include `openid`, `profile`, `email` 7. Set `OKTA_AUTH_SERVER_ID=aus1234567890abcdef` in your environment > **Important:** When using a custom authorization server, M2M tokens will have the audience set to your API identifier (e.g., `api://ai-registry`), not the client ID. The auth server automatically handles this validation. **Groups enrichment for M2M tokens:** When M2M tokens are issued with empty groups (common with custom authorization servers), the registry enriches them from DocumentDB/MongoDB: 1. M2M token is validated successfully but has no groups claim (or empty array) 2. Registry queries `idp_m2m_clients` collection for the client ID 3. Groups from the database are injected into the authorization context 4. Standard group-to-scope mapping applies This allows scalable M2M authorization without hardcoding client IDs in authorization server expressions. ## Step 3: Create Groups for Access Control Okta group names must match the group names in your registry's `scopes.yml`. The default configuration expects groups like `registry-admins` and `public-mcp-users`. 1. Go to **Directory** → **Groups** → **Add Group** 2. Create groups that match your `scopes.yml` group mappings: - `registry-admins` — full admin access to the registry - `public-mcp-users` — read-only access to public MCP servers 3. Assign users to groups via each group's **Assign people** tab ### Group-to-Scope Mapping The registry uses `scopes.yml` to map Okta groups to authorization scopes. Example mapping: ```yaml # scopes.yml groups: registry-admins: - registry:admin:full - mcp:servers:read - mcp:servers:write - mcp:servers:delete public-mcp-users: - mcp:servers:read - mcp:servers:list ``` **How it works:** 1. User logs in with Okta → ID token contains `groups` claim: `["registry-admins"]` 2. Registry extracts groups from token → queries DocumentDB for group-to-scope mappings 3. Scopes are assigned based on group membership 4. User can access resources matching their scopes **For M2M tokens:** 1. M2M client authenticates with Client Credentials flow 2. If token has empty `groups` (common with custom auth servers) 3. Registry queries `idp_m2m_clients` collection in DocumentDB for client groups 4. Groups are enriched and mapped to scopes using same `scopes.yml` logic ## Step 3a: Create and Manage Users ### Creating Users Manually (Okta Console) 1. Go to **Directory** → **People** → **Add Person** 2. Fill in user details: - **First name** and **Last name** - **Username** (email format) - **Primary email** - **Password**: Choose activation method 3. Click **Save** 4. Assign to groups: - Open the user's profile - Go to **Groups** tab - Click **Edit** → Select groups → **Save** ### Creating Users via Registry IAM API If `OKTA_API_TOKEN` is configured, you can create users through the registry: ```bash # Create a new user curl -X POST https://your-registry/api/iam/users \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "username": "john.doe@example.com", "email": "john.doe@example.com", "firstName": "John", "lastName": "Doe", "groups": ["public-mcp-users"] }' ``` ### Creating M2M Service Accounts M2M service accounts are OAuth2 clients with Client Credentials grant: **Via Okta Console:** 1. Go to **Applications** → **Applications** → **Create App Integration** 2. Select **API Services** (not Web Application) 3. **Name**: `ai-agent-3` (or your service name) 4. Click **Save** → Copy **Client ID** and **Client Secret** 5. The application is created but groups are managed separately in the registry **Via Registry IAM API:** ```bash # Create M2M account with groups curl -X POST https://your-registry/api/iam/m2m \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "ai-agent-3", "description": "AI Agent for autonomous operations", "groups": ["public-mcp-users", "ai-agents"] }' # Response includes client_id and client_secret { "client_id": "0oa9876543210fedcba", "client_secret": "secret-value-here", "groups": ["public-mcp-users", "ai-agents"], "okta_app_id": "0oa9876543210fedcba" } ``` The M2M account is stored in DocumentDB's `idp_m2m_clients` collection for groups enrichment. **Testing M2M token:** ```bash # Get M2M token curl -X POST https://dev-123456.okta.com/oauth2/v1/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials&scope=openid" \ -u "CLIENT_ID:CLIENT_SECRET" # Use token to call registry curl https://your-registry/api/servers \ -H "Authorization: Bearer M2M_TOKEN" ``` ## Step 4: Create API Token (Optional) Only required if you need IAM operations (user/group management through the registry). 1. Go to **Security** → **API** → **Tokens** → **Create Token** 2. Name it `MCP Gateway IAM` and copy the token value immediately 3. For least-privilege access, create a custom admin role with only the permissions you need: | Operation | Required Permission | |-----------|-------------------| | List users | `okta.users.read` | | List groups | `okta.groups.read` | | Create/delete users | `okta.users.manage` | | Create/delete groups | `okta.groups.manage` | | Create service accounts | `okta.apps.manage` | ## Environment Variables ### Core Configuration | Variable | Required | Description | |----------|----------|-------------| | `AUTH_PROVIDER` | Yes | Set to `okta` | | `OKTA_DOMAIN` | Yes | Your Okta org domain (e.g., `dev-123456.okta.com`) | | `OKTA_CLIENT_ID` | Yes | OAuth2 client ID from Step 1 | | `OKTA_CLIENT_SECRET` | Yes | OAuth2 client secret from Step 1 | ### Optional Configuration | Variable | Required | Description | |----------|----------|-------------| | `OKTA_AUTH_SERVER_ID` | Optional | Custom authorization server ID from Step 2a (e.g., `aus1234567890abcdef`). If not set, uses Org Authorization Server. | | `OKTA_M2M_CLIENT_ID` | Optional | Separate M2M client ID (defaults to `OKTA_CLIENT_ID`) | | `OKTA_M2M_CLIENT_SECRET` | Optional | Separate M2M client secret (defaults to `OKTA_CLIENT_SECRET`) | | `OKTA_API_TOKEN` | For IAM | Admin API token from Step 4 (required for user/group management) | ## Example Configuration ### Basic Setup (Org Authorization Server) ```bash # .env or docker-compose environment AUTH_PROVIDER=okta OKTA_DOMAIN=dev-123456.okta.com OKTA_CLIENT_ID=0oa1234567890abcdef OKTA_CLIENT_SECRET=your-client-secret-here # Optional: Admin API token for IAM operations # OKTA_API_TOKEN=your-api-token-here ``` ### Advanced Setup (Custom Authorization Server for M2M) ```bash # .env or docker-compose environment AUTH_PROVIDER=okta OKTA_DOMAIN=dev-123456.okta.com OKTA_CLIENT_ID=0oa1234567890abcdef OKTA_CLIENT_SECRET=your-client-secret-here # Custom authorization server for M2M tokens OKTA_AUTH_SERVER_ID=aus1234567890abcdef # Optional: Separate M2M credentials OKTA_M2M_CLIENT_ID=0oa0987654321fedcba OKTA_M2M_CLIENT_SECRET=your-m2m-secret-here # Admin API token for IAM operations OKTA_API_TOKEN=your-api-token-here ``` ### Terraform Configuration ```terraform # terraform.tfvars okta_enabled = true okta_domain = "dev-123456.okta.com" okta_client_id = "0oa1234567890abcdef" okta_client_secret = "your-client-secret-here" okta_m2m_client_id = "0oa0987654321fedcba" okta_m2m_client_secret = "your-m2m-secret-here" okta_api_token = "your-api-token-here" okta_auth_server_id = "aus1234567890abcdef" # Optional - for custom auth server # Ensure other providers are disabled entra_enabled = false ``` ## Okta Endpoints (Auto-Derived) The application automatically constructs OAuth2 endpoints based on your configuration: ### Org Authorization Server (default, when `OKTA_AUTH_SERVER_ID` is not set) | Endpoint | URL Pattern | |----------|-------------| | Authorization | `https://{OKTA_DOMAIN}/oauth2/v1/authorize` | | Token | `https://{OKTA_DOMAIN}/oauth2/v1/token` | | UserInfo | `https://{OKTA_DOMAIN}/oauth2/v1/userinfo` | | JWKS | `https://{OKTA_DOMAIN}/oauth2/v1/keys` | | Logout | `https://{OKTA_DOMAIN}/oauth2/v1/logout` | | Issuer | `https://{OKTA_DOMAIN}` | ### Custom Authorization Server (when `OKTA_AUTH_SERVER_ID` is set) | Endpoint | URL Pattern | |----------|-------------| | Authorization | `https://{OKTA_DOMAIN}/oauth2/{OKTA_AUTH_SERVER_ID}/v1/authorize` | | Token | `https://{OKTA_DOMAIN}/oauth2/{OKTA_AUTH_SERVER_ID}/v1/token` | | UserInfo | `https://{OKTA_DOMAIN}/oauth2/{OKTA_AUTH_SERVER_ID}/v1/userinfo` | | JWKS | `https://{OKTA_DOMAIN}/oauth2/{OKTA_AUTH_SERVER_ID}/v1/keys` | | Logout | `https://{OKTA_DOMAIN}/oauth2/{OKTA_AUTH_SERVER_ID}/v1/logout` | | Issuer | `https://{OKTA_DOMAIN}/oauth2/{OKTA_AUTH_SERVER_ID}` | **Example with custom auth server:** - `OKTA_DOMAIN=dev-123456.okta.com` - `OKTA_AUTH_SERVER_ID=aus1234567890abcdef` - JWKS URL: `https://dev-123456.okta.com/oauth2/aus1234567890abcdef/v1/keys` ## Verifying Your Setup Test the JWKS endpoint: ```bash curl https://dev-123456.okta.com/oauth2/v1/keys ``` Test client credentials token generation: ```bash curl -X POST https://dev-123456.okta.com/oauth2/v1/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials&scope=openid" \ -u "CLIENT_ID:CLIENT_SECRET" ``` ## Troubleshooting **"Permission Required" error after login** Your Okta groups don't match the group names in `scopes.yml`. Create groups in Okta that match (e.g., `registry-admins`) and assign your user to them. See Step 3. **Groups not appearing in tokens** The groups claim must be configured on the app's Sign On tab under "Show legacy configuration", not on the Authorization Server's Claims tab. See Step 2. Also verify your user is assigned to at least one group. **"One or more scopes are not configured" error** This happens when using the default custom authorization server (`/oauth2/default/v1/*`) instead of the Org Authorization Server (`/oauth2/v1/*`). The Org Authorization Server has a built-in `groups` scope. Verify your endpoints use `/oauth2/v1/*`. **Can't find Client Secret after app creation** Regenerate it: App → General tab → Client Credentials → Edit → Regenerate Secret. **API token permission errors** Check **Security** → **Administrators** for the role assigned to the token. Create a custom admin role with the specific scopes needed. **Non-standard domain warning in logs** The provider validates domains against `*.okta.com`, `*.oktapreview.com`, and `*.okta-emea.com`. Custom domains will log a warning but still work. **"No matching key found for kid" error** This means the JWT token was signed by a different authorization server than the one configured. Common causes: - Token was issued by custom auth server, but `OKTA_AUTH_SERVER_ID` is not set → Set the auth server ID - Token was issued by org auth server, but `OKTA_AUTH_SERVER_ID` is set → Remove or correct the auth server ID - Check the token's `iss` claim matches your issuer configuration Verify JWKS endpoint: ```bash # For Org Authorization Server curl https://dev-123456.okta.com/oauth2/v1/keys # For Custom Authorization Server curl https://dev-123456.okta.com/oauth2/aus1234567890abcdef/v1/keys ``` **"Audience doesn't match" error for M2M tokens** When using a custom authorization server, M2M tokens have `aud` set to your API identifier (e.g., `api://ai-registry`), not the client ID. This is expected behavior. The auth server automatically handles this validation when `OKTA_AUTH_SERVER_ID` is configured. **M2M token returns 0 servers despite valid groups** Check that groups are being mapped to scopes: 1. Verify `scopes.yml` contains mappings for the M2M client's groups 2. Check auth server logs for group enrichment messages: ``` Groups enriched from MongoDB for client {client_id}: {groups} Mapped okta groups {groups} to scopes: {scopes} ``` 3. If using custom auth server, ensure `groups` claim is configured (see Step 2a) 4. Verify the M2M client exists in `idp_m2m_clients` collection with correct groups **M2M groups not enriched from database** The groups enrichment only activates when: - Token validation succeeds (`valid: true`) - Token has no groups OR empty groups array - Token contains a `client_id` claim (M2M tokens) Check DocumentDB: ```bash # Connect to mongo container docker exec -it mcp-mongodb mongosh # Query M2M clients collection use mcp_registry_default db.idp_m2m_clients.find({ client_id: "0oa9876543210fedcba" }) ``` Expected document structure: ```json { "client_id": "0oa9876543210fedcba", "name": "ai-agent-3", "groups": ["public-mcp-users", "ai-agents"], "provider": "okta", "enabled": true, "created_at": "2026-03-15T12:00:00Z" } ``` ================================================ FILE: docs/podman-apple-silicon.md ================================================ # Podman on Apple Silicon - Known Issues & Solutions ## TL;DR - Quick Solution **Don't use `--prebuilt` with Podman on Apple Silicon. Build locally instead:** ```bash # CORRECT - Build for ARM64 ./build_and_run.sh --podman # WRONG - Causes "proxy already running" error ./build_and_run.sh --prebuilt --podman ``` ## The Problem ### Architecture Mismatch - **Pre-built images**: `linux/amd64` (Intel x86_64) - **Apple Silicon Macs**: `linux/arm64` (ARM64) - **Result**: Containers fail to start, Podman proxy gets stuck ### Symptoms ``` WARNING: image platform (linux/amd64) does not match the expected platform (linux/arm64) ... Error: unable to start container "...": something went wrong with the request: "proxy already running\n" ``` ## Solutions ### Option 1: Build Locally with Podman (Recommended) Build ARM64-native images from source: ```bash # Complete reset if proxy is stuck podman compose down --remove-orphans podman system prune -a -f podman machine stop podman machine rm -f podman-machine-default # Recreate Podman machine podman machine init --cpus 4 --memory 8192 --disk-size 50 podman machine start # Build for ARM64 (takes 10-15 minutes first time) ./build_and_run.sh --podman ``` **Pros:** - Native ARM64 images (better performance) - No architecture warnings - Reliable container startup **Cons:** - ⏱️ Slower first build (10-15 minutes) ### Option 2: Use Docker Desktop (Easiest) Docker Desktop handles multi-arch images automatically: ```bash # Stop Podman podman machine stop # Install Docker Desktop (if not already) # Download: https://www.docker.com/products/docker-desktop/ # Use pre-built images with Docker ./build_and_run.sh --prebuilt # Access at http://localhost (port 80) ``` **Pros:** - Fast deployment (2-3 minutes) - Pre-built images work reliably - Better multi-arch support **Cons:** - Requires Docker Desktop - Uses privileged ports (80/443) ### Option 3: Fix Stuck Proxy Manually If the proxy is stuck and reset doesn't work: ```bash # Find stuck gvproxy processes ps aux | grep gvproxy # Kill them (replace with actual process ID) kill -9 # Find stuck Podman processes ps aux | grep podman | grep -v grep kill -9 # Remove socket files rm -rf ~/Library/Containers/com.github.containers.podman.* # Remove state files rm -rf ~/.config/containers/podman/machine/* rm -rf ~/.local/share/containers/podman/machine/* # Recreate Podman machine podman machine stop podman machine rm -f podman-machine-default podman machine init --cpus 4 --memory 8192 --disk-size 50 podman machine start # Build locally (no --prebuilt!) ./build_and_run.sh --podman ``` ## Why This Happens ### The Chain of Events 1. **User runs**: `./build_and_run.sh --prebuilt --podman` 2. **Script pulls**: `linux/amd64` images from Docker Hub 3. **Podman tries**: To run amd64 images on arm64 system 4. **Containers fail**: Due to architecture incompatibility 5. **gvproxy stuck**: Networking proxy doesn't clean up properly 6. **Subsequent attempts**: Fail with "proxy already running" ### Technical Details - **Podman on macOS**: Runs in a QEMU VM (similar to Docker Desktop) - **Architecture emulation**: QEMU can emulate amd64 on arm64, but unreliably - **gvproxy networking**: Podman's networking proxy (`gvproxy`) handles port forwarding - **Cleanup issues**: When containers crash, proxy doesn't always terminate properly - **Socket conflicts**: Stuck proxy prevents new containers from binding ports ## Verification After deployment, verify you're running ARM64 images: ```bash # Check architecture of running containers podman inspect | grep Architecture # Should show: "Architecture": "arm64" # NOT: "Architecture": "amd64" ``` ## Performance Comparison | Method | Architecture | First Deploy | Subsequent Deploys | Reliability | |--------|--------------|--------------|-------------------|-------------| | Podman + Local Build | ARM64 (native) | 10-15 min | 2-3 min | ⭐⭐⭐⭐⭐ | | Podman + Pre-built | AMD64 (emulated) | 2-3 min | 2-3 min | ⭐⭐ (unstable) | | Docker + Pre-built | AMD64 (emulated) | 2-3 min | 2-3 min | ⭐⭐⭐⭐ | | Docker + Local Build | ARM64 (native) | 10-15 min | 2-3 min | ⭐⭐⭐⭐⭐ | ## Best Practices ### ✅ DO - **Build locally** with Podman on Apple Silicon - **Use Docker Desktop** if you want pre-built images - **Check architecture** after deployment - **Reset Podman machine** if you encounter proxy errors ### ❌ DON'T - **Don't use** `--prebuilt` with Podman on ARM64 - **Don't mix** Docker and Podman (use one at a time) - **Don't ignore** architecture warnings - **Don't assume** emulation will work reliably ## Future Improvements We're working on: - [ ] ARM64 pre-built images on Docker Hub - [ ] Multi-arch manifest support - [ ] Automatic architecture detection in script - [ ] Better error messages for architecture mismatches ## Additional Resources - [Podman Documentation](https://docs.podman.io/) - [Docker Multi-Platform Images](https://docs.docker.com/build/building/multi-platform/) - [Apple Silicon Support](https://www.docker.com/blog/apple-silicon-m1-chips-and-docker/) ## Still Having Issues? If you continue to experience problems: 1. **Share full logs**: Include output from `podman machine logs` 2. **System info**: Run `podman info` and share output 3. **Open an issue**: [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) 4. **Include details**: Mac model, macOS version, Podman version ## Quick Reference Commands ```bash # Check your architecture uname -m # Should show: arm64 # Check Podman version podman --version # Check container architecture podman inspect | grep Architecture # Full Podman reset podman machine stop podman machine rm -f podman-machine-default podman system reset -f podman machine init --cpus 4 --memory 8192 --disk-size 50 podman machine start # Deploy correctly on Apple Silicon ./build_and_run.sh --podman # NO --prebuilt! ``` ================================================ FILE: docs/prebuilt-images.md ================================================ # Pre-built Docker Images for MCP Gateway Registry When using the `--prebuilt` option with `build_and_run.sh`, the following pre-built Docker images are pulled from Docker Hub. These images are published to the `mcpgateway` organization on Docker Hub. ## MCP Gateway Images | Service | Image | Default Tag | Description | Port | |---------|-------|-------------|-------------|------| | Registry | `mcpgateway/registry:latest` | latest | Main registry service with nginx, SSL, FAISS, and models | 80, 443, 7860 | | Auth Server | `mcpgateway/auth-server:latest` | latest | Authentication service supporting Cognito, GitHub, Google, and Keycloak | 8888 | | Metrics Service | `mcpgateway/metrics-service:latest` | latest | Metrics collection service with SQLite storage and OTEL support | 8890, 9465 | | Current Time Server | `mcpgateway/currenttime-server:latest` | latest | MCP server providing current time functionality | 8000 | | Financial Info Server | `mcpgateway/fininfo-server:latest` | latest | MCP server for financial information | 8001 | | MCPGW Server | `mcpgateway/mcpgw-server:latest` | latest | MCP Gateway server for service management | 8003 | | Real Server Fake Tools | `mcpgateway/realserverfaketools-server:latest` | latest | Example MCP server with mock tools | 8002 | ## External Images The following external images are pulled from their original sources: | Service | Image | Source | Description | Port | |---------|-------|--------|-------------|------| | Alpine Linux | `alpine:latest` | Docker Hub Official | Lightweight Linux for metrics database initialization | N/A | | Prometheus | `prom/prometheus:latest` | Docker Hub Official | Metrics collection and time-series database | 9090 | | Grafana | `grafana/grafana:latest` | Docker Hub Official | Metrics visualization and dashboards | 3000 | | PostgreSQL | `postgres:16-alpine` | Docker Hub Official | Database for Keycloak | 5432 (internal) | | Keycloak | `quay.io/keycloak/keycloak:25.0` | Quay.io | Identity and access management service | 8080 | | MongoDB CE | `mongo:8.2` | Docker Hub Official | MongoDB Community Edition 8.2 with replica set support for local development | 27017 (internal) | ## Manual Download Commands To manually pull these images for Kubernetes deployment or offline use: ```bash # MCP Gateway images from Docker Hub docker pull mcpgateway/registry:latest docker pull mcpgateway/auth-server:latest docker pull mcpgateway/metrics-service:latest docker pull mcpgateway/currenttime-server:latest docker pull mcpgateway/fininfo-server:latest docker pull mcpgateway/mcpgw-server:latest docker pull mcpgateway/realserverfaketools-server:latest # External images docker pull alpine:latest docker pull prom/prometheus:latest docker pull grafana/grafana:latest docker pull postgres:16-alpine docker pull quay.io/keycloak/keycloak:25.0 docker pull mongo:8.2 ``` ## HTTPS Configuration By default, pre-built images run on HTTP (port 80) only. To enable HTTPS (port 443): ### Option 1: Let's Encrypt Certificates ```bash # Install certbot sudo apt-get update && sudo apt-get install -y certbot # Obtain certificate (requires domain and port 80) sudo certbot certonly --standalone -d your-domain.com # Certificate files will be at: # - /etc/letsencrypt/live/your-domain/fullchain.pem # - /etc/letsencrypt/live/your-domain/privkey.pem ``` ### Option 2: Commercial CA Certificates Purchase SSL certificates from a trusted Certificate Authority. ### Copy Certificates to Expected Location ```bash # Create the ssl directory structure mkdir -p ${HOME}/mcp-gateway/ssl/certs mkdir -p ${HOME}/mcp-gateway/ssl/private # Copy your certificate files # Replace paths below with your actual certificate locations cp /etc/letsencrypt/live/your-domain/fullchain.pem ${HOME}/mcp-gateway/ssl/certs/fullchain.pem cp /etc/letsencrypt/live/your-domain/privkey.pem ${HOME}/mcp-gateway/ssl/private/privkey.pem # Set proper permissions chmod 644 ${HOME}/mcp-gateway/ssl/certs/fullchain.pem chmod 600 ${HOME}/mcp-gateway/ssl/private/privkey.pem ``` **Note**: If SSL certificates are not present at `${HOME}/mcp-gateway/ssl/certs/fullchain.pem` and `${HOME}/mcp-gateway/ssl/private/privkey.pem`, the MCP Gateway will automatically run in HTTP-only mode. Then restart: ```bash ./build_and_run.sh --prebuilt ``` The registry container will detect the certificates and enable HTTPS automatically. Check logs: ```bash docker compose logs registry | grep -i ssl # Expected: "SSL certificates found - HTTPS enabled" ``` ================================================ FILE: docs/quickstart.md ================================================ # Quick Start Guide This guide walks you through setting up the MCP Gateway & Registry using pre-built Docker images. For other deployment options, see the [Installation Guide](installation.md). ## Prerequisites
Click to expand: Install Docker, Node.js, Python, and UV **Install Docker and Docker Compose:** ```bash # Install Docker sudo apt-get update sudo apt-get install -y apt-transport-https ca-certificates curl software-properties-common # Add Docker's official GPG key curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg # Add Docker repository echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list # Install Docker Engine sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin # Add user to docker group (logout/login required, or use newgrp) sudo usermod -aG docker $USER newgrp docker # Verify installation docker --version docker compose version ``` **Install Node.js 20.x:** ```bash curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - sudo apt-get install -y nodejs node --version # Should show v20.x.x ``` **Install Python and UV:** ```bash sudo apt-get install -y python3.14 python3.14-venv python3-pip # Install UV package manager curl -LsSf https://astral.sh/uv/install.sh | sh echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc source ~/.bashrc uv --version ``` **Install additional tools:** ```bash sudo apt-get install -y git jq curl wget ```
--- ## Step 1: Clone and Setup ```bash git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry cp .env.example .env # Setup Python virtual environment # (Enterprise Macs: export UV_NATIVE_TLS=true if you hit TLS certificate errors) uv sync source .venv/bin/activate ``` --- ## Step 2: Download Embeddings Model Download the required sentence-transformers model using the [HuggingFace CLI](https://huggingface.co/docs/huggingface_hub/main/en/guides/cli): ```bash # Install huggingface_hub if not already installed uv pip install -U huggingface_hub # Download the model hf download sentence-transformers/all-MiniLM-L6-v2 --local-dir ${HOME}/mcp-gateway/models/all-MiniLM-L6-v2 ``` --- ## Step 3: Configure Environment
Click to expand: Edit .env file with your settings Edit the `.env` file with your preferred editor: ```bash nano .env ``` **Required changes:** ```bash # Authentication provider (do not change) AUTH_PROVIDER=keycloak # Set secure passwords (CHANGE THESE!) KEYCLOAK_ADMIN_PASSWORD=YourSecureAdminPassword123! INITIAL_ADMIN_PASSWORD=YourSecureAdminPassword123! # MUST match KEYCLOAK_ADMIN_PASSWORD KEYCLOAK_DB_PASSWORD=SecureKeycloakDB123! # Session cookie security (CRITICAL for local development) # For HTTP access (localhost): MUST be false SESSION_COOKIE_SECURE=false # For HTTPS access (production): set to true # SESSION_COOKIE_SECURE=true # Leave these as defaults KEYCLOAK_URL=http://localhost:8080 KEYCLOAK_REALM=mcp-gateway KEYCLOAK_CLIENT_ID=mcp-gateway-client ``` **Generate and set SECRET_KEY:** ```bash SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(64))") sed -i "s/^#*\s*SECRET_KEY=.*/SECRET_KEY=$SECRET_KEY/" .env echo "Generated SECRET_KEY: $SECRET_KEY" ``` Save and exit (Ctrl+X, then Y, then Enter if using nano).
**Set environment variables for deployment:** ```bash export DOCKERHUB_ORG=mcpgateway source .env export KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}" ``` --- ## Step 4: Deploy with Pre-built Images ```bash ./build_and_run.sh --prebuilt ``` > **Port Differences:** > - **Docker**: Services run on privileged ports (`http://localhost`, `https://localhost`) > - **Podman**: Services run on non-privileged ports (`http://localhost:8080`, `https://localhost:8443`) Once the build completes and you see the container logs streaming, you can press **Ctrl+C** to exit the log view and continue with the next steps. The containers will continue running in the background. Wait for all services to start (2-3 minutes), then verify: ```bash docker compose ps # All services should show as "Up" ``` --- ## Step 5: Initialize MongoDB Initialize the MongoDB database with required collections, indexes, and default scopes: ```bash # Run the MongoDB initialization container docker compose up mongodb-init # Verify collections were created docker exec mcp-mongodb mongosh --eval "use mcp_registry; show collections" # Should show: mcp_servers_default, mcp_agents_default, mcp_scopes_default, etc. # Restart auth-server to load the new scopes docker compose restart auth-server ``` --- ## Step 6: Initialize Keycloak
Click to expand: Complete Keycloak setup instructions **6a. Wait for Keycloak to be ready:** ```bash # Monitor logs until you see "Keycloak started" docker compose logs -f keycloak # Press Ctrl+C when you see "Keycloak 25.x.x started" # Or check health endpoint curl http://localhost:8080/realms/master # Should return JSON with realm information ``` **6b. Disable SSL for master realm (required for HTTP access):** ```bash ADMIN_TOKEN=$(curl -s -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=${KEYCLOAK_ADMIN}" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli" | \ jq -r '.access_token') && \ curl -X PUT "http://localhost:8080/admin/realms/master" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"sslRequired": "none"}' ``` **6c. Initialize Keycloak realm and clients:** ```bash chmod +x keycloak/setup/init-keycloak.sh ./keycloak/setup/init-keycloak.sh ``` **6d. Disable SSL for application realm:** ```bash ADMIN_TOKEN=$(curl -s -X POST "http://localhost:8080/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=${KEYCLOAK_ADMIN}" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli" | \ jq -r '.access_token') && \ curl -X PUT "http://localhost:8080/admin/realms/mcp-gateway" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"sslRequired": "none"}' ``` **6e. Retrieve and save client credentials:** ```bash chmod +x keycloak/setup/get-all-client-credentials.sh ./keycloak/setup/get-all-client-credentials.sh ``` **6f. Update .env with client secrets:** ```bash # View the retrieved secrets cat .oauth-tokens/keycloak-client-secrets.txt # Update .env with the actual secret values shown above nano .env # Find and update: KEYCLOAK_CLIENT_SECRET and KEYCLOAK_M2M_CLIENT_SECRET ``` **6g. Recreate containers to apply new credentials:** ```bash # Recreate containers to pick up the updated .env values ./build_and_run.sh --prebuilt ``` Once logs are streaming, press **Ctrl+C** to exit - containers will continue running.
--- ## Step 7: Set Up Users and Service Accounts ```bash chmod +x ./cli/bootstrap_user_and_m2m_setup.sh ./cli/bootstrap_user_and_m2m_setup.sh ``` This creates: - **3 groups**: `registry-users-lob1`, `registry-users-lob2`, `registry-admins` - **6 users**: - **LOB1**: `lob1-bot` (M2M) and `lob1-user` (human) - **LOB2**: `lob2-bot` (M2M) and `lob2-user` (human) - **Admin**: `admin-bot` (M2M) and `admin-user` (human) All credentials are saved to `.oauth-tokens/` directory. --- ## Step 8: Create AI Agent Account (Optional)
Click to expand: Create additional agent accounts ```bash chmod +x keycloak/setup/setup-agent-service-account.sh # Create a test agent with full access ./keycloak/setup/setup-agent-service-account.sh \ --agent-id test-agent \ --group mcp-servers-unrestricted # Create an agent for AI coding assistants ./keycloak/setup/setup-agent-service-account.sh \ --agent-id ai-coding-assistant \ --group mcp-servers-unrestricted # Retrieve credentials for the new agents ./keycloak/setup/get-all-client-credentials.sh ```
--- ## Step 9: Access the Registry
Click to expand: Remote Access Options (EC2, Port Forwarding, etc.) The method to access the web UI depends on where you're running the MCP Gateway: **Option A: Local Machine (Linux/macOS)** If you're running on your local machine, simply open a browser - you're already on localhost. **Option B: AWS EC2 with Port Forwarding** If you're running on EC2 and want to access from your local machine via SSH tunnels: ```bash # From your local machine, create SSH tunnels ssh -i your-key.pem -L 7860:localhost:7860 -L 8080:localhost:8080 -L 8888:localhost:8888 -L 80:localhost:80 ubuntu@your-ec2-ip # Then access in your local browser: http://localhost:7860 ``` **Option C: AWS EC2 with Remote Desktop (GUI Access)** If you prefer a full desktop environment on your EC2 instance: ```bash # Install XFCE desktop and XRDP sudo apt update && sudo apt install -y xfce4 xfce4-goodies xrdp firefox echo "xfce4-session" > ~/.xsession sudo systemctl enable xrdp && sudo systemctl start xrdp sudo passwd ubuntu # Set password for RDP login ``` **AWS Security Group**: Add inbound rule for port 3389 (RDP) from your IP. **Connect**: Use Remote Desktop Connection (Windows) or Microsoft Remote Desktop (macOS) with `your-ec2-ip:3389`, username `ubuntu`. See [Remote Desktop Setup Guide](remote-desktop-setup.md) for detailed instructions.
```bash # On macOS: open http://localhost:7860 # On Linux (install xdg-utils if xdg-open is not available): # sudo apt install xdg-utils xdg-open http://localhost:7860 # Or open http://localhost:7860 in your browser ``` Login with: - **Username**: `admin` (or any user created in Step 6) - **Password**: The `KEYCLOAK_ADMIN_PASSWORD` you set in Step 3 --- ## Step 10: Register Example Servers and Agents (Optional) To register example MCP servers and A2A agents, first get a JWT token from the Registry UI: 1. In the Registry UI, click the **"Get JWT Token"** button (top-left corner) 2. In the popup, click **"Copy JSON"** to copy the full token JSON 3. Save it to a `.token` file: ```bash # Create .token file with the copied JSON # Note: .token is already in .gitignore so it won't be committed to the repo cat > .token << 'EOF' EOF ``` Then register servers and agents using the Registry Management CLI: > **Note:** Registration includes automatic security scanning using [Cisco AI Defense MCP Scanner](https://github.com/cisco-ai-defense/mcp-scanner) for servers and [Cisco AI Defense A2A Scanner](https://github.com/cisco-ai-defense/a2a-scanner) for agents. Each registration may take a few seconds while the security scan completes. ```bash # Register MCP servers uv run python api/registry_management.py --registry-url http://localhost --token-file .token \ register --config cli/examples/mcpgw.json uv run python api/registry_management.py --registry-url http://localhost --token-file .token \ register --config cli/examples/cloudflare-docs-server-config.json uv run python api/registry_management.py --registry-url http://localhost --token-file .token \ register --config cli/examples/context7-server-config.json uv run python api/registry_management.py --registry-url http://localhost --token-file .token \ register --config cli/examples/currenttime.json # Register A2A agents uv run python api/registry_management.py --registry-url http://localhost --token-file .token \ agent-register --config cli/examples/travel_assistant_agent_card.json uv run python api/registry_management.py --registry-url http://localhost --token-file .token \ agent-register --config cli/examples/flight_booking_agent_card.json # Verify registrations uv run python api/registry_management.py --registry-url http://localhost --token-file .token list uv run python api/registry_management.py --registry-url http://localhost --token-file .token agent-list ``` Servers and agents are registered as **disabled** by default. Refresh the Registry UI to see them, then enable them using the toggle controls on each server/agent card. --- ## Step 11: Test the Setup Test the registry using the Registry Management CLI: ```bash # List registered servers uv run python api/registry_management.py --registry-url http://localhost --token-file .token list # List registered agents uv run python api/registry_management.py --registry-url http://localhost --token-file .token agent-list # Search for servers by natural language uv run python api/registry_management.py --registry-url http://localhost --token-file .token \ server-search --query "documentation tools" # Search for agents by natural language uv run python api/registry_management.py --registry-url http://localhost --token-file .token \ agent-search --query "travel booking" # Invoke a tool on an MCP server (e.g., get current time) # This exercises the "Gateway" functionality - the request is routed through the # MCP Gateway to the backend currenttime server, demonstrating centralized access uv run python cli/mcp_client.py --url http://localhost/currenttime/mcp --token-file .token \ call --tool current_time_by_timezone --args '{"tz_name": "America/New_York"}' ``` --- ## Next Steps - [Authentication Setup](auth.md) - Configure OAuth and identity providers - [AI Coding Assistants Setup](ai-coding-assistants-setup.md) - Integrate with VS Code, Cursor, Claude Code - [Complete Installation Guide](installation.md) - Additional deployment options - [Configuration Reference](configuration.md) - Environment variables and settings ## Alternative Deployment Options ### Podman (Rootless) For macOS and rootless Linux environments, see the [Installation Guide](installation.md#podman-installation) and [macOS Setup Guide](macos-setup-guide.md#podman-deployment). ### Build from Source For customization or development, see the [Complete Setup Guide](complete-setup-guide.md). ================================================ FILE: docs/registration-webhooks.md ================================================ # Registration Webhooks and Gate MCP Gateway Registry provides two external integration points for registration lifecycle events: **notification webhooks** that fire after a registration or deletion, and a **registration gate** (admission control) that can approve or deny registrations and updates before they are persisted. ## Notification Webhooks MCP Gateway Registry can send HTTP webhook notifications when servers, agents, or skills are registered (added) or deleted (removed). This enables external systems to react to registry changes in real time, for example updating a CMDB, triggering a CI/CD pipeline, sending a Slack notification, or syncing with a third-party inventory. ## Overview Registration webhooks are **fire-and-forget**: the registry sends an async POST to a configurable URL after a successful registration or deletion, logs the result, and moves on. A webhook failure never blocks or rolls back the operation that triggered it. ### Supported Events | Event Type | Trigger | Asset Types | |------------|---------|-------------| | `registration` | A new asset is added to the registry | server, agent, skill | | `deletion` | An existing asset is removed from the registry | server, agent, skill | ### Key Design Decisions | Decision | Choice | Rationale | |----------|--------|-----------| | Delivery model | Fire-and-forget | Registry availability is never affected by webhook failures | | Failure handling | Log at WARNING level | Operators can monitor via CloudWatch or log aggregation | | Auth header handling | Auto-prefix Bearer for Authorization header | Follows RFC 6750 convention without extra config | | HTTPS enforcement | Warn but allow HTTP | Avoids breaking dev/test setups while flagging insecure production use | ## Configuration ### Environment Variables | Variable | Type | Default | Description | |----------|------|---------|-------------| | `REGISTRATION_WEBHOOK_URL` | string | `""` (disabled) | Full URL to POST to. Only `http://` and `https://` schemes are accepted. Leave empty to disable. | | `REGISTRATION_WEBHOOK_AUTH_HEADER` | string | `Authorization` | Name of the HTTP header used for authentication. If set to `Authorization`, the token is auto-prefixed with `Bearer `. For any other header (e.g. `X-API-Key`), the token is sent as-is. | | `REGISTRATION_WEBHOOK_AUTH_TOKEN` | string | `""` | Auth token value. Leave empty for unauthenticated webhooks. | | `REGISTRATION_WEBHOOK_TIMEOUT_SECONDS` | int | `10` | HTTP timeout per request in seconds. | ### Example Configurations **Unauthenticated webhook (dev/test):** ```bash REGISTRATION_WEBHOOK_URL=https://hooks.example.com/registry REGISTRATION_WEBHOOK_AUTH_HEADER=Authorization REGISTRATION_WEBHOOK_AUTH_TOKEN= REGISTRATION_WEBHOOK_TIMEOUT_SECONDS=10 ``` **Bearer token authentication:** ```bash REGISTRATION_WEBHOOK_URL=https://hooks.example.com/registry REGISTRATION_WEBHOOK_AUTH_HEADER=Authorization REGISTRATION_WEBHOOK_AUTH_TOKEN=my-secret-bearer-token REGISTRATION_WEBHOOK_TIMEOUT_SECONDS=10 ``` The request will include `Authorization: Bearer my-secret-bearer-token`. **Custom API key header:** ```bash REGISTRATION_WEBHOOK_URL=https://hooks.example.com/registry REGISTRATION_WEBHOOK_AUTH_HEADER=X-API-Key REGISTRATION_WEBHOOK_AUTH_TOKEN=my-api-key-value REGISTRATION_WEBHOOK_TIMEOUT_SECONDS=5 ``` The request will include `X-API-Key: my-api-key-value`. ## Webhook Payload Every webhook POST sends a JSON body with the following structure: ```json { "event_type": "registration", "registration_type": "agent", "timestamp": "2026-04-23T14:30:00.000000+00:00", "performed_by": "admin@example.com", "card": { "name": "My Agent", "path": "/agents/my-agent", "description": "An example A2A agent", "...": "full card data as stored in the registry" } } ``` ### Payload Fields | Field | Type | Description | |-------|------|-------------| | `event_type` | string | `"registration"` (asset added) or `"deletion"` (asset removed) | | `registration_type` | string | `"server"`, `"agent"`, or `"skill"` | | `timestamp` | string | ISO 8601 timestamp in UTC | | `performed_by` | string or null | Username of the operator who performed the action (null if unknown) | | `card` | object | The full card JSON as stored in the registry | ### HTTP Request Details | Aspect | Value | |--------|-------| | Method | `POST` | | Content-Type | `application/json` | | Timeout | Configurable via `REGISTRATION_WEBHOOK_TIMEOUT_SECONDS` | | Retries | None (fire-and-forget) | | TLS verification | Enabled by default (httpx default behavior) | ## Deployment Configuration The webhook environment variables must be set on the **registry** service (not the auth server). ### Docker Compose All three Compose files (`docker-compose.yml`, `docker-compose.podman.yml`, `docker-compose.prebuilt.yml`) pass the variables to the `mcp-gateway-registry` service: ```yaml services: mcp-gateway-registry: environment: - REGISTRATION_WEBHOOK_URL=${REGISTRATION_WEBHOOK_URL:-} - REGISTRATION_WEBHOOK_AUTH_HEADER=${REGISTRATION_WEBHOOK_AUTH_HEADER:-Authorization} - REGISTRATION_WEBHOOK_AUTH_TOKEN=${REGISTRATION_WEBHOOK_AUTH_TOKEN:-} - REGISTRATION_WEBHOOK_TIMEOUT_SECONDS=${REGISTRATION_WEBHOOK_TIMEOUT_SECONDS:-10} ``` ### Terraform / ECS The variables are defined in `terraform/aws-ecs/variables.tf` and wired into the registry ECS task definition via `terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf` (inside `module "ecs_service_registry"`). Set values in `terraform.tfvars`: ```hcl registration_webhook_url = "https://hooks.example.com/registry" registration_webhook_auth_header = "X-API-Key" registration_webhook_auth_token = "my-api-key" registration_webhook_timeout_seconds = 10 ``` For sensitive values (tokens), use AWS Secrets Manager references instead of plaintext in tfvars. ### Helm / EKS The variables are defined in `charts/registry/values.yaml` and mapped in the deployment template and secret: ```yaml # charts/registry/values.yaml registrationWebhook: url: "" authHeader: "Authorization" authToken: "" timeoutSeconds: 10 ``` Sensitive values (auth tokens) are stored in the Kubernetes secret (`charts/registry/templates/secret.yaml`) and injected via `secretKeyRef`. ## Logging and Observability The webhook service logs at three levels: | Level | Condition | Example Message | |-------|-----------|-----------------| | INFO | Webhook sent successfully | `Registration webhook sent: event=registration, type=agent, status=200, url=https://...` | | WARNING | Timeout or connection failure | `Registration webhook timed out after 10s: event=registration, type=agent, url=https://...` | | WARNING | HTTP (not HTTPS) URL configured | `Registration webhook URL uses HTTP (not HTTPS). Credential data may be transmitted insecurely.` | | ERROR | Invalid URL scheme | `Invalid webhook URL scheme: ftp://...` | In ECS deployments, these log messages appear in the registry task's CloudWatch Log Group. ## Building a Webhook Receiver A minimal webhook receiver only needs to accept a POST with a JSON body and return a 2xx status code. Here is a Python example: ```python from fastapi import FastAPI, Request app = FastAPI() @app.post("/webhook") async def handle_webhook(request: Request): payload = await request.json() event = payload.get("event_type") asset_type = payload.get("registration_type") card = payload.get("card", {}) name = card.get("name") or card.get("display_name", "unknown") print(f"Received {event} event for {asset_type}: {name}") # Your custom logic here: # - Send a Slack notification # - Update a CMDB # - Trigger a CI/CD pipeline # - Sync with an external inventory return {"status": "ok"} ``` Run with: `uvicorn receiver:app --host 0.0.0.0 --port 6789` ## Troubleshooting | Symptom | Cause | Fix | |---------|-------|-----| | No webhook logs at all | `REGISTRATION_WEBHOOK_URL` is empty or not set | Set the variable in the correct service | | Webhook env vars set but no calls | Variables on the wrong ECS service | Ensure they are on the **registry** service, not the auth server | | Timeout warnings | Receiver too slow or unreachable | Increase `REGISTRATION_WEBHOOK_TIMEOUT_SECONDS` or check network connectivity | | HTTP warning in logs | URL uses `http://` instead of `https://` | Switch to HTTPS for production | --- ## Registration Gate (Admission Control) ![Registration Gate Configuration](img/registration-gate.png) The **registration gate** is an admission control webhook called **before** a registration or update is persisted. Unlike the notification webhook above (which fires after the fact and cannot block the operation), the registration gate can **approve or deny** a request based on custom business logic such as naming conventions, compliance rules, or approval workflows. ### How It Differs from the Notification Webhook | Aspect | Notification Webhook | Registration Gate | |--------|---------------------|-------------------| | Timing | After the registration is persisted | Before the registration is persisted | | Can block registration | No (fire-and-forget) | Yes (approve/deny) | | Failure behavior | Logged, never blocks caller | Fail-closed: blocks registration if gate is unavailable | | Retries | None | Configurable with exponential backoff | | Applies to | Registration and deletion events | Registration and update events | | Credential handling | Full card data sent | Credentials stripped from payload | ### Capabilities - Approve or deny registrations and updates for servers, agents, and skills - Configurable authentication: none, API key, or Bearer token - Fail-closed design: if the gate is unreachable after retries, registration is blocked - Custom denial messages returned to the caller as HTTP 403 - Sensitive fields (credentials, tokens, passwords) are automatically stripped from the payload sent to the gate - Exponential backoff retries (0.5s, 1s, 2s, ...) - Startup connectivity check (non-blocking, logs warnings if gate is unreachable) ### Gate Protocol The registry sends a POST request to the gate URL with the following JSON body: ```json { "asset_type": "agent", "operation": "register", "source_api": "/api/agents/register", "registration_payload": { ... }, "request_headers": { "host": "...", "content-type": "..." } } ``` **Fields:** | Field | Description | |-------|-------------| | `asset_type` | `"agent"`, `"server"`, or `"skill"` | | `operation` | `"register"` or `"update"` | | `source_api` | The API path that triggered the request | | `registration_payload` | The registration data with sensitive fields removed | | `request_headers` | HTTP headers from the original request (sensitive headers excluded) | **Gate Response Codes:** | Status Code | Meaning | |-------------|---------| | `200` | Registration allowed | | `403` | Registration denied. Response body may include `{"error": "reason"}` | | Any other | Triggers retry (unexpected status) | ### Credential Sanitization The following fields are automatically removed from `registration_payload` before sending to the gate: - Fields named: `auth_credential`, `auth_credential_encrypted`, `auth_header_name` - Fields containing: `credential`, `secret`, `token`, `password`, `api_key` Sensitive request headers are also excluded: `authorization`, `cookie`, `x-csrf-token`. ### Configuration | Variable | Default | Description | |----------|---------|-------------| | `REGISTRATION_GATE_ENABLED` | `false` | Enable/disable the gate | | `REGISTRATION_GATE_URL` | (empty) | URL of the gate endpoint. Must be set when enabled | | `REGISTRATION_GATE_AUTH_TYPE` | `none` | Auth type: `none`, `api_key`, or `bearer` | | `REGISTRATION_GATE_AUTH_CREDENTIAL` | (empty) | API key or Bearer token value | | `REGISTRATION_GATE_AUTH_HEADER_NAME` | `X-Api-Key` | Header name for `api_key` auth type | | `REGISTRATION_GATE_TIMEOUT_SECONDS` | `5` | HTTP timeout per attempt (seconds) | | `REGISTRATION_GATE_MAX_RETRIES` | `2` | Retry attempts after first failure (exponential backoff) | ### Endpoints Covered The gate is checked on the following operations: | Asset Type | Operation | Endpoint | |------------|-----------|----------| | Agent | Register | `POST /api/agents/register` | | Agent | Update | `PUT /api/agents/{path}` | | Server | Register | `POST /servers/register`, `POST /internal/register`, `POST /api/servers/register` | | Server | Update | `POST /edit/{path}` | | Skill | Register | `POST /api/skills` | | Skill | Update | `PUT /api/skills/{path}` | ### Example: Simple Gate Endpoint A minimal Python gate endpoint that approves all registrations: ```python from fastapi import FastAPI, Request app = FastAPI() @app.post("/gate") async def gate(request: Request): body = await request.json() # Implement your approval logic here return {"status": "allowed"} ``` To deny a registration, return HTTP 403 with an error message: ```python from fastapi import FastAPI, Request from fastapi.responses import JSONResponse app = FastAPI() @app.post("/gate") async def gate(request: Request): body = await request.json() name = body.get("registration_payload", {}).get("name", "") if not name.startswith("prod-"): return JSONResponse( status_code=403, content={"error": "All production assets must start with 'prod-'"}, ) return {"status": "allowed"} ``` See [issue #809](https://github.com/agentic-community/mcp-gateway-registry/issues/809) for the full design specification. ================================================ FILE: docs/registry-api-auth.md ================================================ # Registry API Authentication This page is the single source of truth for how callers authenticate against the **Registry API** (`/api/*`, `/v0.1/*`) — the HTTP surface used by the UI, the `registry_management.py` CLI, and any script or service that talks to the registry. **Scope clarification.** This document covers the **Registry API** only. The **MCP Gateway** surface (`//tools/list`, `//messages`, etc.) always requires full IdP authentication and is governed by `scopes.yml` / `mcp_scope_default`. MCP gateway authn/authz is described in [auth.md](auth.md) and [scopes.md](scopes.md). ## Table of contents 1. [The big picture](#the-big-picture) 2. [Accepted credentials today](#accepted-credentials-today) 3. [Static API token (`REGISTRY_API_TOKEN`)](#static-api-token-registry_api_token) 4. [Multi-key static tokens (`REGISTRY_API_KEYS`)](#multi-key-static-tokens-registry_api_keys) 5. [Session cookie (browser UI)](#session-cookie-browser-ui) 6. [IdP-issued JWT (Okta / Entra / Cognito / Keycloak)](#idp-issued-jwt) 7. [UI-issued self-signed JWT](#ui-issued-self-signed-jwt) 8. [Coexistence rules (who wins when)](#coexistence-rules) 9. [Threat model for static tokens](#threat-model-for-static-tokens) 10. [Roadmap: near-term improvements](#roadmap-near-term-improvements) - [#826 — external user access tokens (service-on-behalf-of-user)](#826--external-user-access-tokens) 11. [Common operator tasks](#common-operator-tasks) 12. [FAQ](#faq) 13. [References](#references) ## The big picture Every call to a Registry API endpoint passes through the **auth server's `/validate` endpoint** before reaching the registry application. The auth server decides, for each incoming request, whether the caller is authenticated and what identity to stamp on the request. ``` Client nginx auth_server:/validate registry │ │ │ │ │── GET /api/... ─────▶│ │ │ │ (cookie or Bearer) │ │ │ │ │── auth_request ─────────▶│ │ │ │ │── 200 + X-Auth-Method, │ │ │ │ X-Scopes, ... │ │ │ │ OR 401/403 │ │ │◀─────────────────────────│ │ │ │ │ │ │ │── proxy_pass ────────────────────────────────────────▶ │ │ │ (with X-Auth-Method and other identity headers) │ │ │ │ │◀─────────────────────│◀────────────────── response ───────────────────────────│ ``` The registry reads `X-Auth-Method` and related headers to decide what the caller can do. It does **not** re-validate the credential — the auth server has the only say on identity. ## Accepted credentials today On a Registry API path the auth server checks credentials in this order (as of [issue #871](https://github.com/agentic-community/mcp-gateway-registry/issues/871)): | # | Credential | Enabled by | `X-Auth-Method` | Notes | |---|---|---|---|---| | 1 | Session cookie (`mcp_gateway_session=...`) | Always | `oauth2` / IdP-specific | UI browser flow. Short-circuits everything else. | | 2 | Federation static token | `FEDERATION_STATIC_TOKEN_AUTH_ENABLED=true` and the request path is `/api/federation/*` or `/api/peers/*` | `federation-static` | Peer-to-peer federation only. Narrow scope. | | 3 | Registry static token(s) (`REGISTRY_API_TOKEN` and/or `REGISTRY_API_KEYS`) | `REGISTRY_STATIC_TOKEN_AUTH_ENABLED=true` | `network-trusted` | Single legacy key or multiple per-key scoped keys. See sections below. | | 4 | IdP-issued JWT (Okta RS256, Entra, Cognito, Keycloak) | Always | `oauth2` (or IdP-specific) | Full per-user identity with groups from the ID token at login time. | | 5 | UI-issued self-signed JWT (HS256) | Always | `self-signed` | Tokens minted by the **Get JWT Token** sidebar button or `POST /api/tokens/generate`. | | — | No credential | — | — | 401 returned. | **Before [issue #871](https://github.com/agentic-community/mcp-gateway-registry/issues/871)**, turning on the registry static token made it the **only** accepted Bearer credential on `/api/*`. IdP and self-signed JWTs were rejected with 401/403 before reaching their validation blocks. After #871, a mismatched or missing bearer on the static-token path **falls through** to the JWT validators instead of terminating. This is what lets mixed-mode deployments (machine callers + per-user callers) share the same registry. ## Static API token (`REGISTRY_API_TOKEN`) A single shared secret (the "legacy" key), validated with `hmac.compare_digest` and mapped to a full-admin identity. This is the simplest setup and is backwards-compatible with all previous releases. ### Configuration | Variable | Type | Default | Notes | |---|---|---|---| | `REGISTRY_STATIC_TOKEN_AUTH_ENABLED` | bool | `false` | When `true`, static tokens are accepted on Registry API paths. | | `REGISTRY_API_TOKEN` | str | empty | The shared secret. At least one of `REGISTRY_API_TOKEN` or `REGISTRY_API_KEYS` must be set for the flag to take effect. | If `REGISTRY_STATIC_TOKEN_AUTH_ENABLED=true` but neither `REGISTRY_API_TOKEN` nor `REGISTRY_API_KEYS` is set, the auth server logs an error and disables the feature at startup. ### Generate a token ```bash python3 -c "import secrets; print(secrets.token_urlsafe(32))" ``` Treat the result like a password: rotate periodically, never commit to git, store in a secrets manager for production. ### Deployment **Docker Compose** — add to your `.env`: ```bash REGISTRY_STATIC_TOKEN_AUTH_ENABLED=true REGISTRY_API_TOKEN=your-generated-token ``` **AWS ECS (terraform)** — add to `terraform.tfvars`: ```hcl registry_static_token_auth_enabled = true registry_api_token = "your-generated-token" ``` Or pass via environment variable to avoid committing the value to a file: ```bash export TF_VAR_registry_api_token="your-generated-token" ``` **Helm** — set `registry.app.registryStaticTokenAuthEnabled=true` and `registry.app.registryApiToken=` in the umbrella chart values. ### Usage ```bash curl -sS -H "Authorization: Bearer $REGISTRY_API_TOKEN" \ "$REGISTRY_URL/api/servers" ``` Via CLI: ```bash echo -n "$REGISTRY_API_TOKEN" > /tmp/static-token uv run python api/registry_management.py \ --registry-url "$REGISTRY_URL" --token-file /tmp/static-token \ list ``` ### Identity granted by the legacy static token When `REGISTRY_API_TOKEN` matches, the auth server returns the legacy admin identity: ```json { "valid": true, "username": "network-user", "client_id": "network-trusted", "method": "network-trusted", "groups": ["mcp-registry-admin"], "scopes": ["mcp-registry-admin", "mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute"] } ``` The `mcp-registry-admin` scope (a UI scope name) ensures the registry resolves this caller as a full admin through the standard permissions path. Anyone holding `REGISTRY_API_TOKEN` is effectively a registry admin. Protect the secret accordingly. ### Where static tokens do NOT work - **MCP gateway paths** (`//tools/list` etc.) always require IdP auth. Static tokens are ignored there. - **Paths outside `/api/*` and `/v0.1/*`** (e.g. health endpoints, audit endpoints behind other prefixes) follow their own rules. ## Multi-key static tokens (`REGISTRY_API_KEYS`) *Added in [issue #779](https://github.com/agentic-community/mcp-gateway-registry/issues/779).* Multiple static API keys, each with its own name and groups. Each key's groups flow through the standard `group_mappings` to scopes resolution, so a read-only key gets read-only permissions and an admin key gets admin permissions. ### Configuration | Variable | Type | Default | Notes | |---|---|---|---| | `REGISTRY_API_KEYS` | JSON string | empty | Map of named keys. Format below. | `REGISTRY_API_KEYS` is only consulted when `REGISTRY_STATIC_TOKEN_AUTH_ENABLED=true`. If both `REGISTRY_API_TOKEN` and `REGISTRY_API_KEYS` are set, they are merged: the legacy token becomes an implicit entry named `legacy` with `groups=["mcp-registry-admin"]`. ### Format ```env REGISTRY_API_KEYS='{"monitoring":{"key":"","groups":["mcp-readonly"]},"deploy":{"key":"","groups":["mcp-registry-admin"]}}' ``` Rules: - **name**: must match `^[a-z0-9][a-z0-9_-]{0,63}$` (log-safe identifier) - **key**: minimum 32 characters (use `python3 -c "import secrets; print(secrets.token_urlsafe(32))"`) - **groups**: non-empty list of group names from your `scopes.yml` / `mcp_scope_default` group_mappings - Reserved names: `legacy`, `network-user`, `network-trusted` cannot be used - Key values must be unique across all entries - On any parse or validation error, the feature is disabled entirely (fail-closed) ### How scopes are resolved At startup, the auth server calls `map_groups_to_scopes(entry.groups)` for each entry to resolve groups into scopes using the same pipeline as IdP/JWT auth. The resolved scopes are cached in memory. When an operator imports or modifies group_mappings (e.g., via `registry_management.py import-group`), the registry triggers an auth server scope reload that also rebuilds the static token map, so changes propagate without a restart. ### Identity for multi-key matches When a named key matches, the auth server returns: ```json { "valid": true, "username": "monitoring", "client_id": "monitoring", "method": "network-trusted", "groups": ["mcp-readonly"], "scopes": ["mcp-readonly/read"] } ``` The key **name** becomes the `username` and `client_id`, which appear in audit logs. This is how operators can answer "which consumer made this call." ### Registry-side authorization The registry no longer hard-codes admin access for `network-trusted` callers. Instead, it resolves permissions from the scopes returned by the auth server, just like any other auth method. A key with `groups=["mcp-readonly"]` will NOT be able to delete servers, register agents, or perform other admin actions. ### Example: read-only monitoring key 1. Ensure your `scopes.yml` has a group like `mcp-readonly` mapped to read-only scopes. 2. Generate a key: `python3 -c "import secrets; print(secrets.token_urlsafe(32))"` 3. Add to your config: ```bash REGISTRY_API_KEYS='{"monitoring":{"key":"YOUR_GENERATED_KEY","groups":["mcp-readonly"]}}' ``` 4. Use it: ```bash curl -sS -H "Authorization: Bearer YOUR_GENERATED_KEY" "$REGISTRY_URL/api/servers" ``` ## Session cookie (browser UI) When a browser user logs in through the UI, the response sets a `mcp_gateway_session=...` cookie. On subsequent calls to `/api/*`, the auth server detects the cookie and short-circuits to session validation — **no static-token check runs**. This is the browser's primary auth path and is unaffected by any of the issues on this page. ## IdP-issued JWT Tokens issued by your configured IdP (`AUTH_PROVIDER=okta|entra|cognito|keycloak|...`) are validated by the provider-specific `validate_token` implementation. Groups are extracted from the token's `groups` claim (or equivalent). These tokens work on `/api/*` **regardless** of whether static-token mode is on, as of #871. ## UI-issued self-signed JWT The auth server's sidebar **Get JWT Token** button produces an HS256 JWT signed with the registry's own secret. These tokens carry the user's groups baked in at mint time and are validated by `_validate_self_signed_token`. They work on `/api/*` just like IdP JWTs. ## Coexistence rules Starting with [#871](https://github.com/agentic-community/mcp-gateway-registry/issues/871), the registry-static-token block is **non-terminal**: 1. If the request has a valid session cookie → session auth wins. 2. Else if the path is a federation path and the federation static token matches → `federation-static`. 3. Else if the path is a Registry API path AND static-token mode is on AND the bearer matches any entry in `_STATIC_TOKEN_MAP` (legacy `REGISTRY_API_TOKEN` or any `REGISTRY_API_KEYS` entry) → `network-trusted`. 4. Else fall through to IdP JWT / self-signed JWT validation. 5. Else 401. **Behavior change since #871**: a bearer that matches neither the static token nor any valid JWT now returns **401** from the JWT block, where it previously returned **403 "Invalid API token"** from the static-token block. No legitimate caller is broken by this — only one that was already sending an invalid credential. ## Threat model for static tokens `REGISTRY_API_KEYS` is a sensitive secret. An attacker who obtains the raw JSON value gains access equivalent to the most privileged key in the map. Specifically: - Any entry whose groups include `mcp-registry-admin` (or any group that maps to admin UI scopes) is equivalent to full admin compromise. - Read-only keys limit the blast radius to data exfiltration (listing servers, reading configs) but cannot mutate. - Key names appear in audit logs, so a compromised key is identifiable after the fact. Mitigations: - Store `REGISTRY_API_KEYS` in a secrets manager (AWS Secrets Manager, Vault, etc.), never in plaintext config files. - Terraform variables use `sensitive = true`; Helm renders the value into a Kubernetes Secret. - Rotate keys by adding a new key, migrating clients, then removing the old key. Restart the auth server after each config change. - Consider using the `existingSecret` Helm pattern to pull from an External Secrets Operator rather than templating the value. ## Roadmap: near-term improvements ### #826 — external user access tokens Tracked at [issue #826](https://github.com/agentic-community/mcp-gateway-registry/issues/826). **Problem.** An external application ("Frontend App") that has its own IdP integration and wants to call the registry API **on behalf of a user** cannot do so today: - The token was issued for the external app, not the registry, so the `aud`/`cid` claim won't match the registry's own client ID. - Okta's org authorization server puts groups in the **ID token**, not the **access token**, so the access token arrives with empty groups. - There's no groups-resolution path for external user tokens today (the M2M enrichment via `idp_m2m_clients` is for client-credentials M2M, not user access tokens). Result: external user tokens get zero scopes and are effectively denied. **Proposed solutions (two options).** **Option A — userinfo group enrichment.** After validating the external user's access token's signature against JWKS, call the IdP's `/userinfo` endpoint with that token to retrieve groups. Cache with a short TTL. Requires a new config of **trusted client IDs** (whose tokens are accepted despite audience mismatch). - Pros: minimal change on the external app side; groups stay fresh; OIDC-standard approach. - Cons: runtime dependency on IdP `/userinfo` for every unique token; subject to IdP rate limits on cache miss. **Option B — token exchange endpoint.** The external app exchanges its ID+access tokens for a **registry-minted self-signed JWT** via a new `POST /oauth2/token-exchange` endpoint. Subsequent API calls use the self-signed token, validated locally with no IdP roundtrip. - Pros: no runtime IdP dependency; proper `aud: "mcp-registry"` on the minted token; delegation visible via `source_client_id` claim. - Cons: external app must implement the exchange + token caching; new endpoint is additional attack surface. **How it composes with #871.** Both options rely on the fall-through behavior #871 introduces — without it, external tokens would be rejected by the static-token block before ever reaching JWT validation (Option A) or `_validate_self_signed_token` (Option B). #871 does not ship either solution; it just makes them possible. **Status.** Design pending. Solution A is the recommended first cut. ## Common operator tasks ### Enable static-token mode ```bash # .env REGISTRY_STATIC_TOKEN_AUTH_ENABLED=true REGISTRY_API_TOKEN=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))") # then: docker compose restart auth-server registry ``` ### Rotate a static token **Legacy single-key (`REGISTRY_API_TOKEN`):** 1. Generate a new token with the `secrets.token_urlsafe` command above. 2. Update `REGISTRY_API_TOKEN` in your deployment config. 3. Restart the auth server. 4. Update all clients that use the token (CI/CD pipelines, scripts). **Multi-key (`REGISTRY_API_KEYS`) zero-downtime rotation:** 1. Add a new entry (e.g. `deploy-v2`) with a fresh key to the JSON map. 2. Restart the auth server. Both old and new keys now work. 3. Migrate clients to the new key. 4. Remove the old entry from the JSON map. 5. Restart the auth server again. This overlap-rotation pattern avoids any window where clients see 401. ### Disable static-token mode Set `REGISTRY_STATIC_TOKEN_AUTH_ENABLED=false`. Session cookies and IdP JWTs keep working unchanged. Any client relying on the static token will start getting 401. ### Verify the System Config UI The current values appear on the **Settings → Authentication** page in the web UI. `REGISTRY_API_TOKEN` is masked. The field registry is defined in [registry/api/config_routes.py:75-76](../registry/api/config_routes.py). ## FAQ See the dedicated FAQ page: [Registry API Authentication FAQ](faq/registry-api-auth-faq.md). ## References - Issue #871: [feat: allow JWT/session auth to coexist with static token auth](https://github.com/agentic-community/mcp-gateway-registry/issues/871) - Issue #779: [feat: Support multiple static API keys with per-key group/scope assignments](https://github.com/agentic-community/mcp-gateway-registry/issues/779) - Issue #826: [feat: Support External User Access Tokens (Service-to-Service on Behalf of Users)](https://github.com/agentic-community/mcp-gateway-registry/issues/826) - Auth server entry point: [`auth_server/server.py`](../auth_server/server.py) — `/validate` endpoint - Registry auth handoff: [`registry/auth/dependencies.py`](../registry/auth/dependencies.py) — consumes `X-Auth-Method` header - Scope configuration format: [`scopes.md`](scopes.md) - General authentication overview: [`auth.md`](auth.md) ================================================ FILE: docs/registry-auth-architecture.md ================================================ # Registry Authentication Architecture This document provides comprehensive technical documentation for the MCP Gateway Registry's authentication and authorization system. While the main [auth.md](./auth.md) covers the overall system architecture, this document focuses specifically on the **registry application's internal authentication mechanisms**, UI-based authentication flows, and technical implementation details. ## Table of Contents 1. [Overview](#overview) 2. [Authentication Architecture](#authentication-architecture) 3. [UI Authentication System](#ui-authentication-system) 4. [Authorization & Permissions](#authorization--permissions) 5. [Technical Implementation](#technical-implementation) 6. [Configuration](#configuration) 7. [Troubleshooting](#troubleshooting) ## Overview The MCP Gateway Registry implements an OAuth2-based authentication system that supports: - **OAuth2/SAML integration** with enterprise identity providers (Cognito, etc.) - **Session-based authentication** using secure HTTP cookies - **Role-based access control** with groups and scopes - **Fine-grained permissions** for server management operations ### Key Features - **Role-Based Access Control**: Admin, User, and custom roles - **Enterprise Integration**: Cognito, SAML, and other IdPs - **Secure Session Management**: Encrypted cookies with expiration - **Permission-Based UI**: Dynamic UI based on user permissions - **Audit Trail**: Comprehensive logging of authentication events ## Authentication Architecture ### High-Level Component Overview ```mermaid graph TB subgraph "Browser" UI[Registry Web UI] LoginForm[Login Form] end subgraph "Registry Application" AuthRoutes[Auth Routes
registry/auth/routes.py] AuthDeps[Auth Dependencies
registry/auth/dependencies.py] ServerRoutes[Protected API Routes
registry/api/server_routes.py] Templates[Jinja2 Templates] end subgraph "Session Management" Cookies[HTTP Cookies
mcp_gateway_session] SessionSigner[URLSafeTimedSerializer] Sessions[Session Data Store] end subgraph "External Systems" AuthServer[Auth Server
:8888] Cognito[Amazon Cognito] end UI --> AuthRoutes LoginForm --> AuthRoutes AuthRoutes --> AuthDeps AuthRoutes --> Templates ServerRoutes --> AuthDeps AuthDeps --> Cookies Cookies --> SessionSigner SessionSigner --> Sessions AuthRoutes -.-> AuthServer AuthServer -.-> Cognito classDef browser fill:#e3f2fd,stroke:#1976d2 classDef registry fill:#f3e5f5,stroke:#7b1fa2 classDef session fill:#fff3e0,stroke:#f57c00 classDef external fill:#e8f5e8,stroke:#388e3c class UI,LoginForm browser class AuthRoutes,AuthDeps,ServerRoutes,Templates registry class Cookies,SessionSigner,Sessions session class AuthServer,Cognito external ``` ### Authentication Flow Architecture ```mermaid sequenceDiagram participant U as User/Browser participant R as Registry App participant AS as Auth Server participant IdP as Identity Provider Note over U,IdP: 1. Initial Access (Unauthenticated) U->>R: GET / (no session cookie) R->>R: Check session cookie R->>U: 302 Redirect to /login Note over U,IdP: 2. Authentication Method Selection U->>R: GET /login R->>AS: GET /oauth2/providers AS->>R: Available OAuth2 providers R->>U: Login form with OAuth2 options Note over U,IdP: 3. OAuth2 Authentication U->>R: GET /auth/{provider} R->>U: 302 Redirect to Auth Server U->>AS: OAuth2 flow initiation AS->>IdP: OAuth2 PKCE flow IdP->>AS: Auth code + user info AS->>AS: Map groups to scopes AS->>AS: Create session cookie AS->>U: Set mcp_gateway_session cookie U->>R: GET /auth/callback R->>R: Validate session cookie R->>U: 302 Redirect to / Note over U,IdP: 4. Authenticated Access U->>R: GET / (with session cookie) R->>R: enhanced_auth() dependency R->>R: Decode & validate session R->>R: Load user permissions R->>U: Filtered dashboard based on permissions ``` ## UI Authentication System ### Login Interface Components The registry provides a modern, responsive login interface that dynamically adapts based on available authentication providers. #### Login Form Structure ```mermaid graph LR subgraph "Login Page (/login)" LoginHeader[Header with Logo] ErrorDisplay[Error Message Display] subgraph "OAuth2 Providers" CognitoBtn[Amazon Cognito Button] SAMLBtn[SAML Provider Button] CustomBtn[Custom Provider Button] end end LoginHeader --> ErrorDisplay ErrorDisplay --> CognitoBtn ErrorDisplay --> SAMLBtn ErrorDisplay --> CustomBtn classDef oauth fill:#fff3e0,stroke:#f57c00 classDef input fill:#f3e5f5,stroke:#7b1fa2 class CognitoBtn,SAMLBtn,CustomBtn oauth class LoginHeader,ErrorDisplay input ``` #### Dynamic Provider Loading The login form dynamically loads available OAuth2 providers: ```python # registry/auth/routes.py async def get_oauth2_providers(): """Fetch available OAuth2 providers from auth server""" try: async with httpx.AsyncClient() as client: response = await client.get(f"{settings.auth_server_url}/oauth2/providers") if response.status_code == 200: return response.json().get("providers", []) except Exception as e: logger.warning(f"Failed to fetch OAuth2 providers: {e}") return [] @router.get("/login", response_class=HTMLResponse) async def login_form(request: Request, error: str | None = None): oauth_providers = await get_oauth2_providers() return templates.TemplateResponse("login.html", { "request": request, "error": error, "oauth_providers": oauth_providers }) ``` ### Dashboard UI with Permission-Based Access The main dashboard dynamically renders content based on user permissions: ```mermaid graph TB subgraph "Dashboard Components" Header[Header with User Info] Sidebar[Navigation Sidebar] MainContent[Main Content Area] subgraph "Header Elements" Logo[Registry Logo] UserDisplay[Username Display] LogoutBtn[Logout Button] end subgraph "Sidebar Elements" AllServers[All Servers Link] UserServers[Accessible Servers] AdminTools[Admin Tools] HealthStatus[Health Status] end subgraph "Main Content" ServiceCards[Service Cards Grid] SearchBar[Search & Filters] ToggleControls[Enable/Disable Toggles] EditButtons[Edit Server Buttons] end end Header --> Sidebar Sidebar --> MainContent Header --> Logo Header --> UserDisplay Header --> LogoutBtn Sidebar --> AllServers Sidebar --> UserServers Sidebar --> AdminTools Sidebar --> HealthStatus MainContent --> ServiceCards MainContent --> SearchBar MainContent --> ToggleControls MainContent --> EditButtons classDef header fill:#e8eaf6,stroke:#3f51b5 classDef sidebar fill:#e0f2f1,stroke:#4caf50 classDef content fill:#fff3e0,stroke:#ff9800 classDef controls fill:#fce4ec,stroke:#e91e63 class Header,Logo,UserDisplay,LogoutBtn header class Sidebar,AllServers,UserServers,AdminTools,HealthStatus sidebar class MainContent,ServiceCards,SearchBar content class ToggleControls,EditButtons controls ``` #### Permission-Based UI Rendering The UI dynamically shows/hides elements based on user permissions: ```html
{{ username }} {% if user_context.is_admin %} Admin {% endif %}
{% for service in services %}

{{ service.display_name }}

{% if user_context.can_modify_servers %} {% endif %}
{% endfor %} ``` ### WebSocket Integration for Real-Time Updates The UI includes real-time health status updates via WebSocket: ```javascript // Health status WebSocket connection const ws = new WebSocket('ws://localhost:7860/ws/health_status'); ws.onmessage = function(event) { const healthData = JSON.parse(event.data); updateHealthStatusUI(healthData); }; function updateHealthStatusUI(healthData) { for (const [servicePath, status] of Object.entries(healthData)) { const card = document.querySelector(`[data-service-path="${servicePath}"]`); if (card) { const statusElement = card.querySelector('.health-status'); statusElement.textContent = status.status; statusElement.className = `health-status ${status.status}`; const toolCount = card.querySelector('.tool-count'); toolCount.textContent = `${status.num_tools} tools`; } } } ``` ## Authorization & Permissions ### Permission Model Overview The registry implements a sophisticated role-based access control (RBAC) system: ```mermaid graph TB subgraph "User Identity" User[User Account] Groups[User Groups] AuthMethod[Auth Method] end subgraph "Permission Mapping" Scopes[MCP Scopes] GroupMapping[Group → Scope Mapping] ServerAccess[Server Access List] end subgraph "Capabilities" ReadAccess[Read Access] ModifyAccess[Modify Access] AdminAccess[Admin Access] ServerSpecific[Server-Specific Access] end User --> Groups User --> AuthMethod Groups --> GroupMapping GroupMapping --> Scopes Scopes --> ServerAccess ServerAccess --> ReadAccess ServerAccess --> ModifyAccess ServerAccess --> AdminAccess ServerAccess --> ServerSpecific classDef identity fill:#e3f2fd,stroke:#1976d2 classDef mapping fill:#f3e5f5,stroke:#7b1fa2 classDef capability fill:#e8f5e8,stroke:#388e3c class User,Groups,AuthMethod identity class Scopes,GroupMapping,ServerAccess mapping class ReadAccess,ModifyAccess,AdminAccess,ServerSpecific capability ``` ### Role Definitions #### 1. Admin Role (`mcp-admin` group) - **Full system access**: Can view, modify, create, and delete all servers - **User management**: Can view all user sessions and permissions - **System configuration**: Can modify global settings - **Unrestricted scopes**: `mcp-servers-unrestricted/read`, `mcp-servers-unrestricted/execute` #### 2. User Role (`mcp-user` group) - **Read-only access**: Can view servers and tools they have permission for - **No modification rights**: Cannot toggle servers or edit configurations - **Filtered view**: Only sees servers they have explicit access to - **Restricted scopes**: Based on group mappings #### 3. Server-Specific Roles (`mcp-server-{name}` groups) - **Targeted access**: Access to specific servers based on group name - **Execute permissions**: Can use tools from assigned servers - **Limited modification**: May have toggle permissions for specific servers ### Scope Configuration System The system uses a YAML-based scope configuration (`auth_server/scopes.yml`): ```yaml # Example scope configuration group_mappings: mcp-admin: - "mcp-servers-unrestricted/read" - "mcp-servers-unrestricted/execute" mcp-user: - "mcp-servers-restricted/read" mcp-server-fininfo: - "mcp-servers-fininfo/read" - "mcp-servers-fininfo/execute" # Scope definitions mcp-servers-fininfo/read: - server: "Financial Info Proxy" permissions: ["read"] mcp-servers-fininfo/execute: - server: "Financial Info Proxy" permissions: ["read", "execute"] ``` ### Permission Checking Logic ```python # registry/auth/dependencies.py def enhanced_auth(session: str = None) -> Dict[str, Any]: """Enhanced authentication with full user context""" session_data = get_user_session_data(session) username = session_data['username'] groups = session_data.get('groups', []) auth_method = session_data.get('auth_method', 'oauth2') # Map groups to scopes based on IdP group mappings scopes = map_cognito_groups_to_scopes(groups) # Calculate permissions accessible_servers = get_user_accessible_servers(scopes) can_modify = user_can_modify_servers(groups, scopes) is_admin = 'mcp-admin' in groups return { 'username': username, 'groups': groups, 'scopes': scopes, 'auth_method': auth_method, 'accessible_servers': accessible_servers, 'can_modify_servers': can_modify, 'is_admin': is_admin } ``` ### Server Access Filtering ```python # registry/services/server_service.py def get_all_servers_with_permissions(self, accessible_servers: Optional[List[str]] = None) -> Dict[str, Dict[str, Any]]: """Get servers filtered by user permissions""" all_servers = self.get_all_servers() if accessible_servers is None: return all_servers # Admin access filtered_servers = {} for path, server_info in all_servers.items(): server_name = server_info.get("server_name", "") if server_name in accessible_servers: filtered_servers[path] = server_info return filtered_servers ``` ## Technical Implementation ### Session Management Deep Dive #### Session Cookie Structure The registry uses `itsdangerous.URLSafeTimedSerializer` for secure session management: ```python # registry/auth/dependencies.py from itsdangerous import URLSafeTimedSerializer signer = URLSafeTimedSerializer(settings.secret_key) def create_session_cookie(username: str, auth_method: str = "oauth2", provider: str = "cognito") -> str: """Create a session cookie for a user""" session_data = { "username": username, "auth_method": auth_method, "provider": provider, "created_at": datetime.utcnow().isoformat(), "groups": [], # Populated during OAuth2 flow "scopes": [] # Calculated from groups } return signer.dumps(session_data) ``` #### Session Validation Flow ```mermaid sequenceDiagram participant R as Request participant D as Auth Dependency participant S as Session Signer participant C as Config/Scopes R->>D: Request with session cookie D->>S: Validate cookie signature alt Valid Cookie S->>D: Decoded session data D->>D: Check expiration D->>C: Load scope mappings D->>D: Calculate permissions D->>R: User context object else Invalid/Expired Cookie S->>D: SignatureExpired/BadSignature D->>R: HTTP 401 Unauthorized end ``` ### Authentication Dependencies Architecture The registry uses FastAPI's dependency injection for authentication: ```python # registry/auth/dependencies.py def get_current_user(session: str = Cookie(alias="mcp_gateway_session")) -> str: """Basic authentication - returns username only""" # Used for simple authentication checks def get_user_session_data(session: str = Cookie(alias="mcp_gateway_session")) -> Dict[str, Any]: """Full session data extraction""" # Used when you need complete session information def enhanced_auth(session: str = Cookie(alias="mcp_gateway_session")) -> Dict[str, Any]: """Enhanced authentication with permissions and context""" # Used for permission-based access control ``` ### Route Protection Patterns ```python # registry/api/server_routes.py @router.get("/", response_class=HTMLResponse) async def read_root(request: Request, user_context: Annotated[dict, Depends(enhanced_auth)]): """Main dashboard with permission-based filtering""" if user_context['is_admin']: all_servers = server_service.get_all_servers() else: all_servers = server_service.get_all_servers_with_permissions( user_context['accessible_servers'] ) # Render dashboard... @router.post("/toggle/{service_path:path}") async def toggle_service_route(service_path: str, user_context: Annotated[dict, Depends(enhanced_auth)]): """Service toggle with permission checking""" if not user_context['can_modify_servers']: raise HTTPException(status_code=403, detail="You do not have permission to modify servers") if not user_context['is_admin']: if not server_service.user_can_access_server_path( service_path, user_context['accessible_servers']): raise HTTPException(status_code=403, detail="You do not have access to this server") # Perform toggle... ``` ### OAuth2 Integration Architecture ```mermaid graph LR subgraph "Registry Components" AuthRoutes[Auth Routes] AuthDeps[Auth Dependencies] Config[Configuration] end subgraph "External Auth Server" OAuth2Handler[OAuth2 Handler] ProviderManager[Provider Manager] TokenValidator[Token Validator] end subgraph "Identity Providers" Cognito[Amazon Cognito] SAML[SAML Provider] Custom[Custom OAuth2] end AuthRoutes --> OAuth2Handler AuthDeps --> Config OAuth2Handler --> ProviderManager ProviderManager --> Cognito ProviderManager --> SAML ProviderManager --> Custom TokenValidator --> Cognito classDef registry fill:#e3f2fd,stroke:#1976d2 classDef auth fill:#f3e5f5,stroke:#7b1fa2 classDef provider fill:#e8f5e8,stroke:#388e3c class AuthRoutes,AuthDeps,Config registry class OAuth2Handler,ProviderManager,TokenValidator auth class Cognito,SAML,Custom provider ``` ### WebSocket Authentication The registry includes real-time features via WebSocket with authentication: ```python # registry/health/routes.py @router.websocket("/ws/health_status") async def websocket_endpoint(websocket: WebSocket): """WebSocket endpoint with authentication""" # WebSocket authentication is handled differently # since cookies are automatically included in WebSocket handshake try: await health_service.add_websocket_connection(websocket) while True: await websocket.receive_text() # Keep alive except WebSocketDisconnect: await health_service.remove_websocket_connection(websocket) ``` ## Configuration ### Environment Variables The registry authentication system requires several configuration parameters: ```bash # Core authentication settings SECRET_KEY=your-secure-secret-key-here SESSION_COOKIE_NAME=mcp_gateway_session SESSION_MAX_AGE_SECONDS=28800 # 8 hours # OAuth2/External auth server integration AUTH_SERVER_URL=http://localhost:8888 AUTH_SERVER_EXTERNAL_URL=http://localhost:8888 # Database/storage paths (auto-configured for container vs local dev) CONTAINER_APP_DIR=/app CONTAINER_REGISTRY_DIR=/app/registry CONTAINER_LOG_DIR=/app/logs ``` ### Development vs Production Configuration #### Local Development (`settings.is_local_dev = True`) ```python # registry/core/config.py @property def is_local_dev(self) -> bool: return not Path("/app").exists() @property def templates_dir(self) -> Path: if self.is_local_dev: return Path.cwd() / "registry" / "templates" return self.container_registry_dir / "templates" ``` #### Container/Production (`settings.is_local_dev = False`) - Paths point to `/app/registry/` structure - Optimized logging and security settings - External auth server integration ### Authentication Provider Configuration #### OAuth2 Provider Setup ```python # External auth server integration async def get_oauth2_providers(): """Fetch available OAuth2 providers from auth server""" try: response = await client.get(f"{settings.auth_server_url}/oauth2/providers") return response.json().get("providers", []) except Exception: return [] # No providers available ``` ## Troubleshooting ### Common Authentication Issues #### 1. Session Cookie Problems **Issue**: User gets redirected to login page repeatedly ```python # Debug session cookie validation try: data = signer.loads(session, max_age=settings.session_max_age_seconds) logger.info(f"Session data: {data}") except SignatureExpired: logger.warning("Session expired") except BadSignature: logger.warning("Invalid session signature") ``` **Solutions**: - Check `SECRET_KEY` consistency across restarts - Verify cookie expiration settings - Ensure browser accepts cookies from the domain #### 2. OAuth2 Integration Issues **Issue**: OAuth2 login fails or redirects incorrectly ```python # Debug OAuth2 callback @router.get("/auth/callback") async def oauth2_callback(request: Request, error: str = None): if error: logger.error(f"OAuth2 error: {error}") return RedirectResponse(url=f"/login?error={error}") # Check session cookie validity session_cookie = request.cookies.get(settings.session_cookie_name) logger.info(f"OAuth2 callback session: {session_cookie[:20]}..." if session_cookie else "No session") ``` **Solutions**: - Verify `AUTH_SERVER_URL` and `AUTH_SERVER_EXTERNAL_URL` settings - Check auth server connectivity: `curl http://localhost:8888/oauth2/providers` - Ensure redirect URIs match in OAuth2 provider configuration #### 3. Permission Issues **Issue**: Users can't access servers they should have permission for ```python # Debug permission calculation def debug_user_permissions(user_context: dict): logger.info(f"User: {user_context['username']}") logger.info(f"Groups: {user_context['groups']}") logger.info(f"Scopes: {user_context['scopes']}") logger.info(f"Accessible servers: {user_context['accessible_servers']}") logger.info(f"Can modify: {user_context['can_modify_servers']}") ``` **Solutions**: - Verify group mappings in `auth_server/scopes.yml` - Check user group assignments in identity provider - Ensure scope configuration matches server names exactly #### 4. WebSocket Authentication Issues **Issue**: Real-time updates not working ```python # Debug WebSocket connections @router.websocket("/ws/health_status") async def websocket_endpoint(websocket: WebSocket): logger.info(f"WebSocket connection from: {websocket.client}") try: await websocket.accept() logger.info("WebSocket connection accepted") except Exception as e: logger.error(f"WebSocket error: {e}") ``` **Solutions**: - Check browser console for WebSocket errors - Verify WebSocket URL scheme (ws:// vs wss://) - Ensure firewall/proxy allows WebSocket connections ### Logging and Debugging #### Enable Debug Logging ```python # registry/main.py logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) ``` #### Authentication Event Logging ```python # Custom auth logging def log_auth_event(event_type: str, username: str = None, details: dict = None): logger.info(f"AUTH_EVENT: {event_type}", extra={ 'username': username, 'event_type': event_type, 'details': details, 'timestamp': datetime.utcnow().isoformat() }) # Usage examples log_auth_event('LOGIN_SUCCESS', username='admin') log_auth_event('PERMISSION_DENIED', username='user', details={'resource': '/toggle/fininfo'}) log_auth_event('SESSION_EXPIRED', username='user') ``` #### Health Check for Auth Components ```python @app.get("/health/auth") async def auth_health_check(): """Health check for authentication components""" health_status = { "session_signer": "ok", "auth_server": "unknown", "oauth2_providers": [] } # Test auth server connectivity try: async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get(f"{settings.auth_server_url}/health") if response.status_code == 200: health_status["auth_server"] = "ok" # Test OAuth2 providers providers_response = await client.get(f"{settings.auth_server_url}/oauth2/providers") if providers_response.status_code == 200: health_status["oauth2_providers"] = providers_response.json().get("providers", []) except Exception as e: health_status["auth_server"] = f"error: {e}" return health_status ``` This comprehensive authentication architecture ensures secure, scalable, and maintainable access control for the MCP Gateway Registry while providing flexibility for both local development and enterprise deployments. ================================================ FILE: docs/registry-auth-detailed.md ================================================ # Registry Authentication & Authorization - Technical Deep Dive This document provides comprehensive technical documentation for the MCP Gateway Registry's internal authentication and authorization system, focusing on the UI-based authentication flows and technical implementation details. ## Table of Contents 1. [Overview](#overview) 2. [Authentication Architecture](#authentication-architecture) 3. [UI Authentication System](#ui-authentication-system) 4. [Authorization & Permissions](#authorization--permissions) 5. [Technical Implementation](#technical-implementation) 6. [Configuration](#configuration) 7. [Troubleshooting](#troubleshooting) ## Overview The MCP Gateway Registry implements an OAuth2-based authentication system designed for enterprise environments: ### Core Authentication Methods - **OAuth2 Integration**: Enterprise IdP integration (Amazon Cognito, SAML, etc.) - **Session Management**: Secure HTTP cookies with digital signatures - **Role-Based Access Control**: Dynamic permissions based on user groups ### Key Features - **RBAC System**: Fine-grained role-based access control - **IdP Integration**: Integration with Cognito and SAML providers - **Secure Sessions**: Encrypted, signed session cookies - **Dynamic UI**: Permission-based interface rendering - **Audit Logging**: Comprehensive authentication event tracking ## Authentication Architecture ### System Component Overview The registry authentication system consists of several interconnected components: ```mermaid graph TB subgraph "Browser Layer" UI[Registry Web UI] LoginForm[Login Interface] Dashboard[Dashboard UI] end subgraph "Registry Application" AuthRoutes[Auth Routes
registry/auth/routes.py] AuthDeps[Auth Dependencies
registry/auth/dependencies.py] ServerRoutes[Protected API Routes
registry/api/server_routes.py] Templates[Jinja2 Templates
registry/templates/] end subgraph "Session Management" Cookies[HTTP Session Cookies
mcp_gateway_session] SessionSigner[URLSafeTimedSerializer
itsdangerous] SessionStore[Session Data Store] end subgraph "External Auth Systems" AuthServer[Auth Server
localhost:8888] Cognito[Amazon Cognito] end UI --> AuthRoutes LoginForm --> AuthRoutes Dashboard --> ServerRoutes AuthRoutes --> AuthDeps ServerRoutes --> AuthDeps AuthRoutes --> Templates AuthDeps --> Cookies Cookies --> SessionSigner SessionSigner --> SessionStore AuthRoutes -.-> AuthServer AuthServer -.-> Cognito classDef browser fill:#e3f2fd,stroke:#1976d2,stroke-width:2px classDef registry fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px classDef session fill:#fff3e0,stroke:#f57c00,stroke-width:2px classDef external fill:#e8f5e8,stroke:#388e3c,stroke-width:2px class UI,LoginForm,Dashboard browser class AuthRoutes,AuthDeps,ServerRoutes,Templates registry class Cookies,SessionSigner,SessionStore session class AuthServer,Cognito external ``` ### Authentication Flow Architecture ```mermaid sequenceDiagram participant U as User Browser participant R as Registry App participant AS as Auth Server participant IdP as Identity Provider Note over U,IdP: Phase 1: Initial Access (Unauthenticated) U->>R: GET / (no session cookie) R->>R: enhanced_auth() dependency check R->>U: 302 Redirect to /login Note over U,IdP: Phase 2: Authentication Method Selection U->>R: GET /login R->>AS: GET /oauth2/providers (fetch available providers) AS-->>R: List of OAuth2 providers R->>R: Render login form with OAuth2 options R->>U: Login page with OAuth2 options Note over U,IdP: Phase 3: OAuth2 Authentication Flow U->>R: GET /auth/{provider} R->>U: 302 Redirect to external auth server U->>AS: OAuth2 PKCE flow initiation AS->>IdP: OAuth2 authorization request IdP->>AS: Authorization code + user info AS->>AS: Exchange code for tokens AS->>AS: Map Cognito groups to MCP scopes AS->>AS: Create compatible session cookie AS->>U: Set-Cookie mcp_gateway_session U->>R: GET /auth/callback R->>R: Validate existing session cookie R->>U: 302 Redirect to / (authenticated) Note over U,IdP: Phase 4: Authenticated Dashboard Access U->>R: GET / (with valid session cookie) R->>R: enhanced_auth() extracts & validates session R->>R: Calculate user permissions & accessible servers R->>R: Filter server list based on permissions R->>U: Rendered dashboard with permission-based UI ``` ### Core Authentication Components #### 1. Authentication Routes (`registry/auth/routes.py`) **Purpose**: Handles all authentication-related HTTP endpoints **Key Endpoints**: - `GET /login` - Login form with dynamic OAuth2 provider loading - `GET /auth/{provider}` - OAuth2 provider redirect - `GET /auth/callback` - OAuth2 callback handling - `GET|POST /logout` - Session termination #### 2. Authentication Dependencies (`registry/auth/dependencies.py`) **Purpose**: FastAPI dependency injection for authentication and authorization **Key Functions**: - `get_current_user()` - Basic user identification - `get_user_session_data()` - Full session data extraction - `enhanced_auth()` - Complete user context with permissions - `map_cognito_groups_to_scopes()` - Group-to-permission mapping #### 3. Session Management System **Purpose**: Secure session cookie creation, validation, and management **Components**: - `URLSafeTimedSerializer` from `itsdangerous` library - Session cookie with configurable expiration - Automatic session validation on all protected routes - Cross-authentication-method compatibility ### Authentication Decision Tree ```mermaid flowchart TD Start([HTTP Request]) --> HasSession{Has Valid
Session Cookie?} HasSession -->|Yes| ValidateSession[Validate Session
Signature & Expiration] HasSession -->|No| RedirectLogin[Redirect to /login] ValidateSession --> SessionValid{Session
Valid?} SessionValid -->|Yes| ExtractUserContext[Extract User Context
Groups, Scopes, Permissions] SessionValid -->|No| RedirectLogin RedirectLogin --> LoginPage[Display Login Page
with Available Providers] LoginPage --> OAuth2Auth[Redirect to
External Provider] OAuth2Auth --> ExternalProvider[External OAuth2 Flow
User Authentication] ExternalProvider --> OAuth2Callback[OAuth2 Callback
with User Info] OAuth2Callback --> CreateOAuth2Session[Create Session Cookie
with Mapped Permissions] CreateOAuth2Session --> SetSessionCookie[Set HTTP Cookie
mcp_gateway_session] SetSessionCookie --> RedirectDashboard[Redirect to Dashboard] RedirectDashboard --> ExtractUserContext ExtractUserContext --> RenderUI[Render Permission-Based UI] classDef startEnd fill:#e8f5e8,stroke:#4caf50,stroke-width:2px classDef decision fill:#fff3e0,stroke:#ff9800,stroke-width:2px classDef process fill:#e3f2fd,stroke:#2196f3,stroke-width:2px classDef error fill:#ffebee,stroke:#f44336,stroke-width:2px class Start,RenderUI startEnd class HasSession,SessionValid decision class ValidateSession,ExtractUserContext,LoginPage,OAuth2Auth,ExternalProvider,OAuth2Callback,CreateOAuth2Session,SetSessionCookie,RedirectDashboard process class RedirectLogin error ``` ## UI Authentication System ### Login Interface Architecture The registry provides a modern, responsive login interface that dynamically adapts based on available authentication providers. ```mermaid graph LR subgraph "Login Page (/login)" LoginHeader[Header with Logo & Branding] ErrorDisplay[Error Message Display] subgraph "OAuth2 Provider Buttons" ProviderButtons[Dynamic Provider Buttons] CognitoBtn[Amazon Cognito Button] SAMLBtn[SAML Provider Button] CustomBtn[Custom OAuth2 Button] end end LoginHeader --> ErrorDisplay ErrorDisplay --> ProviderButtons ProviderButtons --> CognitoBtn ProviderButtons --> SAMLBtn ProviderButtons --> CustomBtn classDef header fill:#e8eaf6,stroke:#3f51b5,stroke-width:2px classDef oauth fill:#fff3e0,stroke:#f57c00,stroke-width:2px class LoginHeader,ErrorDisplay header class ProviderButtons,CognitoBtn,SAMLBtn,CustomBtn oauth ``` #### Dynamic Provider Loading Implementation The login form dynamically loads available OAuth2 providers from the auth server: ```python # registry/auth/routes.py async def get_oauth2_providers(): """Fetch available OAuth2 providers from auth server""" try: async with httpx.AsyncClient() as client: response = await client.get( f"{settings.auth_server_url}/oauth2/providers", timeout=5.0 ) if response.status_code == 200: data = response.json() return data.get("providers", []) except Exception as e: logger.warning(f"Failed to fetch OAuth2 providers: {e}") return [] @router.get("/login", response_class=HTMLResponse) async def login_form(request: Request, error: str | None = None): """Show login form with OAuth2 providers""" oauth_providers = await get_oauth2_providers() return templates.TemplateResponse("login.html", { "request": request, "error": error, "oauth_providers": oauth_providers }) ``` #### Login Template Structure ```html ``` ### Dashboard UI with Permission-Based Rendering The main dashboard dynamically renders content based on user permissions and accessible servers: ```mermaid graph TB subgraph "Dashboard Layout" HeaderSection[Header Section] MainContainer[Main Container] subgraph "Header Components" Logo[Registry Logo] UserInfo[User Information Display] LogoutControls[Logout Controls] end subgraph "Main Content Area" Sidebar[Navigation Sidebar] ContentArea[Primary Content Area] end subgraph "Sidebar Elements" ServerList[Server Navigation] AdminTools[Admin Tools Panel] HealthStatus[Health Status Display] end subgraph "Content Elements" ServiceGrid[Service Cards Grid] SearchFilters[Search & Filter Controls] ManagementControls[Management Actions] end subgraph "Permission-Based Elements" ToggleSwitches[Enable/Disable Toggles] EditButtons[Edit Server Buttons] CreateButtons[Create New Server] AdminPanels[Admin-Only Panels] end end HeaderSection --> Logo HeaderSection --> UserInfo HeaderSection --> LogoutControls MainContainer --> Sidebar MainContainer --> ContentArea Sidebar --> ServerList Sidebar --> AdminTools Sidebar --> HealthStatus ContentArea --> ServiceGrid ContentArea --> SearchFilters ContentArea --> ManagementControls ManagementControls --> ToggleSwitches ManagementControls --> EditButtons ManagementControls --> CreateButtons ManagementControls --> AdminPanels classDef header fill:#e8eaf6,stroke:#3f51b5,stroke-width:2px classDef sidebar fill:#e0f2f1,stroke:#4caf50,stroke-width:2px classDef content fill:#fff3e0,stroke:#ff9800,stroke-width:2px classDef permissions fill:#fce4ec,stroke:#e91e63,stroke-width:2px class HeaderSection,Logo,UserInfo,LogoutControls header class Sidebar,ServerList,AdminTools,HealthStatus sidebar class ContentArea,ServiceGrid,SearchFilters,ManagementControls content class ToggleSwitches,EditButtons,CreateButtons,AdminPanels permissions ``` #### Permission-Based UI Rendering The UI template conditionally renders elements based on user permissions: ```html
{{ username }} {% if user_context.is_admin %} Administrator {% elif user_context.groups %} {{ user_context.groups|join(', ') }} {% endif %}
{% for service in services %}

{{ service.display_name }}

{% if user_context.can_modify_servers %} {% endif %}

{{ service.description or "No description available." }}

{% for tag in service.tags %} {{ tag }} {% endfor %}
{% endfor %}
{% if user_context.can_modify_servers %}
{% endif %} ``` ### Real-Time WebSocket Integration The UI includes real-time health status updates via WebSocket connections: ```mermaid sequenceDiagram participant UI as Dashboard UI participant WS as WebSocket Connection participant HMS as Health Monitoring Service participant SS as Server Service Note over UI,SS: WebSocket Initialization UI->>WS: Connect to /ws/health_status WS->>HMS: Register new connection HMS->>UI: Send initial health status data Note over UI,SS: Real-Time Health Updates loop Background Health Checks HMS->>SS: Check server health status SS-->>HMS: Updated health data HMS->>HMS: Compare with previous status alt Status Changed HMS->>WS: Broadcast health update WS->>UI: Send updated status UI->>UI: Update service card UI end end Note over UI,SS: User-Triggered Actions UI->>SS: Toggle server state SS->>HMS: Immediate health check HMS->>WS: Broadcast status update WS->>UI: Real-time status update UI->>UI: Update toggle switch & status ``` #### WebSocket Client Implementation ```javascript // Real-time health status WebSocket connection class HealthStatusManager { constructor() { this.ws = null; this.reconnectInterval = 5000; this.maxReconnectAttempts = 10; this.reconnectAttempts = 0; } connect() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/ws/health_status`; this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log('Health status WebSocket connected'); this.reconnectAttempts = 0; }; this.ws.onmessage = (event) => { try { const healthData = JSON.parse(event.data); this.updateHealthStatusUI(healthData); } catch (error) { console.error('Error parsing health status data:', error); } }; this.ws.onclose = () => { console.log('Health status WebSocket disconnected'); this.attemptReconnect(); }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); }; } updateHealthStatusUI(healthData) { for (const [servicePath, status] of Object.entries(healthData)) { const serviceCard = document.querySelector(`[data-service-path="${servicePath}"]`); if (serviceCard) { // Update health status indicator const statusElement = serviceCard.querySelector('.health-status'); if (statusElement) { statusElement.textContent = status.status; statusElement.className = `health-status ${status.status.replace(/[^a-zA-Z0-9]/g, '-')}`; } // Update tool count const toolCountElement = serviceCard.querySelector('.tool-count'); if (toolCountElement) { toolCountElement.textContent = `${status.num_tools} tools`; } // Update timestamp const timestampElement = serviceCard.querySelector('.timestamp'); if (timestampElement && status.last_checked_iso) { timestampElement.textContent = `Last checked: ${status.last_checked_iso}`; } } } } attemptReconnect() { if (this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`); setTimeout(() => this.connect(), this.reconnectInterval); } else { console.error('Max reconnection attempts reached'); } } } // Initialize WebSocket connection when page loads document.addEventListener('DOMContentLoaded', () => { const healthManager = new HealthStatusManager(); healthManager.connect(); }); ``` ### Server Management UI Workflows #### Service Toggle Workflow ```mermaid sequenceDiagram participant U as User participant UI as Dashboard UI participant R as Registry Backend participant HMS as Health Monitoring participant WS as WebSocket U->>UI: Click toggle switch UI->>UI: Disable toggle (show loading) UI->>R: POST /toggle/{service_path} R->>R: Check user permissions alt Insufficient Permissions R->>UI: 403 Forbidden UI->>UI: Show error message UI->>UI: Revert toggle state else Sufficient Permissions R->>R: Update service state R->>HMS: Trigger immediate health check HMS->>HMS: Perform health check R->>UI: 200 OK with new state UI->>UI: Update toggle state HMS->>WS: Broadcast health update WS->>UI: Real-time status update UI->>UI: Update status indicators end ``` #### Server Creation Workflow (Admin Only) ```mermaid flowchart TD Start([User clicks "Add Server"]) --> CheckPerms{User has
modify permissions?} CheckPerms -->|No| ShowError[Show permission error] CheckPerms -->|Yes| ShowForm[Display server creation form] ShowForm --> UserFillsForm[User fills server details] UserFillsForm --> ValidateForm{Form validation
passes?} ValidateForm -->|No| ShowValidationErrors[Show validation errors] ValidateForm -->|Yes| SubmitForm[Submit form to backend] SubmitForm --> BackendValidation[Backend validates data] BackendValidation --> ServerExists{Server path
already exists?} ServerExists -->|Yes| ShowConflictError[Show conflict error] ServerExists -->|No| CreateServer[Create server entry] CreateServer --> UpdateFAISS[Update FAISS index] UpdateFAISS --> UpdateNginx[Regenerate Nginx config] UpdateNginx --> BroadcastUpdate[Broadcast health update] BroadcastUpdate --> Success[Redirect to dashboard] ShowError --> End([End]) ShowValidationErrors --> ShowForm ShowConflictError --> ShowForm Success --> End classDef success fill:#e8f5e8,stroke:#4caf50,stroke-width:2px classDef error fill:#ffebee,stroke:#f44336,stroke-width:2px classDef process fill:#e3f2fd,stroke:#2196f3,stroke-width:2px classDef decision fill:#fff3e0,stroke:#ff9800,stroke-width:2px class Success success class ShowError,ShowValidationErrors,ShowConflictError error class ShowForm,UserFillsForm,SubmitForm,BackendValidation,CreateServer,UpdateFAISS,UpdateNginx,BroadcastUpdate process class CheckPerms,ValidateForm,ServerExists decision ``` ## Authorization & Permissions ### Permission Model Overview The registry implements a sophisticated role-based access control (RBAC) system with multiple layers of authorization: ```mermaid graph TB subgraph "User Identity Layer" User[User Account] Groups[User Groups
from IdP] AuthMethod[Authentication Method] end subgraph "Permission Mapping Layer" ScopeMapping[Group → Scope Mapping
auth_server/scopes.yml] Scopes[MCP Scopes] ServerAccess[Accessible Server List] end subgraph "Capability Layer" ReadAccess[Read Access
View servers & tools] ModifyAccess[Modify Access
Toggle, edit servers] AdminAccess[Admin Access
Full system control] ServerSpecific[Server-Specific Access
Fine-grained permissions] end subgraph "UI Rendering Layer" AdminUI[Admin Interface Elements] ModifyUI[Modification Controls] ReadOnlyUI[Read-Only Displays] FilteredContent[Filtered Server Lists] end User --> Groups User --> AuthMethod Groups --> ScopeMapping ScopeMapping --> Scopes Scopes --> ServerAccess ServerAccess --> ReadAccess ServerAccess --> ModifyAccess ServerAccess --> AdminAccess ServerAccess --> ServerSpecific ReadAccess --> FilteredContent ModifyAccess --> ModifyUI AdminAccess --> AdminUI ServerSpecific --> FilteredContent classDef identity fill:#e3f2fd,stroke:#1976d2,stroke-width:2px classDef mapping fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px classDef capability fill:#e8f5e8,stroke:#388e3c,stroke-width:2px classDef ui fill:#fff3e0,stroke:#f57c00,stroke-width:2px class User,Groups,AuthMethod identity class ScopeMapping,Scopes,ServerAccess mapping class ReadAccess,ModifyAccess,AdminAccess,ServerSpecific capability class AdminUI,ModifyUI,ReadOnlyUI,FilteredContent ui ``` ### Role Definitions & Capabilities #### 1. Administrator Role (`mcp-admin` group) **Full System Access**: - View, create, edit, and delete all servers - Access to all MCP tools regardless of server - System configuration and user management - Complete audit trail visibility **Granted Scopes**: - `mcp-servers-unrestricted/read` - `mcp-servers-unrestricted/execute` **UI Capabilities**: - All server cards visible and interactive - Edit buttons on all servers - "Add New Server" functionality - Toggle switches on all services - Admin-only configuration panels #### 2. Regular User Role (`mcp-user` group) **Limited Read Access**: - View only servers explicitly assigned to user - Read-only access to server information - Cannot modify server configurations - Cannot toggle server states **Granted Scopes**: - `mcp-servers-restricted/read` (limited scope) **UI Capabilities**: - Filtered server list (only accessible servers) - Read-only status indicators instead of toggles - No edit buttons or admin controls - Basic server information display #### 3. Server-Specific Roles (`mcp-server-{name}` groups) **Targeted Access**: - Access to specific servers based on group name - Execute permissions for assigned servers - May include toggle permissions for specific services **Example Scopes**: - `mcp-servers-fininfo/read` + `mcp-servers-fininfo/execute` - `mcp-servers-currenttime/read` + `mcp-servers-currenttime/execute` **UI Capabilities**: - Filtered view showing only assigned servers - Toggle functionality for assigned servers - Edit access may be granted for specific servers ### Scope Configuration System The authorization system uses a YAML-based configuration file (`auth_server/scopes.yml`) to map groups to permissions: ```yaml # Group to scope mappings group_mappings: # Administrator - full access mcp-admin: - "mcp-servers-unrestricted/read" - "mcp-servers-unrestricted/execute" # Regular user - restricted read-only access mcp-user: - "mcp-servers-restricted/read" # Server-specific access groups mcp-server-fininfo: - "mcp-servers-fininfo/read" - "mcp-servers-fininfo/execute" mcp-server-currenttime: - "mcp-servers-currenttime/read" - "mcp-servers-currenttime/execute" # Scope definitions with server mappings mcp-servers-fininfo/read: - server: "Financial Info Proxy" permissions: ["read"] mcp-servers-fininfo/execute: - server: "Financial Info Proxy" permissions: ["read", "execute"] mcp-servers-currenttime/read: - server: "Current Time API" permissions: ["read"] mcp-servers-currenttime/execute: - server: "Current Time API" permissions: ["read", "execute"] # Unrestricted scopes (for admins) mcp-servers-unrestricted/read: # Grants access to all servers mcp-servers-unrestricted/execute: # Grants execute access to all servers ``` ### Permission Checking Implementation #### Enhanced Authentication Dependency ```python # registry/auth/dependencies.py def enhanced_auth(session: str = Cookie(alias="mcp_gateway_session")) -> Dict[str, Any]: """Enhanced authentication dependency with full permission context""" session_data = get_user_session_data(session) username = session_data['username'] groups = session_data.get('groups', []) auth_method = session_data.get('auth_method', 'oauth2') logger.info(f"Enhanced auth for {username}: groups={groups}, auth_method={auth_method}") # Map groups to scopes based on Cognito/IdP group mappings scopes = map_cognito_groups_to_scopes(groups) logger.info(f"User {username} mapped to scopes: {scopes}") # Calculate accessible servers from scopes accessible_servers = get_user_accessible_servers(scopes) # Determine modification permissions can_modify = user_can_modify_servers(groups, scopes) # Check for admin privileges is_admin = 'mcp-admin' in groups user_context = { 'username': username, 'groups': groups, 'scopes': scopes, 'auth_method': auth_method, 'provider': session_data.get('provider', 'oauth2'), 'accessible_servers': accessible_servers, 'can_modify_servers': can_modify, 'is_admin': is_admin } logger.debug(f"Final user context for {username}: {user_context}") return user_context ``` #### Group to Scope Mapping ```python # registry/auth/dependencies.py def map_cognito_groups_to_scopes(groups: List[str]) -> List[str]: """Map Cognito groups to MCP scopes using scopes.yml configuration""" scopes = [] group_mappings = SCOPES_CONFIG.get('group_mappings', {}) for group in groups: if group in group_mappings: group_scopes = group_mappings[group] scopes.extend(group_scopes) logger.debug(f"Mapped group '{group}' to scopes: {group_scopes}") else: logger.debug(f"No scope mapping found for group: {group}") # Remove duplicates while preserving order unique_scopes = list(dict.fromkeys(scopes)) logger.info(f"Final mapped scopes: {unique_scopes}") return unique_scopes def get_user_accessible_servers(user_scopes: List[str]) -> List[str]: """Get list of all servers the user has access to based on their scopes""" accessible_servers = set() for scope in user_scopes: # Check for unrestricted access if scope in ['mcp-servers-unrestricted/read', 'mcp-servers-unrestricted/execute']: return ['*'] # Special marker for all servers # Get servers for specific scopes server_names = get_servers_for_scope(scope) accessible_servers.update(server_names) return list(accessible_servers) def user_can_modify_servers(user_groups: List[str], user_scopes: List[str]) -> bool: """Check if user can modify servers (toggle, edit)""" # Admin users can always modify if 'mcp-admin' in user_groups: return True # Users with unrestricted execute access can modify if 'mcp-servers-unrestricted/execute' in user_scopes: return True # Check for any execute permissions execute_scopes = [scope for scope in user_scopes if '/execute' in scope] return len(execute_scopes) > 0 ``` ### Server Access Filtering #### Permission-Based Server Filtering ```python # registry/services/server_service.py def get_all_servers_with_permissions(self, accessible_servers: Optional[List[str]] = None) -> Dict[str, Dict[str, Any]]: """Get servers filtered by user permissions""" all_servers = self.get_all_servers() # Admin users or users with unrestricted access see all servers if accessible_servers is None or '*' in accessible_servers: logger.info("User has unrestricted server access") return all_servers # Filter servers based on accessible server names filtered_servers = {} for path, server_info in all_servers.items(): server_name = server_info.get("server_name", "") if server_name in accessible_servers: filtered_servers[path] = server_info logger.debug(f"Server '{server_name}' accessible to user") else: logger.debug(f"Server '{server_name}' filtered out for user") logger.info(f"Filtered server list: {len(filtered_servers)} of {len(all_servers)} servers accessible") return filtered_servers def user_can_access_server_path(self, path: str, accessible_servers: List[str]) -> bool: """Check if user can access a specific server path""" if '*' in accessible_servers: return True # Unrestricted access server_info = self.get_server_info(path) if not server_info: return False server_name = server_info.get("server_name", "") return server_name in accessible_servers ``` ### Route-Level Permission Enforcement #### Protected Route Examples ```python # registry/api/server_routes.py @router.get("/", response_class=HTMLResponse) async def read_root(request: Request, user_context: Annotated[dict, Depends(enhanced_auth)]): """Main dashboard with permission-based server filtering""" # Filter servers based on user permissions if user_context['is_admin']: all_servers = server_service.get_all_servers() logger.info(f"Admin user accessing all {len(all_servers)} servers") else: all_servers = server_service.get_all_servers_with_permissions( user_context['accessible_servers'] ) logger.info(f"User accessing {len(all_servers)} permitted servers") # Render dashboard with filtered content return templates.TemplateResponse("index.html", { "request": request, "services": service_data, "username": user_context['username'], "user_context": user_context }) @router.post("/toggle/{service_path:path}") async def toggle_service_route(service_path: str, user_context: Annotated[dict, Depends(enhanced_auth)]): """Service toggle with multi-level permission checking""" # Check global modification permission if not user_context['can_modify_servers']: logger.warning(f"User {user_context['username']} attempted toggle without modify permissions") raise HTTPException(status_code=403, detail="You do not have permission to modify servers") # For non-admin users, check specific server access if not user_context['is_admin']: if not server_service.user_can_access_server_path( service_path, user_context['accessible_servers']): logger.warning(f"User {user_context['username']} attempted to access {service_path} without permission") raise HTTPException(status_code=403, detail="You do not have access to this server") # Proceed with toggle operation return perform_toggle_operation(service_path, user_context) @router.get("/api/server_details/{service_path:path}") async def get_server_details(service_path: str, user_context: Annotated[dict, Depends(enhanced_auth)]): """Server details with permission-based filtering""" # Handle special '/all' endpoint for admins if service_path == '/all': if user_context['is_admin']: return server_service.get_all_servers() else: return server_service.get_all_servers_with_permissions( user_context['accessible_servers'] ) # Check individual server access server_info = server_service.get_server_info(service_path) if not server_info: raise HTTPException(status_code=404, detail="Service not found") if not user_context['is_admin']: if not server_service.user_can_access_server_path( service_path, user_context['accessible_servers']): raise HTTPException(status_code=403, detail="Access denied to this server") return server_info ``` ### Permission Validation Flow ```mermaid flowchart TD RequestStart([HTTP Request]) --> ExtractSession[Extract Session Cookie] ExtractSession --> ValidateSession{Session Valid?} ValidateSession -->|No| Unauthorized[Return 401 Unauthorized] ValidateSession -->|Yes| ExtractUserContext[Extract User Context] ExtractUserContext --> LoadGroups[Load User Groups] LoadGroups --> MapScopes[Map Groups to Scopes] MapScopes --> CalculateServers[Calculate Accessible Servers] CalculateServers --> CheckModifyPermission[Check Modify Permissions] CheckModifyPermission --> RouteSpecificCheck{Route Requires
Specific Permissions?} RouteSpecificCheck -->|Global Access| AllowAccess[Allow Request] RouteSpecificCheck -->|Server-Specific| CheckServerAccess{User Can Access
Specific Server?} RouteSpecificCheck -->|Modify Required| CheckModifyCapability{User Can
Modify Servers?} CheckServerAccess -->|Yes| AllowAccess CheckServerAccess -->|No| Forbidden[Return 403 Forbidden] CheckModifyCapability -->|Yes| CheckServerAccess CheckModifyCapability -->|No| Forbidden AllowAccess --> FilterContent[Filter Content by Permissions] FilterContent --> RenderResponse[Render Response] Unauthorized --> End([End]) Forbidden --> End RenderResponse --> End classDef success fill:#e8f5e8,stroke:#4caf50,stroke-width:2px classDef error fill:#ffebee,stroke:#f44336,stroke-width:2px classDef process fill:#e3f2fd,stroke:#2196f3,stroke-width:2px classDef decision fill:#fff3e0,stroke:#ff9800,stroke-width:2px class AllowAccess,RenderResponse success class Unauthorized,Forbidden error class ExtractSession,ExtractUserContext,LoadGroups,MapScopes,CalculateServers,CheckModifyPermission,FilterContent process class ValidateSession,RouteSpecificCheck,CheckServerAccess,CheckModifyCapability decision ``` ## Technical Implementation ### Session Management Deep Dive #### Session Cookie Architecture The registry uses `itsdangerous.URLSafeTimedSerializer` for secure, stateless session management: ```python # registry/auth/dependencies.py from itsdangerous import URLSafeTimedSerializer, SignatureExpired, BadSignature # Initialize session signer with secret key signer = URLSafeTimedSerializer(settings.secret_key) def create_session_cookie(username: str, auth_method: str = "oauth2", provider: str = "cognito") -> str: """Create a secure session cookie for a user""" session_data = { "username": username, "auth_method": auth_method, # 'oauth2' "provider": provider, # 'cognito', 'saml', etc. "created_at": datetime.utcnow().isoformat(), "groups": [], # Populated during OAuth2 flow "scopes": [] # Calculated from groups } # Create signed, time-limited cookie return signer.dumps(session_data) ``` #### Session Validation Implementation ```python def get_user_session_data(session: str = Cookie(alias="mcp_gateway_session")) -> Dict[str, Any]: """Extract and validate session data from cookie""" if not session: raise HTTPException(status_code=401, detail="Authentication required") try: # Validate signature and expiration data = signer.loads(session, max_age=settings.session_max_age_seconds) if not data.get('username'): raise HTTPException(status_code=401, detail="Invalid session data") return data except SignatureExpired: raise HTTPException(status_code=401, detail="Session has expired") except BadSignature: raise HTTPException(status_code=401, detail="Invalid session") except Exception as e: logger.error(f"Session validation error: {e}") raise HTTPException(status_code=401, detail="Authentication failed") ``` ### OAuth2 Integration Architecture #### External Auth Server Communication ```mermaid sequenceDiagram participant Registry as Registry App participant AuthServer as Auth Server
(:8888) participant IdP as Identity Provider
(Cognito/SAML) Note over Registry,IdP: Provider Discovery Registry->>AuthServer: GET /oauth2/providers AuthServer->>Registry: Available provider list Note over Registry,IdP: OAuth2 Login Initiation Registry->>AuthServer: GET /oauth2/login/{provider}?redirect_uri=... AuthServer->>IdP: OAuth2 PKCE flow initiation IdP->>AuthServer: Authorization code Note over Registry,IdP: Token Exchange & Session Creation AuthServer->>IdP: Exchange code for tokens IdP->>AuthServer: Access token + user info AuthServer->>AuthServer: Extract user groups AuthServer->>AuthServer: Map groups to MCP scopes AuthServer->>AuthServer: Create registry-compatible session cookie AuthServer->>Registry: Set session cookie + redirect Note over Registry,IdP: Session Validation Registry->>Registry: Validate session cookie signature Registry->>Registry: Extract user context & permissions Registry->>Registry: Render permission-based UI ``` #### Provider Configuration Management ```python # registry/auth/routes.py async def get_oauth2_providers(): """Dynamically fetch available OAuth2 providers from auth server""" try: async with httpx.AsyncClient() as client: response = await client.get( f"{settings.auth_server_url}/oauth2/providers", timeout=5.0 ) if response.status_code == 200: data = response.json() providers = data.get("providers", []) logger.info(f"Loaded {len(providers)} OAuth2 providers") return providers except httpx.TimeoutException: logger.warning("Timeout fetching OAuth2 providers from auth server") except httpx.ConnectError: logger.warning("Cannot connect to auth server for provider discovery") except Exception as e: logger.warning(f"Failed to fetch OAuth2 providers: {e}") return [] # No providers available ``` ### Authentication Dependencies System #### Dependency Injection Hierarchy ```mermaid graph TB subgraph "FastAPI Dependency Hierarchy" BasicAuth[get_current_user
Returns: username only] SessionAuth[get_user_session_data
Returns: full session data] EnhancedAuth[enhanced_auth
Returns: permissions + context] end subgraph "Usage Patterns" SimpleRoutes[Simple Routes
Basic user identification] DataRoutes[Data Routes
Need session info] ProtectedRoutes[Protected Routes
Permission-based access] end BasicAuth --> SimpleRoutes SessionAuth --> DataRoutes EnhancedAuth --> ProtectedRoutes BasicAuth -.-> SessionAuth SessionAuth -.-> EnhancedAuth classDef dependency fill:#e3f2fd,stroke:#1976d2,stroke-width:2px classDef usage fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px class BasicAuth,SessionAuth,EnhancedAuth dependency class SimpleRoutes,DataRoutes,ProtectedRoutes usage ``` #### Implementation Examples ```python # registry/auth/dependencies.py # Level 1: Basic Authentication def get_current_user(session: str = Cookie(alias="mcp_gateway_session")) -> str: """Basic authentication - returns username only""" if not session: raise HTTPException(status_code=401, detail="Authentication required") try: data = signer.loads(session, max_age=settings.session_max_age_seconds) username = data.get('username') if not username: raise HTTPException(status_code=401, detail="Invalid session data") return username except (SignatureExpired, BadSignature): raise HTTPException(status_code=401, detail="Invalid or expired session") # Level 2: Session Data Extraction def get_user_session_data(session: str = Cookie(alias="mcp_gateway_session")) -> Dict[str, Any]: """Full session data extraction with validation""" # Implementation shown above pass # Level 3: Enhanced Authentication with Permissions def enhanced_auth(session: str = Cookie(alias="mcp_gateway_session")) -> Dict[str, Any]: """Complete user context with permissions and authorization""" session_data = get_user_session_data(session) # Calculate permissions and accessible servers # Implementation shown in Authorization section return user_context ``` ### WebSocket Authentication Handling #### WebSocket Session Validation ```python # registry/health/routes.py @router.websocket("/ws/health_status") async def websocket_endpoint(websocket: WebSocket): """WebSocket endpoint with automatic session validation""" connection_added = False try: # WebSocket cookies are automatically included in handshake # Validate session before accepting connection session_cookie = None for cookie in websocket.cookies: if cookie.name == settings.session_cookie_name: session_cookie = cookie.value break if session_cookie: try: # Validate session session_data = signer.loads( session_cookie, max_age=settings.session_max_age_seconds ) username = session_data.get('username') if username: logger.info(f"WebSocket connection from authenticated user: {username}") else: raise ValueError("No username in session") except Exception as e: logger.warning(f"WebSocket authentication failed: {e}") await websocket.close(code=1008, reason="Authentication failed") return else: logger.warning("WebSocket connection without valid session cookie") await websocket.close(code=1008, reason="Authentication required") return # Accept connection after successful authentication connection_added = await health_service.add_websocket_connection(websocket) if not connection_added: return # Connection rejected (server at capacity) # Keep connection alive while True: try: await asyncio.wait_for(websocket.receive_text(), timeout=30.0) except asyncio.TimeoutError: await websocket.ping() # Keep-alive ping except WebSocketDisconnect: logger.debug(f"WebSocket client disconnected") except Exception as e: logger.warning(f"WebSocket error: {e}") finally: if connection_added: await health_service.remove_websocket_connection(websocket) ``` ### Database-Free Architecture The registry implements a **stateless, file-based architecture** that doesn't require a traditional database: #### Server Data Storage ```python # registry/services/server_service.py class ServerService: """File-based server management service""" def __init__(self): self.servers: Dict[str, Dict[str, Any]] = {} self.enabled_services: Set[str] = set() def load_servers_and_state(self): """Load server definitions from JSON files and state from state file""" # Load individual server definitions servers_dir = settings.servers_dir for json_file in servers_dir.glob("*.json"): if json_file.name == "server_state.json": continue # Skip state file try: with open(json_file, "r") as f: server_data = json.load(f) path = server_data.get("path") if path: self.servers[path] = server_data logger.info(f"Loaded server definition: {path}") except Exception as e: logger.error(f"Error loading {json_file}: {e}") # Load service state self._load_service_state() def _load_service_state(self): """Load enabled/disabled state from server_state.json""" state_file = settings.state_file_path if state_file.exists(): try: with open(state_file, "r") as f: state_data = json.load(f) for path, enabled in state_data.items(): if enabled and path in self.servers: self.enabled_services.add(path) logger.info(f"Loaded service state for {len(state_data)} services") except Exception as e: logger.error(f"Error loading service state: {e}") def save_service_state(self): """Save current enabled/disabled state to file""" state_data = {} for path in self.servers: state_data[path] = path in self.enabled_services try: with open(settings.state_file_path, "w") as f: json.dump(state_data, f, indent=2) logger.info("Service state saved successfully") except Exception as e: logger.error(f"Error saving service state: {e}") ``` #### Configuration Management ```python # registry/core/config.py class Settings(BaseSettings): """Centralized configuration with environment variable support""" # Development vs Production path detection @property def is_local_dev(self) -> bool: """Detect if running in local development mode""" return not Path("/app").exists() @property def servers_dir(self) -> Path: """Dynamic path resolution for server definitions""" if self.is_local_dev: return Path.cwd() / "registry" / "servers" return self.container_registry_dir / "servers" @property def templates_dir(self) -> Path: """Dynamic path resolution for templates""" if self.is_local_dev: return Path.cwd() / "registry" / "templates" return self.container_registry_dir / "templates" ``` ## Configuration ### Environment Variables Reference #### Core Authentication Settings ```bash # Session Management SECRET_KEY=your-secure-random-secret-key-here SESSION_COOKIE_NAME=mcp_gateway_session SESSION_MAX_AGE_SECONDS=28800 # 8 hours default # External Auth Server Integration AUTH_SERVER_URL=http://localhost:8888 AUTH_SERVER_EXTERNAL_URL=http://localhost:8888 # For browser redirects # Path Configuration (auto-detected in most cases) CONTAINER_APP_DIR=/app CONTAINER_REGISTRY_DIR=/app/registry CONTAINER_LOG_DIR=/app/logs ``` #### OAuth2 Provider Configuration ```bash # Amazon Cognito Integration (if using Cognito directly) COGNITO_DOMAIN=your-cognito-domain COGNITO_CLIENT_ID=your-cognito-client-id COGNITO_REGION=us-east-1 # Custom OAuth2 Providers (configured in auth server) # These are typically configured in the auth_server application ``` ### Development vs Production Configuration #### Local Development Setup ```python # Automatic detection and configuration # registry/core/config.py @property def is_local_dev(self) -> bool: """Check if running in local development mode""" return not Path("/app").exists() # Development paths if settings.is_local_dev: # Paths resolve to ./registry/ subdirectories servers_dir = Path.cwd() / "registry" / "servers" templates_dir = Path.cwd() / "registry" / "templates" static_dir = Path.cwd() / "registry" / "static" log_file = Path.cwd() / "logs" / "registry.log" ``` #### Container/Production Setup ```python # Production paths (when /app exists) else: # Paths resolve to /app/registry/ structure servers_dir = Path("/app/registry/servers") templates_dir = Path("/app/registry/templates") static_dir = Path("/app/registry/static") log_file = Path("/app/logs/registry.log") ``` ### Authentication Provider Setup #### OAuth2 Integration Setup 1. **Configure Auth Server** (separate application): ```yaml # auth_server/config.yml providers: cognito: domain: your-cognito-domain client_id: your-client-id region: us-east-1 saml: endpoint: https://your-saml-provider.com/saml entity_id: your-entity-id ``` 2. **Configure Group Mappings**: ```yaml # auth_server/scopes.yml group_mappings: mcp-admin: - "mcp-servers-unrestricted/read" - "mcp-servers-unrestricted/execute" ``` ### Security Configuration #### Secret Key Management ```python # registry/core/config.py def __init__(self, **kwargs): super().__init__(**kwargs) # Generate secret key if not provided if not self.secret_key: self.secret_key = secrets.token_hex(32) logger.warning("Generated random SECRET_KEY - sessions will not persist across restarts") ``` #### Session Security Settings ```python # Session cookie configuration response.set_cookie( key=settings.session_cookie_name, value=session_data, max_age=settings.session_max_age_seconds, httponly=True, # Prevent XSS access samesite="lax", # CSRF protection secure=False # Set to True in production with HTTPS ) ``` ### Deployment Configuration Examples #### Docker Compose Setup ```yaml # docker-compose.yml services: registry: build: . environment: - SECRET_KEY=${SECRET_KEY} - AUTH_SERVER_URL=http://auth-server:8888 - AUTH_SERVER_EXTERNAL_URL=http://localhost:8888 volumes: - ./registry/servers:/app/registry/servers - ./logs:/app/logs ports: - "7860:7860" auth-server: build: ./auth_server environment: - COGNITO_DOMAIN=${COGNITO_DOMAIN} - COGNITO_CLIENT_ID=${COGNITO_CLIENT_ID} ports: - "8888:8888" ``` #### Environment File Template ```bash # .env file template SECRET_KEY=generate-a-secure-random-key-here # Auth Server Configuration AUTH_SERVER_URL=http://localhost:8888 AUTH_SERVER_EXTERNAL_URL=http://localhost:8888 # OAuth2 Provider Settings (if applicable) COGNITO_DOMAIN=your-cognito-domain COGNITO_CLIENT_ID=your-client-id COGNITO_REGION=us-east-1 # Optional: Custom paths (usually auto-detected) # CONTAINER_REGISTRY_DIR=/custom/path/registry # CONTAINER_LOG_DIR=/custom/path/logs ``` ## Troubleshooting ### Common Authentication Issues #### 1. Session Cookie Problems **Issue**: Users get redirected to login page repeatedly **Diagnosis**: ```python # Add debug logging to session validation def get_user_session_data(session: str = None) -> Dict[str, Any]: logger.info(f"Session cookie received: {session[:20] if session else 'None'}...") try: data = signer.loads(session, max_age=settings.session_max_age_seconds) logger.info(f"Session data valid for user: {data.get('username')}") return data except SignatureExpired: logger.warning("Session cookie has expired") raise HTTPException(status_code=401, detail="Session has expired") except BadSignature: logger.warning("Invalid session cookie signature") raise HTTPException(status_code=401, detail="Invalid session") ``` **Common Solutions**: - **Inconsistent SECRET_KEY**: Ensure `SECRET_KEY` is consistent across application restarts - **Clock Skew**: Check system time if using multiple servers - **Cookie Domain Issues**: Verify cookie domain matches request domain - **Browser Issues**: Clear browser cookies and try again #### 2. OAuth2 Integration Issues **Issue**: OAuth2 login fails or redirects incorrectly **Diagnosis**: ```python # Debug OAuth2 callback handling @router.get("/auth/callback") async def oauth2_callback(request: Request, error: str = None, details: str = None): logger.info(f"OAuth2 callback received - Error: {error}, Details: {details}") if error: logger.error(f"OAuth2 authentication error: {error} - {details}") return RedirectResponse(url=f"/login?error={urllib.parse.quote(error)}") # Check session cookie from auth server session_cookie = request.cookies.get(settings.session_cookie_name) logger.info(f"OAuth2 callback session cookie: {'Present' if session_cookie else 'Missing'}") if session_cookie: try: session_data = signer.loads(session_cookie, max_age=settings.session_max_age_seconds) logger.info(f"OAuth2 session valid for: {session_data.get('username')}") return RedirectResponse(url="/", status_code=302) except Exception as e: logger.error(f"OAuth2 session validation failed: {e}") return RedirectResponse(url="/login?error=oauth2_session_invalid", status_code=302) ``` **Common Solutions**: - **Auth Server Connectivity**: Test auth server: `curl http://localhost:8888/oauth2/providers` - **URL Configuration**: Verify `AUTH_SERVER_URL` and `AUTH_SERVER_EXTERNAL_URL` settings - **Provider Configuration**: Check OAuth2 client configuration in identity provider - **Redirect URI Mismatch**: Ensure redirect URIs match in provider configuration #### 3. Permission and Authorization Issues **Issue**: Users can't access servers they should have permission for **Diagnosis**: ```python # Debug permission calculation def debug_user_permissions(user_context: dict): logger.info("=== USER PERMISSION DEBUG ===") logger.info(f"Username: {user_context['username']}") logger.info(f"Auth Method: {user_context['auth_method']}") logger.info(f"Groups: {user_context['groups']}") logger.info(f"Scopes: {user_context['scopes']}") logger.info(f"Accessible Servers: {user_context['accessible_servers']}") logger.info(f"Can Modify: {user_context['can_modify_servers']}") logger.info(f"Is Admin: {user_context['is_admin']}") logger.info("============================") # Add to enhanced_auth function def enhanced_auth(session: str = None) -> Dict[str, Any]: # ... existing code ... user_context = { # ... context building ... } debug_user_permissions(user_context) # Add this line return user_context ``` **Common Solutions**: - **Group Mapping Issues**: Verify `auth_server/scopes.yml` configuration - **User Group Assignment**: Check user group assignments in identity provider (Cognito) - **Server Name Mismatch**: Ensure server names in scopes.yml exactly match server definitions - **Scope Configuration**: Verify scope definitions reference correct server names #### 4. WebSocket Authentication Issues **Issue**: Real-time updates not working, WebSocket connections failing **Diagnosis**: ```python # Debug WebSocket authentication @router.websocket("/ws/health_status") async def websocket_endpoint(websocket: WebSocket): logger.info(f"WebSocket connection attempt from: {websocket.client}") # Debug cookie extraction session_cookie = None logger.info(f"WebSocket cookies: {list(websocket.cookies.keys())}") for cookie_name, cookie_value in websocket.cookies.items(): logger.info(f"Cookie: {cookie_name} = {cookie_value[:20]}...") if cookie_name == settings.session_cookie_name: session_cookie = cookie_value if not session_cookie: logger.warning("WebSocket connection without session cookie") await websocket.close(code=1008, reason="No session cookie") return try: session_data = signer.loads(session_cookie, max_age=settings.session_max_age_seconds) username = session_data.get('username') logger.info(f"WebSocket authenticated for user: {username}") await websocket.accept() except Exception as e: logger.error(f"WebSocket authentication failed: {e}") await websocket.close(code=1008, reason="Authentication failed") ``` **Common Solutions**: - **Browser Cookie Issues**: Check browser developer tools for cookie presence - **WebSocket URL**: Verify WebSocket URL scheme (ws:// vs wss://) - **Proxy Configuration**: Ensure reverse proxy supports WebSocket upgrades - **Firewall Issues**: Check if WebSocket ports are accessible ### Health Check and Monitoring #### Authentication Health Endpoint ```python # registry/main.py or separate health module @app.get("/health/auth") async def auth_health_check(): """Comprehensive authentication system health check""" health_status = { "timestamp": datetime.utcnow().isoformat(), "components": { "session_signer": "unknown", "auth_server": "unknown", "oauth2_providers": [], "scope_config": "unknown" } } # Test session signer try: test_data = {"test": "data"} test_cookie = signer.dumps(test_data) decoded_data = signer.loads(test_cookie, max_age=60) if decoded_data == test_data: health_status["components"]["session_signer"] = "ok" else: health_status["components"]["session_signer"] = "error: data mismatch" except Exception as e: health_status["components"]["session_signer"] = f"error: {e}" # Test auth server connectivity try: async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get(f"{settings.auth_server_url}/health") if response.status_code == 200: health_status["components"]["auth_server"] = "ok" # Test OAuth2 providers endpoint providers_response = await client.get(f"{settings.auth_server_url}/oauth2/providers") if providers_response.status_code == 200: providers_data = providers_response.json() health_status["components"]["oauth2_providers"] = providers_data.get("providers", []) else: health_status["components"]["oauth2_providers"] = "error: provider endpoint failed" else: health_status["components"]["auth_server"] = f"error: HTTP {response.status_code}" except httpx.TimeoutException: health_status["components"]["auth_server"] = "error: timeout" except httpx.ConnectError: health_status["components"]["auth_server"] = "error: connection failed" except Exception as e: health_status["components"]["auth_server"] = f"error: {e}" # Test scope configuration try: from .auth.dependencies import SCOPES_CONFIG if SCOPES_CONFIG and "group_mappings" in SCOPES_CONFIG: group_count = len(SCOPES_CONFIG["group_mappings"]) health_status["components"]["scope_config"] = f"ok: {group_count} group mappings" else: health_status["components"]["scope_config"] = "warning: no scope configuration loaded" except Exception as e: health_status["components"]["scope_config"] = f"error: {e}" # Overall health determination error_components = [k for k, v in health_status["components"].items() if str(v).startswith("error")] if error_components: health_status["status"] = "unhealthy" health_status["errors"] = error_components else: health_status["status"] = "healthy" return health_status ``` #### Authentication Event Logging ```python # Enhanced logging for authentication events def log_auth_event(event_type: str, username: str = None, details: dict = None, request: Request = None): """Comprehensive authentication event logging""" log_data = { 'event_type': event_type, 'username': username, 'timestamp': datetime.utcnow().isoformat(), 'details': details or {} } if request: log_data.update({ 'client_ip': request.client.host if request.client else 'unknown', 'user_agent': request.headers.get('user-agent', 'unknown'), 'request_path': str(request.url.path), 'request_method': request.method }) logger.info(f"AUTH_EVENT: {event_type}", extra=log_data) # Usage examples throughout the application log_auth_event('LOGIN_SUCCESS', username='admin', request=request) log_auth_event('LOGIN_FAILED', details={'reason': 'invalid_credentials'}, request=request) log_auth_event('PERMISSION_DENIED', username='user', details={'resource': '/toggle/fininfo', 'required_permission': 'modify'}, request=request) log_auth_event('SESSION_EXPIRED', username='user', request=request) log_auth_event('OAUTH2_LOGIN_START', details={'provider': 'cognito'}, request=request) log_auth_event('OAUTH2_LOGIN_SUCCESS', username='user@example.com', details={'provider': 'cognito', 'groups': ['mcp-user']}, request=request) ``` ### Common Configuration Mistakes #### 1. Incorrect Path Configuration ```bash # Wrong - mixing local and container paths CONTAINER_REGISTRY_DIR=/app/registry # But running locally where paths should be ./registry/ # Solution: Let the application auto-detect paths or set correctly # For local development, omit these variables entirely ``` #### 2. Secret Key Issues ```bash # Wrong - using a weak or default secret key SECRET_KEY=mysecret # Correct - use a strong, randomly generated key SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(32))") ``` #### 3. Auth Server URL Mismatch ```bash # Wrong - internal and external URLs are the same in Docker AUTH_SERVER_URL=http://localhost:8888 AUTH_SERVER_EXTERNAL_URL=http://localhost:8888 # Correct - distinguish internal vs external access AUTH_SERVER_URL=http://auth-server:8888 # Internal Docker communication AUTH_SERVER_EXTERNAL_URL=http://localhost:8888 # Browser-accessible URL ``` This comprehensive documentation provides complete coverage of the registry's authentication and authorization system, from high-level architecture to specific implementation details and troubleshooting guidance. ================================================ FILE: docs/registry-deployment-modes.md ================================================ # Registry Deployment and Registry Mode Configuration This guide explains the `DEPLOYMENT_MODE` and `REGISTRY_MODE` environment variables that control how the MCP Gateway Registry operates. ## Overview The registry supports two configuration settings that control its behavior: | Setting | Purpose | Options | |---------|---------|---------| | `DEPLOYMENT_MODE` | Controls nginx/gateway integration | `with-gateway`, `registry-only` | | `REGISTRY_MODE` | Controls which features are enabled (informational) | `full`, `skills-only`, `mcp-servers-only`, `agents-only` | ## DEPLOYMENT_MODE The `DEPLOYMENT_MODE` setting determines whether the registry operates as a full gateway with nginx reverse proxy integration, or as a standalone catalog/discovery service. ### Mode: with-gateway (Default) ```bash DEPLOYMENT_MODE=with-gateway ``` **Behavior:** - Nginx configuration is regenerated when MCP servers are registered or deleted - Frontend shows gateway authentication instructions (Authorization: Bearer token) - MCP proxy requests are routed through nginx to backend servers - Full gateway functionality enabled **Use when:** - Running the registry as part of the MCP Gateway infrastructure - MCP servers are accessed through the nginx reverse proxy - You need centralized authentication and routing ### Mode: registry-only ```bash DEPLOYMENT_MODE=registry-only ``` **Behavior:** - Nginx configuration is NOT updated when servers are registered/deleted - Frontend shows "Direct Connection Mode" with `proxy_pass_url` - MCP proxy requests return 503 Service Unavailable with JSON error - Registry operates as a catalog/discovery service only **Use when:** - Registry is separate from gateway infrastructure - Clients connect directly to MCP servers (not through gateway) - You only need server/agent discovery and metadata management ## REGISTRY_MODE The `REGISTRY_MODE` setting controls which feature flags are returned in the `/api/config` endpoint. This is informational and intended for frontend UI feature gating. **Note:** Currently, all APIs remain active regardless of this setting. The feature flags are for UI display purposes only. ### Mode Comparison Table | Mode | MCP Servers | Agents | Skills | Federation | Gateway Proxy | |------|-------------|--------|--------|------------|---------------| | `full` | Enabled | Enabled | Enabled | Enabled | Based on DEPLOYMENT_MODE | | `skills-only` | Disabled | Disabled | Enabled | Disabled | Disabled | | `mcp-servers-only` | Enabled | Disabled | Disabled | Disabled | Based on DEPLOYMENT_MODE | | `agents-only` | Disabled | Enabled | Disabled | Disabled | Based on DEPLOYMENT_MODE | ### Mode: full (Default) ```bash REGISTRY_MODE=full ``` All features enabled. The `gateway_proxy` flag depends on `DEPLOYMENT_MODE`. ### Mode: skills-only ```bash REGISTRY_MODE=skills-only ``` Only the skills feature flag is enabled. Intended for deployments focused solely on Agent Skills management. ### Mode: mcp-servers-only ```bash REGISTRY_MODE=mcp-servers-only ``` Only the MCP servers feature flag is enabled. ### Mode: agents-only ```bash REGISTRY_MODE=agents-only ``` Only the A2A agents feature flag is enabled. ## Configuration Combinations ### Valid Combinations | DEPLOYMENT_MODE | REGISTRY_MODE | Use Case | |-----------------|---------------|----------| | `with-gateway` | `full` | Full MCP Gateway with all features | | `with-gateway` | `mcp-servers-only` | Gateway for MCP servers only | | `with-gateway` | `agents-only` | Gateway for A2A agents only | | `registry-only` | `full` | Standalone catalog with all metadata | | `registry-only` | `skills-only` | Skills catalog only | | `registry-only` | `mcp-servers-only` | MCP server catalog only | | `registry-only` | `agents-only` | Agent catalog only | ### Invalid Combination (Auto-Corrected) | DEPLOYMENT_MODE | REGISTRY_MODE | Auto-Corrected To | |-----------------|---------------|-------------------| | `with-gateway` | `skills-only` | `registry-only` + `skills-only` | **Rationale:** Skills-only mode doesn't require gateway proxy functionality. The system automatically corrects this invalid combination and logs a warning. ## API Configuration Endpoint The `/api/config` endpoint returns the current configuration: ```bash curl http://localhost/api/config ``` **Example Response (with-gateway + full):** ```json { "deployment_mode": "with-gateway", "registry_mode": "full", "nginx_updates_enabled": true, "features": { "mcp_servers": true, "agents": true, "skills": true, "federation": true, "gateway_proxy": true } } ``` **Example Response (registry-only + skills-only):** ```json { "deployment_mode": "registry-only", "registry_mode": "skills-only", "nginx_updates_enabled": false, "features": { "mcp_servers": false, "agents": false, "skills": true, "federation": false, "gateway_proxy": false } } ``` ## Environment Configuration ### Docker Compose In your `.env` file: ```bash # Deployment mode: with-gateway (default) or registry-only DEPLOYMENT_MODE=registry-only # Registry mode: full (default), skills-only, mcp-servers-only, or agents-only REGISTRY_MODE=skills-only ``` ### Terraform (AWS ECS) In `terraform.tfvars`: ```hcl # Deployment mode deployment_mode = "registry-only" # Registry mode (optional, defaults to "full") registry_mode = "skills-only" ``` Or via environment variables: ```bash export TF_VAR_deployment_mode="registry-only" export TF_VAR_registry_mode="skills-only" ``` ## Frontend Behavior ### ServerConfigModal The `ServerConfigModal` component adapts based on `deployment_mode`: **with-gateway mode:** - Shows gateway URL constructed from current hostname - Displays "Authentication Required" warning - Shows `[YOUR_AUTH_TOKEN]` placeholder in configuration **registry-only mode:** - Shows `proxy_pass_url` (direct server URL) - Displays "Direct Connection Mode" banner - No gateway authentication headers in configuration ### Feature Flags (Future) The `features` object in `/api/config` is intended for frontend navigation gating: ```typescript const { config } = useRegistryConfig(); // Hide navigation items based on features {config?.features.mcp_servers && MCP Servers} {config?.features.agents && A2A Agents} {config?.features.skills && Skills} {config?.features.federation && Federation} ``` **Note:** This frontend gating is not yet implemented. Currently all navigation items are visible regardless of mode. ## Startup Logging The registry logs its configuration at startup: ``` INFO: Registry Configuration: INFO: DEPLOYMENT_MODE: registry-only INFO: REGISTRY_MODE: skills-only INFO: Nginx updates: DISABLED ``` If an invalid combination is detected: ``` WARNING: ============================================================ WARNING: Invalid configuration detected! WARNING: DEPLOYMENT_MODE=with-gateway is incompatible with REGISTRY_MODE=skills-only WARNING: Auto-correcting to DEPLOYMENT_MODE=registry-only WARNING: ============================================================ ``` ## Nginx Behavior in Registry-Only Mode When `DEPLOYMENT_MODE=registry-only`: 1. **Server Registration:** Nginx configuration is NOT updated 2. **Server Deletion:** Nginx configuration is NOT updated 3. **MCP Proxy Requests:** Return 503 with JSON error: ```json { "error": "gateway_proxy_disabled", "message": "Gateway proxy is disabled in registry-only mode. Use proxy_pass_url from server metadata for direct connection." } ``` The 503 response applies to all paths except: - `/api/*` - Registry API endpoints - `/oauth2/*` - Authentication endpoints - `/keycloak/*`, `/realms/*`, `/resources/*` - Keycloak paths - `/v0.1/*` - Anthropic-compatible API - `/health` - Health check - `/static/*`, `/assets/*`, `/_next/*` - Static assets - `/validate` - Token validation ## CLI Testing Use the registry management CLI to check configuration: ```bash # Check current configuration uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ config --json # Output formatted for readability uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ config ``` ## Related Documentation - [Configuration Reference](configuration.md) - All environment variables - [AWS ECS Deployment](../terraform/aws-ecs/README.md) - Production deployment guide - [Static Token Auth](static-token-auth.md) - API authentication without IdP ================================================ FILE: docs/registry_api.md ================================================ # MCP Gateway Registry API Documentation This document provides a comprehensive overview of all API endpoints available in the MCP Gateway Registry service. ## Table of Contents - [Authentication](#authentication) - [OAuth2 Login](#oauth2-login) - [Logout](#logout) - [Server Management](#server-management) - [Register a New Service](#register-a-new-service) - [Toggle Service Status](#toggle-service-status) - [Edit Service Details](#edit-service-details) - [API Endpoints](#api-endpoints) - [Get Server Details](#get-server-details) - [Get Service Tools](#get-service-tools) - [Refresh Service](#refresh-service) - [WebSocket Endpoints](#websocket-endpoints) - [Health Status Updates](#health-status-updates) ## Authentication > **IMPORTANT**: Most endpoints in this API require authentication via OAuth2 (Keycloak). Users authenticate through the browser-based OAuth2 flow, which sets a session cookie. The examples below use `-b cookies.txt` to include the session cookie in requests. For programmatic API access, use a JWT Bearer token obtained from your OAuth2 provider. ### OAuth2 Login Authentication is handled via OAuth2 providers (Keycloak). Navigate to `/login` in your browser to initiate the OAuth2 flow. **URL:** `/login` **Method:** `GET` **Response:** Login page with OAuth2 provider buttons **URL:** `/auth/{provider}` **Method:** `GET` **Description:** Redirects to the OAuth2 provider for authentication. After successful authentication, a session cookie is set automatically. ### Logout Logs out the current user by invalidating their session. **URL:** `/logout` **Method:** `POST` **Authentication:** Required (session cookie) **Response:** Redirects to `/login` **Example:** ```bash curl -X POST http://localhost:7860/logout \ -b cookies.txt ``` ## Server Management > **Note**: All endpoints in this section require authentication via a session cookie obtained from the OAuth2 login flow. ### Register a New Service Registers a new MCP service with the gateway. **URL:** `/register` **Method:** `POST` **Content-Type:** `application/x-www-form-urlencoded` **Authentication:** Required (session cookie) **Parameters:** - `name` (required): Display name of the service - `description` (required): Description of the service - `path` (required): URL path for the service - `proxy_pass_url` (required): URL to proxy requests to - `tags` (optional): Comma-separated list of tags - `num_tools` (optional): Number of tools provided by the service - `num_stars` (optional): Star rating for the service - `is_python` (optional): Whether the service is Python-based - `license` (optional): License information - `metadata` (optional): JSON object with custom metadata for organization, compliance, and integration tracking. Fully searchable via semantic search. **Metadata Examples:** ```json { "team": "data-platform", "owner": "alice@example.com", "compliance_level": "PCI-DSS", "cost_center": "engineering", "deployment_region": "us-east-1" } ``` **Response:** - Success: JSON response with status code 201 - Failure: JSON response with error details **Example:** ```bash # Uses the session cookie from the login request curl -X POST http://localhost:7860/register \ -b cookies.txt \ -d "name=Weather Service&description=Provides weather forecasts&path=/weather&proxy_pass_url=http://localhost:8000&tags=weather,forecast&num_tools=3&num_stars=4&is_python=true&license=MIT" ``` ### Toggle Service Status Enables or disables a registered service. **URL:** `/toggle/{service_path}` **Method:** `POST` **Content-Type:** `application/x-www-form-urlencoded` **Authentication:** Required (session cookie) **URL Parameters:** - `service_path`: Path of the service to toggle **Form Parameters:** - `enabled`: "on" to enable, omit to disable **Response:** JSON with updated service status **Example:** ```bash # Enable a service (requires session cookie) curl -X POST http://localhost:7860/toggle/weather \ -b cookies.txt \ -d "enabled=on" # Disable a service (requires session cookie) curl -X POST http://localhost:7860/toggle/weather \ -b cookies.txt ``` ### Edit Service Details Updates the details of an existing service. **URL:** `/edit/{service_path}` **Method:** `POST` **Content-Type:** `application/x-www-form-urlencoded` **Authentication:** Required (session cookie) **URL Parameters:** - `service_path`: Path of the service to edit **Form Parameters:** - `name` (required): Display name of the service - `proxy_pass_url` (required): URL to proxy requests to - `description` (optional): Description of the service - `tags` (optional): Comma-separated list of tags - `num_tools` (optional): Number of tools provided by the service - `num_stars` (optional): Star rating for the service - `is_python` (optional): Whether the service is Python-based - `license` (optional): License information **Response:** Redirects to the main page on success **Example:** ```bash # Requires session cookie from login curl -X POST http://localhost:7860/edit/weather \ -b cookies.txt \ -d "name=Weather API&description=Updated weather service&proxy_pass_url=http://localhost:8001&tags=weather,api&num_tools=5&num_stars=5&is_python=true&license=MIT" ``` ## API Endpoints > **Note**: All endpoints in this section require authentication via a session cookie obtained from the OAuth2 login flow. ### Get Server Details Retrieves detailed information about a registered service. **URL:** `/api/server_details/{service_path}` **Method:** `GET` **Authentication:** Required (session cookie) **URL Parameters:** - `service_path`: Path of the service to get details for, or "all" to get details for all services **Response:** JSON with server details **Example:** ```bash # Get details for a specific service (requires session cookie) curl -X GET http://localhost:7860/api/server_details/weather \ -b cookies.txt # Get details for all services (requires session cookie) curl -X GET http://localhost:7860/api/server_details/all \ -b cookies.txt ``` ### Get Service Tools Retrieves the list of tools provided by a service. **URL:** `/api/tools/{service_path}` **Method:** `GET` **Authentication:** Required (session cookie) **URL Parameters:** - `service_path`: Path of the service to get tools for, or "all" to get tools from all services **Response:** JSON with tool details **Example:** ```bash # Get tools for a specific service (requires session cookie) curl -X GET http://localhost:7860/api/tools/weather \ -b cookies.txt # Get tools from all services (requires session cookie) curl -X GET http://localhost:7860/api/tools/all \ -b cookies.txt ``` ### Refresh Service Manually triggers a health check and tool discovery for a service. **URL:** `/api/refresh/{service_path}` **Method:** `POST` **Authentication:** Required (session cookie) **URL Parameters:** - `service_path`: Path of the service to refresh **Response:** JSON with updated service status **Example:** ```bash # Requires session cookie from login curl -X POST http://localhost:7860/api/refresh/weather \ -b cookies.txt ``` ## WebSocket Endpoints ### Health Status Updates Provides real-time updates on the health status of all registered services. **URL:** `/ws/health_status` **Protocol:** WebSocket **Authentication:** Not required (public endpoint) **Response:** JSON messages with health status updates **Example using websocat:** First, install websocat: ```bash sudo wget -qO /usr/local/bin/websocat https://github.com/vi/websocat/releases/latest/download/websocat.x86_64-unknown-linux-musl sudo chmod +x /usr/local/bin/websocat ``` Then connect to the WebSocket endpoint: ```bash websocat ws://localhost:7860/ws/health_status ``` This will display the JSON messages with health status updates in real-time in your terminal. **Example using Python:** ```python # Python example using websockets library import asyncio import json import websockets async def health_status_monitor(): uri = "ws://localhost:7860/ws/health_status" async with websockets.connect(uri) as websocket: print("WebSocket connection established") while True: try: # Receive health status updates message = await websocket.recv() data = json.loads(message) print("Health status update received:") for path, info in data.items(): print(f"Service {path}: {info['status']}") print(f"Last checked: {info['last_checked_iso']}") print(f"Number of tools: {info['num_tools']}") print("---") except websockets.exceptions.ConnectionClosed: print("Connection closed") break # Run the async function asyncio.run(health_status_monitor()) ``` ## Authentication Flow 1. **Login**: Navigate to `/login` in your browser and authenticate via your OAuth2 provider (Keycloak). The session cookie is set automatically after successful authentication. 2. **Programmatic Access**: For API access, obtain a JWT Bearer token from your OAuth2 provider and include it in the `Authorization` header: ```bash curl -X GET http://localhost:7860/api/server_details/all \ -H "Authorization: Bearer " ``` 3. **Session Expiration**: The session cookie is valid for 8 hours. After expiration, you'll need to login again. ## API Summary * `GET /login`: Display login page with OAuth2 provider options. * `GET /auth/{provider}`: Redirect to OAuth2 provider for authentication. * `POST /logout`: Log out user and invalidate session cookie. * `GET /`: Main dashboard (web UI, requires authentication). * `GET /edit/{service_path}`: Edit service form (web UI, requires authentication). * `POST /register`: Register a new service (requires authentication). * `POST /toggle/{service_path}`: Enable/disable a service (requires authentication). * `POST /edit/{service_path}`: Update service details (requires authentication). * `GET /api/server_details/{service_path}`: Get full details for a service (requires authentication). * `GET /api/tools/{service_path}`: Get the discovered tool list for a service (requires authentication). * `POST /api/refresh/{service_path}`: Manually trigger a health check/tool update (requires authentication). * `WebSocket /ws/health_status`: Real-time connection for receiving server health status updates. ================================================ FILE: docs/remote-desktop-setup.md ================================================ # Remote Desktop Setup for Ubuntu 24.04 AWS EC2 This guide explains how to set up remote desktop access on an Ubuntu 24.04 AWS EC2 instance so you can connect from a Windows machine. ## System Information This setup is tested on: - **OS**: Ubuntu 24.04 LTS (AWS EC2) - **Architecture**: x86_64 - **Kernel**: Linux 6.14.0-1011-aws ## Option 1: XRDP (Recommended for Windows RDP) XRDP allows you to use Windows' built-in Remote Desktop Connection to connect to your Ubuntu machine. ### Installation Steps 1. **Update the system**: ```bash sudo apt update && sudo apt upgrade -y ``` 2. **Install desktop environment (XFCE - lightweight)**: ```bash sudo apt install -y xfce4 xfce4-goodies ``` 3. **Install XRDP**: ```bash sudo apt install -y xrdp ``` 4. **Configure XRDP to use XFCE**: ```bash echo "xfce4-session" > ~/.xsession ``` 5. **Start and enable XRDP service**: ```bash sudo systemctl enable xrdp sudo systemctl start xrdp ``` 6. **Configure firewall** (if ufw is enabled): ```bash sudo ufw allow 3389 ``` 7. **Set password for ubuntu user**: ```bash sudo passwd ubuntu ``` ### Install Firefox Browser ```bash sudo apt install -y firefox ``` ## Option 2: VNC Server (Alternative) VNC provides cross-platform remote desktop access but requires a separate VNC client. ### Installation Steps 1. **Install VNC server and desktop**: ```bash sudo apt update sudo apt install -y ubuntu-desktop-minimal tigervnc-standalone-server tigervnc-common ``` 2. **Set VNC password**: ```bash vncpasswd ``` 3. **Start VNC server**: ```bash vncserver :1 -geometry 1920x1080 -depth 24 ``` 4. **Configure firewall**: ```bash sudo ufw allow 5901 ``` ## AWS Security Group Configuration **Important**: You must configure your AWS Security Group to allow remote desktop connections. 1. Go to AWS Console → EC2 → Security Groups 2. Select your instance's security group 3. Add inbound rule: - **For XRDP**: - Type: Custom TCP - Port: 3389 - Source: Your IP address (for security) - **For VNC**: - Type: Custom TCP - Port: 5901 - Source: Your IP address (for security) ## Connecting from Windows ### Using XRDP (Option 1) 1. Open "Remote Desktop Connection" (built into Windows) 2. Computer: `your-ec2-hostname:3389` or `your-ec2-public-ip:3389` 3. Username: `ubuntu` 4. Password: The password you set with `sudo passwd ubuntu` ### Using VNC (Option 2) 1. Install a VNC client (like RealVNC Viewer) 2. Connect to: `your-ec2-hostname:5901` or `your-ec2-public-ip:5901` 3. Enter the VNC password you set with `vncpasswd` ## Troubleshooting ### XRDP Issues - **Black screen**: Make sure you set the session with `echo "xfce4-session" > ~/.xsession` - **Connection refused**: Check if XRDP is running: `sudo systemctl status xrdp` - **Can't connect**: Verify AWS Security Group allows port 3389 ### VNC Issues - **Display not found**: Start VNC server with `vncserver :1` - **Connection timeout**: Check AWS Security Group allows port 5901 - **Poor performance**: Try reducing color depth: `vncserver :1 -depth 16` ### General Network Issues - Verify your EC2 instance's public IP hasn't changed - Check that your home/office IP is allowed in the security group - Ensure the EC2 instance is running and accessible via SSH ## Security Considerations - **Limit source IPs**: Always restrict remote desktop access to your specific IP addresses - **Use strong passwords**: Set complex passwords for user accounts - **Consider VPN**: For production environments, consider accessing through a VPN - **Disable when not needed**: Stop XRDP/VNC services when not in use: ```bash sudo systemctl stop xrdp # For XRDP vncserver -kill :1 # For VNC ``` ## Performance Tips - **XFCE is lightweight**: We chose XFCE desktop environment for better performance over RDP - **Adjust resolution**: Use appropriate screen resolution for your connection speed - **Close unused applications**: Remote desktop uses bandwidth, so close unnecessary programs - **Use compression**: Some RDP clients offer compression options for slower connections ================================================ FILE: docs/scan_report_example.md ================================================ # MCP Server Security Scan Report **Scan Date:** 2025-10-21 23:50:03 UTC **Analyzers Used:** yara ## Executive Summary - **Total Servers Scanned:** 5 - **Passed:** 4 (80%) - **Failed:** 1 (20%) ### Aggregate Vulnerability Statistics | Severity | Count | |----------|-------| | Critical | 0 | | High | 1 | | Medium | 0 | | Low | 0 | ## Per-Server Scan Results ### io.mcpgateway/currenttime - **URL:** `https://mcpgateway.ddns.net/currenttime/mcp` - **Status:** ✅ SAFE | Severity | Count | |----------|-------| | Critical | 0 | | High | 0 | | Medium | 0 | | Low | 0 | ### io.mcpgateway/fininfo - **URL:** `https://mcpgateway.ddns.net/fininfo/mcp` - **Status:** ✅ SAFE | Severity | Count | |----------|-------| | Critical | 0 | | High | 0 | | Medium | 0 | | Low | 0 | **Error:** Scanner exit code: 1 ### io.mcpgateway/mcpgw - **URL:** `https://mcpgateway.ddns.net/mcpgw/mcp` - **Status:** ❌ UNSAFE | Severity | Count | |----------|-------| | Critical | 0 | | High | 1 | | Medium | 0 | | Low | 0 | #### Detailed Findings **Tool: `healthcheck`** - **Analyzer:** yara_analyzer - **Severity:** HIGH - **Threats:** INJECTION ATTACK - **Summary:** Detected 1 threat: sql injection **Taxonomy:** ```json { "scanner_category": "INJECTION ATTACK", "aitech": "AITech-9.1", "aitech_name": "Model or Agentic System Manipulation", "aisubtech": "AISubtech-9.1.4", "aisubtech_name": "Injection Attacks (SQL, Command Execution, XSS)", "description": "Injecting malicious payloads such as SQL queries, command sequences, or scripts into MCP servers or tools that process model or user input, leading to data exposure, remote code execution, or compromise of the underlying system environment." } ```
Tool Description ``` Retrieves health status information from all registered MCP servers via the registry's internal API. Returns: Dict[str, Any]: Health status information for all registered servers, including: - status: 'healthy' or 'disabled' - last_checked_iso: ISO timestamp of when the server was last checked - num_tools: Number of tools provided by the server Raises: Exception: If the API call fails or data cannot be retrieved ```
**Error:** Scanner exit code: 1 ### io.mcpgateway/realserverfaketools - **URL:** `https://mcpgateway.ddns.net/realserverfaketools/mcp` - **Status:** ✅ SAFE | Severity | Count | |----------|-------| | Critical | 0 | | High | 0 | | Medium | 0 | | Low | 0 | ### io.mcpgateway/sre-gateway - **URL:** `https://mcpgateway.ddns.net/sre-gateway/mcp` - **Status:** ✅ SAFE | Severity | Count | |----------|-------| | Critical | 0 | | High | 0 | | Medium | 0 | | Low | 0 | **Error:** Scanner exit code: 1 --- *Report generated on 2025-10-21 23:50:03 UTC* ================================================ FILE: docs/scopes-mgmt.md ================================================ # Scopes Management This document describes the scope configuration file format used by the MCP Gateway Registry for fine-grained access control. ## Overview Scopes define what resources (MCP servers, agents) users can access and what actions they can perform. The registry uses JSON-based scope configuration files that can be loaded during initialization or managed via the CLI. ## Scope Configuration File Format ### Example Files - `scripts/registry-admins.json` - Bootstrap admin scope loaded during database initialization - `cli/examples/public-mcp-users.json` - Example scope for users with limited access ### Complete Field Reference ```json { "_id": "scope-name", "scope_name": "scope-name", "description": "Human-readable description of this scope", "group_mappings": ["group-name-1", "group-uuid-2"], "server_access": [ { "server": "server-name", "methods": ["initialize", "tools/list", "tools/call"], "tools": ["tool-name-1", "tool-name-2"] }, { "agents": { "actions": [ {"action": "list_agents", "resources": ["/agent-path"]}, {"action": "get_agent", "resources": ["/agent-path"]} ] } } ], "ui_permissions": { "list_agents": ["all"], "get_agent": ["/specific-agent"], "publish_agent": [], "list_service": ["all"], "toggle_service": ["service-name"] }, "create_in_idp": true } ``` ## Field Descriptions ### Top-Level Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `_id` | string | Yes | Unique identifier for the scope document in MongoDB. Should match `scope_name`. | | `scope_name` | string | No | Human-readable scope name. If omitted, `_id` is used. | | `description` | string | No | Description explaining the purpose of this scope. | | `group_mappings` | array | Yes | List of IdP group names or IDs that map to this scope. | | `server_access` | array | Yes | List of MCP server access rules and agent action permissions. | | `ui_permissions` | object | No | UI-level permissions for the registry web interface. | | `create_in_idp` | boolean | No | When true, the CLI will create the group in the IdP (Keycloak/Entra). | ### group_mappings Field The `group_mappings` array contains IdP group identifiers that should be mapped to this scope. When a user authenticates, their IdP groups are matched against these mappings to determine their effective scopes. **Important for Entra ID:** - Entra ID uses Group Object IDs (GUIDs), not group names - You must include the Group Object ID from Azure Portal > Groups > Overview - Example: `"5f605d68-06bc-4208-b992-bb378eee12c5"` **For Keycloak:** - Use the group name as defined in Keycloak - Example: `"public-mcp-users"` **Example with both:** ```json { "group_mappings": [ "public-mcp-users", "5f605d68-06bc-4208-b992-bb378eee12c5" ] } ``` This means users in either the Keycloak group `public-mcp-users` OR the Entra ID group with Object ID `5f605d68-06bc-4208-b992-bb378eee12c5` will receive this scope. ### server_access Field The `server_access` array defines what MCP servers and methods users can access. Each entry can be either a server access rule or an agent actions block. #### Server Access Rule ```json { "server": "server-name-or-wildcard", "methods": ["method-1", "method-2"], "tools": ["tool-name-or-wildcard"] } ``` | Field | Description | |-------|-------------| | `server` | Server name or `"*"` for all servers | | `methods` | List of allowed MCP methods (see below) | | `tools` | List of allowed tool names or `["*"]` for all tools | **Standard MCP Methods:** - `initialize` - Initialize MCP session - `notifications/initialized` - Session initialized notification - `ping` - Health check - `tools/list` - List available tools - `tools/call` - Execute a tool - `resources/list` - List available resources - `resources/templates/list` - List resource templates - `GET`, `POST`, `PUT`, `DELETE` - HTTP methods for REST API access **Example - Full MCP access to specific servers:** ```json { "server": "context7", "methods": [ "initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list" ], "tools": ["*"] } ``` **Example - Wildcard access (admin):** ```json { "server": "*", "methods": ["all"], "tools": ["all"] } ``` #### Agent Actions Block Agent actions define what operations users can perform on A2A agents. ```json { "agents": { "actions": [ {"action": "action-name", "resources": ["/agent-path-1", "/agent-path-2"]} ] } } ``` **Available Agent Actions:** | Action | Description | API Endpoint | |--------|-------------|--------------| | `list_agents` | View agents in listings | `GET /api/agents` | | `get_agent` | View agent details | `GET /api/agents/{path}` | | `publish_agent` | Register new agents | `POST /api/agents/register` | | `modify_agent` | Update existing agents | `PUT /api/agents/{path}` | | `delete_agent` | Remove agents | `DELETE /api/agents/{path}` | **Resource Patterns:** - `/agent-name` - Specific agent path (e.g., `/flight-booking`) - `all` - All agents (wildcard access) **Example - Limited agent access:** ```json { "agents": { "actions": [ {"action": "list_agents", "resources": ["/flight-booking", "/code-reviewer"]}, {"action": "get_agent", "resources": ["/flight-booking", "/code-reviewer"]} ] } } ``` **Example - Full agent admin access:** ```json { "agents": { "actions": [ {"action": "list_agents", "resources": ["all"]}, {"action": "get_agent", "resources": ["all"]}, {"action": "publish_agent", "resources": ["all"]}, {"action": "modify_agent", "resources": ["all"]}, {"action": "delete_agent", "resources": ["all"]} ] } } ``` ### ui_permissions Field UI permissions control what actions users can perform in the web interface and REST API for service/agent management. ```json { "ui_permissions": { "permission_name": ["resource-1", "resource-2"] } } ``` **Available UI Permissions:** | Permission | Description | Applies To | |------------|-------------|------------| | `list_agents` | View agents in UI | Agent paths or `"all"` | | `get_agent` | View agent details | Agent paths or `"all"` | | `publish_agent` | Register new agents via UI | Agent paths or `"all"` | | `modify_agent` | Edit agents via UI | Agent paths or `"all"` | | `delete_agent` | Delete agents via UI | Agent paths or `"all"` | | `list_service` | View MCP servers in UI | Server names or `"all"` | | `register_service` | Register new MCP servers | Server names or `"all"` | | `health_check_service` | Run health checks | Server names or `"all"` | | `toggle_service` | Enable/disable servers | Server names or `"all"` | | `modify_service` | Edit server configurations | Server names or `"all"` | **Example - Read-only access:** ```json { "ui_permissions": { "list_service": ["all"], "list_agents": ["/flight-booking"], "get_agent": ["/flight-booking"] } } ``` **Example - Full admin access:** ```json { "ui_permissions": { "list_agents": ["all"], "get_agent": ["all"], "publish_agent": ["all"], "modify_agent": ["all"], "delete_agent": ["all"], "list_service": ["all"], "register_service": ["all"], "health_check_service": ["all"], "toggle_service": ["all"], "modify_service": ["all"] } } ``` ## Complete Examples ### Admin Scope (registry-admins.json) Full access to all servers, agents, and UI functions: ```json { "_id": "registry-admins", "group_mappings": ["registry-admins"], "server_access": [ { "server": "*", "methods": ["all"], "tools": ["all"] }, { "agents": { "actions": [ {"action": "list_agents", "resources": ["all"]}, {"action": "get_agent", "resources": ["all"]}, {"action": "publish_agent", "resources": ["all"]}, {"action": "modify_agent", "resources": ["all"]}, {"action": "delete_agent", "resources": ["all"]} ] } } ], "ui_permissions": { "list_agents": ["all"], "get_agent": ["all"], "publish_agent": ["all"], "modify_agent": ["all"], "delete_agent": ["all"], "list_service": ["all"], "register_service": ["all"], "health_check_service": ["all"], "toggle_service": ["all"], "modify_service": ["all"] } } ``` ### Limited User Scope (public-mcp-users.json) Access to specific MCP servers and one agent: ```json { "scope_name": "public-mcp-users", "description": "Users with access to public MCP servers and flight-booking agent", "server_access": [ { "server": "context7", "methods": [ "initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list" ], "tools": ["*"] }, { "server": "api", "methods": ["initialize", "GET", "POST", "servers", "agents", "search"], "tools": [] }, { "agents": { "actions": [ {"action": "list_agents", "resources": ["/flight-booking"]}, {"action": "get_agent", "resources": ["/flight-booking"]} ] } } ], "group_mappings": [ "public-mcp-users", "5f605d68-06bc-4208-b992-bb378eee12c5" ], "ui_permissions": { "list_service": ["all"], "list_agents": ["/flight-booking"], "get_agent": ["/flight-booking"] }, "create_in_idp": true } ``` ## Managing Scopes ### Using the CLI Import a scope from JSON file: ```bash uv run python api/registry_management.py \ --token-file .token \ --registry-url https://registry.example.com \ import-group cli/examples/public-mcp-users.json ``` List all scopes: ```bash uv run python api/registry_management.py \ --token-file .token \ --registry-url https://registry.example.com \ list-groups ``` ### Bootstrap Admin Scope The `registry-admins` scope is automatically loaded during database initialization: - **Local (MongoDB CE)**: `docker compose up mongodb-init` - **Production (DocumentDB)**: `./terraform/aws-ecs/scripts/run-documentdb-init.sh` ### Server Path Variations When defining server access, you may need to include path variations to handle different URL patterns: ```json { "server_access": [ {"server": "context7", "methods": [...], "tools": ["*"]}, {"server": "/context7", "methods": [...], "tools": ["*"]}, {"server": "/context7/", "methods": [...], "tools": ["*"]} ] } ``` This ensures access works regardless of whether the server is accessed as: - `context7` - `/context7` - `/context7/` ## Entra ID Integration When using Microsoft Entra ID (Azure AD) as the identity provider: 1. **Create a group in Azure Portal:** - Navigate to Azure Portal > Azure Active Directory > Groups - Create a new Security group - Note the Group Object ID (GUID) 2. **Add the Object ID to group_mappings:** ```json { "group_mappings": [ "my-keycloak-group", "12345678-1234-1234-1234-123456789012" ] } ``` 3. **Assign users to the Azure AD group:** - Users in this group will receive the scope permissions when they authenticate 4. **Configure Entra ID app to include groups in tokens:** - In the App Registration, configure the `groups` claim - Set `groupMembershipClaims` to `"SecurityGroup"` in the manifest ## Troubleshooting ### User Not Getting Expected Permissions 1. Check group membership in IdP (Keycloak/Entra) 2. Verify `group_mappings` includes the correct group name/ID 3. Check registry logs for scope mapping messages 4. Use the debug endpoint: `GET /api/debug/user-context` ### Scope Not Found 1. Ensure the scope was imported: `list-groups` command 2. Check MongoDB collection: `mcp_scopes_default` 3. Re-run database initialization if bootstrap scope missing ### Entra ID Groups Not Working 1. Verify Group Object ID (not display name) is in `group_mappings` 2. Check that `groupMembershipClaims` is configured in app manifest 3. Verify user is assigned to the group in Azure Portal 4. Check that optional claims include `groups` in ID token ================================================ FILE: docs/scopes.md ================================================ # Fine-Grained Access Control System Documentation > **Note**: While this document discusses Fine-Grained Access Control (FGAC) in the context of Amazon Cognito, the concepts and implementation apply to any Identity Provider (IdP). The same scope-based authorization model can be used with other OAuth2/OIDC providers by adapting the group mapping and token validation mechanisms. This document provides comprehensive documentation for the fine-grained access control system in the MCP Gateway Registry, explaining how the scope-based authorization model works and how to configure it properly. ## Table of Contents 1. [Overview](#overview) 2. [Scope System Architecture](#scope-system-architecture) 3. [Scope Types and Structure](#scope-types-and-structure) 4. [Methods vs Tools Access Control](#methods-vs-tools-access-control) 5. [Cognito Integration](#cognito-integration) 6. [Scope Validation Logic](#scope-validation-logic) 7. [Configuration Examples](#configuration-examples) 8. [Virtual MCP Server Access Control](#virtual-mcp-server-access-control) 9. [Security Considerations](#security-considerations) 10. [Troubleshooting](#troubleshooting) ## Overview The MCP Gateway Registry implements a sophisticated fine-grained access control system that provides granular permissions for accessing MCP servers, methods, and tools. The system is built around a scope-based authorization model that: - Maps Amazon Cognito user groups to MCP server scopes - Controls access to specific MCP servers, methods, and individual tools - Supports both user identity mode (OAuth2 PKCE) and agent identity mode (Machine-to-Machine) - Uses hierarchical scope validation for precise permission control - Follows the principle of least privilege by default The access control system is defined in [`auth_server/scopes.yml`](../auth_server/scopes.yml) and enforced by the validation logic in [`auth_server/server.py`](../auth_server/server.py). ## Scope System Architecture ### Core Components The access control system consists of three main components: 1. **Scope Configuration** ([`auth_server/scopes.yml`](../auth_server/scopes.yml)): Defines all available scopes and their permissions 2. **Group Mappings**: Maps Amazon Cognito groups to both UI and server scopes 3. **Validation Engine** ([`auth_server/server.py`](../auth_server/server.py)): Enforces access control decisions ### Authentication Flow Integration The scope system integrates seamlessly with both authentication modes: - **User Identity Mode**: Users authenticate via OAuth2 PKCE, and their Cognito groups are mapped to scopes - **Agent Identity Mode**: Agents authenticate via M2M JWT tokens with custom scopes directly assigned ### Relationship with Cognito The system leverages Amazon Cognito's group membership feature to assign permissions: 1. Users are assigned to Cognito groups (e.g., `mcp-registry-admin`, `mcp-registry-user`) 2. Groups are mapped to scopes via the `group_mappings` configuration 3. Scopes define specific permissions for UI operations and MCP server access 4. The validation engine checks these scopes against requested operations ## Scope Types and Structure The system defines several types of scopes, each serving different purposes: ### UI Scopes UI scopes control access to registry management functions through the web interface: - **`mcp-registry-admin`**: Full administrative access to all registry functions - **`mcp-registry-user`**: Limited user access to specific servers and operations - **`mcp-registry-developer`**: Developer access for service registration and management - **`mcp-registry-operator`**: Operational access for service control without registration rights #### UI Scope Permissions Each UI scope defines permissions for specific registry operations: ```yaml UI-Scopes: mcp-registry-admin: list_service: [all] # Can list all services register_service: [all] # Can register any service health_check_service: [all] # Can check health of all services toggle_service: [all] # Can enable/disable all services modify_service: [all] # Can modify all services ``` ### Server Scopes Server scopes control access to MCP servers with read and execute permissions: - **`mcp-servers-unrestricted/read`**: Read access to all MCP servers and tools - **`mcp-servers-unrestricted/execute`**: Execute access to all MCP servers and tools - **`mcp-servers-restricted/read`**: Limited read access to specific servers and tools - **`mcp-servers-restricted/execute`**: Limited execute access to specific servers and tools #### Permission Levels - **Read Permission**: Allows listing tools and reading server information - **Execute Permission**: Allows calling tools and executing server methods ### Group Mappings Group mappings connect Cognito groups to both UI and server scopes: ```yaml group_mappings: mcp-registry-admin: - mcp-registry-admin # UI permissions - mcp-servers-unrestricted/read # Server read access - mcp-servers-unrestricted/execute # Server execute access mcp-registry-user: - mcp-registry-user # Limited UI permissions - mcp-servers-restricted/read # Limited server access ``` > **Important**: All group names (such as `mcp-registry-admin`, `mcp-registry-user`) and scope names (such as `mcp-servers-unrestricted/read`, `mcp-servers-restricted/execute`) are completely customizable by the platform administrator deploying this solution. These names are examples and can be changed to match your organization's naming conventions and security requirements. The same group names must be configured consistently in both your Identity Provider (IdP) and the `scopes.yml` configuration file. ## Methods vs Tools Access Control One of the key features of the access control system is its ability to differentiate between MCP protocol methods and specific tools, providing granular control over what operations users can perform. ### MCP Protocol Methods Methods are standard MCP protocol operations that all servers support: - **`initialize`**: Initialize connection with the server - **`notifications/initialized`**: Handle initialization notifications - **`ping`**: Health check operation - **`tools/list`**: List available tools on the server - **`tools/call`**: Call a specific tool (requires additional tool-level validation) ### Tool-Specific Access Control Tools are server-specific functions that can be called via the `tools/call` method. The system provides two levels of validation: 1. **Method-Level Validation**: Check if the user can call `tools/call` 2. **Tool-Level Validation**: Check if the user can call the specific tool #### Validation Logic for `tools/call` When a user attempts to call a tool via `tools/call`, the system performs enhanced validation: ```python # For tools/call, check if the specific tool is allowed if method == 'tools/call' and tool_name: if tool_name in allowed_tools: # Access granted - user can call this specific tool return True else: # Access denied - user cannot call this tool return False ``` #### Example: Tool Access Configuration ```yaml mcp-servers-restricted/execute: - server: fininfo methods: - initialize - notifications/initialized - ping - tools/list - tools/call # Can call tools/call method tools: - get_stock_aggregates # Can call this specific tool - print_stock_data # Can call this specific tool # Note: Cannot call other tools like advanced analytics tools ``` ### Access Control Scenarios #### Scenario 1: Method Access Only User has permission for `tools/list` but not `tools/call`: - ✅ Can list available tools - ❌ Cannot execute any tools #### Scenario 2: Method + Specific Tool Access User has permission for `tools/call` and specific tools: - ✅ Can call `get_stock_aggregates` - ✅ Can call `print_stock_data` - ❌ Cannot call `advanced_analytics_tool` (not in allowed tools list) #### Scenario 3: Unrestricted Access User has unrestricted execute permissions: - ✅ Can call any method - ✅ Can call any tool listed in the scope configuration ## Cognito Integration The access control system integrates deeply with Amazon Cognito for both user and agent authentication modes. ### User Identity Mode Integration For users authenticating through the web interface: 1. **User Authentication**: Users log in via OAuth2 PKCE flow 2. **Group Membership**: Cognito returns user's group memberships 3. **Scope Mapping**: Groups are mapped to scopes using `group_mappings` 4. **Session Management**: Scopes are stored in session cookies for subsequent requests ### Agent Identity Mode Integration For agents using their own identity: 1. **M2M Authentication**: Agents authenticate using client credentials flow 2. **Custom Scopes**: Agents are assigned custom scopes directly in Cognito 3. **JWT Token**: Scopes are embedded in JWT tokens 4. **Direct Validation**: Scopes are validated directly without group mapping ### Cognito Configuration Requirements #### User Pool Setup - Create user groups matching the scope system (e.g., `mcp-registry-admin`) - Assign users to appropriate groups - Configure OAuth2 flows for web application access #### Resource Server Setup (for M2M) - Create resource server with identifier (e.g., `mcp-gateway-api`) - Define custom scopes matching server scope names - Configure client credentials flow for agent applications For detailed Cognito setup instructions, see [`docs/cognito.md`](./cognito.md). ## Scope Validation Logic The scope validation is implemented in the [`validate_server_tool_access()`](../auth_server/server.py) function, which follows a systematic approach to determine access permissions. ### Validation Algorithm ```python def validate_server_tool_access(server_name: str, method: str, tool_name: str, user_scopes: List[str]) -> bool: """ Validate if the user has access to the specified server method/tool based on scopes. Returns True if access is allowed, False otherwise """ ``` ### Step-by-Step Validation Process 1. **Input Validation**: Validate server name, method, tool name, and user scopes 2. **Scope Iteration**: Check each user scope for matching permissions 3. **Server Matching**: Find server configurations that match the requested server 4. **Method Validation**: Check if the requested method is allowed 5. **Tool Validation**: For `tools/call`, validate specific tool permissions 6. **Access Decision**: Grant access if any scope allows the operation ### Validation Flow Diagram ``` Request: server_name, method, tool_name, user_scopes ↓ For each user_scope: ↓ Find scope configuration ↓ For each server in scope: ↓ Does server name match? ↓ (Yes) Is method in allowed_methods? ↓ (Yes) Is method == 'tools/call'? ↓ (Yes) ↓ (No) Is tool_name in Grant Access allowed_tools? ↓ (Yes) ↓ (No) Grant Access Continue to next scope ``` ### Access Decision Logic - **Default Deny**: Access is denied by default if no scope grants permission - **First Match Wins**: Access is granted as soon as any scope allows the operation - **Explicit Permission Required**: Both method and tool permissions must be explicitly granted - **Error Handling**: Access is denied if validation encounters errors ## Configuration Examples ### Example 1: Basic User Setup Create a basic user with read-only access to specific servers: ```yaml # In scopes.yml group_mappings: mcp-registry-basic-user: - mcp-registry-user - mcp-servers-restricted/read mcp-servers-restricted/read: - server: currenttime methods: - initialize - notifications/initialized - ping - tools/list tools: - current_time_by_timezone ``` **Cognito Setup:** 1. Create group: `mcp-registry-basic-user` 2. Assign users to this group 3. Users can list and read time tools but cannot execute them ### Example 2: Developer with Service Management Create a developer role with service registration capabilities: ```yaml group_mappings: mcp-registry-developer: - mcp-registry-developer - mcp-servers-restricted/read - mcp-servers-restricted/execute UI-Scopes: mcp-registry-developer: list_service: [all] register_service: [all] health_check_service: [all] ``` ### Example 3: Agent with Specific Tool Access Configure an agent with access to specific financial tools: ```yaml # Agent scope (assigned directly in Cognito resource server) mcp-servers-restricted/execute: - server: fininfo methods: - initialize - notifications/initialized - ping - tools/list - tools/call tools: - get_stock_aggregates - print_stock_data ``` **Cognito Setup:** 1. Create resource server: `mcp-gateway-api` 2. Create custom scope: `mcp-servers-restricted/execute` 3. Assign scope to agent client ### Example 4: Administrative Access Full administrative access configuration: ```yaml group_mappings: mcp-registry-admin: - mcp-registry-admin - mcp-servers-unrestricted/read - mcp-servers-unrestricted/execute UI-Scopes: mcp-registry-admin: list_service: [all] register_service: [all] health_check_service: [all] toggle_service: [all] modify_service: [all] ``` ## Virtual MCP Server Access Control Virtual MCP Servers use the same access control model as regular MCP servers. The key difference is that you reference the virtual server by its path (e.g., `/virtual/scoped-tools`) instead of a backend server name. ### How It Works Virtual servers are treated identically to regular MCP servers in scope definitions: 1. **Server Identification**: Use the virtual server path as the `server` value 2. **Method Control**: Same MCP methods apply (`initialize`, `tools/list`, `tools/call`, etc.) 3. **Tool Control**: You can restrict access to specific tools exposed by the virtual server ### Example: Virtual Server Scope Configuration ```json { "scope_name": "virtual-scoped-tools-users", "description": "Users with access to the scoped virtual server", "server_access": [ { "server": "/virtual/scoped-tools", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call"], "tools": ["*"] }, { "server": "api", "methods": ["GET", "POST", "servers", "virtual-servers", "search"], "tools": [] } ], "group_mappings": ["virtual-scoped-tools-users"], "custom_scopes": ["virtual-scoped-tools/access"], "create_in_idp": true } ``` See [virtual-server-scoped-users.json](../cli/examples/virtual-server-scoped-users.json) for the complete example. ### Key Points | Aspect | Regular MCP Server | Virtual MCP Server | |--------|-------------------|-------------------| | Server identifier | Server name (e.g., `fininfo`) | Virtual path (e.g., `/virtual/scoped-tools`) | | Methods | Standard MCP methods | Same standard MCP methods | | Tools | Backend server tools | Aggregated tools (possibly aliased) | | Scope configuration | Identical | Identical | ### Virtual Server-Level Scopes Virtual servers also support their own `required_scopes` field, which provides an additional layer of access control: ```json { "path": "/virtual/scoped-tools", "required_scopes": ["virtual-scoped-tools/access"], "tool_scope_overrides": [ { "tool_alias": "sensitive-tool", "required_scopes": ["virtual-scoped-tools/admin"] } ] } ``` This means access control happens at two levels: 1. **Gateway level**: Defined in `scopes.yml` or scope configuration JSON 2. **Virtual server level**: Defined in the virtual server's `required_scopes` Both must be satisfied for access to be granted. ### Testing Virtual Server Access Control An E2E test script is provided for testing scope-based access control with virtual servers: ```bash ./tests/integration/test_virtual_server_scopes_e2e.sh \ --registry-url http://localhost \ --token-file .token \ --no-cleanup ``` See [Virtual Server Operations Guide](virtual-server-operations.md#scope-based-access-control) for more details. ## Security Considerations ### Principle of Least Privilege The access control system is designed around the principle of least privilege: - **Default Deny**: All access is denied by default unless explicitly granted - **Explicit Permissions**: Each permission must be explicitly configured - **Granular Control**: Permissions can be granted at the method and tool level - **Scope Separation**: UI and server permissions are managed separately ### Best Practices #### 1. Group Design - Create specific groups for different roles (admin, user, developer, operator) - Avoid overly broad permissions - Regularly review group memberships #### 2. Scope Configuration - Use restricted scopes for most users - Reserve unrestricted access for administrators only - Implement tool-level restrictions for sensitive operations #### 3. Monitoring and Auditing - Enable detailed logging for access decisions - Monitor failed access attempts - Regularly audit scope configurations #### 4. Production Deployment - Use separate Cognito user pools for different environments - Implement proper secret management for client credentials - Enable MFA for administrative accounts ### Security Boundaries The system enforces several security boundaries: - **Authentication Boundary**: Users must authenticate via Cognito - **Authorization Boundary**: Scopes control what authenticated users can access - **Server Boundary**: Each server's tools are independently controlled - **Method Boundary**: Protocol methods and tools have separate permissions ## Troubleshooting ### Common Issues and Solutions #### Issue 1: User Cannot Access Server **Symptoms:** - User receives "Access denied" errors - Server appears unavailable to user **Diagnosis:** 1. Check user's Cognito group membership 2. Verify group mapping in `scopes.yml` 3. Confirm server is listed in user's scopes **Solution:** ```yaml # Ensure user's group has appropriate server scope group_mappings: user-group-name: - mcp-servers-restricted/read # Add appropriate scope ``` #### Issue 2: Tool Call Fails Despite Method Access **Symptoms:** - User can list tools but cannot call specific tools - `tools/call` method fails with permission error **Diagnosis:** 1. Verify user has `tools/call` method permission 2. Check if specific tool is listed in allowed tools 3. Confirm tool name matches exactly **Solution:** ```yaml mcp-servers-restricted/execute: - server: server-name methods: - tools/call # Method permission tools: - specific-tool-name # Tool permission ``` #### Issue 3: Scope Configuration Not Loading **Symptoms:** - All access is allowed (fallback behavior) - Scope validation logs show "No scopes configuration loaded" **Diagnosis:** 1. Check `scopes.yml` file exists in `auth_server/` directory 2. Verify YAML syntax is valid 3. Check file permissions **Solution:** ```bash # Validate YAML syntax python -c "import yaml; yaml.safe_load(open('auth_server/scopes.yml'))" # Check file permissions ls -la auth_server/scopes.yml ``` #### Issue 4: Group Mapping Not Working **Symptoms:** - User has correct Cognito group but wrong scopes - Scope mapping appears incorrect **Diagnosis:** 1. Verify group name matches exactly in Cognito and `scopes.yml` 2. Check for typos in group names 3. Confirm group mapping syntax **Solution:** ```yaml # Ensure exact match between Cognito group name and mapping key group_mappings: exact-cognito-group-name: # Must match Cognito exactly - scope-name ``` ### Debugging Tools #### Enable Verbose Logging The validation function provides detailed logging for troubleshooting: ```python # Logs show complete validation process logger.info(f"=== VALIDATE_SERVER_TOOL_ACCESS START ===") logger.info(f"Requested server: '{server_name}'") logger.info(f"Requested method: '{method}'") logger.info(f"Requested tool: '{tool_name}'") logger.info(f"User scopes: {user_scopes}") ``` #### Test Scope Configuration Create a simple test script to validate scope configurations: ```python import yaml def test_scope_config(): with open('auth_server/scopes.yml', 'r') as f: config = yaml.safe_load(f) # Test group mappings for group, scopes in config.get('group_mappings', {}).items(): print(f"Group: {group} -> Scopes: {scopes}") # Test scope definitions for scope in ['mcp-servers-restricted/read', 'mcp-servers-restricted/execute']: if scope in config: print(f"Scope {scope} has {len(config[scope])} server configurations") test_scope_config() ``` ### Performance Considerations - **Scope Caching**: Scope configurations are loaded once at startup - **Validation Efficiency**: Validation stops at first matching scope - **Memory Usage**: Large scope configurations may impact memory usage - **Logging Overhead**: Verbose logging can impact performance in production For production deployments, consider: - Reducing log verbosity - Monitoring validation performance - Optimizing scope configuration structure - Implementing scope configuration caching strategies --- This documentation provides a comprehensive guide to understanding and configuring the fine-grained access control system. For additional information about Cognito setup and integration, refer to [`docs/cognito.md`](./cognito.md) and [`docs/auth.md`](./auth.md). ================================================ FILE: docs/security-posture.md ================================================ # Security Posture - Enterprise-Grade Security for MCP Gateway & Registry **Last Updated:** March 13, 2026 **Version:** 1.0.16+ --- ## Executive Summary The MCP Gateway & Registry implements defense-in-depth security across all layers of the stack. Our comprehensive security approach ensures that enterprises can safely deploy AI agent infrastructure while maintaining compliance with industry standards and best practices. This document outlines our security architecture, controls, and practices that make the MCP Gateway & Registry enterprise-ready. ### Security Pillars 1. **Infrastructure Security** - Multi-layered AWS security controls 2. **Data Protection** - Encryption at rest and in transit 3. **Identity & Access Management** - Enterprise SSO and fine-grained authorization 4. **Container Security** - Hardened container images following CIS benchmarks 5. **Application Security** - Secure coding practices with automated scanning 6. **Supply Chain Security** - Automated security analysis of third-party MCP servers 7. **Observability** - Comprehensive audit logging and monitoring ### Deployment Platforms The MCP Gateway & Registry supports multiple deployment platforms. Security controls are categorized by applicability: **🟦 ECS Deployment** - AWS ECS with Terraform (uses DocumentDB, ALB, CloudFront, Lambda) **🟩 EKS Deployment** - Kubernetes/EKS with Helm (uses MongoDB-CE, Kubernetes native features) **🟨 Universal** - Applies to all deployment platforms (containers, application code, authentication) --- ## Table of Contents 1. [Encryption & Key Management](#encryption--key-management) 2. [Secrets Management & Rotation](#secrets-management--rotation) 3. [Network Security](#network-security) 4. [Access Logging & Audit Trail](#access-logging--audit-trail) 5. [Container Hardening](#container-hardening) 6. [Kubernetes Security](#kubernetes-security) 7. [Application Security](#application-security) 8. [Supply Chain Security](#supply-chain-security) 9. [Identity & Access Management](#identity--access-management) 10. [Monitoring & Alerting](#monitoring--alerting) 11. [Security Testing & Validation](#security-testing--validation) 12. [Compliance & Standards](#compliance--standards) --- ## Encryption & Key Management **🟦 ECS Deployment** | **🟨 Universal (TLS)** ### Encryption at Rest **🟦 ECS Deployment Only** All sensitive data is encrypted at rest using AWS Key Management Service (KMS) with customer-managed keys. **Encrypted Resources:** - **AWS Secrets Manager**: All secrets encrypted with dedicated KMS keys - DocumentDB database credentials - RDS PostgreSQL credentials - JWT signing keys - Session encryption keys - API tokens and service credentials - **AWS Systems Manager Parameter Store**: All SecureString parameters encrypted - Admin passwords - Database connection strings - Configuration secrets - **Amazon DocumentDB** (ECS): Cluster encrypted with customer-managed KMS key - **Amazon RDS PostgreSQL** (ECS): Database encrypted with customer-managed KMS key - **Amazon S3** (ECS): All buckets use server-side encryption (SSE-S3 or KMS) **🟩 EKS Deployment:** - **MongoDB-CE**: Uses Kubernetes secrets for credentials (can be encrypted with KMS via EKS encryption provider) - **RDS PostgreSQL** (Keycloak): Same as ECS - encrypted with customer-managed KMS key **KMS Key Architecture:** Three dedicated KMS keys with distinct purposes: 1. **DocumentDB Key** (`alias/mcp-gateway-documentdb`) - Encrypts DocumentDB cluster - Encrypts DocumentDB credentials in Secrets Manager - Encrypts related SSM parameters 2. **RDS Key** (`alias/keycloak-rds`) - Encrypts RDS PostgreSQL database - Encrypts RDS credentials in Secrets Manager - Encrypts Keycloak configuration parameters 3. **Gateway Secrets Key** (module-specific) - Encrypts MCP Gateway application secrets - Encrypts JWT signing keys - Encrypts session encryption keys **Key Management Features:** - ✅ Automatic key rotation enabled (annual rotation) - ✅ Restrictive key policies following least-privilege principle - ✅ CloudTrail logging of all key usage - ✅ Cross-account access controls - ✅ Key deletion protection with 7-day waiting period ### Encryption in Transit **🟨 Universal** All network communication uses TLS encryption: **TLS Configuration:** - **External Traffic**: TLS 1.2+ enforced on all ALBs (ECS) / Ingress controllers (EKS) and CloudFront distributions - **Internal Traffic**: TLS connections to DocumentDB (ECS) / MongoDB-CE (EKS) and RDS - **API Communication**: HTTPS-only for all REST API endpoints - **MCP Protocol**: Encrypted SSE (Server-Sent Events) over HTTPS **S3 Bucket Policies** (ECS): - TLS enforcement via bucket policies (deny all non-HTTPS requests) - Applied to all S3 buckets (logs, artifacts, backups) --- ## Secrets Management & Rotation **🟦 ECS Deployment** | **🟨 Universal (Application-Level)** ### Automated Secret Rotation **🟦 ECS Deployment Only** Credentials are automatically rotated on a 30-day schedule using AWS Lambda functions, eliminating manual password management and reducing credential exposure windows. **Rotation Implementation:** **DocumentDB Credentials (ECS):** - Automated rotation Lambda function - Updates master password in DocumentDB cluster - Updates stored credentials in Secrets Manager - Zero-downtime rotation with connection draining **RDS PostgreSQL Credentials (ECS and EKS):** - Automated rotation Lambda function - Updates master password in RDS cluster - Updates stored credentials in Secrets Manager - Coordinated updates to application configurations **Rotation Features (ECS):** - ✅ 30-day automatic rotation schedule - ✅ VPC-integrated Lambda functions (secure network access) - ✅ CloudWatch logging for all rotation events - ✅ Automatic rollback on rotation failure - ✅ CloudWatch alarms for rotation failures **🟩 EKS Deployment:** - MongoDB-CE credentials stored in Kubernetes secrets - Manual rotation recommended (can be automated with Kubernetes CronJobs) - RDS credentials use same AWS Secrets Manager rotation as ECS ### Secrets Access Control **🟦 ECS Deployment:** **IAM-Based Access (ECS):** - Secrets accessible only by authorized ECS task execution roles - KMS key policies restrict decryption to specific IAM principals - No secrets stored in environment variables or code **🟩 EKS Deployment:** - Kubernetes RBAC controls access to secrets - IAM Roles for Service Accounts (IRSA) for AWS API access - Secrets can be encrypted at rest with KMS via EKS encryption provider **Application-Level Encryption (Universal):** - Backend MCP server credentials encrypted with Fernet encryption - JWT tokens signed with cryptographically secure keys - Session data encrypted before storage --- ## Network Security **🟦 ECS Deployment (AWS-specific)** | **🟨 Universal (Concepts)** ### Public Access Prevention **🟦 ECS Deployment** All storage resources are protected against public exposure: **S3 Bucket Security:** - Public access completely blocked on all buckets - Bucket policies deny any public ACLs or policies - Applied to: - ALB access logs bucket - CloudFront access logs bucket - CodeBuild artifacts bucket - Backup storage buckets **Database Access:** - **DocumentDB** (ECS): Cluster deployed in private subnets (no public endpoint) - **MongoDB-CE** (EKS): Pod-to-pod communication within cluster, no external exposure - **RDS PostgreSQL** (ECS/EKS): Deployed in private subnets (no public endpoint) - Security groups (ECS) / Network Policies (EKS) allow connections only from authorized workloads ### Security Groups & Network Segmentation **🟦 ECS Deployment** **Principle of Least Privilege:** - Dedicated security groups per service layer - Ingress rules limited to specific ports and source security groups - Egress rules restricted to required destinations only **Security Group Architecture:** ``` [ALB Security Group] ↓ TCP 8080 (HTTP) [Registry ECS Security Group] ↓ TCP 27017 (MongoDB) [DocumentDB Security Group] [ALB Security Group] ↓ TCP 8080 (HTTP) [Auth Server ECS Security Group] ↓ TCP 5432 (PostgreSQL) [RDS Security Group] ``` **Lambda Function Security (ECS):** - Secret rotation Lambdas deployed in VPC - Dedicated security group with minimal permissions - Access to databases via security group rules only **🟩 EKS Deployment** **Kubernetes Network Policies:** - Define ingress/egress rules for pods - Restrict pod-to-pod communication - Isolate application tiers (frontend, backend, database) - Default deny-all with explicit allow rules --- ## Access Logging & Audit Trail **🟦 ECS (Infrastructure Logs)** | **🟨 Universal (Application Logs)** ### Comprehensive Access Logging All traffic to the platform is logged for security analysis and compliance. **🟦 Application Load Balancer Logging (ECS):** - **MCP Gateway ALB**: All HTTP/HTTPS requests logged to S3 - **Keycloak ALB**: All authentication traffic logged to S3 - **Log Format**: W3C Extended Log Format - **Storage**: Dedicated S3 bucket with 90-day retention - **Encryption**: SSE-S3 (AES-256) encryption **🟦 CloudFront Access Logging (ECS):** - **MCP Gateway Distribution**: All CDN requests logged - **Keycloak Distribution**: All auth-related CDN traffic logged - **Log Format**: W3C Extended Log Format (compressed .gz) - **Storage**: Dedicated S3 bucket with separate prefixes per distribution - **Retention**: 90-day lifecycle policy **🟦 DocumentDB Audit Logging (ECS):** - **Audit Events Captured**: - Authentication events (login attempts, failures) - Authorization decisions (access control checks) - DDL operations (schema changes, index creation) - User management (user creation, role assignments) - Administrative commands (cluster configuration changes) - **Destination**: CloudWatch Logs (`/aws/docdb/mcp-gateway-registry/audit`) - **Query**: CloudWatch Logs Insights for analysis ### Application Audit Logging **🟨 Universal (All Deployments)** **Registry Audit Log:** - All API requests logged to DocumentDB (ECS) or MongoDB-CE (EKS) - All MCP tool invocations logged - User authentication events tracked - Configuration changes recorded **Audit Log Fields:** - Timestamp (UTC with timezone) - Username and session ID - HTTP method and status code - Request path and query parameters - Response time and size - User agent and source IP - Error details (if applicable) **Audit Features:** - ✅ Searchable filters (username, method, status code, date range) - ✅ Statistics dashboard (event counts, unique users, timelines) - ✅ Export to CSV/JSONL for external analysis - ✅ Automatic TTL-based retention (configurable, default 7 days) - ✅ DocumentDB indexing for fast queries --- ## Container Hardening **🟨 Universal (All Deployments)** ### CIS Docker Benchmark Compliance All container images are hardened following CIS Docker Benchmark 4.1 requirements, regardless of deployment platform (ECS, EKS, Docker Compose). **Non-Root User Execution:** Every container runs as a non-privileged user (UID 1000): ```dockerfile # Create non-root user early for security RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser appuser # Copy files with correct ownership (fast, secure) COPY --from=builder --chown=appuser:appuser /app/.venv /app/.venv # Switch to non-root user USER appuser ``` **Container Images Secured (12 total):** - Registry service (with nginx) - Auth server - MCP servers (3 variants: GPU, CPU, lightweight) - Metrics service - Keycloak - Database initialization containers - Grafana **Security Controls Per Container:** - ✅ Non-root user execution (CIS 4.1) - ✅ No sudo package installed - ✅ Health checks configured (CIS 4.6) - ✅ Multi-stage builds (minimal attack surface) - ✅ No build tools in runtime images - ✅ Minimal base images (python:3.14-slim) ### Container Runtime Security **Docker Compose Security Options:** ```yaml services: registry: security_opt: - no-new-privileges:true cap_drop: - ALL ports: - "80:8080" # High port for non-root - "443:8443" # High port for non-root ``` **Security Features:** - `no-new-privileges:true` - Prevents privilege escalation - `cap_drop: ALL` - Drops all Linux capabilities - High port binding (8080, 8443) - Non-root operation - Read-only root filesystem (where possible) **MongoDB Capability Exception:** MongoDB requires `SETUID` and `SETGID` capabilities because its entrypoint uses `gosu` to drop privileges from `root` to the `mongodb` user at startup. Without these capabilities, MongoDB fails with: ```text error: failed switching to 'mongodb': operation not permitted ``` The correct least-privilege pattern is to drop all capabilities and then explicitly add back only the minimum required: ```yaml mongodb: security_opt: - no-new-privileges:true cap_drop: - ALL cap_add: - SETUID # Required by gosu to switch to mongodb user at startup - SETGID # Required by gosu to switch to mongodb group at startup ``` This follows CIS Docker Benchmark guidance: explicitly enumerate the minimum capabilities a container needs rather than leaving it with a broad default set. ### Image Supply Chain **Image Signing & Verification:** - Official images published to Docker Hub - Versioned releases with semantic versioning - Automated builds via GitHub Actions - Container vulnerability scanning in CI/CD --- ## Kubernetes Security **🟩 EKS Deployment Only** ### Pod Security Standards All Kubernetes Pods implement Pod Security Standards (PSS) at the **Restricted** level - the most stringent security profile. **Pod-Level Security Context:** ```yaml spec: securityContext: runAsNonRoot: true runAsUser: 1000 runAsGroup: 1000 fsGroup: 1000 seccompProfile: type: RuntimeDefault ``` **Container-Level Security Context:** ```yaml containers: - name: container securityContext: allowPrivilegeEscalation: false runAsNonRoot: true runAsUser: 1000 capabilities: drop: - ALL ``` **Security Controls:** - ✅ **runAsNonRoot**: Prevents containers from running as root - ✅ **Drop ALL Capabilities**: Removes all Linux capabilities - ✅ **No Privilege Escalation**: Blocks privilege escalation attempts - ✅ **Seccomp Profile**: Restricts system calls - ✅ **Read-Only Root Filesystem**: Where application permits **Helm Charts Secured:** - Registry Deployment - Auth Server Deployment - MCP Gateway (mcpgw) Deployment - MongoDB Configuration Job - Keycloak Configuration Job ### EKS-Specific Security When deployed to Amazon EKS: - IAM Roles for Service Accounts (IRSA) for AWS API access - EKS security group policies - Pod Security Policy (PSP) enforcement (EKS < 1.25) - Pod Security Standards (PSS) enforcement (EKS ≥ 1.25) - Network policies for pod-to-pod communication --- ## Application Security **🟨 Universal (All Deployments)** ### Secure Coding Practices The application codebase follows secure coding standards validated by automated security scanning, regardless of deployment platform. **Bandit Static Analysis:** All Python code is continuously scanned with Bandit security linter to detect: - SQL injection vulnerabilities - Command injection risks - Hardcoded credentials - Insecure cryptographic functions - Subprocess misuse - Unsafe deserialization - And 50+ other security patterns **Security Issues Addressed:** **Subprocess Security:** - Always use list form (never `shell=True`) - Validate command arguments against allowlists - Add timeouts to prevent DoS - Proper error handling with logging ```python # Secure subprocess pattern result = subprocess.run( ["nginx", "-s", "reload"], capture_output=True, text=True, timeout=5, ) ``` **SQL Injection Prevention:** - Parameterized queries for all database operations - Table/column name validation against allowlists - No string interpolation in SQL statements ```python # Secure SQL pattern table = validate_table_name(table) # Allowlist check query = f"DELETE FROM {table} WHERE created_at < ?" cursor.execute(query, (cutoff,)) ``` **Request Timeout Protection:** - All HTTP requests include timeout parameters - Prevents resource exhaustion DoS attacks - Default 30-second timeout for external APIs **Secure Configuration:** - No hardcoded credentials in code - All sensitive config via environment variables - Bind addresses configurable (default 127.0.0.1) - TLS-only communication in production ### Dependency Management **Vulnerability Scanning:** - Automated dependency vulnerability scanning in CI/CD - Regular updates for security patches - Pinned versions for reproducible builds **Python Dependencies:** - `uv` package manager for fast, reproducible installs - `pyproject.toml` for dependency management - No pip cache to reduce image size --- ## Supply Chain Security **🟨 Universal (All Deployments)** ### Automated Security Scanning Third-party MCP servers, A2A agents, and Agent Skills are automatically scanned before being made available to users, regardless of deployment platform. **Scanning Infrastructure:** **MCP Server Scanning:** - Scanner: [Cisco AI Defense MCP Scanner](https://github.com/cisco-ai-defense/mcp-scanner) - Analyzers: YARA (pattern-based), LLM (semantic analysis) - Detection: SQL injection, command injection, XSS, path traversal, hardcoded secrets **A2A Agent Scanning:** - Scanner: [Cisco AI Defense A2A Scanner](https://github.com/cisco-ai-defense/a2a-scanner) - Analyzers: YARA, Heuristic, Spec validation, Endpoint analysis - Detection: Protocol violations, malicious behaviors, security misconfigurations **Agent Skills Scanning:** - Scanner: [Cisco AI Defense Skill Scanner](https://github.com/cisco-ai-defense/cisco-ai-skill-scanner) - Analyzers: Static analysis, Behavioral analysis, LLM semantic analysis - Detection: Prompt injection, command injection, data exfiltration, social engineering ### Scanning Workflows **1. Automatic Registration-Time Scanning:** Every new MCP server/agent/skill is scanned before being enabled: - Scan triggered automatically on registration - Results analyzed for severity (Critical, High, Medium, Low) - Safe items: Enabled immediately - Unsafe items: Disabled with `security-pending` tag - Detailed report saved for administrator review **2. Manual On-Demand Scanning:** Administrators can trigger scans via API or CLI: ```bash # Rescan MCP server curl -X POST /api/servers/{path}/rescan -H "Authorization: Bearer $TOKEN" # Rescan A2A agent curl -X POST /api/agents/{path}/rescan -H "Authorization: Bearer $TOKEN" # Rescan Agent Skill curl -X POST /api/skills/{path}/rescan -H "Authorization: Bearer $TOKEN" ``` **3. Periodic Registry Scanning:** Comprehensive scans of all enabled servers on a schedule: - Detects newly discovered vulnerabilities - Generates executive security reports - Tracks vulnerability trends over time ### Threat Detection **Security Threats Detected:** - SQL injection patterns - Command injection vulnerabilities - Cross-site scripting (XSS) vectors - Path traversal attempts - Hardcoded credentials and secrets - Malicious code patterns - Prompt injection attacks (skills) - Data exfiltration risks - Privilege escalation patterns - SSRF vulnerabilities **Automated Response:** - Critical/High severity: Server/agent/skill automatically disabled - Security-pending tag applied for admin review - Detailed JSON report saved to `security_scans/` directory - UI indicators (shield icons) show security status For complete details, see [Security Scanner Documentation](security-scanner.md). --- ## Identity & Access Management **🟨 Universal (All Deployments)** ### Enterprise Identity Integration **Supported Identity Providers (All Deployments):** - **Keycloak** (default, self-hosted) - **Microsoft Entra ID** (Azure AD) - **AWS Cognito** - Any OIDC-compliant provider **SSO Features:** - Single Sign-On (SSO) with identity provider session - Proper OIDC logout flow with `id_token_hint` - Multi-factor authentication (MFA) support - Conditional access policies (Entra ID) ### Authorization Model **Role-Based Access Control (RBAC):** **Admin Role:** - Full system access and configuration - User and group management - Security scan triggers - Audit log access - System health monitoring **User Role:** - MCP server registration (own servers) - Tool discovery and execution - Dashboard and API access - Limited configuration access **Service Role:** - API authentication with static tokens - Registry API access (federation) - Metrics collection and export ### Fine-Grained Access Control **Scope-Based Permissions:** - OAuth scopes for granular API access control - Tool-level permissions (read, execute) - Resource-level isolation (user can only manage own servers) - IAM group-based tool access control **Token Security:** - JWT tokens signed with SECRET_KEY - Short expiration windows (configurable) - Secure cookie transmission (HttpOnly, Secure, SameSite) - Rate limiting: 100 tokens per user per hour **Session Security:** - Session data encrypted with SECRET_KEY (Fernet) - Secure cookie domain configuration - HTTPS-only transmission (production) - SameSite=Lax CSRF protection For complete details, see [Fine-Grained Access Control](scopes.md). --- ## Monitoring & Alerting **🟦 ECS (CloudWatch Alarms)** | **🟨 Universal (Metrics & Dashboards)** ### CloudWatch Alarms **🟦 ECS Deployment Only** Proactive monitoring with automated alerts for security-critical resources. **KMS Monitoring (2 alarms):** - KMS API throttling detection (DocumentDB key) - KMS API throttling detection (RDS key) - Threshold: >10 errors in 1 minute - Impact: Prevents secret decryption failures **DocumentDB Monitoring (1 alarm):** - Audit log failure detection - Threshold: >10 failures in 5 minutes - Impact: Identifies compliance gaps **S3 Cost Control (2 alarms):** - ALB logs bucket size monitoring - CloudFront logs bucket size monitoring - Threshold: >100 GB - Impact: Prevents unexpected costs **WAF Attack Detection (4 alarms):** - Blocked requests monitoring (both ALBs) - Rate limit trigger detection (both ALBs) - Threshold: Configurable per alarm type - Impact: Early warning of attacks/DDoS **Alarm Configuration:** - Optional SNS topic for email/SMS notifications - Alarms created but not intrusive if SNS not configured - Multiple evaluation periods to reduce false positives - `treat_missing_data: notBreaching` for newly created resources **🟩 EKS Deployment:** - Uses Kubernetes-native monitoring (Prometheus, Alertmanager) - Pod resource monitoring via Kubernetes metrics server - Custom Prometheus alerts for application and infrastructure ### Metrics & Observability **🟨 Universal (All Deployments)** **Prometheus Metrics:** - Tool execution counters and duration histograms - System resource usage (CPU, memory, connections) - Authentication metrics (login, logout, token vending) - Error rates and response times **Grafana Dashboards (All Deployments):** - MCP data-plane performance metrics - System health and resource utilization - Tool usage analytics - Real-time performance monitoring **🟦 Amazon Managed Prometheus (AMP) - ECS Deployment:** - Native AWS integration for ECS deployments - Metrics service collects and exports to AMP - OpenTelemetry support for external platforms (Datadog, etc.) **🟩 Prometheus - EKS Deployment:** - Self-hosted Prometheus in Kubernetes cluster - Metrics scraped from pods via ServiceMonitor CRDs - Persistent storage for metrics retention --- ## Security Testing & Validation **🟨 Universal (All Deployments)** ### Automated Security Testing **Container Security Tests (All Deployments):** - Test suite: `tests/security/test_container_security.py` - Validates: USER directive, no sudo, HEALTHCHECK, environment config - Coverage: 12 Dockerfiles × 7 test categories = 84 test cases **Pre-Commit Hooks:** Automated security checks before every commit: ```bash # Hooks include: - Ruff linter (security rules enabled) - Bandit security scan - MyPy type checking - Trailing whitespace removal - YAML/JSON validation - Python syntax validation - Shell script syntax validation ``` **Semgrep Static Analysis:** Comprehensive multi-language static code analysis: - **Languages**: Python, JavaScript/TypeScript, YAML, Terraform, Dockerfile - **Rule Sets**: - SQL injection detection - JWT security validation - Secret detection (credentials, tokens, API keys) - Docker Compose security best practices - Terraform infrastructure security - Path traversal prevention - CSRF protection validation - **Scan Coverage**: 162 initial findings → 25 actionable items (84% reduction) - **Resolution Status**: - ✅ SQL injection - Column validation implemented in metrics service - ✅ Docker Compose - `security_opt` and `cap_drop` added to all services - ✅ Terraform secrets - KMS encryption enabled for all AWS Secrets Manager secrets - ✅ JWT verification - Confirmed secure (two-step validation pattern) - ✅ Path traversal - Fixed in CLI and API endpoints - **False Positive Filtering**: `.semgrepignore` excludes docs and tests - **Tracking**: GitHub Issue [#650](https://github.com/agentic-community/mcp-gateway-registry/issues/650) **CI/CD Pipeline:** GitHub Actions run on every pull request: - Bandit security scan (fail on high/critical) - Ruff linting with security rules - Unit tests (701 tests) - Integration tests (57 tests) - Type checking with MyPy - Container security validation ### Manual Security Testing **Penetration Testing:** - Recommended: Annual third-party penetration testing - Internal security reviews before major releases - Vulnerability disclosure program **Security Audits:** - Code review with security focus - Infrastructure security assessment - Compliance gap analysis --- ## Compliance & Standards **🟨 Universal (All Deployments)** ### Industry Standards **CIS Docker Benchmark (All Deployments):** - ✅ 4.1: Non-root user execution - ✅ 4.2: Health checks configured - ✅ 4.3: No unnecessary packages - ✅ 4.5: Environment security (PIP_NO_CACHE_DIR) - ✅ 4.6: Security options in orchestration **OWASP Top 10 (2021):** - ✅ A01: Broken Access Control - IAM, RBAC, fine-grained permissions - ✅ A02: Cryptographic Failures - KMS encryption, TLS everywhere - ✅ A03: Injection - Parameterized queries, subprocess validation - ✅ A05: Security Misconfiguration - Hardened defaults, security contexts - ✅ A07: Authentication Failures - Enterprise SSO, MFA, proper session management - ✅ A09: Logging Failures - Comprehensive audit logging, CloudWatch - ✅ A10: SSRF - Input validation, URL allowlists **Kubernetes Pod Security Standards (PSS):** - ✅ Restricted level compliance (most stringent) - ✅ runAsNonRoot enforcement - ✅ All capabilities dropped - ✅ No privilege escalation - ✅ Seccomp profiles applied ### Compliance Frameworks **SOC 2 Controls:** - Encryption at rest and in transit - Access control and authentication - Audit logging and monitoring - Change management and versioning - Incident response procedures **PCI-DSS:** - Encryption of sensitive data - Secure authentication mechanisms - Network segmentation and firewalls - Audit logging and monitoring - Access control and least privilege **HIPAA (Healthcare):** - Data encryption (at rest and in transit) - Access controls and authentication - Audit controls and logging - Integrity controls - Transmission security **GDPR (Data Protection):** - Data encryption - Access controls and consent management - Audit trails - Data retention policies (TTL-based) - Right to erasure (data deletion capabilities) --- ## Verification & Validation ### Infrastructure Verification **🟦 ECS Deployment** **Verify KMS Encryption:** ```bash # Check secret encryption aws secretsmanager describe-secret \ --secret-id mcp-gateway/documentdb/credentials \ --query 'KmsKeyId' # Check KMS key rotation aws kms get-key-rotation-status \ --key-id alias/mcp-gateway-documentdb ``` **Verify Access Logging:** ```bash # Check ALB logs aws s3 ls s3://mcp-gateway-{region}-{account}-alb-logs/ --recursive | head -20 # Check CloudFront logs aws s3 ls s3://mcp-gateway-{region}-{account}-cloudfront-logs/ --recursive | head -20 # Check DocumentDB audit logs aws logs describe-log-groups --log-group-name-prefix /aws/docdb ``` **Verify CloudWatch Alarms:** ```bash # List all security alarms aws cloudwatch describe-alarms \ --alarm-name-prefix mcp-gateway \ --query 'MetricAlarms[*].[AlarmName,StateValue]' \ --output table ``` **🟩 EKS Deployment** **Verify Pod Security Standards:** ```bash # Check pod security context kubectl get pod -n mcp-gateway -o jsonpath='{.spec.securityContext}' # Check container security context kubectl get pod -n mcp-gateway -o jsonpath='{.spec.containers[0].securityContext}' # Verify non-root user kubectl exec -n mcp-gateway -- whoami # Expected output: appuser ``` **Verify Network Policies:** ```bash # List network policies kubectl get networkpolicies -n mcp-gateway # Describe specific policy kubectl describe networkpolicy -n mcp-gateway ``` **Verify Kubernetes Secrets:** ```bash # Check if secrets are encrypted at rest (EKS encryption provider) kubectl get secret -n mcp-gateway -o jsonpath='{.metadata.annotations}' ``` ### Application Verification **🟨 Universal (All Deployments)** **Run Security Tests:** ```bash # Container security tests pytest tests/security/test_container_security.py -v # Bandit security scan uv run bandit -r registry/ auth_server/ api/ -ll # Pre-commit checks pre-commit run --all-files ``` **Verify Container Security:** ```bash # Check non-root user docker compose exec registry whoami # Expected output: appuser # Check security options docker compose config | grep -A 5 "security_opt" ``` **Verify Supply Chain Security:** ```bash # Check MCP server scan results cat security_scans/{server-url}.json | jq '.tool_results[].is_safe' # Trigger manual scan curl -X POST /api/servers/{path}/rescan -H "Authorization: Bearer $TOKEN" ``` --- ## Security Incident Response ### Incident Detection **Monitoring Channels:** - CloudWatch Alarms (immediate notification) - Audit log anomaly detection - Security scan failure alerts - WAF blocked request spikes ### Response Procedures **Severity Levels:** - **Critical**: Data breach, system compromise, authentication bypass - **High**: Unauthorized access, privilege escalation, DoS attack - **Medium**: Suspicious activity, failed authentication spike, misconfiguration - **Low**: Policy violation, informational security event **Response Steps:** 1. **Detection**: Alert received via CloudWatch, logs, or monitoring 2. **Triage**: Assess severity and impact 3. **Containment**: Isolate affected resources, disable compromised accounts 4. **Investigation**: Review audit logs, analyze attack patterns 5. **Remediation**: Patch vulnerabilities, rotate credentials, update policies 6. **Recovery**: Restore services, verify security posture 7. **Post-Mortem**: Document incident, update procedures, implement preventions ### Security Contacts **Report Security Vulnerabilities:** - AWS Security: http://aws.amazon.com/security/vulnerability-reporting/ - Email: aws-security@amazon.com - **Do NOT create public GitHub issues for security vulnerabilities** **Security Updates:** - Monitor [release notes](../release-notes/) for security patches - Subscribe to [GitHub Security Advisories](https://github.com/agentic-community/mcp-gateway-registry/security/advisories) --- ## Summary The MCP Gateway & Registry implements enterprise-grade security across all layers: ✅ **Encryption Everywhere** - At rest (KMS) and in transit (TLS) ✅ **Zero-Trust Architecture** - Identity verification, least-privilege access ✅ **Defense-in-Depth** - Multiple security layers at infrastructure, application, and container levels ✅ **Automated Secrets Management** - 30-day rotation, encrypted storage ✅ **Comprehensive Logging** - ALB, CloudFront, DocumentDB, application audit logs ✅ **Supply Chain Security** - Automated scanning of third-party MCP servers ✅ **Container Hardening** - CIS benchmark compliance, non-root execution ✅ **Proactive Monitoring** - CloudWatch alarms, Prometheus metrics, Grafana dashboards ✅ **Compliance Ready** - SOC 2, PCI-DSS, HIPAA, GDPR controls This security posture enables enterprises to confidently deploy AI agent infrastructure while maintaining regulatory compliance and protecting sensitive data. ### Security Controls by Deployment Platform | Security Control | ECS | EKS | Universal | |------------------|-----|-----|-----------| | **KMS Encryption (AWS Secrets Manager, SSM)** | ✅ | ⚠️ Optional* | ❌ | | **Automated Secret Rotation (Lambda)** | ✅ | ⚠️ RDS only | ❌ | | **ALB Access Logging** | ✅ | ⚠️ Ingress logs | ❌ | | **CloudFront Logging** | ✅ | ✅ | ❌ | | **DocumentDB Audit Logging** | ✅ | ❌ | ❌ | | **MongoDB-CE Audit Logging** | ❌ | ⚠️ Optional* | ❌ | | **CloudWatch Alarms** | ✅ | ⚠️ Custom | ❌ | | **S3 Security (Public Block, TLS)** | ✅ | ⚠️ If used | ❌ | | **Security Groups** | ✅ | ❌ | ❌ | | **Kubernetes Network Policies** | ❌ | ✅ | ❌ | | **Pod Security Standards (PSS)** | ❌ | ✅ | ❌ | | **Container Hardening (CIS)** | ✅ | ✅ | ✅ | | **Non-Root Containers** | ✅ | ✅ | ✅ | | **Application Security (Bandit)** | ✅ | ✅ | ✅ | | **Supply Chain Security (Scanners)** | ✅ | ✅ | ✅ | | **IAM / RBAC** | ✅ | ✅ | ✅ | | **Enterprise SSO (OIDC)** | ✅ | ✅ | ✅ | | **Application Audit Logging** | ✅ | ✅ | ✅ | | **Prometheus Metrics** | ✅ | ✅ | ✅ | | **Grafana Dashboards** | ✅ | ✅ | ✅ | **Legend:** - ✅ Fully supported and implemented - ⚠️ Partially supported or requires configuration - ❌ Not applicable for this platform - *EKS can optionally use KMS for Kubernetes secrets encryption via encryption provider - *MongoDB-CE audit logging can be enabled in configuration **Key Differences:** - **ECS**: Uses AWS-native services (ALB, DocumentDB, Secrets Manager, Lambda, CloudWatch) - **EKS**: Uses Kubernetes-native features (Network Policies, PSS, Ingress, MongoDB-CE) - **Universal**: Application-level controls work across all platforms --- ## References ### Documentation - [Security Scanner Documentation](security-scanner.md) - Supply chain security for MCP servers - [Fine-Grained Access Control](scopes.md) - Permission management - [Audit Logging](audit-logging.md) - Comprehensive event tracking - [Authentication Guide](auth.md) - Identity provider integration - [Configuration Reference](configuration.md) - Security configuration options ### Standards & Frameworks - [CIS Docker Benchmark](https://www.cisecurity.org/benchmark/docker) - Container security standards - [OWASP Top 10](https://owasp.org/www-project-top-ten/) - Application security risks - [Kubernetes Pod Security Standards](https://kubernetes.io/docs/concepts/security/pod-security-standards/) - Pod security profiles - [AWS Security Best Practices](https://docs.aws.amazon.com/security/) - Cloud security guidance - [Bandit Security Linter](https://bandit.readthedocs.io/) - Python security scanning ### Security Tools - [Cisco AI Defense MCP Scanner](https://github.com/cisco-ai-defense/mcp-scanner) - MCP server security analysis - [Cisco AI Defense A2A Scanner](https://github.com/cisco-ai-defense/a2a-scanner) - Agent security analysis - [Cisco AI Defense Skill Scanner](https://github.com/cisco-ai-defense/cisco-ai-skill-scanner) - Agent Skills security analysis --- ================================================ FILE: docs/security-scanner.md ================================================ # MCP Security Scanner - Supply Chain Security for MCP Servers, A2A Agents, and Agent Skills ## Introduction [Watch the Security Scanning Demo Video](https://github.com/user-attachments/assets/9450f027-ef7f-4ed7-a55c-ce970bf26fd8) As organizations integrate Model Context Protocol (MCP) servers, Agent-to-Agent (A2A) agents, and Agent Skills into their AI workflows, supply chain security becomes critical. These third-party components provide tools, capabilities, and behavioral guidance to AI systems, making them potential vectors for security vulnerabilities, malicious code injection, and data exfiltration. The MCP Gateway Registry addresses this challenge by integrating automated security scanning powered by three specialized tools: - **[Cisco AI Defence MCP Scanner](https://github.com/cisco-ai-defense/mcp-scanner)** - For MCP server security analysis - **[Cisco AI Defence A2A Scanner](https://github.com/cisco-ai-defense/a2a-scanner)** - For Agent-to-Agent protocol security analysis - **[Cisco AI Defense Skill Scanner](https://github.com/cisco-ai-defense/cisco-ai-skill-scanner)** - For Agent Skills (SKILL.md files) security analysis These open-source security tools perform deep analysis of MCP servers, A2A agents, and Agent Skills to identify vulnerabilities before they can be exploited in production environments. **GitHub Repositories:** - MCP Scanner: https://github.com/cisco-ai-defense/mcp-scanner - A2A Scanner: https://github.com/cisco-ai-defense/a2a-scanner - Skill Scanner: https://github.com/cisco-ai-defense/cisco-ai-skill-scanner ### Security Scanning Workflows The registry implements multiple complementary security scanning workflows for MCP servers, A2A agents, and Agent Skills: #### MCP Server Scanning 1. **Automated Scanning During Server Registration** - Every new server is scanned before being made available to AI agents 2. **Manual On-Demand Scans via API** - Administrators can trigger security scans for specific servers 3. **Query Scan Results via API** - View detailed security scan results for any registered server 4. **Periodic Registry Scans** - Comprehensive security audits across all enabled servers in the registry #### A2A Agent Scanning 1. **Automated Scanning During Agent Registration** - Every new agent is scanned before being enabled in the registry 2. **Manual On-Demand Agent Scans via API** - Administrators can trigger security scans for specific agents 3. **Query Agent Scan Results** - View detailed security scan results for any registered agent #### Agent Skills Scanning 1. **Automated Scanning During Skill Registration** - Every new skill (SKILL.md file) is scanned before being made available 2. **Manual On-Demand Skill Scans via API** - Administrators can trigger security scans for specific skills 3. **Query Skill Scan Results via API** - View detailed security scan results for any registered skill These workflows ensure continuous security monitoring throughout the MCP server, A2A agent, and Agent Skills lifecycle, from initial registration through ongoing operations. ### Architecture Diagram ```mermaid sequenceDiagram autonumber participant Client as Client/Admin participant Registry as MCP Gateway Registry
(Scan Orchestrator + MongoDB-CE/DocumentDB) participant Scanner as Cisco AI Defense
(YARA | LLM | Cisco Proprietary) participant Target as MCP Server / A2A Agent %% Registration-time scanning rect rgb(225, 245, 254) note over Client,Target: Registration-Time Scanning (Server or Agent) Client->>Registry: Register Server/Agent Registry->>Target: Connect & Fetch Tools/Skills Target-->>Registry: Tool/Skill Definitions Registry->>Scanner: Analyze with configured scanner(s) Note right of Scanner: Configured via env vars:
SECURITY_ANALYZERS=yara
or yara,llm
or cisco Scanner-->>Registry: Findings (severity, threats) alt SAFE - No Critical/High Issues Registry->>Registry: Store (enabled=true) else UNSAFE - Critical/High Issues Found Registry->>Registry: Store (enabled=false, tag=security-pending) end Registry-->>Client: Registration Response + Scan Summary end %% On-demand scanning rect rgb(255, 243, 224) note over Client,Target: On-Demand Scanning (Admin API) Client->>Registry: POST /api/servers/{path}/rescan
or POST /api/agents/{path}/rescan Registry->>Target: Connect & Fetch Tools/Skills Target-->>Registry: Tool/Skill Definitions Registry->>Scanner: Analyze with configured scanner(s) Scanner-->>Registry: Findings (severity, threats) Registry->>Registry: Update Status & Store Results Registry-->>Client: Scan Results Response end ``` ## Security Scanning During Server Registration When adding a new MCP server to the registry, a security scan is automatically performed as part of the registration workflow. This pre-deployment scanning prevents vulnerable or malicious servers from being exposed to AI agents. ### Command Format ```bash uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost register --config ``` **Parameters:** - ``: JSON configuration file containing server details - Security scanning is automatically enabled by default (configured via environment variables) **Environment Variables for Security Scanning:** - `SECURITY_SCAN_ENABLED=true` - Enable/disable security scanning (default: true) - `SECURITY_SCAN_ON_REGISTRATION=true` - Scan during registration (default: true) - `SECURITY_SCAN_BLOCK_UNSAFE_SERVERS=true` - Auto-disable unsafe servers (default: true) - `SECURITY_ANALYZERS=yara` - Comma-separated list of analyzers (default: yara) - `SECURITY_SCAN_TIMEOUT=60` - Scan timeout in seconds (default: 60) - `MCP_SCANNER_LLM_API_KEY=` - API key for LLM analyzer (optional) ### Example: Registering Cloudflare Documentation Server **Configuration File** (`cli/examples/cloudflare-docs-server-config.json`): ```json { "server_name": "Cloudflare Documentation MCP Server", "description": "Search Cloudflare documentation and get migration guides", "path": "/cloudflare-docs", "proxy_pass_url": "https://docs.mcp.cloudflare.com/mcp", "supported_transports": ["streamable-http"] } ``` **Registering the Server (Security Scan Automatic):** ```bash # Register with automatic security scan (default YARA analyzer) uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost register --config cli/examples/cloudflare-docs-server-config.json # To use LLM analyzer, set environment variable first export MCP_SCANNER_LLM_API_KEY=sk-your-api-key export SECURITY_ANALYZERS=yara,llm uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost register --config cli/examples/cloudflare-docs-server-config.json ``` ### Security Scan Results The scanner analyzes each tool provided by the MCP server and generates a detailed security report. Here's an example of scan results for the Cloudflare Documentation server: **Scan Output** (`security_scans/docs.mcp.cloudflare.com_mcp.json`): ```json { "analysis_results": { "yara_analyzer": { "findings": [ { "tool_name": "search_cloudflare_documentation", "severity": "SAFE", "threat_names": [], "threat_summary": "No threats detected", "is_safe": true }, { "tool_name": "migrate_pages_to_workers_guide", "severity": "SAFE", "threat_names": [], "threat_summary": "No threats detected", "is_safe": true } ] } }, "tool_results": [ { "tool_name": "search_cloudflare_documentation", "tool_description": "Search the Cloudflare documentation...", "status": "completed", "is_safe": true, "findings": { "yara_analyzer": { "severity": "SAFE", "threat_names": [], "threat_summary": "No threats detected", "total_findings": 0 } } }, { "tool_name": "migrate_pages_to_workers_guide", "tool_description": "ALWAYS read this guide before migrating Pages projects to Workers.", "status": "completed", "is_safe": true, "findings": { "yara_analyzer": { "severity": "SAFE", "threat_names": [], "threat_summary": "No threats detected", "total_findings": 0 } } } ] } ``` ### What Happens When a Scan Fails If the security scan detects critical or high severity vulnerabilities: 1. **Server is Added but Disabled** - The server is registered in the database but marked as `disabled` 2. **Security-Pending Tag** - The server receives a `security-pending` tag to flag it for review 3. **AI Agents Cannot Access** - Disabled servers are excluded from agent discovery and tool routing 4. **Visible in UI** - The server appears in the registry UI with clear indicators of its security status 5. **Detailed Report Generated** - A comprehensive JSON report is saved to `security_scans/` directory **Console Output for Failed Scan:** ``` === Security Scan === Scanning server for security vulnerabilities... Security scan failed - Server has critical or high severity issues Server will be registered but marked as UNHEALTHY with security-pending status Security Issues Found: Critical: 2 High: 3 Medium: 1 Low: 0 Detailed report: security_scans/scan_example.com_mcp_20251022_103045.json === Security Status Update === Marking server as UNHEALTHY due to failed security scan... Server registered but flagged as security-pending Review the security scan report before enabling this server Service example-server successfully added and verified WARNING: Server failed security scan - Review required before use ``` **Screenshot:** ![Failed Security Scan - Server in Disabled State with Security-Pending Tag](img/failed_scan.png) *Servers that fail security scans are automatically added in disabled state with a `security-pending` tag, requiring administrator review before being enabled.* This workflow ensures that vulnerable servers never become accessible to AI agents without explicit administrator review and remediation. ## Manual On-Demand Security Scans (API) Administrators can trigger manual security scans for specific servers using the REST API or CLI commands. This is useful for: - Re-scanning servers after updates or patches - On-demand security assessments - Validating security fixes - Regular compliance checks ### API Endpoints #### Trigger Security Scan (Admin Only) **Endpoint:** `POST /api/servers/{path}/rescan` **Description:** Initiates a new security scan for the specified server and returns the results. **Authentication:** JWT Bearer token or session cookie **Authorization:** Requires admin privileges **Example using CLI:** ```bash # Trigger security scan for a specific server uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost rescan --path /cloudflare-docs ``` **Example Output:** ``` Security scan completed for server '/cloudflare-docs': Status: SAFE Scan timestamp: 2025-12-15T15:24:46.956393Z Analyzers used: yara Severity counts: Critical: 0 High: 0 Medium: 0 Low: 0 ``` **Example using curl:** ```bash curl -X POST http://localhost/api/servers/cloudflare-docs/rescan \ -H "Authorization: Bearer $JWT_TOKEN" ``` #### Query Scan Results **Endpoint:** `GET /api/servers/{path}/security-scan` **Description:** Retrieves the latest security scan results for a server, including detailed threat analysis and tool-level findings. **Authentication:** JWT Bearer token or session cookie **Authorization:** Requires admin privileges or access to the server **Example using CLI:** ```bash # Get security scan results uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost security-scan --path /cloudflare-docs # Get results in JSON format uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost security-scan --path /cloudflare-docs --json ``` **Example Output:** ``` Security scan results for server '/cloudflare-docs': Analyzer: yara_analyzer Findings: 2 - search_cloudflare_documentation: SAFE - migrate_pages_to_workers_guide: SAFE Total tools scanned: 2 Safe tools: 2 ``` **Example using curl:** ```bash curl -X GET http://localhost/api/servers/cloudflare-docs/security-scan \ -H "Authorization: Bearer $JWT_TOKEN" ``` ### Python Client Library The registry includes a Python client library with built-in security scan support: ```python from api.registry_client import RegistryClient # Initialize client client = RegistryClient( registry_url="http://localhost", token_file=".oauth-tokens/ingress.json" ) # Trigger security scan scan_result = client.rescan_server(path="/cloudflare-docs") print(f"Scan Status: {'SAFE' if scan_result.is_safe else 'UNSAFE'}") print(f"Critical Issues: {scan_result.critical_issues}") # Get scan results results = client.get_security_scan(path="/cloudflare-docs") for analyzer_name, analyzer_data in results.analysis_results.items(): print(f"Analyzer: {analyzer_name}") print(f" Findings: {len(analyzer_data.get('findings', []))}") ``` ### Scan Results Storage All security scan results are automatically saved to the `security_scans/` directory: - **Latest Scans:** `security_scans/.json` - **Archived Scans:** `security_scans/YYYY-MM-DD/scan__YYYYMMDD_HHMMSS.json` The results can be queried via the API or accessed directly from the filesystem. ## A2A Agent Security Scanning The registry provides comprehensive security scanning for Agent-to-Agent (A2A) protocol agents using the [Cisco AI Defence A2A Scanner](https://github.com/cisco-ai-defense/a2a-scanner). This ensures that agents registered in the system are safe and compliant with security standards before being made available. ### Automated Scanning During Agent Registration When registering a new A2A agent, security scanning is automatically performed as part of the registration workflow. **Command Format:** ```bash uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost agent-register --config ``` **Environment Variables for Agent Security Scanning:** - `AGENT_SECURITY_SCAN_ENABLED=true` - Enable/disable agent security scanning (default: true) - `AGENT_SECURITY_SCAN_ON_REGISTRATION=true` - Scan during registration (default: true) - `AGENT_SECURITY_BLOCK_UNSAFE_AGENTS=true` - Auto-disable unsafe agents (default: true) - `AGENT_SECURITY_ANALYZERS=yara,spec` - Comma-separated list of analyzers (default: yara,spec) - `AGENT_SECURITY_SCAN_TIMEOUT=60` - Scan timeout in seconds (default: 60) - `AGENT_SECURITY_ADD_PENDING_TAG=true` - Add security-pending tag to unsafe agents (default: true) **Example: Registering Flight Booking Agent** ```bash # Register agent with automatic security scan uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost agent-register \ --config cli/examples/flight_booking_agent_card.json ``` **Example Output:** ```json { "message": "Agent registered successfully", "agent": { "name": "Flight Booking Agent", "path": "/flight-booking", "url": "http://flight-booking-agent:9000/", "num_skills": 5, "is_enabled": true } } ``` The agent is automatically enabled if it passes the security scan. If vulnerabilities are detected, the agent is registered but disabled with a `security-pending` tag. ### Manual On-Demand Agent Scans (API) Administrators can trigger manual security scans for specific agents using CLI commands or the REST API. #### Trigger Agent Security Scan (Admin Only) **Endpoint:** `POST /api/agents/{path}/rescan` **Description:** Initiates a new security scan for the specified agent and returns the results. **Authentication:** JWT Bearer token or session cookie **Authorization:** Requires admin privileges **Example using CLI:** ```bash # Trigger security scan for a specific agent uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost agent-rescan --path /flight-booking ``` **Example Output:** ``` Security scan completed for agent '/flight-booking': Status: SAFE Scan timestamp: 2025-12-17T19:05:37.499170Z Analyzers used: yara, spec Severity counts: Critical: 0 High: 0 Medium: 0 Low: 0 Output file: /app/agent_security_scans/flight-booking.json ``` **Example with JSON output:** ```bash # Get JSON format output uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost agent-rescan --path /flight-booking --json ``` ### Query Agent Scan Results Agent security scan results are stored in the `agent_security_scans/` directory and can be accessed directly: **Storage Locations:** - **Latest Scans:** `agent_security_scans/.json` - **Archived Scans:** `agent_security_scans/YYYY-MM-DD/scan__YYYYMMDD_HHMMSS.json` **Viewing scan results:** ```bash # Set registry container variable export REGISTRY_CONTAINER=$(docker ps --filter "name=registry" --format "{{.Names}}" | grep "registry-1") # View scan results inside container docker exec $REGISTRY_CONTAINER cat /app/agent_security_scans/flight-booking.json | jq '.' # Copy scan results to local machine docker cp $REGISTRY_CONTAINER:/app/agent_security_scans/flight-booking.json ./flight_booking_scan.json ``` **Example Scan Result:** ```json { "analysis_results": {}, "scan_results": { "target_name": "Flight Booking Agent", "target_type": "agent_card", "status": "completed", "analyzers": ["yara", "heuristic", "spec", "endpoint"], "findings": [], "metadata": { "agent_id": null, "url": "http://flight-booking-agent:9000/" }, "total_findings": 0, "high_severity_count": 0 } } ``` ### Python Client Library for Agent Scanning ```python from api.registry_client import RegistryClient # Initialize client client = RegistryClient( registry_url="http://localhost", token_file=".oauth-tokens/ingress.json" ) # Trigger agent security scan scan_result = client.rescan_agent(path="/flight-booking") print(f"Scan Status: {'SAFE' if scan_result.is_safe else 'UNSAFE'}") print(f"Critical Issues: {scan_result.critical_issues}") print(f"Analyzers Used: {', '.join(scan_result.analyzers_used)}") ``` ### What Happens When an Agent Scan Fails If the security scan detects critical or high severity vulnerabilities: 1. **Agent is Registered but Disabled** - The agent is added to the database but marked as `is_enabled=false` 2. **Security-Pending Tag** - The agent receives a `security-pending` tag to flag it for review 3. **Excluded from Discovery** - Disabled agents are not returned in agent discovery queries 4. **Detailed Report Generated** - A comprehensive JSON report is saved to `agent_security_scans/` directory Administrators must review the security scan results and remediate any issues before manually enabling the agent. ## Agent Skills Security Scanning The registry provides comprehensive security scanning for Agent Skills (SKILL.md files) using the [Cisco AI Defense Skill Scanner](https://github.com/cisco-ai-defense/cisco-ai-skill-scanner). This ensures that skills registered in the system are safe and do not contain malicious instructions, prompt injection attempts, or other security threats before being made available to AI coding assistants. ### Automated Scanning During Skill Registration When registering a new Agent Skill, security scanning is automatically performed as part of the registration workflow. **Environment Variables for Skill Security Scanning:** - `SKILL_SECURITY_SCAN_ENABLED=true` - Enable/disable skill security scanning (default: true) - `SKILL_SECURITY_SCAN_ON_REGISTRATION=true` - Scan during registration (default: true) - `SKILL_SECURITY_BLOCK_UNSAFE_SKILLS=true` - Auto-disable unsafe skills (default: true) - `SKILL_SECURITY_ANALYZERS=static` - Comma-separated list of analyzers (default: static) - `SKILL_SECURITY_SCAN_TIMEOUT=120` - Scan timeout in seconds (default: 120) - `SKILL_SECURITY_ADD_PENDING_TAG=true` - Add security-pending tag to unsafe skills (default: true) - `SKILL_SECURITY_LLM_API_KEY=` - API key for LLM analyzer (optional) - `SKILL_SECURITY_VIRUSTOTAL_API_KEY=` - API key for VirusTotal integration (optional) - `SKILL_SECURITY_AI_DEFENSE_API_KEY=` - API key for Cisco AI Defense (optional) **Available Analyzers:** - `static` - Static code analysis for common security patterns - `behavioral` - Behavioral analysis of skill instructions - `llm` - LLM-powered semantic analysis (requires API key) - `virustotal` - VirusTotal URL reputation checking (requires API key) - `ai-defense` - Cisco AI Defense cloud analysis (requires API key) - `meta` - Meta-analyzer combining results from other analyzers **Example: Registering a Skill with Security Scan** Using the UI: 1. Navigate to Skills section in the dashboard 2. Click "Register Skill" 3. Enter the SKILL.md URL (e.g., `https://github.com/org/repo/blob/main/skills/pdf/SKILL.md`) 4. The skill is automatically scanned during registration 5. View scan results in the skill card's security shield icon Using the CLI: ```bash # Register skill with automatic security scan uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost skill-register \ --name pdf \ --url "https://github.com/anthropics/skills/blob/main/skills/pdf/SKILL.md" \ --description "Create and manipulate PDF documents" \ --tags pdf,documents,conversion \ --visibility public ``` ### Manual On-Demand Skill Scans (API) Administrators can trigger manual security scans for specific skills using CLI commands or the REST API. #### Trigger Skill Security Scan (Admin Only) **Endpoint:** `POST /api/skills/{path}/rescan` **Description:** Initiates a new security scan for the specified skill and returns the results. **Authentication:** JWT Bearer token or session cookie **Authorization:** Requires admin privileges **Example using CLI:** ```bash # Trigger security scan for a specific skill uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost skill-rescan --path pdf ``` **Example Output:** ``` Security scan completed for skill '/pdf': Status: SAFE Scan timestamp: 2026-02-20T10:30:00.000000Z Analyzers used: static Severity counts: Critical: 0 High: 0 Medium: 0 Low: 0 ``` **Example using curl:** ```bash curl -X POST http://localhost/api/skills/pdf/rescan \ -H "Authorization: Bearer $JWT_TOKEN" ``` #### Query Skill Scan Results **Endpoint:** `GET /api/skills/{path}/security-scan` **Description:** Retrieves the latest security scan results for a skill, including detailed threat analysis and findings. **Authentication:** JWT Bearer token or session cookie **Authorization:** Requires admin privileges or access to the skill **Example using CLI:** ```bash # Get security scan results uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost skill-security-scan --path pdf # Get results in JSON format uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost skill-security-scan --path pdf --json ``` **Example Output:** ```json { "skill_path": "/pdf", "skill_md_url": "https://github.com/anthropics/skills/blob/main/skills/pdf/SKILL.md", "scan_timestamp": "2026-02-20T10:30:00.000000Z", "is_safe": true, "critical_issues": 0, "high_severity": 0, "medium_severity": 0, "low_severity": 0, "analyzers_used": ["static"], "scan_failed": false } ``` ### Python Client Library for Skill Scanning ```python from api.registry_client import RegistryClient # Initialize client client = RegistryClient( registry_url="http://localhost", token_file=".oauth-tokens/ingress.json" ) # Trigger skill security scan scan_result = client.rescan_skill(path="/pdf") print(f"Scan Status: {'SAFE' if scan_result.is_safe else 'UNSAFE'}") print(f"Critical Issues: {scan_result.critical_issues}") print(f"Analyzers Used: {', '.join(scan_result.analyzers_used)}") # Get skill scan results results = client.get_skill_security_scan(path="/pdf") print(f"Last Scan: {results.scan_timestamp}") print(f"Is Safe: {results.is_safe}") ``` ### What the Skill Scanner Detects The Cisco AI Defense Skill Scanner analyzes SKILL.md files for various security threats: 1. **Prompt Injection Attempts** - Malicious instructions designed to manipulate AI behavior 2. **Command Injection Patterns** - Dangerous shell command patterns in skill instructions 3. **Data Exfiltration Risks** - Instructions that could leak sensitive information 4. **Privilege Escalation** - Instructions attempting to gain elevated permissions 5. **Social Engineering** - Deceptive patterns designed to trick users or AI systems 6. **Malicious URL References** - Links to known malicious domains 7. **Sensitive Data Handling** - Improper handling of credentials, tokens, or PII ### What Happens When a Skill Scan Fails If the security scan detects critical or high severity vulnerabilities: 1. **Skill is Registered but Disabled** - The skill is added to the database but marked as `is_enabled=false` 2. **Security-Pending Tag** - The skill receives a `security-pending` tag to flag it for review 3. **Excluded from Discovery** - Disabled skills are not returned in skill discovery queries 4. **Shield Icon Indicator** - The skill card shows a red shield icon in the UI 5. **Detailed Report Available** - Click the shield icon to view detailed findings Administrators must review the security scan results and remediate any issues before manually enabling the skill. ## Periodic Registry Scans Beyond initial registration security checks, the registry supports comprehensive periodic scans of all enabled servers. This ongoing monitoring detects newly discovered vulnerabilities and ensures continued security compliance. ### Command to Run Periodic Scans ```bash cd /home/ubuntu/repos/mcp-gateway-registry uv run cli/scan_all_servers.py --base-url https://mcpgateway.example.com ``` **Command Options:** ```bash # Scan with default YARA analyzer uv run cli/scan_all_servers.py --base-url https://mcpgateway.example.com # Scan with both YARA and LLM analyzers (requires API key in .env) uv run cli/scan_all_servers.py --base-url https://mcpgateway.example.com --analyzers yara,llm # Specify custom output directory uv run cli/scan_all_servers.py --base-url https://mcpgateway.example.com --output-dir custom_scans ``` ### Generated Report The periodic scan generates a comprehensive markdown report that provides an executive summary and detailed vulnerability breakdown for each server in the registry. **Report Locations:** - **Latest Report:** `security_scans/scan_report.md` (always current) - **Archived Reports:** `security_scans/reports/scan_report_YYYYMMDD_HHMMSS.md` (timestamped history) For a complete example of the report format and structure, see [scan_report_example.md](scan_report_example.md). ### Report Contents The generated security report includes: 1. **Executive Summary** - Total servers scanned - Pass/fail statistics - Overall security posture metrics 2. **Aggregate Vulnerability Statistics** - Total count by severity level (Critical, High, Medium, Low) - Trend analysis across multiple scans 3. **Per-Server Vulnerability Breakdown** - Individual server security status - Severity distribution per server - Scan timestamp and analyzer information 4. **Detailed Findings for Vulnerable Tools** - Specific tool names and descriptions - Threat categories and taxonomy - AI Security Framework (AITech) classification - Remediation guidance **Example Report Summary:** --- # MCP Server Security Scan Report **Scan Date:** 2025-10-21 23:50:03 UTC **Analyzers Used:** yara ## Executive Summary - **Total Servers Scanned:** 6 - **Passed:** 2 (33.3%) - **Failed:** 4 (66.7%) ### Aggregate Vulnerability Statistics | Severity | Count | |----------|-------| | Critical | 0 | | High | 3 | | Medium | 0 | | Low | 0 | --- These reports enable security teams to track vulnerability trends, prioritize remediation efforts, and maintain compliance with organizational security policies. ## Analyzers The MCP Scanner supports two analyzer types, each with distinct capabilities and use cases: ### YARA Analyzer **Type:** Pattern-based detection **Speed:** Fast (seconds per server) **API Key Required:** No **Best For:** Known threat patterns, common vulnerabilities The YARA analyzer uses signature-based detection rules to identify known security threats including: - SQL injection patterns - Command injection vulnerabilities - Cross-site scripting (XSS) vectors - Path traversal attempts - Hardcoded credentials - Malicious code patterns YARA scanning is ideal for automated workflows and continuous integration pipelines due to its speed and zero-configuration requirements. ### LLM Analyzer **Type:** AI-powered semantic analysis **Speed:** Slower (requires API calls) **API Key Required:** Yes (OpenAI-compatible API) **Best For:** Sophisticated threats, zero-day vulnerabilities, context-aware analysis The LLM analyzer uses large language models to perform deep semantic analysis of tool code and descriptions. It can detect: - Subtle logic vulnerabilities - Context-dependent security issues - Novel attack patterns - Business logic flaws - Privacy concerns in data handling LLM scanning is recommended for high-value or high-risk MCP servers where comprehensive security analysis justifies the additional time and API costs. ### Analyzer Comparison | Feature | YARA | LLM | Both | |---------|------|-----|------| | **Speed** | Fast (seconds) | Slower (minutes) | Slower | | **API Key** | Not required | Required | Required | | **Detection Type** | Pattern-based | Semantic analysis | Comprehensive | | **False Positives** | Low | Medium | Low | | **Coverage** | Known threats | Known + novel threats | Maximum | | **Use Case** | Automated scans, CI/CD | Critical servers, deep analysis | High-security environments | ### Configuring LLM Analyzer To use the LLM analyzer, set the API key in your `.env` file: ```bash # Add to .env file MCP_SCANNER_LLM_API_KEY=sk-your-openai-api-key ``` **Using Both Analyzers:** ```bash # During server addition ./cli/service_mgmt.sh add config.json yara,llm # During periodic scans uv run cli/scan_all_servers.py --base-url https://mcpgateway.example.com --analyzers yara,llm ``` ### Recommendation - **Default (YARA only):** Suitable for most use cases, provides fast scanning with no API costs - **Add LLM:** For critical production servers, sensitive data environments, or when unknown threats are a concern - **Both analyzers:** Recommended for maximum security coverage in high-stakes deployments The combination of both analyzers provides defense-in-depth, with YARA catching known threats quickly and LLM performing deeper analysis for sophisticated attacks. ## Prerequisites ### Set LLM API Key (Optional) Only required if using the LLM analyzer: ```bash # Add to .env file (recommended) echo "MCP_SCANNER_LLM_API_KEY=sk-your-api-key" >> .env ``` ## Troubleshooting ### API Key Issues If you see errors about missing API keys when using the LLM analyzer: ```bash # Verify the key is set echo $MCP_SCANNER_LLM_API_KEY # Add to .env file echo "MCP_SCANNER_LLM_API_KEY=sk-your-key" >> .env ``` Note: The scanner uses `MCP_SCANNER_LLM_API_KEY`, not `OPENAI_API_KEY`. ### Permission Issues Ensure the `security_scans/` directory is writable: ```bash mkdir -p security_scans chmod 755 security_scans ``` ## Additional Resources ### Documentation - **Cisco AI Defence MCP Scanner:** https://github.com/cisco-ai-defense/mcp-scanner - **Cisco AI Defence A2A Scanner:** https://github.com/cisco-ai-defense/a2a-scanner - **Cisco AI Defense Skill Scanner:** https://github.com/cisco-ai-defense/cisco-ai-skill-scanner - **Example Report:** [scan_report_example.md](scan_report_example.md) ### CLI Tools - **Registry Management CLI:** `api/registry_management.py` - Main CLI for server and agent registration with security scanning - **Periodic Scan Script:** `cli/scan_all_servers.py` - Comprehensive registry-wide security audits for MCP servers ### MCP Server API Endpoints - **Trigger Server Scan:** `POST /api/servers/{path}/rescan` - Admin-only manual security scan for MCP servers - **Query Server Results:** `GET /api/servers/{path}/security-scan` - Retrieve MCP server scan results ### A2A Agent API Endpoints - **Trigger Agent Scan:** `POST /api/agents/{path}/rescan` - Admin-only manual security scan for A2A agents - **Query Agent Results:** `GET /api/agents/{path}/security-scan` - Retrieve A2A agent scan results (file system access recommended) ### Agent Skills API Endpoints - **Trigger Skill Scan:** `POST /api/skills/{path}/rescan` - Admin-only manual security scan for Agent Skills - **Query Skill Results:** `GET /api/skills/{path}/security-scan` - Retrieve Agent Skills scan results ### Registry Management CLI Commands #### MCP Server Security Commands ```bash # Trigger server security scan uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost rescan --path /server-path # Get server scan results uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost security-scan --path /server-path ``` #### A2A Agent Security Commands ```bash # Trigger agent security scan uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost agent-rescan --path /agent-path # Get agent scan results (with JSON output) uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost agent-rescan --path /agent-path --json ``` #### Agent Skills Security Commands ```bash # Trigger skill security scan uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost skill-rescan --path skill-path # Get skill scan results uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost skill-security-scan --path skill-path # Get skill scan results (with JSON output) uv run python api/registry_management.py --token-file .oauth-tokens/ingress.json \ --registry-url http://localhost skill-security-scan --path skill-path --json ``` ### Python Client - **Registry Client:** `api/registry_client.py` - Python library with security scanning methods: - `rescan_server(path)` - Trigger MCP server security scan - `get_security_scan(path)` - Get MCP server scan results - `rescan_agent(path)` - Trigger A2A agent security scan - `get_agent_security_scan(path)` - Get A2A agent scan results - `rescan_skill(path)` - Trigger Agent Skill security scan - `get_skill_security_scan(path)` - Get Agent Skill scan results ================================================ FILE: docs/server-versioning-operations.md ================================================ # MCP Server Versioning - Operations Guide This guide covers the operational workflows for managing multiple versions of MCP servers in the gateway registry. For the technical design and architecture details, see [Server Versioning Design](design/server-versioning.md). ### UI Demo The following shows the version badges on server cards (both the MCP server-reported version and the user-provided routing version) and the version swap workflow using the Version Selector Modal: ![MCP Server Versioning UI Flow](img/mcp-server-versioning.gif) --- ## Understanding What You See on the Dashboard ### Version Badges on Server Cards Each server card can display up to two version indicators: **Routing Version Badge** (e.g., `v2.0.0` with a dropdown arrow): - This is the user-provided version label that controls which backend receives traffic. - Only appears when the server has multiple versions registered. - Clicking the badge opens the Version Selector Modal where you can switch the active version. - Single-version servers do not show this badge. **MCP Server Version Badge** (e.g., `srv 2.14.5`): - This is the software version reported by the running MCP server during health checks. - This value is determined automatically -- it comes from the `serverInfo.version` field in the MCP `initialize` response. - A small green dot appears if this version changed within the last 24 hours, indicating the upstream server deployed a new build. - This badge is informational only and does not affect routing. These two versions are independent. The routing version is an operational label you control. The MCP server version is a fact about what code is running at the backend URL. --- ## Workflow 1: MCP Server Updates Its Own Version **Scenario**: An MCP server developer deploys a new build. The server at `https://mcp.context7.com/mcp` starts reporting version `2.14.5` instead of `2.14.4`. No admin action was taken. **What happens automatically**: 1. The next health check runs against the active version endpoint 2. The health check reads `serverInfo.version` from the MCP `initialize` response 3. The registry detects the version changed from `2.14.4` to `2.14.5` 4. The registry stores: - `mcp_server_version`: `2.14.5` (new value) - `mcp_server_version_previous`: `2.14.4` (old value) - `mcp_server_version_updated_at`: current timestamp 5. A WARNING log message is emitted noting the version change 6. The dashboard card shows `srv 2.14.5` with a green dot indicator **What the admin sees**: - The `srv` badge on the server card updates to show the new version - A green dot appears next to the version for 24 hours - Hovering over the badge shows: "MCP Server Version: 2.14.5 (previously 2.14.4)" **No action required**. This is purely informational. The routing configuration does not change. Traffic continues to flow to the same backend URL. The user-provided routing version label (e.g., `v1.0.0`) is unaffected. --- ## Workflow 2: Platform Admin Registers a New Version of a Server **Scenario**: A platform admin wants to add version `v2.0.0` of Context7 pointing to a new backend URL, while keeping `v1.0.0` active. ### Step 1: Register the New Version Register the server with the same path but a different version. The registry detects this is a new version of an existing server and creates it as an inactive version document. **Using the CLI**: ```bash uv run python -m api.registry_management register \ --name "Context7 MCP Server" \ --path /context7 \ --version v2.0.0 \ --status beta \ --proxy-url "https://mcp-v2.context7.com/mcp" \ --transport streamable-http \ --tags documentation,search,libraries ``` **Using the API**: ```bash curl -X POST https://gateway.example.com/api/servers/register \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "server_name": "Context7 MCP Server", "path": "/context7", "version": "v2.0.0", "status": "beta", "proxy_pass_url": "https://mcp-v2.context7.com/mcp", "supported_transports": ["streamable-http"], "tags": ["documentation", "search", "libraries"] }' ``` **Using a JSON config file**: ```bash uv run python -m api.registry_management register --config cli/examples/context7-v2-server-config.json ``` Example config file (`context7-v2-server-config.json`): ```json { "server_name": "Context7 MCP Server", "description": "Up-to-date Docs for LLMs and AI code editors (Version 2 - Beta)", "path": "/context7", "version": "v2.0.0", "status": "beta", "proxy_pass_url": "https://mcp-v2.context7.com/mcp", "supported_transports": ["streamable-http"], "tags": ["documentation", "search", "libraries", "packages", "api-reference", "code-examples"] } ``` ### What Happens After Registration 1. The registry detects that `/context7` already exists with version `v1.0.0` 2. A new **inactive** version document is created at `_id: /context7:v2.0.0` 3. The active version document at `_id: /context7` is updated with `/context7:v2.0.0` added to its `other_version_ids` array 4. The nginx configuration is regenerated to include a version map entry for `v2.0.0` 5. Nginx is reloaded The API response includes `"is_new_version": true` to confirm a version was added rather than a new server created. ### What the Admin Sees on the Dashboard - The existing Context7 server card now shows a **version badge** (e.g., `v1.0.0` with a dropdown arrow) - Clicking the badge opens the **Version Selector Modal** showing both versions - `v1.0.0` is marked as `ACTIVE` (green badge) - `v2.0.0` is marked as `beta` (blue badge) ### Step 2: Test the New Version Before promoting `v2.0.0` to active, test it using the `X-MCP-Server-Version` header: **In an AI coding assistant** (e.g., Roo Code, Claude Desktop): Add the header to the MCP server configuration: ```json { "mcpServers": { "context7": { "type": "streamable-http", "url": "https://gateway.example.com/context7", "headers": { "X-MCP-Server-Version": "v2.0.0", "X-Authorization": "Bearer " } } } } ``` **With curl**: ```bash curl -X POST https://gateway.example.com/context7 \ -H "X-MCP-Server-Version: v2.0.0" \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}' ``` Traffic without the header continues to route to `v1.0.0`. Only requests with the explicit header reach `v2.0.0`. ### Step 3: Promote to Active Once testing is complete, switch the active version: **Using the Version Selector Modal (UI)**: 1. Click the version badge on the Context7 server card 2. In the modal, click "Set Active" on version `v2.0.0` 3. The modal closes and the card updates **Using the API**: ```bash curl -X PUT https://gateway.example.com/api/servers/context7/versions/default \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"version": "v2.0.0"}' ``` **Using the CLI**: ```bash uv run python -m api.registry_management set-default-version \ --path /context7 \ --version v2.0.0 ``` ### What Happens During the Switch 1. The current active document (`/context7`, version `v1.0.0`) becomes an inactive document at `_id: /context7:v1.0.0` 2. The target inactive document (`/context7:v2.0.0`) becomes the new active document at `_id: /context7` 3. The `other_version_ids` array is updated to reference `/context7:v1.0.0` instead of `/context7:v2.0.0` 4. The FAISS search index is re-indexed with `v2.0.0` metadata 5. The nginx configuration is regenerated and reloaded 6. A background health check is triggered for the newly active version 7. The dashboard updates to show `v2.0.0` as active All traffic without the `X-MCP-Server-Version` header now routes to `v2.0.0`. Clients that still send `X-MCP-Server-Version: v1.0.0` continue to reach the old version. --- ## Workflow 3: Instant Rollback **Scenario**: After promoting `v2.0.0`, you discover an issue and need to revert to `v1.0.0`. ```bash # API curl -X PUT https://gateway.example.com/api/servers/context7/versions/default \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"version": "v1.0.0"}' # CLI uv run python -m api.registry_management set-default-version \ --path /context7 \ --version v1.0.0 ``` The switch is immediate: - All default traffic reverts to `v1.0.0` - Clients with `X-MCP-Server-Version: v2.0.0` can still reach `v2.0.0` for debugging - A health check runs automatically for the restored version --- ## Workflow 4: Deprecate and Remove an Old Version ### Mark as Deprecated When registering the version, use `--status deprecated`: ```bash uv run python -m api.registry_management register \ --name "Context7 MCP Server" \ --path /context7 \ --version v1.0.0 \ --status deprecated \ --proxy-url "https://mcp.context7.com/mcp" \ --transport streamable-http ``` The Version Selector Modal shows deprecated versions with an amber badge. ### Remove a Version ```bash # API curl -X DELETE https://gateway.example.com/api/servers/context7/versions/v1.0.0 \ -H "Authorization: Bearer " # CLI uv run python -m api.registry_management remove-version \ --path /context7 \ --version v1.0.0 ``` Constraints: - You **cannot remove the currently active version**. Switch to a different version first. - Removing a version deletes its document from the database and removes its nginx map entry. - Clients sending `X-MCP-Server-Version: v1.0.0` will fall back to the default version after removal. --- ## Workflow 5: List All Versions ```bash # API curl https://gateway.example.com/api/servers/context7/versions \ -H "Authorization: Bearer " # CLI uv run python -m api.registry_management list-versions --path /context7 ``` Response: ```json { "path": "/context7", "default_version": "v2.0.0", "versions": [ { "version": "v2.0.0", "proxy_pass_url": "https://mcp-v2.context7.com/mcp", "status": "stable", "is_default": true }, { "version": "v1.0.0", "proxy_pass_url": "https://mcp.context7.com/mcp", "status": "deprecated", "is_default": false, "sunset_date": "2026-06-01" } ] } ``` --- ## Workflow 6: Delete a Server (All Versions) When you delete a server entirely, all version documents are cascade-deleted: ```bash # CLI uv run python -m api.registry_management delete --path /context7 # API curl -X DELETE https://gateway.example.com/api/servers/context7 \ -H "Authorization: Bearer " ``` This removes: - The active version document at `/context7` - All inactive version documents matching `/context7:*` - The FAISS search index entry - The nginx location block and map entries --- ## How Versioning Affects Search Only the **active version** of each server appears in search results. Inactive versions are excluded at index time (they are never added to the FAISS vector index), so they do not consume result slots. When you switch the active version, the search index is automatically re-indexed with the new active version's metadata (name, description, tags, tools). This means search results always reflect the currently active version. --- ## How Versioning Affects Health Checks Only the **active version** is health-checked. Inactive versions are skipped during the health check cycle. When you switch the active version, a health check for the newly active version is triggered immediately in the background. --- ## Client Configuration for Version Pinning Clients that need to pin to a specific version add the `X-MCP-Server-Version` header to their requests: ### Claude Desktop / Roo Code / Other MCP Clients ```json { "mcpServers": { "context7": { "type": "streamable-http", "url": "https://gateway.example.com/context7", "headers": { "X-MCP-Server-Version": "v1.0.0" } } } } ``` ### Programmatic Access ```python import httpx response = httpx.post( "https://gateway.example.com/context7", headers={ "X-MCP-Server-Version": "v1.0.0", "Content-Type": "application/json", }, json={"jsonrpc": "2.0", "method": "tools/list", "id": 1}, ) ``` ### Header Values | Value | Behavior | |-------|----------| | Omitted | Routes to active (default) version | | `latest` | Routes to active (default) version | | `v1.0.0` | Routes to version v1.0.0 specifically | | Unknown value | Falls back to default backend URL | ================================================ FILE: docs/service-management.md ================================================ # Service Management Guide This guide documents how to manage MCP servers, users, and access groups in the MCP Gateway Registry using the **Registry Management API**. ## Table of Contents - [Overview](#overview) - [What's New](#whats-new) - [Prerequisites](#prerequisites) - [Quick Start](#quick-start) - [Service Management](#service-management) - [Add Server](#add-server) - [Delete Server](#delete-server) - [List Servers](#list-servers) - [Enable/Disable Server](#enabledisable-server) - [Group Management](#group-management) - [Create Group](#create-group) - [Delete Group](#delete-group) - [List Groups](#list-groups) - [Add Server to Group](#add-server-to-group) - [Remove Server from Group](#remove-server-from-group) - [User Management](#user-management) - [Create M2M User](#create-m2m-user) - [Create Human User](#create-human-user) - [Delete User](#delete-user) - [List Users](#list-users) - [Complete Workflow Example](#complete-workflow-example) - [Configuration Format](#configuration-format) - [Troubleshooting](#troubleshooting) ## Overview The MCP Gateway Registry provides a comprehensive **Registry Management API** for programmatic access to all registry operations. This API replaces the previous shell script approach with a modern, type-safe Python interface. **Management Options:** 1. **Registry Management API** (`api/registry_management.py`): Core API for server, group, and user management 2. **Registry Client** (`api/registry_client.py`): High-level Python client with authentication handling 3. **REST API Endpoints**: Direct HTTP API access at `/api/management/*` These tools work together to provide: - **Server Registration**: Validates config and registers new servers - **Access Control**: Fine-grained permissions via groups - **User Management**: M2M service accounts and human users - **Health Verification**: Confirms servers are working and discoverable - **FAISS Integration**: Automatic indexing for intelligent tool discovery ## What's New **Registry Management API** (New in v1.0.7): - Modern Python API for all registry operations - Type-safe interfaces using Pydantic models - Automatic FAISS indexing on server registration - Integrated health checking and validation - RESTful HTTP endpoints for external integrations - Comprehensive error handling and logging The new API provides the same functionality as the previous shell scripts but with better error handling, type safety, and integration capabilities. ## Prerequisites Before using the Registry Management API, ensure: 1. **MCP Gateway is running**: All containers should be up ```bash docker compose ps ``` 2. **Authentication is configured**: You need OAuth2/JWT access ```bash # Obtain an access token via OAuth2 flow or API authentication ``` 3. **Python environment**: Use `uv` for package management ```bash # Ensure uv is installed uv --version ``` ## Quick Start ### Using the Registry Client (Python) ```python from api.registry_client import RegistryClient # Initialize client client = RegistryClient( base_url="http://localhost" ) # Add a server client.add_server( server_name="My MCP Server", path="/my-server", proxy_pass_url="http://my-server:8000", description="My custom MCP server", tags=["productivity", "automation"] ) # List all servers servers = client.list_servers() for server in servers: print(f"{server['name']}: {server['path']}") # Delete a server client.delete_server("my-server") ``` ### Using the REST API (HTTP) ```bash # Get access token via OAuth2 client credentials flow TOKEN=$(curl -X POST http://localhost/api/auth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d 'grant_type=client_credentials&client_id=your_client_id&client_secret=your_client_secret' | jq -r '.access_token') # Add a server curl -X POST http://localhost/api/management/servers \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "server_name": "My MCP Server", "path": "/my-server", "proxy_pass_url": "http://my-server:8000", "description": "My custom MCP server", "tags": ["productivity", "automation"] }' # List servers curl -X GET http://localhost/api/management/servers \ -H "Authorization: Bearer $TOKEN" # Delete a server curl -X DELETE http://localhost/api/management/servers/my-server \ -H "Authorization: Bearer $TOKEN" ``` ## Service Management ### Add Server #### Using Python Client ```python from api.registry_client import RegistryClient client = RegistryClient( base_url="http://localhost" ) # Add server with all options response = client.add_server( server_name="Advanced MCP Server", path="/advanced-server", proxy_pass_url="http://advanced-server:8001/", description="A server with all optional fields", tags=["productivity", "automation", "enterprise"], num_tools=5, num_stars=4, is_python=True, license="MIT", metadata={ "team": "data-platform", "owner": "alice@example.com", "compliance_level": "PCI-DSS", "cost_center": "engineering", "deployment_region": "us-east-1" } ) print(f"Server added: {response['name']}") ``` #### Custom Metadata Servers support optional custom metadata for organization, compliance, and integration tracking. All metadata is fully searchable via semantic search. **Example with Metadata:** ```python # Add server with custom metadata response = client.add_server( server_name="Payment Processor", path="/payment-processor", proxy_pass_url="http://payment:8080/", description="Payment processing service", tags=["finance", "payments"], metadata={ "team": "finance-platform", "owner": "bob@example.com", "compliance_level": "PCI-DSS", "data_classification": "confidential", "cost_center": "finance-ops", "deployment_region": "us-east-1", "environment": "production", "jira_ticket": "FIN-789" } ) ``` **Search by Metadata:** ```python # Servers with metadata are searchable via semantic search API # Example queries: # - "team:finance-platform servers" # - "PCI-DSS compliant services" # - "bob@example.com owned servers" # - "us-east-1 deployed services" ``` **Metadata Use Cases:** - **Organization:** team, owner, department - **Compliance:** compliance_level, data_classification, audit_logging - **Cost Tracking:** cost_center, project_code, budget_allocation - **Deployment:** deployment_region, environment, version - **Integration:** jira_ticket, service_now_id, monitoring_url #### What Happens During Registration 1. Config validation (required fields, constraints) 2. Server registration with the gateway 3. Nginx configuration update 4. FAISS index update (automatic) 5. Health check verification ### Delete Server ```python # Delete by server name client.delete_server("advanced-server") ``` ### List Servers ```python # Get all servers servers = client.list_servers() for server in servers: print(f"Name: {server['name']}") print(f"Path: {server['path']}") print(f"Status: {server['enabled']}") print(f"Tags: {', '.join(server.get('tags', []))}") print("---") ``` ### Enable/Disable Server ```python # Disable a server (removes from FAISS, keeps in registry) client.disable_server("my-server") # Enable a server (adds back to FAISS) client.enable_server("my-server") ``` ## Group Management ### Create Group ```python # Create a new access control group client.create_group( group_name="mcp-servers-finance/read", description="Finance services with read access" ) ``` **What this does:** - Creates the group in Keycloak - Adds the group to scopes.yml - Reloads the auth server to apply changes immediately ### List Groups ```python # Get all groups groups = client.list_groups() for group in groups: print(f"Group: {group['name']}") print(f"Synced: {group['synced']}") ``` ### Delete Group ```python # Delete a group client.delete_group("mcp-servers-finance/read") ``` ### Add Server to Group ```python # Add server to one or more groups client.add_server_to_groups( server_name="mcpgw", groups=["mcp-servers-finance/read"] ) # Add to multiple groups client.add_server_to_groups( server_name="fininfo", groups=["mcp-servers-finance/read", "mcp-servers-finance/execute"] ) ``` ### Remove Server from Group ```python # Remove server from groups client.remove_server_from_groups( server_name="fininfo", groups=["mcp-servers-finance/read"] ) ``` ## User Management ### Create M2M User ```python # Create machine-to-machine service account credentials = client.create_m2m_user( name="finance-analyst-bot", groups=["mcp-servers-finance/read", "mcp-servers-finance/execute"], description="Finance analyst bot with full access" ) print(f"Client ID: {credentials['client_id']}") print(f"Client Secret: {credentials['client_secret']}") ``` **What this does:** - Creates a new Keycloak M2M client with service account - Assigns the service account to specified groups - Generates client credentials - Returns client_id and client_secret ### Create Human User ```python # Create human user account client.create_human_user( username="jdoe", email="jdoe@example.com", firstname="John", lastname="Doe", password="secure_password", groups=["mcp-servers-restricted/read"] ) ``` ### List Users ```python # Get all users users = client.list_users() for user in users: print(f"Username: {user['username']}") print(f"Email: {user.get('email', 'N/A')}") print(f"Enabled: {user['enabled']}") ``` ### Delete User ```python # Delete a user client.delete_user(username="finance-analyst-bot") ``` ## Complete Workflow Example This example demonstrates the complete workflow using the Registry Management API: ```python from api.registry_client import RegistryClient # Initialize client client = RegistryClient( base_url="http://localhost" ) # Step 1: Create a new access group print("Creating group...") client.create_group( group_name="mcp-servers-time/read", description="Time-related services with read access" ) # Step 2: Add servers to the group print("Adding servers to group...") # Add mcpgw (provides intelligent_tool_finder) client.add_server_to_groups( server_name="mcpgw", groups=["mcp-servers-time/read"] ) # Add currenttime server client.add_server_to_groups( server_name="currenttime", groups=["mcp-servers-time/read"] ) # Step 3: Create M2M service account print("Creating M2M user...") credentials = client.create_m2m_user( name="time-service-bot", groups=["mcp-servers-time/read"], description="Bot for accessing time-related services" ) print(f"M2M Account Created:") print(f" Client ID: {credentials['client_id']}") print(f" Client Secret: {credentials['client_secret']}") # Step 4: Create human user print("Creating human user...") client.create_human_user( username="time-user", email="time-user@example.com", firstname="Time", lastname="User", password="secure_password", groups=["mcp-servers-time/read"] ) # Step 5: Verify setup print("\nVerifying setup...") print(f"Groups: {client.list_groups()}") print(f"Servers: {[s['name'] for s in client.list_servers()]}") print(f"Users: {[u['username'] for u in client.list_users()]}") print("\nWorkflow complete!") ``` ## Configuration Format ### Required Fields ```python { "server_name": "Display name for the server", "path": "/unique-url-path", "proxy_pass_url": "http://server-host:port" } ``` ### Complete Example ```python { "server_name": "Advanced MCP Server", "path": "/advanced-server", "proxy_pass_url": "http://advanced-server:8001/", "description": "A server with all optional fields", "tags": ["productivity", "automation", "enterprise"], "num_tools": 5, "num_stars": 4, "is_python": True, "license": "MIT" } ``` ### Field Constraints **Required Fields:** - `server_name`: Non-empty string - `path`: Must start with `/` and be more than just `/` - `proxy_pass_url`: Must start with `http://` or `https://` **Optional Fields:** - `description`: String description - `tags`: Array of strings - `num_tools`: Non-negative integer - `num_stars`: Non-negative integer - `is_python`: Boolean - `license`: String ## Troubleshooting ### Common Issues #### Authentication Errors ``` ERROR: Authentication failed: 401 Unauthorized ``` **Solution**: Verify your OAuth2 credentials or JWT token are valid and not expired #### Server Already Exists ``` ERROR: Server already exists: /my-server ``` **Solution**: Delete the existing server first or use a different path #### Group Not Found ``` ERROR: Group not found: mcp-servers-custom/read ``` **Solution**: Create the group first using `create_group()` #### Connection Refused ``` ERROR: Connection refused to http://localhost ``` **Solution**: Ensure MCP Gateway is running (`docker compose ps`) ### Debug Tips ```python # Enable debug logging import logging logging.basicConfig(level=logging.DEBUG) # Test connectivity from api.registry_client import RegistryClient client = RegistryClient(base_url="http://localhost") # This will show detailed request/response logs servers = client.list_servers() ``` ### API Documentation For complete API reference, see: - Registry Management API: `api/registry_management.py` - Registry Client: `api/registry_client.py` - REST API endpoints: `http://localhost/api/management/docs` (OpenAPI/Swagger) ## Best Practices 1. **Use the Python Client**: The `RegistryClient` handles authentication and error handling automatically 2. **Version Control Configurations**: Store server configurations in JSON files 3. **Test After Adding**: Verify servers are accessible after registration 4. **Use Descriptive Names**: Make server names and groups clear and searchable 5. **Always Include mcpgw**: Add `mcpgw` to custom groups for `intelligent_tool_finder` functionality 6. **Handle Errors**: Wrap API calls in try/except blocks for production use ## Integration with CI/CD ```python #!/usr/bin/env python3 from api.registry_client import RegistryClient import sys def deploy_server(config_file): """Deploy server from configuration file""" client = RegistryClient( base_url="http://localhost" ) try: # Load configuration with open(config_file) as f: config = json.load(f) # Add server response = client.add_server(**config) print(f"Server deployed successfully: {response['name']}") return 0 except Exception as e: print(f"Deployment failed: {e}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(deploy_server("production-server.json")) ``` For advanced operations and direct API usage, see the [API documentation](../api/README.md). ================================================ FILE: docs/static-token-auth.md ================================================ # Static Token Auth for Registry API > This page has been superseded by [**Registry API Authentication**](registry-api-auth.md), which covers the static token flow alongside session cookies, IdP JWTs, UI-issued self-signed JWTs, and the roadmap for per-key static tokens (#779) and external user access tokens (#826). > > See the [Static API token section](registry-api-auth.md#static-api-token-registry_api_token) for the content previously on this page. ================================================ FILE: docs/supported-protocol-and-trust-fields.md ================================================ # Supported Protocol, Trust Level, and Visibility Fields ## Overview The Agent Registry now supports registering **any agent** -- not just [A2A (Agent-to-Agent)](https://a2a-protocol.org/latest/specification/) protocol agents. A new `supported_protocol` field distinguishes A2A agents from non-A2A agents, while `trust_level` and `visibility` defaults have been updated for consistency across all layers (backend, API, CLI, frontend). ## Supported Protocol Field The `supported_protocol` field indicates which protocol an agent implements: | Value | Description | |---------|-------------| | `a2a` | Agent implements the A2A protocol specification | | `other` | Agent uses a different protocol (HTTP REST, gRPC, custom, etc.) | - **Registration API**: `supportedProtocol` is **required** when registering a new agent - **Agent Card model**: `supported_protocol` defaults to `None` for backward compatibility with existing agents - **Agent listing**: the field appears in all agent list and detail responses ### Registering via the UI The registration form includes a **"This is an A2A Protocol Agent"** checkbox. When checked, the agent is registered with `supported_protocol: "a2a"`. When unchecked, it is registered as `"other"`. The edit dialog also includes a **Supported Protocol** dropdown (A2A / Other) so you can update an existing agent's protocol type. ### Registering via the API Include the `supportedProtocol` field in your registration request: ```bash curl -X POST http://localhost/api/agents/register \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{ "name": "My Agent", "description": "An example agent", "url": "https://my-agent.example.com", "version": "1.0.0", "supportedProtocol": "a2a", "tags": ["example"] }' ``` ### Registering via the CLI ```bash uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ agent-register \ --name "My Agent" \ --url "https://my-agent.example.com" \ --supported-protocol a2a \ --tags "example" ``` ## Updated Default Values ### Trust Level The default `trust_level` has changed from `"unverified"` to `"community"` across all layers: | Trust Level | Description | |-------------|-------------| | `unverified` | No verification performed | | `community` | Community-contributed agent (new default) | | `verified` | Verified by registry administrators | | `trusted` | Fully trusted agent | ### Visibility The default `visibility` has changed from `"internal"` to `"public"` across all layers: | Visibility | Description | |-------------------|-------------| | `public` | Visible to all users (new default) | | `group-restricted`| Visible only to members of allowed groups | | `internal` | Visible only to the agent owner | ## Backfill Script for Existing Agents Existing agents in MongoDB that were created before this change will not have the `supported_protocol` field, and may still have the old default values for `trust_level` and `visibility`. A one-time backfill script normalizes these: ```bash uv run python scripts/backfill_agent_fields.py ``` The script performs three operations: 1. **`supported_protocol`** -- Sets `"other"` on all agents that don't have the field. Agents already registered as A2A are not affected. 2. **`trust_level`** -- Updates agents with `"unverified"` to `"community"` (the new default). 3. **`visibility`** -- Updates agents with `"internal"` to `"public"` (the new default). ### Configuration The script connects to MongoDB at `localhost:27017` by default. For production deployments (e.g., Amazon DocumentDB), update the `MONGODB_URI` constant in `scripts/backfill_agent_fields.py` before running. The script is **idempotent** -- running it multiple times has no additional effect. Each operation logs how many documents were modified. ## Agent Card and Server Card Generation Skills Two Claude Code skills are available to help generate registration cards by analyzing source code: ### Generate Agent Card Analyzes agent source code (local folder or GitHub URL) and generates an A2A-compliant agent card JSON file. Detects agent name, skills, tools, auth mechanisms, protocol bindings, and streaming support. ``` /generate-agent-card /path/to/agent/folder /generate-agent-card https://github.com/org/agent-repo ``` See [.claude/skills/generate-agent-card/SKILL.md](../.claude/skills/generate-agent-card/SKILL.md) for details. ### Generate Server Card Analyzes MCP server source code and generates a registry-compatible server card JSON file. Detects server name, tools, transport type, auth scheme, and deployment URLs. ``` /generate-server-card /path/to/server/folder /generate-server-card https://github.com/org/server-repo ``` See [.claude/skills/generate-server-card/SKILL.md](../.claude/skills/generate-server-card/SKILL.md) for details. ## Frontend Changes - The Dashboard now shows an **"A2A Protocol"** badge on agent cards for agents with `supported_protocol: "a2a"` - Agent details modal shows a clickable A2A card URL for A2A agents - Trust level and visibility values are read from the API (no longer hardcoded) - The edit dialog includes dropdowns for Trust Level, Supported Protocol, and Visibility ## API Response Format The `supported_protocol`, `trust_level`, and `visibility` fields are included in all agent API responses: ```json { "name": "Flight Booking Agent", "path": "/flight-booking", "supported_protocol": "a2a", "trust_level": "community", "visibility": "public", ... } ``` Agents that predate this feature will show `"supported_protocol": null` until the backfill script is run. ================================================ FILE: docs/testing/MAINTENANCE.md ================================================ # Test Maintenance Guide Guide for maintaining a healthy test suite over time. ## Table of Contents - [Coverage Monitoring](#coverage-monitoring) - [When Coverage Drops](#when-coverage-drops) - [Updating Tests](#updating-tests) - [Test Performance](#test-performance) - [CI/CD Integration](#cicd-integration) - [Troubleshooting Flaky Tests](#troubleshooting-flaky-tests) - [Test Isolation Issues](#test-isolation-issues) - [Deprecating Tests](#deprecating-tests) ## Coverage Monitoring ### Current Coverage Requirements The project maintains **80% minimum code coverage** across all source code. ### Checking Current Coverage Check coverage locally: ```bash # Quick check make test-coverage # Detailed report uv run pytest --cov=registry --cov-report=term-missing # HTML report for detailed analysis uv run pytest --cov=registry --cov-report=html open htmlcov/index.html ``` ### Coverage Reports Coverage reports show: - Overall coverage percentage - Coverage per module - Missing lines (not covered by tests) - Branch coverage (conditional paths) Example output: ``` Name Stmts Miss Cover Missing --------------------------------------------------------------- registry/services/server.py 45 3 93% 12, 45-47 registry/api/routes.py 120 15 88% 78-82, 156-162 registry/core/config.py 25 0 100% --------------------------------------------------------------- TOTAL 450 35 92% ``` ### Monitoring Coverage in CI/CD Coverage is automatically checked in CI/CD: 1. **GitHub Actions**: Every PR and commit 2. **Codecov**: Tracks coverage over time 3. **PR Comments**: Shows coverage changes ### Coverage Badges Add coverage badge to README: ```markdown [![codecov](https://codecov.io/gh/username/repo/branch/main/graph/badge.svg)](https://codecov.io/gh/username/repo) ``` ## When Coverage Drops ### Identifying Uncovered Code 1. **Run coverage report**: ```bash uv run pytest --cov=registry --cov-report=html open htmlcov/index.html ``` 2. **Check the HTML report**: - Red lines: Not covered - Yellow lines: Partially covered (some branches) - Green lines: Fully covered 3. **Focus on critical paths first**: - API endpoints - Business logic - Error handling - Data validation ### Adding Tests for Uncovered Code Example: Adding tests for uncovered function ```python # Original uncovered function def calculate_score(metrics: Dict[str, float]) -> float: """Calculate composite score from metrics.""" if not metrics: return 0.0 total = sum(metrics.values()) count = len(metrics) return total / count # Add tests to cover this function @pytest.mark.unit class TestScoreCalculation: """Tests for calculate_score function.""" def test_calculate_score_with_valid_metrics(self): """Test score calculation with valid metrics.""" metrics = {"metric1": 0.8, "metric2": 0.9, "metric3": 0.7} score = calculate_score(metrics) assert score == pytest.approx(0.8, rel=0.01) def test_calculate_score_with_empty_metrics(self): """Test score calculation with no metrics.""" score = calculate_score({}) assert score == 0.0 def test_calculate_score_with_single_metric(self): """Test score calculation with single metric.""" score = calculate_score({"metric1": 0.5}) assert score == 0.5 ``` ### Strategies for Improving Coverage 1. **Start with low-hanging fruit**: Test simple functions first 2. **Focus on new code**: Ensure new features have tests 3. **Test error paths**: Add tests for exception handling 4. **Test edge cases**: Boundary conditions, empty inputs, etc. 5. **Add integration tests**: Cover component interactions ## Updating Tests ### When Code Changes Update tests when code changes: 1. **API changes**: Update API tests 2. **Function signatures**: Update unit tests 3. **New features**: Add new tests 4. **Bug fixes**: Add regression tests 5. **Refactoring**: Update mocks and fixtures ### Test Update Checklist When updating code: - [ ] Update affected unit tests - [ ] Update integration tests if needed - [ ] Add tests for new functionality - [ ] Verify all tests still pass - [ ] Check coverage hasn't dropped - [ ] Update test documentation ### Example: Updating Tests After Code Change **Code change**: Add pagination to list_servers endpoint ```python # Old code def list_servers(): return server_service.list_servers() # New code def list_servers(page: int = 1, page_size: int = 10): return server_service.list_servers_paginated(page, page_size) ``` **Update tests**: ```python # Old test def test_list_servers(server_service): servers = server_service.list_servers() assert isinstance(servers, list) # Updated test def test_list_servers_default_pagination(server_service): """Test list servers with default pagination.""" result = server_service.list_servers_paginated(page=1, page_size=10) assert isinstance(result["items"], list) assert result["page"] == 1 assert result["page_size"] == 10 def test_list_servers_custom_pagination(server_service): """Test list servers with custom pagination.""" result = server_service.list_servers_paginated(page=2, page_size=5) assert result["page"] == 2 assert result["page_size"] == 5 def test_list_servers_invalid_page_raises_error(server_service): """Test invalid page number raises error.""" with pytest.raises(ValueError): server_service.list_servers_paginated(page=0, page_size=10) ``` ## Test Performance ### Identifying Slow Tests Find slow tests: ```bash # Show test durations uv run pytest --durations=10 # Show all durations uv run pytest --durations=0 # Run only slow tests uv run pytest -m slow ``` ### Optimizing Test Performance 1. **Use appropriate fixtures**: ```python # Good - Function-scoped for isolation @pytest.fixture def temp_database(): db = create_database() yield db db.cleanup() # Better - Module-scoped for performance @pytest.fixture(scope="module") def shared_database(): db = create_database() yield db db.cleanup() ``` 2. **Mock expensive operations**: ```python # Slow - Real API calls def test_fetch_data(): data = external_api.fetch() assert data is not None # Fast - Mocked API @patch('module.external_api.fetch') def test_fetch_data(mock_fetch): mock_fetch.return_value = {"data": "test"} data = external_api.fetch() assert data == {"data": "test"} ``` 3. **Run tests in parallel**: ```bash # Install pytest-xdist uv add --dev pytest-xdist # Run tests in parallel uv run pytest -n auto ``` 4. **Skip slow tests during development**: ```python # Mark slow tests @pytest.mark.slow def test_expensive_operation(): pass # Skip in development pytest -m "not slow" ``` ### Test Performance Goals - **Unit tests**: < 1 second each - **Integration tests**: < 5 seconds each - **E2E tests**: < 30 seconds each - **Total test suite**: < 5 minutes ## CI/CD Integration ### GitHub Actions Configuration Example test workflow: ```yaml name: Tests on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.14' - name: Install uv run: pip install uv - name: Install dependencies run: uv sync - name: Run tests run: uv run pytest --cov=registry --cov-report=xml - name: Upload coverage uses: codecov/codecov-action@v3 with: files: ./coverage.xml ``` ### Handling CI/CD Test Failures When tests fail in CI/CD: 1. **Check the logs**: - Look for error messages - Check which test failed - Review stack traces 2. **Reproduce locally**: ```bash # Run the same test uv run pytest tests/path/to/test.py::test_name # Run with same markers uv run pytest -m integration ``` 3. **Common CI/CD issues**: - Missing environment variables - Service dependencies not running - File permissions - Timing-sensitive tests 4. **Fix and verify**: - Make necessary changes - Run tests locally - Push fix - Verify CI/CD passes ## Troubleshooting Flaky Tests ### Identifying Flaky Tests Flaky tests pass/fail intermittently. Signs: - Tests fail randomly in CI/CD - Tests pass when run individually - Tests fail when run with others - Different results on different machines ### Finding Flaky Tests Run tests multiple times: ```bash # Run tests 10 times for i in {1..10}; do uv run pytest tests/test_file.py || echo "Failed on iteration $i" done # Use pytest-repeat uv add --dev pytest-repeat uv run pytest --count=10 tests/test_file.py ``` ### Common Causes of Flaky Tests 1. **Timing issues**: ```python # Flaky - Depends on timing def test_async_operation(): start_background_task() time.sleep(0.1) # May not be enough assert task_complete() # Fixed - Wait for condition def test_async_operation(): start_background_task() wait_for_condition(lambda: task_complete(), timeout=5) assert task_complete() ``` 2. **Shared state**: ```python # Flaky - Modifies global state def test_with_global_state(): global_config.update({"key": "value"}) assert process_data() == expected # Fixed - Isolated state def test_with_isolated_state(monkeypatch): test_config = {"key": "value"} monkeypatch.setattr('module.global_config', test_config) assert process_data() == expected ``` 3. **Order dependencies**: ```python # Flaky - Depends on test order def test_first(): create_resource("test") def test_second(): resource = get_resource("test") # Assumes test_first ran assert resource is not None # Fixed - Independent tests def test_first(): create_resource("test1") assert get_resource("test1") is not None def test_second(): create_resource("test2") assert get_resource("test2") is not None ``` 4. **Non-deterministic data**: ```python # Flaky - Random data def test_with_random_data(): data = generate_random_data() assert process(data) > 0 # May fail with certain random values # Fixed - Deterministic data def test_with_fixed_data(): data = [1, 2, 3, 4, 5] assert process(data) == 15 ``` ## Test Isolation Issues ### Ensuring Test Isolation Tests should not affect each other: ```python # Bad - Tests share state class TestSharedState: shared_list = [] def test_append(self): self.shared_list.append(1) assert len(self.shared_list) == 1 # Fails on second run def test_length(self): assert len(self.shared_list) == 0 # Fails if test_append ran first # Good - Tests are isolated class TestIsolatedState: def test_append(self): test_list = [] test_list.append(1) assert len(test_list) == 1 def test_length(self): test_list = [] assert len(test_list) == 0 ``` ### Using Fixtures for Isolation ```python @pytest.fixture def isolated_list(): """Provide a fresh list for each test.""" return [] def test_append(isolated_list): isolated_list.append(1) assert len(isolated_list) == 1 def test_length(isolated_list): assert len(isolated_list) == 0 ``` ### Cleanup After Tests Always cleanup: ```python @pytest.fixture def temp_file(): """Create and cleanup temporary file.""" path = Path("temp.txt") path.write_text("test") yield path # Cleanup if path.exists(): path.unlink() @pytest.fixture def database(): """Create and cleanup test database.""" db = create_database() yield db # Cleanup db.drop_all_tables() db.close() ``` ## Deprecating Tests ### When to Deprecate Tests Deprecate tests when: - Feature is removed - API is changed significantly - Test is replaced by better test - Test is no longer relevant ### How to Deprecate Tests 1. **Mark as deprecated**: ```python @pytest.mark.skip(reason="Deprecated - Use test_new_feature instead") def test_old_feature(): pass ``` 2. **Add deprecation warning**: ```python import warnings def test_legacy_feature(): warnings.warn( "This test is deprecated and will be removed in v2.0", DeprecationWarning ) # Test code... ``` 3. **Document migration path**: ```python # DEPRECATED: This test is deprecated as of v1.5.0 # Use test_new_implementation in test_new_feature.py instead # Will be removed in v2.0.0 @pytest.mark.skip(reason="Deprecated - see test_new_implementation") def test_old_implementation(): pass ``` ## Best Practices 1. **Monitor coverage regularly**: Check coverage on every PR 2. **Keep tests fast**: Optimize slow tests 3. **Fix flaky tests immediately**: Don't ignore them 4. **Update tests with code**: Tests are part of the codebase 5. **Document test patterns**: Help others write good tests 6. **Review test code**: Tests deserve code review too 7. **Refactor tests**: Keep test code clean 8. **Delete obsolete tests**: Remove tests for removed features ## Maintenance Checklist ### Weekly - [ ] Review test failures in CI/CD - [ ] Check for slow tests - [ ] Monitor coverage trends ### Monthly - [ ] Review and fix flaky tests - [ ] Update test dependencies - [ ] Refactor duplicate test code - [ ] Update test documentation ### Quarterly - [ ] Audit test coverage - [ ] Remove obsolete tests - [ ] Review test performance - [ ] Update testing guidelines ## Summary Key maintenance tasks: 1. Monitor and maintain 80% coverage 2. Keep tests fast and reliable 3. Fix flaky tests immediately 4. Ensure test isolation 5. Update tests with code changes 6. Optimize test performance 7. Clean up obsolete tests For more information, see: - [Testing Guide](./README.md) - [Writing Tests Guide](./WRITING_TESTS.md) ================================================ FILE: docs/testing/QUICK-START.md ================================================ # Test Suite Quick Start Guide ## TL;DR - Just Run Tests Safely ```bash # Run all tests (memory-safe, serial execution) uv run pytest # Or use the test runner script python scripts/test.py full ``` ## Problem Solved Previously, running tests would crash EC2 instances due to: - Heavy ML model loading (sentence-transformers, FAISS) - Parallel execution spawning multiple model copies - Memory multiplication across workers **Now fixed!** All tests use mocked models by default. ## Quick Commands ### Safe for All EC2 Instances ```bash # Run unit tests (fast) python scripts/test.py unit # Run specific domains python scripts/test.py auth python scripts/test.py servers # Run fast tests (2 workers, still safe) python scripts/test.py fast # Full test suite (serial, safest) python scripts/test.py full ``` ### If You Have More Memory (16GB+ RAM) ```bash # Run with 2 workers python scripts/test.py full -n 2 # Run with 4 workers (requires 16GB+ RAM) python scripts/test.py unit -n 4 ``` ## What Changed ### 1. Mocked Dependencies (Automatic) All tests now automatically use mocked versions of: - FAISS vector database - Sentence-transformers embedding models - PyTorch model loading No changes needed to existing tests - it just works! ### 2. Serial Execution by Default Tests run one at a time by default to prevent memory issues: ```bash # Before (would crash) pytest -n auto # ❌ Crashes EC2 # Now (safe) pytest # ✅ Runs serially, no crash ``` ### 3. Optional Parallelization Use the `-n` flag to control workers: ```bash # 2 workers (safe for most EC2) python scripts/test.py unit -n 2 # 4 workers (needs 16GB+ RAM) python scripts/test.py unit -n 4 ``` ## Memory Guidelines | EC2 Instance | Safe Workers | Notes | |--------------|--------------|-------| | t3.small (2GB) | 1 (serial) | ✅ Now works! | | t3.medium (4GB) | 1-2 | ✅ Now works! | | t3.large (8GB) | 2 | ✅ Recommended | | t3.xlarge (16GB+) | 2-4 | ✅ Can use more workers | ## Monitoring Memory While tests run: ```bash # Check current memory usage free -h # Watch memory in real-time watch -n 1 free -h ``` ## Writing New Tests Tests automatically use mocked models - no special setup needed: ```python import pytest @pytest.mark.unit def test_my_feature(server_service): # FAISS and embeddings are automatically mocked result = server_service.do_something() assert result is not None ``` ## When Tests Fail ```bash # Run specific failing test pytest tests/unit/auth/test_auth_routes.py::test_login -v # Show debug output pytest tests/unit/auth/ --log-cli-level=DEBUG # Stop on first failure pytest -x ``` ## Getting Coverage ```bash # Generate coverage report python scripts/test.py coverage # View in browser open htmlcov/index.html ``` ## More Information - **[Memory Management Details](./memory-management.md)** - In-depth explanation - **[Test Categories](./test-categories.md)** - How tests are organized - **[Main Testing README](./README.md)** - Complete reference ## Still Having Issues? If tests still crash: 1. **Check you're on the latest version:** ```bash git pull uv sync --extra dev ``` 2. **Verify mocking is enabled:** ```bash pytest tests/unit/core/test_config.py -v ``` Should pass quickly (< 1 second) without loading models 3. **Run completely serially:** ```bash pytest -x # Stop on first failure ``` 4. **Check memory before running:** ```bash free -h # Should have several GB free ``` ## Summary ✅ Tests now run safely on any EC2 instance ✅ No more OOM crashes ✅ Automatic model mocking ✅ Serial execution by default ✅ Optional parallelization with `-n` flag ✅ Existing tests work without changes ================================================ FILE: docs/testing/README.md ================================================ # Testing Guide Comprehensive testing documentation for the MCP Gateway Registry project. ## Table of Contents - [Quick Start](#quick-start) - [Test Structure](#test-structure) - [Running Tests](#running-tests) - [Test Categories](#test-categories) - [Coverage Requirements](#coverage-requirements) - [CI/CD Integration](#cicd-integration) - [Troubleshooting](#troubleshooting) ## Quick Start Run all tests: ```bash make test ``` Run specific test categories: ```bash # Unit tests only (fast) make test-unit # Integration tests make test-integration # E2E tests (slow) make test-e2e # With coverage report make test-coverage ``` Run tests using pytest directly: ```bash # All tests uv run pytest # Specific test file uv run pytest tests/unit/test_server_service.py # Specific test class uv run pytest tests/unit/test_server_service.py::TestServerService # Specific test function uv run pytest tests/unit/test_server_service.py::TestServerService::test_register_server # With verbose output uv run pytest -v # With coverage uv run pytest --cov=registry --cov-report=html ``` ## Test Structure The test suite is organized into three main categories: ``` tests/ ├── unit/ # Unit tests (fast, isolated) │ ├── services/ # Service layer tests │ ├── api/ # API endpoint tests │ ├── core/ # Core functionality tests │ └── agents/ # Agent-specific tests ├── integration/ # Integration tests (slower) │ ├── test_server_integration.py │ ├── test_api_integration.py │ └── test_e2e_workflows.py ├── fixtures/ # Shared test fixtures │ └── factories.py # Factory functions for test data ├── conftest.py # Shared pytest configuration └── reports/ # Test reports and coverage data ``` ### Test File Organization - **Unit tests**: Test individual components in isolation - Mock external dependencies - Fast execution (< 1 second per test) - High coverage of edge cases - **Integration tests**: Test component interactions - May use real services (databases, files) - Moderate execution time (< 5 seconds per test) - Test realistic workflows - **E2E tests**: Test complete user workflows - Test entire system end-to-end - Slower execution (5-30 seconds per test) - Marked with `@pytest.mark.slow` ## Running Tests ### Using Make Commands The project includes convenient Make targets for running tests: ```bash # Run all tests make test # Run only unit tests (fast) make test-unit # Run only integration tests make test-integration # Run E2E tests make test-e2e # Run with coverage report make test-coverage # Run and open HTML coverage report make test-coverage-html ``` ### Using Pytest Directly For more control, use pytest commands: ```bash # Run all tests uv run pytest # Run tests with specific markers uv run pytest -m unit # Only unit tests uv run pytest -m integration # Only integration tests uv run pytest -m "not slow" # Skip slow tests # Run tests in parallel (faster) uv run pytest -n auto # Auto-detect CPU count # Run with verbose output uv run pytest -v # Show print statements uv run pytest -s # Run specific tests by keyword uv run pytest -k "server" # All tests with "server" in name # Stop on first failure uv run pytest -x # Run last failed tests uv run pytest --lf # Run failed tests first uv run pytest --ff ``` ### Integration Test Requirements Integration and E2E tests may require: 1. **Authentication tokens**: Generate tokens before running: ```bash ./keycloak/setup/generate-agent-token.sh admin-bot ./keycloak/setup/generate-agent-token.sh lob1-bot ./keycloak/setup/generate-agent-token.sh lob2-bot ``` 2. **Running services**: Ensure Docker containers are running: ```bash docker-compose up -d ``` 3. **Environment variables**: ```bash export BASE_URL="http://localhost" export TOKEN_FILE=".oauth-tokens/admin-bot-token.json" ``` ## Test Categories Tests are organized using pytest markers: ### Available Markers - `@pytest.mark.unit` - Unit tests (fast, isolated) - `@pytest.mark.integration` - Integration tests - `@pytest.mark.e2e` - End-to-end tests - `@pytest.mark.slow` - Slow tests (> 5 seconds) - `@pytest.mark.auth` - Authentication/authorization tests - `@pytest.mark.servers` - Server management tests - `@pytest.mark.agents` - Agent-specific tests - `@pytest.mark.search` - Search functionality tests - `@pytest.mark.health` - Health monitoring tests ### Running Tests by Marker ```bash # Run only unit tests uv run pytest -m unit # Run integration tests uv run pytest -m integration # Run E2E tests uv run pytest -m e2e # Skip slow tests uv run pytest -m "not slow" # Run auth and agent tests uv run pytest -m "auth or agents" # Run integration but not slow tests uv run pytest -m "integration and not slow" ``` ## Coverage Requirements The project maintains **80% minimum code coverage**. ### Checking Coverage ```bash # Run tests with coverage report uv run pytest --cov=registry --cov-report=term-missing # Generate HTML coverage report uv run pytest --cov=registry --cov-report=html # Open HTML report open htmlcov/index.html # macOS xdg-open htmlcov/index.html # Linux ``` ### Coverage Configuration Coverage settings are configured in `pyproject.toml`: ```toml [tool.pytest.ini_options] addopts = [ "--cov=registry", "--cov-report=term-missing", "--cov-report=html", "--cov-fail-under=80", ] ``` ### What Gets Covered Coverage includes: - All source code in `registry/` directory - Excludes: tests, migrations, __init__.py files - Reports missing lines for easy identification ## CI/CD Integration Tests run automatically in CI/CD pipelines on: - Every pull request - Every push to main branch - Nightly scheduled runs ### GitHub Actions The project uses GitHub Actions for CI/CD. Test workflows are defined in: ``` .github/workflows/ ├── test.yml # Main test workflow ├── coverage.yml # Coverage reporting └── integration.yml # Integration test workflow ``` ### Pre-commit Hooks Install pre-commit hooks to run tests before commits: ```bash # Install pre-commit pip install pre-commit # Install hooks pre-commit install # Run hooks manually pre-commit run --all-files ``` ## Troubleshooting ### Common Issues #### 1. Token File Not Found **Error**: `Token file not found: .oauth-tokens/admin-bot-token.json` **Solution**: Generate authentication tokens: ```bash ./keycloak/setup/generate-agent-token.sh admin-bot ``` #### 2. Docker Containers Not Running **Error**: `Cannot connect to gateway at http://localhost` **Solution**: Start Docker containers: ```bash docker-compose up -d ``` #### 3. Import Errors **Error**: `ModuleNotFoundError: No module named 'registry'` **Solution**: Ensure you're using `uv run`: ```bash uv run pytest # Correct pytest # May fail if environment not activated ``` #### 4. Fixture Not Found **Error**: `fixture 'some_fixture' not found` **Solution**: Check fixture is defined in: - `tests/conftest.py` (shared fixtures) - Test file's conftest.py - Imported from fixtures module #### 5. Slow Tests **Issue**: Tests taking too long **Solution**: Skip slow tests during development: ```bash uv run pytest -m "not slow" ``` #### 6. Failed Async Tests **Error**: `RuntimeError: Event loop is closed` **Solution**: Check async fixtures are properly defined: ```python @pytest.fixture async def async_client(): async with AsyncClient() as client: yield client ``` #### 7. Coverage Too Low **Error**: `FAIL Required test coverage of 80% not reached` **Solution**: Add tests for uncovered code: ```bash # Check which lines are missing uv run pytest --cov=registry --cov-report=term-missing # Generate detailed HTML report uv run pytest --cov=registry --cov-report=html open htmlcov/index.html ``` ### Debug Mode Run tests in debug mode for detailed output: ```bash # Show print statements uv run pytest -s # Verbose output uv run pytest -v # Very verbose (shows fixtures) uv run pytest -vv # Show local variables on failure uv run pytest -l # Enter debugger on failure uv run pytest --pdb ``` ### Logging During Tests Enable logging output: ```bash # Show all logs uv run pytest --log-cli-level=DEBUG # Show only INFO and above uv run pytest --log-cli-level=INFO # Log to file uv run pytest --log-file=tests/reports/test.log ``` ## Additional Resources - [Writing Tests Guide](./WRITING_TESTS.md) - How to write effective tests - [Test Maintenance Guide](./MAINTENANCE.md) - Maintaining test suite health - [Pytest Documentation](https://docs.pytest.org/) - Official pytest docs - [Coverage.py Documentation](https://coverage.readthedocs.io/) - Coverage tool docs ## Getting Help If you encounter issues: 1. Check this troubleshooting guide 2. Review test output for error messages 3. Check relevant documentation 4. Ask in team chat or create an issue ## Summary Key commands to remember: ```bash # Development workflow make test-unit # Quick unit tests make test-coverage # Full test with coverage uv run pytest -m "not slow" # Skip slow tests # Before committing make test # Run all tests pre-commit run --all-files # Run all checks # Debugging uv run pytest -v -s # Verbose with prints uv run pytest --pdb # Debug on failure ``` ================================================ FILE: docs/testing/WRITING_TESTS.md ================================================ # Writing Tests Guide A comprehensive guide to writing effective tests for the MCP Gateway Registry project. ## Table of Contents - [Test Writing Principles](#test-writing-principles) - [Test Structure](#test-structure) - [Test Patterns](#test-patterns) - [Using Fixtures](#using-fixtures) - [Mocking Strategies](#mocking-strategies) - [Async Testing](#async-testing) - [Factory Pattern](#factory-pattern) - [Best Practices](#best-practices) - [Examples](#examples) ## Test Writing Principles ### 1. Follow AAA Pattern Organize tests using Arrange-Act-Assert: ```python def test_register_server(server_service, sample_server): # Arrange - Set up test data and preconditions server_id = "test-server" server_info = sample_server # Act - Perform the action being tested result = server_service.register_server(server_id, server_info) # Assert - Verify the outcome assert result is not None assert result["id"] == server_id ``` ### 2. One Assertion Per Test (When Possible) Each test should verify one specific behavior: ```python # Good - Tests one thing def test_server_registration_succeeds(server_service, sample_server): result = server_service.register_server("test", sample_server) assert result is not None def test_server_registration_stores_data(server_service, sample_server): result = server_service.register_server("test", sample_server) assert result["name"] == sample_server["name"] # Avoid - Tests too many things def test_server_registration(server_service, sample_server): result = server_service.register_server("test", sample_server) assert result is not None assert result["name"] == sample_server["name"] assert len(server_service.list_servers()) == 1 assert server_service.get_server("test") == result ``` ### 3. Descriptive Test Names Use clear, descriptive names that explain what is being tested: ```python # Good - Clear and descriptive def test_register_server_with_valid_data_succeeds(): pass def test_register_server_with_duplicate_id_raises_error(): pass def test_list_servers_returns_empty_list_when_no_servers(): pass # Avoid - Vague names def test_server(): pass def test_register(): pass def test_list(): pass ``` ## Test Structure ### File Organization Organize tests to mirror the source code structure: ``` registry/ ├── services/ │ ├── server_service.py │ └── agent_service.py └── api/ └── routes.py tests/ ├── unit/ │ ├── services/ │ │ ├── test_server_service.py │ │ └── test_agent_service.py │ └── api/ │ └── test_routes.py ``` ### Test Class Structure Group related tests in classes: ```python import pytest @pytest.mark.unit class TestServerService: """Tests for ServerService class.""" def test_register_server_succeeds(self, server_service): """Test successful server registration.""" pass def test_register_server_duplicate_fails(self, server_service): """Test that duplicate server IDs are rejected.""" pass def test_list_servers_returns_all(self, server_service): """Test listing all registered servers.""" pass @pytest.mark.unit class TestServerServiceValidation: """Tests for ServerService validation logic.""" def test_validate_server_info_with_valid_data(self): """Test validation passes with valid server info.""" pass def test_validate_server_info_rejects_missing_name(self): """Test validation fails when name is missing.""" pass ``` ## Test Patterns ### Unit Test Pattern Test individual functions/methods in isolation: ```python @pytest.mark.unit def test_calculate_health_score(): """Test health score calculation.""" # Arrange server_status = { "available": True, "response_time": 100, "error_rate": 0.01 } # Act score = calculate_health_score(server_status) # Assert assert 0.0 <= score <= 1.0 assert score > 0.9 # Healthy server ``` ### Integration Test Pattern Test component interactions: ```python @pytest.mark.integration async def test_server_registration_workflow( server_service, health_service, sample_server, ): """Test complete server registration workflow.""" # Register server server_id = "integration-test" result = server_service.register_server(server_id, sample_server) # Verify health monitoring started await asyncio.sleep(0.1) health_status = health_service.get_health_status(server_id) assert result is not None assert health_status is not None ``` ### E2E Test Pattern Test complete user workflows: ```python @pytest.mark.e2e @pytest.mark.slow async def test_complete_agent_lifecycle( base_url, auth_headers, test_agent_data, ): """Test complete agent lifecycle: create, update, delete.""" async with httpx.AsyncClient() as client: # Create agent response = await client.post( f"{base_url}/api/agents/register", headers=auth_headers, json=test_agent_data, ) assert response.status_code == 200 agent_path = response.json()["path"] # Update agent response = await client.put( f"{base_url}/api/agents/{agent_path}", headers=auth_headers, json={"description": "Updated"}, ) assert response.status_code == 200 # Delete agent response = await client.delete( f"{base_url}/api/agents/{agent_path}", headers=auth_headers, ) assert response.status_code in [200, 204] ``` ## Using Fixtures ### Built-in Fixtures Leverage pytest's built-in fixtures: ```python def test_with_temp_directory(tmp_path): """Use tmp_path for temporary directories.""" test_file = tmp_path / "test.json" test_file.write_text('{"key": "value"}') assert test_file.exists() def test_with_monkeypatch(monkeypatch): """Use monkeypatch to modify environment.""" monkeypatch.setenv("TEST_VAR", "test_value") assert os.getenv("TEST_VAR") == "test_value" ``` ### Custom Fixtures Create reusable test fixtures in `conftest.py`: ```python # tests/conftest.py import pytest @pytest.fixture def sample_server(): """Create a sample server for testing.""" return { "name": "Test Server", "url": "http://test.example.com", "description": "Test server for unit tests" } @pytest.fixture def authenticated_client(test_client, auth_token): """Create an authenticated test client.""" test_client.headers["Authorization"] = f"Bearer {auth_token}" return test_client ``` ### Fixture Scopes Use appropriate fixture scopes: ```python @pytest.fixture(scope="function") # Default - new instance per test def temp_database(): """Create a fresh database for each test.""" db = create_test_database() yield db db.cleanup() @pytest.fixture(scope="class") # Shared across test class def shared_resource(): """Create resource shared by all tests in class.""" resource = expensive_setup() yield resource resource.cleanup() @pytest.fixture(scope="module") # Shared across module def module_database(): """Create database shared by all tests in module.""" db = create_test_database() yield db db.cleanup() ``` ## Mocking Strategies ### Using unittest.mock Mock external dependencies: ```python from unittest.mock import Mock, AsyncMock, patch def test_with_mock_dependency(): """Test with mocked dependency.""" # Create mock mock_service = Mock() mock_service.get_data.return_value = {"key": "value"} # Use mock result = function_under_test(mock_service) # Verify mock was called mock_service.get_data.assert_called_once() assert result is not None async def test_with_async_mock(): """Test with async mock.""" mock_service = AsyncMock() mock_service.fetch_data.return_value = {"data": "test"} result = await async_function_under_test(mock_service) mock_service.fetch_data.assert_called_once() assert result == {"data": "test"} ``` ### Patching Functions Use `@patch` decorator or context manager: ```python @patch('registry.services.external_api_call') def test_with_patched_function(mock_api): """Test with patched external function.""" mock_api.return_value = {"status": "success"} result = function_that_calls_api() mock_api.assert_called_once() assert result["status"] == "success" def test_with_patch_context_manager(): """Test using patch as context manager.""" with patch('registry.services.external_api_call') as mock_api: mock_api.return_value = {"status": "success"} result = function_that_calls_api() assert result["status"] == "success" ``` ### Mock Configuration Configure mocks for specific behaviors: ```python def test_mock_configuration(): """Test with configured mock.""" mock_service = Mock() # Configure return values mock_service.get.return_value = "value" mock_service.list.return_value = ["item1", "item2"] # Configure side effects mock_service.process.side_effect = [1, 2, 3] # Configure exceptions mock_service.fail.side_effect = ValueError("Test error") # Use configured mock assert mock_service.get() == "value" assert mock_service.process() == 1 assert mock_service.process() == 2 with pytest.raises(ValueError): mock_service.fail() ``` ## Async Testing ### Async Test Functions Use `async def` for async tests: ```python @pytest.mark.asyncio async def test_async_function(): """Test async function.""" result = await async_function() assert result is not None @pytest.mark.asyncio async def test_async_client(async_client): """Test with async HTTP client.""" response = await async_client.get("/api/endpoint") assert response.status_code == 200 ``` ### Async Fixtures Create async fixtures: ```python @pytest.fixture async def async_database(): """Create async database connection.""" db = await create_async_database() yield db await db.close() @pytest.mark.asyncio async def test_with_async_fixture(async_database): """Test using async fixture.""" result = await async_database.query("SELECT * FROM table") assert result is not None ``` ### Testing Async Context Managers Test async context managers: ```python @pytest.mark.asyncio async def test_async_context_manager(): """Test async context manager.""" async with AsyncResource() as resource: result = await resource.do_something() assert result is not None ``` ## Factory Pattern ### Creating Test Data Factories Use factories to generate test data: ```python # tests/fixtures/factories.py def ServerInfoFactory( name: str = "Test Server", url: str = "http://test.example.com", **kwargs ) -> Dict[str, Any]: """Factory for creating server info dictionaries.""" return { "name": name, "url": url, "description": kwargs.get("description", "Test server"), "tags": kwargs.get("tags", ["test"]), "version": kwargs.get("version", "1.0.0"), } def create_multiple_servers(count: int = 3) -> Dict[str, Dict[str, Any]]: """Create multiple test servers.""" return { f"server-{i}": ServerInfoFactory( name=f"Test Server {i}", url=f"http://server{i}.example.com" ) for i in range(count) } def create_server_with_tools(num_tools: int = 5) -> Dict[str, Any]: """Create a server with tools.""" server = ServerInfoFactory() server["tools"] = [ { "name": f"tool_{i}", "description": f"Test tool {i}", "parameters": {} } for i in range(num_tools) ] return server ``` ### Using Factories in Tests ```python def test_with_factory(server_service): """Test using factory-created data.""" # Create single server server = ServerInfoFactory(name="Custom Server") result = server_service.register_server("test", server) assert result["name"] == "Custom Server" def test_with_multiple_factories(server_service): """Test with multiple factory-created servers.""" servers = create_multiple_servers(count=5) for server_id, server_info in servers.items(): server_service.register_server(server_id, server_info) assert len(server_service.list_servers()) == 5 ``` ## Best Practices ### 1. Test Independence Tests should be independent and not rely on execution order: ```python # Good - Independent tests def test_register_server(server_service, sample_server): """Test registers its own server.""" result = server_service.register_server("test1", sample_server) assert result is not None def test_list_servers(server_service, sample_server): """Test creates its own data.""" server_service.register_server("test2", sample_server) servers = server_service.list_servers() assert len(servers) >= 1 # Avoid - Tests depend on each other def test_register_server_first(server_service, sample_server): """Test creates server for other tests.""" server_service.register_server("shared", sample_server) def test_list_servers_second(server_service): """Test assumes server from previous test exists.""" servers = server_service.list_servers() assert "shared" in servers # Fragile! ``` ### 2. Test Edge Cases Test boundary conditions and edge cases: ```python def test_edge_cases(): """Test edge cases and boundary conditions.""" # Empty input assert process_data([]) == [] # Single item assert process_data([1]) == [1] # Large input assert len(process_data(range(10000))) == 10000 # Null/None input with pytest.raises(ValueError): process_data(None) # Invalid type with pytest.raises(TypeError): process_data("not a list") ``` ### 3. Test Error Handling Verify error handling behavior: ```python def test_error_handling(): """Test error handling.""" # Test specific exception with pytest.raises(ValueError): function_that_raises_value_error() # Test exception message with pytest.raises(ValueError, match="Invalid input"): function_with_specific_error() # Test exception attributes with pytest.raises(CustomError) as exc_info: function_with_custom_error() assert exc_info.value.code == 400 assert "error" in str(exc_info.value) ``` ### 4. Use Parametrize for Similar Tests Use `@pytest.mark.parametrize` to test multiple inputs: ```python @pytest.mark.parametrize("input,expected", [ (1, 2), (2, 4), (3, 6), (0, 0), (-1, -2), ]) def test_double(input, expected): """Test double function with multiple inputs.""" assert double(input) == expected @pytest.mark.parametrize("server_id,should_fail", [ ("valid-id", False), ("valid_id", False), ("invalid id", True), # Spaces not allowed ("", True), # Empty string ("a" * 256, True), # Too long ]) def test_server_id_validation(server_id, should_fail): """Test server ID validation with various inputs.""" if should_fail: with pytest.raises(ValueError): validate_server_id(server_id) else: validate_server_id(server_id) # Should not raise ``` ### 5. Clean Up Resources Always clean up resources after tests: ```python @pytest.fixture def temp_file(): """Create temporary file and clean up after.""" file_path = Path("temp_test_file.txt") file_path.write_text("test data") yield file_path # Cleanup if file_path.exists(): file_path.unlink() @pytest.fixture def database_connection(): """Create database connection and close after.""" connection = create_connection() yield connection # Cleanup connection.close() ``` ## Examples ### Complete Unit Test Example ```python import pytest from unittest.mock import Mock from registry.services.server_service import ServerService @pytest.mark.unit class TestServerService: """Tests for ServerService.""" def test_register_server_with_valid_data( self, server_service, sample_server, ): """Test registering a server with valid data.""" # Arrange server_id = "test-server" # Act result = server_service.register_server(server_id, sample_server) # Assert assert result is not None assert result["id"] == server_id assert result["name"] == sample_server["name"] def test_register_server_with_duplicate_id_raises_error( self, server_service, sample_server, ): """Test that duplicate server IDs raise an error.""" # Arrange server_id = "test-server" server_service.register_server(server_id, sample_server) # Act & Assert with pytest.raises(ValueError, match="already registered"): server_service.register_server(server_id, sample_server) def test_list_servers_returns_all_registered_servers( self, server_service, ): """Test listing all registered servers.""" # Arrange servers = create_multiple_servers(count=3) for server_id, server_info in servers.items(): server_service.register_server(server_id, server_info) # Act result = server_service.list_servers() # Assert assert len(result) == 3 assert all(s["id"] in servers for s in result) ``` ### Complete Integration Test Example ```python import pytest import httpx @pytest.mark.integration class TestAgentAPI: """Integration tests for Agent API.""" async def test_complete_agent_workflow( self, base_url, auth_headers, ): """Test complete agent registration workflow.""" async with httpx.AsyncClient() as client: # Create agent agent_data = { "name": "Test Agent", "description": "Integration test agent", "url": "http://test.example.com", } response = await client.post( f"{base_url}/api/agents/register", headers=auth_headers, json=agent_data, ) assert response.status_code == 200 agent_path = response.json()["path"] # Retrieve agent response = await client.get( f"{base_url}/api/agents/{agent_path}", headers=auth_headers, ) assert response.status_code == 200 agent = response.json() assert agent["name"] == "Test Agent" # Update agent response = await client.put( f"{base_url}/api/agents/{agent_path}", headers=auth_headers, json={"description": "Updated description"}, ) assert response.status_code == 200 # Delete agent response = await client.delete( f"{base_url}/api/agents/{agent_path}", headers=auth_headers, ) assert response.status_code in [200, 204] ``` ## Summary Key points for writing effective tests: 1. Follow AAA pattern (Arrange, Act, Assert) 2. Write descriptive test names 3. Test one thing per test 4. Use fixtures for reusable test data 5. Mock external dependencies 6. Test edge cases and error handling 7. Use parametrize for similar tests 8. Keep tests independent 9. Clean up resources 10. Maintain good test coverage For more information, see: - [Testing Guide](./README.md) - [Test Maintenance](./MAINTENANCE.md) ================================================ FILE: docs/testing/memory-management.md ================================================ # Test Suite Memory Management ## Problem Running the full test suite with parallel execution can cause Out-of-Memory (OOM) crashes on EC2 instances, especially smaller instances with limited RAM. ### Root Cause The test suite includes: - **38 test files** with over 14,000 lines of test code - Heavy dependencies including: - Sentence-transformers embedding models (~120-200MB per process) - FAISS vector indexes - Full FastAPI application stack When using pytest-xdist with `-n auto`, pytest spawns one worker process per CPU core (4 workers on a 4-core EC2 instance). Each worker loads: - The embedding model - FAISS indexes - Test fixtures and data - The full application **Memory multiplication:** 4 workers × ~500MB per worker = ~2GB+ just for test processes This can overwhelm EC2 instances with 8-16GB of RAM, especially when the OS and other services are also running. ## Solution ### Default Behavior (Serial Execution) The test suite now runs **serially by default** to prevent OOM crashes: ```bash # Safe for all EC2 instances - runs tests one at a time python scripts/test.py full ``` ### Parallel Execution (Use with Caution) If you have sufficient memory (16GB+ RAM), you can enable parallel execution: ```bash # Run with 2 workers (safer for smaller EC2 instances) python scripts/test.py full -n 2 # Run fast tests with 2 workers python scripts/test.py fast # Run unit tests with 4 workers (requires more memory) python scripts/test.py unit -n 4 ``` ### Monitoring Memory Usage Before running tests with parallelization, check available memory: ```bash # Check memory usage free -h # Monitor memory in real-time watch -n 1 free -h # Check processes by memory usage ps aux --sort=-%mem | head -20 ``` ### Memory Guidelines | EC2 Instance Type | Recommended Workers | Notes | |-------------------|---------------------|-------| | t3.small (2GB) | 1 (serial) | Parallel execution will crash | | t3.medium (4GB) | 1 (serial) | May work with -n 2 for unit tests | | t3.large (8GB) | 2 | Safe for most tests | | t3.xlarge (16GB) | 3-4 | Can handle full parallelization | | t3.2xlarge (32GB) | auto | Full parallel execution safe | ## Test Commands ### Recommended Commands for EC2 ```bash # Check dependencies first python scripts/test.py check # Run unit tests only (fastest, safest) python scripts/test.py unit # Run integration tests python scripts/test.py integration # Run fast tests with 2 workers python scripts/test.py fast # Run full test suite serially (safe but slow) python scripts/test.py full # Generate coverage report (always serial) python scripts/test.py coverage ``` ### Advanced Options ```bash # Run specific domain tests python scripts/test.py auth # Authentication tests python scripts/test.py servers # Server management tests python scripts/test.py search # Search and AI tests python scripts/test.py health # Health monitoring tests python scripts/test.py core # Core infrastructure tests # Enable debug logging python scripts/test.py unit --debug # Run with custom worker count python scripts/test.py unit -n 3 ``` ## Direct pytest Usage If using pytest directly, be aware of memory implications: ```bash # DANGEROUS: May crash EC2 instance pytest -n auto # Spawns workers = CPU cores # SAFER: Limit workers pytest -n 2 # SAFEST: Serial execution (no -n flag) pytest ``` ## Optimizations ### For Local Development If running locally with sufficient RAM (16GB+): ```bash # Fast parallel execution for unit tests pytest tests/unit -n auto # Fast parallel for specific domains pytest tests/unit/auth -n auto ``` ### For CI/CD GitHub Actions and other CI environments typically have limited memory. Use: ```bash # Serial execution in CI pytest # Or limit workers pytest -n 2 ``` ### Future Improvements To further reduce memory usage: 1. **Mock Heavy Dependencies**: Mock sentence-transformers and FAISS in unit tests 2. **Test Fixtures Optimization**: Share model loading across tests using session-scoped fixtures 3. **Test Categorization**: Split heavy integration tests from lightweight unit tests 4. **Lazy Loading**: Only load ML models when actually needed in tests ## Troubleshooting ### OOM Crash Symptoms - EC2 instance becomes unresponsive - SSH connection drops - Test suite hangs indefinitely - System logs show "Out of memory: Killed process" ### Recovery Steps 1. Reboot the EC2 instance if unresponsive 2. Run tests serially: `python scripts/test.py full` 3. Consider upgrading to a larger instance type 4. Run tests in batches by domain: ```bash python scripts/test.py auth python scripts/test.py servers python scripts/test.py search ``` ### Debugging Memory Issues ```bash # Check which process is using memory during tests watch -n 1 'ps aux --sort=-%mem | head -20' # Check for OOM killer logs dmesg | grep -i "out of memory" sudo journalctl | grep -i "out of memory" ``` ## Summary - **Default:** Tests run serially to prevent OOM crashes - **Safe Parallel:** Use `-n 2` for faster execution on typical EC2 instances - **Full Parallel:** Only use `-n auto` or higher worker counts on instances with 16GB+ RAM - **Monitor:** Always monitor memory usage when experimenting with parallelization ================================================ FILE: docs/testing/test-categories.md ================================================ # Test Categories and Best Practices ## Test Markers Tests are organized using pytest markers to enable selective test execution: ### Primary Categories - **`@pytest.mark.unit`** - Fast, isolated unit tests - No external dependencies - Mocked services and models - Should run in < 1 second each - **`@pytest.mark.integration`** - Integration tests - May interact with services - May use real HTTP clients - Can take longer to run - **`@pytest.mark.e2e`** - End-to-end workflow tests - Test complete user workflows - May involve multiple components - Typically slower ### Domain-Specific Markers - **`@pytest.mark.auth`** - Authentication and authorization tests - **`@pytest.mark.servers`** - Server management tests - **`@pytest.mark.search`** - Search and AI functionality tests - **`@pytest.mark.health`** - Health monitoring tests - **`@pytest.mark.core`** - Core infrastructure tests ### Special Markers - **`@pytest.mark.slow`** - Slow-running tests (> 5 seconds) - Excluded by default in fast test runs - Should be minimized - **`@pytest.mark.requires_models`** - Tests requiring real ML models - Will load actual embeddings models and FAISS - **WARNING**: These tests can cause OOM on small EC2 instances - Should only be used when absolutely necessary - Consider if the functionality can be tested with mocks instead ## Default Test Behavior (Memory-Safe) By default, **ALL** tests use mocked versions of heavy dependencies to prevent OOM crashes: - **FAISS service** - Mocked automatically - **Embeddings models** - Mocked automatically - **Sentence-transformers** - Mocked automatically - **PyTorch model loading** - Blocked This means tests run fast and safely on any EC2 instance size. ## Writing Memory-Safe Tests ### Good Example (Default) ```python import pytest @pytest.mark.unit def test_server_registration(server_service, sample_server): """Test server registration with mocked dependencies.""" # FAISS and embeddings are automatically mocked server_service.register_server(sample_server) assert server_service.is_registered(sample_server["name"]) ``` ### When You Need Real Models (Use Sparingly) Only use real models when: 1. Testing the actual ML model functionality 2. Testing embeddings quality or accuracy 3. Integration testing with real vector search ```python import pytest @pytest.mark.requires_models # Mark as requiring real models @pytest.mark.slow # Will be slow @pytest.mark.integration # Not a unit test def test_real_embeddings_search(real_faiss_service): """Test search with real embeddings model. WARNING: This test loads real ML models and may cause OOM on small instances. """ # This test actually loads sentence-transformers and FAISS await real_faiss_service.initialize() results = await real_faiss_service.search_services("test query") assert len(results) > 0 ``` **Running tests that require models:** ```bash # Run all tests including those requiring models (WARNING: High memory usage) pytest -m requires_models # Exclude tests requiring models (safe for EC2) pytest -m "not requires_models" ``` ## Test Fixtures ### Automatically Available (Mocked) These fixtures are automatically mocked for all tests: - `mock_faiss_service` - Mocked FAISS vector database - `mock_embeddings` - Mocked embeddings client - `prevent_real_model_loading` - Prevents torch/sentence-transformers loading ### Commonly Used Test Fixtures - `test_client` - FastAPI TestClient - `async_client` - Async HTTP client - `mock_authenticated_user` - Simulates authenticated user - `server_service` - Server management service - `health_service` - Health monitoring service - `sample_server` - Sample server data for testing - `sample_servers` - Multiple sample servers - `temp_dir` - Temporary directory for tests ### Settings and Configuration - `test_settings` - Test configuration with temp directories - `mock_settings` - Globally mocked settings ## Writing Good Tests ### Unit Test Example ```python import pytest from unittest.mock import Mock, AsyncMock @pytest.mark.unit @pytest.mark.auth class TestAuthService: """Tests for authentication service.""" def test_valid_token_verification(self, auth_service): """Test that valid tokens are verified correctly.""" token = "valid-token-12345" result = auth_service.verify_token(token) assert result is True async def test_token_generation(self, auth_service): """Test JWT token generation.""" user_data = {"username": "testuser", "role": "admin"} token = await auth_service.generate_token(user_data) assert token is not None assert len(token) > 50 ``` ### Integration Test Example ```python import pytest from httpx import AsyncClient @pytest.mark.integration @pytest.mark.servers class TestServerRegistration: """Integration tests for server registration API.""" async def test_register_server_endpoint( self, async_client: AsyncClient, sample_server, integration_auth_headers ): """Test server registration via API endpoint.""" response = await async_client.post( "/api/servers", json=sample_server, headers=integration_auth_headers ) assert response.status_code == 201 data = response.json() assert data["name"] == sample_server["name"] ``` ## Test Organization ``` tests/ ├── unit/ # Unit tests (fast, isolated) │ ├── auth/ # Authentication tests │ ├── api/ # API endpoint tests │ ├── core/ # Core functionality tests │ ├── services/ # Service layer tests │ └── ... ├── integration/ # Integration tests │ ├── test_server_routes.py │ ├── test_search_routes.py │ └── test_e2e_workflows.py ├── fixtures/ # Test data factories │ └── factories.py ├── reports/ # Generated test reports └── conftest.py # Shared fixtures and configuration ``` ## Best Practices ### DO ✅ Use markers to categorize tests ✅ Mock heavy dependencies by default ✅ Keep unit tests fast (< 1 second) ✅ Test one thing per test function ✅ Use descriptive test names ✅ Clean up resources in fixtures ✅ Use AAA pattern (Arrange, Act, Assert) ### DON'T ❌ Load real ML models in unit tests ❌ Make network calls in unit tests ❌ Share state between tests ❌ Test implementation details ❌ Write tests longer than 30 lines ❌ Use `time.sleep()` - use mocks instead ### Memory-Safe Testing ✅ Use mocked services by default ✅ Mark tests requiring real models with `@pytest.mark.requires_models` ✅ Run tests serially on EC2 by default ✅ Monitor memory usage during test development ❌ Don't use `-n auto` on small EC2 instances ❌ Don't load real models unless absolutely necessary ❌ Don't skip mocking fixtures without good reason ## Running Tests Efficiently ```bash # Fast unit tests only (seconds) python scripts/test.py unit # Specific domain tests python scripts/test.py auth python scripts/test.py servers # Exclude slow tests python scripts/test.py fast # Full test suite (serial, safe) python scripts/test.py full # With parallelization (if you have memory) python scripts/test.py full -n 2 # Exclude tests requiring real models pytest -m "not requires_models" # Run only tests requiring models (high memory!) pytest -m requires_models ``` ## Debugging Test Failures ```bash # Run with verbose output pytest tests/unit/auth/test_auth_routes.py -v # Run specific test pytest tests/unit/auth/test_auth_routes.py::test_login_success -v # Run with debug output pytest tests/unit/auth/ -v --log-cli-level=DEBUG # Stop on first failure pytest -x # Show local variables on failure pytest -l # Run last failed tests pytest --lf ``` ## Coverage Requirements - Minimum overall coverage: 80% - All new code should have tests - Critical paths should have 100% coverage ```bash # Generate coverage report python scripts/test.py coverage # View coverage report open htmlcov/index.html ``` ================================================ FILE: docs/testing.md ================================================ # MCP Gateway Testing Guide This guide provides comprehensive testing instructions for the MCP Gateway using both the CLI client and the Python agent. ## Table of Contents - [Regenerate Credentials](#regenerate-credentials) - [Quick Start Testing](#quick-start-testing) - [CLI Testing with mcp_client.py](#cli-testing-with-mcp_clientpy) - [Python Agent Testing](#python-agent-testing) - [Authentication Testing](#authentication-testing) - [Service Management Testing](#service-management-testing) - [Troubleshooting](#troubleshooting) ## Regenerate Credentials **⚠️ Important:** Unless changed, Keycloak has an access token lifetime of only 5 minutes. You will most likely need to regenerate credentials before testing. ### Generate Fresh Credentials Run the credential generation script to create fresh tokens: ```bash # Generate new credentials for all agents and services ./credentials-provider/generate_creds.sh ``` This script will: - Generate fresh access tokens for all configured agents - Create M2M (machine-to-machine) tokens for service authentication - Update all credential files in `.oauth-tokens/` directory - Ensure tokens are valid for the current testing session **Note:** The script should be run whenever you encounter authentication errors or when tokens have expired (every 5 minutes by default). ## Quick Start Testing ### Prerequisites 1. Ensure all containers are running: ```bash docker-compose ps ``` 2. Set up authentication (choose one method): ```bash # Method 1: Source M2M credentials source .oauth-tokens/agent-test-agent-m2m.env # Method 2: Automatic ingress token # The CLI will automatically use .oauth-tokens/ingress.json if available ``` ### Basic Connectivity Test ```bash # Test gateway connectivity uv run python cli/mcp_client.py ping # List available tools uv run python cli/mcp_client.py list ``` ## CLI Testing with mcp_client.py The `mcp_client.py` tool provides direct access to MCP servers and gateway functionality. ### Core Commands #### 1. Ping (Connectivity Test) ```bash # Ping default gateway uv run python cli/mcp_client.py ping # Ping specific server uv run python cli/mcp_client.py --url http://localhost/currenttime/mcp ping ``` #### 2. List Tools ```bash # List tools from gateway uv run python cli/mcp_client.py list # List tools from specific server uv run python cli/mcp_client.py --url http://localhost/currenttime/mcp list ``` #### 3. Call Tools ```bash # Find tools using natural language uv run python cli/mcp_client.py call \ --tool intelligent_tool_finder \ --args '{"natural_language_query": "get current time"}' # Call specific tool with arguments uv run python cli/mcp_client.py --url http://localhost/currenttime/mcp call \ --tool current_time_by_timezone \ --args '{"tz_name": "America/New_York"}' # Health check all services uv run python cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool healthcheck \ --args '{}' ``` ### Advanced Examples #### Tool Discovery ```bash # Find tools by description uv run python cli/mcp_client.py call \ --tool intelligent_tool_finder \ --args '{"natural_language_query": "time zone tools", "top_n_tools": 5}' # Find tools by tags uv run python cli/mcp_client.py call \ --tool intelligent_tool_finder \ --args '{"tags": ["time", "timezone"], "top_n_tools": 3}' ``` #### Service Management ```bash # List all registered services uv run python cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool list_services \ --args '{}' # Register a new service uv run python cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool register_service \ --args '{"server_name": "Test Server", "path": "/test", "proxy_pass_url": "http://test:8000"}' # Remove a service uv run python cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool remove_service \ --args '{"service_path": "/test"}' ``` ## Python Agent Testing The Python agent (`agents/agent.py`) provides advanced AI capabilities with LangGraph-based multi-turn conversations. ### Prerequisites ```bash # Install dependencies cd agents pip install -r requirements.txt ``` ### Basic Usage #### Non-Interactive Mode ```bash # Simple query with default settings uv run python agents/agent.py --prompt "What time is it in Tokyo?" # Use specific model provider uv run python agents/agent.py --provider anthropic --prompt "Get the current time" # Use Amazon Bedrock uv run python agents/agent.py --provider bedrock --model anthropic.claude-3-5-sonnet-20240620-v1:0 \ --prompt "What tools are available?" ``` #### Interactive Mode ```bash # Start interactive conversation uv run python agents/agent.py --interactive # Interactive with specific model uv run python agents/agent.py --interactive --provider anthropic # Interactive with verbose output uv run python agents/agent.py --interactive --verbose ``` ### Authentication Options #### Using Agent Credentials ```bash # Load credentials from .oauth-tokens/{agent-name}.json uv run python agents/agent.py --agent-name test-agent --prompt "List available tools" ``` #### Using JWT Token ```bash # Use pre-generated JWT token uv run python agents/agent.py --jwt-token "your-jwt-token" --prompt "Get current time" ``` #### Using Session Cookie ```bash # Use session cookie authentication uv run python agents/agent.py --use-session-cookie --prompt "What tools are available?" ``` #### Using Direct Access Token ```bash # Override with direct access token uv run python agents/agent.py --access-token "your-token" --prompt "List services" ``` ### Advanced Agent Examples #### Tool Filtering ```bash # Filter to use specific MCP tool uv run python agents/agent.py --mcp-tool-name current_time_by_timezone \ --prompt "What time is it in Paris?" ``` #### Custom MCP Registry URL ```bash # Use different registry uv run python agents/agent.py --mcp-registry-url https://your-registry.com \ --prompt "List available services" ``` #### Verbose Debugging ```bash # Enable HTTP debugging uv run python agents/agent.py --verbose --prompt "Test connection" ``` ## Authentication Testing ### M2M Authentication ```bash # Set environment variables export CLIENT_ID=your_client_id export CLIENT_SECRET=your_client_secret export KEYCLOAK_URL=http://localhost:8080 export KEYCLOAK_REALM=mcp-gateway # Test with M2M auth uv run python cli/mcp_client.py list ``` ### Ingress Token ```bash # CLI automatically uses .oauth-tokens/ingress.json if available uv run python cli/mcp_client.py ping ``` ### Testing Different Scopes ```bash # Test with specific scopes (agent.py) uv run python agents/agent.py --scopes "read:tools" "execute:tools" \ --prompt "List and execute time tools" ``` ## Service Management Testing Use the `service_mgmt.sh` script for comprehensive server lifecycle management: ### Add a Service ```bash # Add service from config file ./cli/service_mgmt.sh add cli/examples/example-server-config.json ``` ### Monitor Services ```bash # Monitor all services ./cli/service_mgmt.sh monitor # Monitor specific service ./cli/service_mgmt.sh monitor cli/examples/example-server-config.json ``` ### Test Service Searchability ```bash # Test if service is discoverable ./cli/service_mgmt.sh test cli/examples/example-server-config.json ``` ### Delete a Service ```bash # Remove service ./cli/service_mgmt.sh delete cli/examples/example-server-config.json ``` ## Troubleshooting ### Common Issues #### Connection Refused ```bash # Check if services are running docker-compose ps # Test direct registry access curl http://localhost:7860/health # Check if MCP server is responding uv run python cli/mcp_client.py ping ``` #### Authentication Errors ```bash # Verify credentials are loaded echo $CLIENT_ID echo $CLIENT_SECRET # Check token file exists ls -la .oauth-tokens/ingress.json # Test with explicit credentials CLIENT_ID=test CLIENT_SECRET=secret uv run python cli/mcp_client.py list ``` #### Tool Not Found ```bash # List all available tools uv run python cli/mcp_client.py list # Search for specific tools uv run python cli/mcp_client.py call \ --tool intelligent_tool_finder \ --args '{"natural_language_query": "your tool description"}' ``` ### Debug Mode #### CLI Debug Output ```bash # The CLI client shows detailed error messages by default uv run python cli/mcp_client.py call --tool nonexistent --args '{}' ``` #### Agent Verbose Mode ```bash # Enable verbose HTTP debugging uv run python agents/agent.py --verbose --prompt "test" ``` ### Health Checks #### Check All Services ```bash # Full health check uv run python cli/mcp_client.py --url http://localhost/mcpgw/mcp call \ --tool healthcheck \ --args '{}' ``` #### Check Specific Server ```bash # Direct server ping uv run python cli/mcp_client.py --url http://localhost/currenttime/mcp ping ``` ## Integration Testing ### CI/CD Pipeline Example ```yaml name: MCP Gateway Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Start services run: docker-compose up -d - name: Wait for services run: sleep 10 - name: Test connectivity run: | uv run python cli/mcp_client.py ping - name: Test tool discovery run: | uv run python cli/mcp_client.py list - name: Test agent run: | uv run python agents/agent.py --prompt "system health check" ``` ### Docker Container Testing ```dockerfile FROM python:3.14-slim WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY cli/ cli/ COPY agents/ agents/ CMD ["python", "cli/mcp_client.py", "ping"] ``` ## Performance Testing ### Load Testing ```bash # Simple load test with multiple requests for i in {1..10}; do uv run python cli/mcp_client.py ping & done wait ``` ### Response Time Testing ```bash # Measure response time time uv run python cli/mcp_client.py list ``` ## Security Testing ### Test Authentication ```bash # Test without credentials (should fail appropriately) unset CLIENT_ID CLIENT_SECRET uv run python cli/mcp_client.py list # Test with invalid credentials CLIENT_ID=invalid CLIENT_SECRET=invalid uv run python cli/mcp_client.py list ``` ### Test Authorization ```bash # Test tool access with different scopes uv run python cli/mcp_client.py call \ --tool restricted_tool \ --args '{}' ``` ## Notes - All examples assume you're running from the project root directory - The CLI client (`mcp_client.py`) automatically handles authentication via environment variables or ingress tokens - The Python agent (`agent.py`) provides more advanced AI capabilities for complex interactions - Use `service_mgmt.sh` for comprehensive server lifecycle management - For production testing, always use proper authentication and secure connections ================================================ FILE: docs/token-refresh-service.md ================================================ # Token Refresh Service The MCP Gateway Registry includes an automated token refresh service that maintains continuous authentication by monitoring token expiration and proactively refreshing them. This service ensures uninterrupted access to external services and generates MCP client configurations for coding assistants. ## Overview The token refresh service provides: - **Automated Token Monitoring** - Continuously monitors OAuth tokens for expiration - **Proactive Token Refresh** - Refreshes tokens before they expire using configurable buffer times - **MCP Configuration Generation** - Creates client configs for VS Code, Cursor, and other coding assistants - **Service Discovery** - Automatically includes both OAuth-authenticated and no-auth services - **Background Operation** - Runs as a daemon service with comprehensive logging ## Architecture ```mermaid graph TB A[Token Refresher Service] --> B[OAuth Token Monitor] A --> C[No-Auth Service Scanner] A --> D[MCP Config Generator] B --> E[.oauth-tokens/*.json] C --> F[registry/servers/*.json] D --> G[.oauth-tokens/mcp.json] D --> H[.oauth-tokens/vscode_mcp.json] E --> I[External OAuth Services] F --> J[Local MCP Servers] G --> K[Roocode/Claude Code] H --> L[VS Code Extensions] ``` The service integrates with: - **External OAuth services** (GitHub, Google, SRE Gateway, etc.) - **Local MCP servers** (Current Time, Real Server Fake Tools, etc.) - **MCP clients** (VS Code extensions, Claude Code, etc.) ## Setup and Configuration ### Prerequisites - Python 3.14+ with `uv` package manager - Valid OAuth tokens in `.oauth-tokens/` directory - MCP server configurations in `registry/servers/` ### Environment Variables | Variable | Description | Default | |----------|-------------|---------| | `TOKEN_REFRESH_INTERVAL` | Check interval in seconds | 300 (5 minutes) | | `TOKEN_EXPIRY_BUFFER` | Refresh buffer time in seconds | 3600 (1 hour) | ### Starting the Service #### Option 1: Using the Launch Script (Recommended) ```bash # Start with interactive prompts ./start_token_refresher.sh # Start with custom configuration export TOKEN_REFRESH_INTERVAL=180 # 3 minutes export TOKEN_EXPIRY_BUFFER=1800 # 30 minutes ./start_token_refresher.sh ``` #### Option 2: Direct Python Execution ```bash # Start with default settings uv run python credentials-provider/token_refresher.py # Start with custom settings uv run python credentials-provider/token_refresher.py \ --interval 300 \ --buffer 3600 ``` ### Command Line Options ``` usage: token_refresher.py [-h] [--interval INTERVAL] [--buffer BUFFER] [--log-level {DEBUG,INFO,WARNING,ERROR}] MCP Gateway OAuth Token Refresher Service options: -h, --help show this help message and exit --interval INTERVAL Token check interval in seconds (default: 300) --buffer BUFFER Token expiry buffer in seconds (default: 3600) --log-level {DEBUG,INFO,WARNING,ERROR} Set the logging level (default: INFO) ``` ## Service Management ### Monitoring Service Status ```bash # Check if service is running pgrep -f "token_refresher.py" # View recent logs tail -f token_refresher.log # Monitor real-time activity tail -f token_refresher.log | grep -E "(REFRESH|CONFIG|ERROR)" ``` ### Stopping the Service ```bash # Graceful shutdown pkill -f "token_refresher.py" # Force kill if needed pkill -9 -f "token_refresher.py" ``` ### Service Health Checks The service creates a PID file (`token_refresher.pid`) for process management and logs all activities to `token_refresher.log`. ## Generated Configurations ### MCP Client Configurations The service automatically generates two MCP configuration files: #### Roocode/Claude Code Configuration **File**: `.oauth-tokens/mcp.json` ```json { "mcpServers": { "sre-gateway": { "command": "uv", "args": ["--directory", "/path/to/project", "run", "mcp"], "env": { "MCP_SERVER_URL": "https://gateway.example.com/mcp/sre-gateway/mcp", "MCP_SERVER_AUTH_TOKEN": "Bearer " } } } } ``` #### VS Code Extension Configuration **File**: `.oauth-tokens/vscode_mcp.json` ```json { "mcpServers": { "sre-gateway": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-fetch"], "env": { "FETCH_BASE_URL": "https://gateway.example.com/mcp/sre-gateway/mcp", "FETCH_HEADERS": "{\"Authorization\": \"Bearer \"}" } } } } ``` ### Service Types The service automatically includes: 1. **OAuth Services** - Services requiring external authentication (e.g., GitHub, SRE Gateway) 2. **No-Auth Services** - Local services with `auth_type: "none"` (e.g., Current Time, Real Server Fake Tools) ## Integration Examples ### With JWT Token Vending Service The token refresh service complements the [JWT Token Vending Service](jwt-token-vending.md) by: 1. **Monitoring vended tokens** for expiration 2. **Automatically refreshing** tokens using stored refresh tokens 3. **Updating MCP configurations** with new tokens 4. **Maintaining continuous service** without manual intervention ### With Existing Authentication Flow ```mermaid sequenceDiagram participant User as User/Script participant Vending as Token Vending Service participant Refresher as Token Refresh Service participant External as External Service participant MCP as MCP Client User->>Vending: Request JWT token Vending->>User: Return token + refresh token Vending->>Refresher: Save tokens to .oauth-tokens/ loop Every 5 minutes Refresher->>Refresher: Check token expiration alt Token expires within buffer time Refresher->>External: Refresh token External->>Refresher: New token Refresher->>Refresher: Update .oauth-tokens/ Refresher->>Refresher: Regenerate MCP configs end end MCP->>Refresher: Read latest MCP config MCP->>External: Use refreshed token ``` ## Monitoring and Logging ### Log Levels - **INFO** - Normal operations, token refreshes, config generation - **WARNING** - Token refresh failures, missing services - **ERROR** - Critical failures, authentication errors - **DEBUG** - Detailed trace information for troubleshooting ### Sample Log Output ``` 2024-09-06 15:30:00,123 - Token refresh check starting... 2024-09-06 15:30:00,124 - Found 2 egress token files to check 2024-09-06 15:30:00,125 - bedrock-agentcore-sre-gateway-egress.json: expires in 2 hours, no refresh needed 2024-09-06 15:30:00,126 - github-github-egress.json: expires in 45 minutes, refreshing... 2024-09-06 15:30:01,234 - Successfully refreshed token for github-github- 2024-09-06 15:30:01,235 - Scanning for no-auth services... 2024-09-06 15:30:01,236 - Found 3 no-auth services: mcpgw, currenttime, realserverfaketools 2024-09-06 15:30:01,237 - Generating MCP configurations... 2024-09-06 15:30:01,345 - Generated Roocode config with 5 servers 2024-09-06 15:30:01,346 - Generated VSCode config with 5 servers 2024-09-06 15:30:01,347 - Token refresh cycle completed successfully ``` ## Troubleshooting ### Common Issues #### Service Won't Start **Symptoms**: Service exits immediately or fails to start **Causes**: - Missing dependencies - Invalid OAuth token files - Permission issues **Solutions**: ```bash # Check dependencies uv run python -c "import httpx, json, time, argparse, asyncio" # Verify token files ls -la .oauth-tokens/*.json # Check permissions chmod +x credentials-provider/token_refresher.py chmod +x start_token_refresher.sh ``` #### Token Refresh Failures **Symptoms**: Tokens not being refreshed, authentication errors **Causes**: - Expired refresh tokens - Invalid OAuth configuration - Network connectivity issues **Solutions**: ```bash # Check token validity cat .oauth-tokens/*egress.json | jq '.expires_at' # Test network connectivity curl -v https://your-oauth-provider.com/token # Re-run initial OAuth flow ./credentials-provider/oauth/egress_oauth.py ``` #### MCP Configuration Issues **Symptoms**: MCP clients can't connect, missing services **Causes**: - Invalid service configurations - Missing environment variables - Incorrect file paths **Solutions**: ```bash # Validate generated configs cat .oauth-tokens/mcp.json | jq '.' cat .oauth-tokens/vscode_mcp.json | jq '.' # Check service definitions ls -la registry/servers/*.json # Verify environment variables env | grep -E "(MCP|TOKEN)" ``` ### Debug Mode Enable detailed logging for troubleshooting: ```bash # Start with debug logging uv run python credentials-provider/token_refresher.py --log-level DEBUG # Or set environment variable export LOG_LEVEL=DEBUG ./start_token_refresher.sh ``` ## Security Considerations ### Token Storage - Token files are stored in `.oauth-tokens/` directory (excluded from Git) - File permissions are set to `600` (owner read/write only) - Refresh tokens are encrypted in transit and at rest ### Network Security - All OAuth communication uses HTTPS/TLS - Tokens are transmitted using secure headers - Failed authentication attempts are logged and monitored ### Access Control - Service runs with minimal required permissions - No network listeners (outbound connections only) - Process isolation using dedicated service account (recommended in production) ## Production Deployment ### Systemd Service (Linux) Create `/etc/systemd/system/token-refresher.service`: ```ini [Unit] Description=MCP Gateway Token Refresh Service After=network.target Wants=network.target [Service] Type=simple User=mcp-gateway WorkingDirectory=${HOME}/mcp-gateway-registry Environment=TOKEN_REFRESH_INTERVAL=300 Environment=TOKEN_EXPIRY_BUFFER=3600 ExecStart=${HOME}/mcp-gateway-registry/.venv/bin/python credentials-provider/token_refresher.py Restart=always RestartSec=10 [Install] WantedBy=multi-user.target ``` Enable and start: ```bash sudo systemctl enable token-refresher sudo systemctl start token-refresher sudo systemctl status token-refresher ``` ### Docker Deployment ```dockerfile FROM python:3.14-slim WORKDIR /app COPY . . RUN pip install uv && uv install CMD ["uv", "run", "python", "credentials-provider/token_refresher.py"] ``` ### Health Monitoring Set up monitoring for production: ```bash # Create health check script cat > /opt/scripts/check-token-refresher.sh << 'EOF' #!/bin/bash if ! pgrep -f "token_refresher.py" > /dev/null; then echo "CRITICAL: Token refresher service is not running" exit 2 fi echo "OK: Token refresher service is running" exit 0 EOF ``` ## API Reference ### Service Methods The token refresher service provides these internal methods: - `_check_token_expiry()` - Check if token needs refresh - `_refresh_oauth_token()` - Refresh an expired token - `_scan_noauth_services()` - Discover no-auth services - `_generate_mcp_configs()` - Generate MCP client configurations - `_save_configurations()` - Write config files to disk ### Configuration Schema #### Egress Token File Format ```json { "access_token": "eyJ...", "refresh_token": "eyJ...", "expires_at": 1725634800, "token_type": "Bearer", "scope": "read write" } ``` #### MCP Server Configuration Format ```json { "server_name": "example-service", "auth_type": "oauth" | "none", "path": "/mcp/example-service/mcp", "supported_transports": ["streamable-http", "sse"] } ``` ## Related Documentation - [Authentication Guide](auth.md) - OAuth setup and configuration - [JWT Token Vending](jwt-token-vending.md) - Token generation and management - [AI Coding Assistants Setup](ai-coding-assistants-setup.md) - Client configuration - [Configuration Reference](configuration.md) - Environment variables and settings ## Support For issues with the token refresh service: 1. Check the [Troubleshooting Guide](faq/index.md) 2. Enable debug logging to gather detailed information 3. Search existing [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) 4. Create a new issue with logs and configuration details ================================================ FILE: docs/virtual-server-operations.md ================================================ # Virtual MCP Server Operations This document describes operations for managing Virtual MCP Servers. Virtual servers aggregate tools from multiple backend MCP servers into a single unified endpoint. For the full design and architecture details, see [Virtual MCP Server Design Document](design/virtual-mcp-server.md). ## Video Demo Watch the video demonstration of Virtual MCP Server creation and management through the web UI: [![Virtual MCP Server Demo](https://img.shields.io/badge/Watch-Video%20Demo-red?style=for-the-badge&logo=youtube)](https://app.vidcast.io/share/954e6296-f217-4559-8d86-88cec25af763) [View Video Demo](https://app.vidcast.io/share/954e6296-f217-4559-8d86-88cec25af763) ## Prerequisites - A valid JWT token (saved to a file, e.g., `.token`) - Registry URL (e.g., `http://localhost` for local development) ## Available CLI Commands | Command | Description | |---------|-------------| | `vs-create` | Create a virtual MCP server from JSON config | | `vs-list` | List all virtual MCP servers | | `vs-get` | Get virtual MCP server details | | `vs-update` | Update a virtual MCP server | | `vs-delete` | Delete a virtual MCP server | | `vs-toggle` | Enable or disable a virtual server | | `vs-rate` | Rate a virtual MCP server (1-5 stars) | | `vs-rating` | Get rating information | ## Configuration File Format Virtual servers are created from a JSON configuration file. Here is an example that combines tools from Context7 (documentation search) and CurrentTime (timezone) servers: ```json { "path": "/virtual/combined-tools", "server_name": "Combined Context7 and CurrentTime Tools", "description": "Virtual server aggregating documentation search tools from Context7 and timezone tools from CurrentTime server", "tool_mappings": [ { "tool_name": "resolve-library-id", "backend_server_path": "/context7" }, { "tool_name": "query-docs", "backend_server_path": "/context7" }, { "tool_name": "current_time_by_timezone", "alias": "get-current-time", "backend_server_path": "/currenttime/" } ], "required_scopes": [], "tool_scope_overrides": [], "tags": [ "documentation", "time", "timezone", "libraries", "combined" ], "supported_transports": [ "streamable-http" ], "is_enabled": true } ``` See [cli/examples/virtual-server-combined-example.json](../cli/examples/virtual-server-combined-example.json) for the full example. ### Configuration Fields | Field | Required | Description | |-------|----------|-------------| | `path` | Yes | Virtual server path (e.g., `/virtual/dev-tools`) | | `server_name` | Yes | Display name for the virtual server | | `description` | No | Description of the virtual server | | `tool_mappings` | Yes | Array of tool mappings (at least one required) | | `required_scopes` | No | Server-level scope requirements | | `tool_scope_overrides` | No | Per-tool scope overrides | | `tags` | No | Tags for categorization | | `supported_transports` | No | Supported transports (default: `["streamable-http"]`) | | `is_enabled` | No | Whether to enable on creation (default: `true`) | ### Tool Mapping Fields | Field | Required | Description | |-------|----------|-------------| | `tool_name` | Yes | Original tool name on backend server | | `backend_server_path` | Yes | Backend server path (e.g., `/github`) | | `alias` | No | Renamed tool name in virtual server | | `backend_version` | No | Pin to specific backend version | | `description_override` | No | Override tool description | ## CLI Usage Examples ### Create a Virtual Server ```bash uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ vs-create --config cli/examples/virtual-server-combined-example.json ``` **Example Output:** ``` Virtual server created: /virtual/combined-tools { "message": "Virtual server created successfully", "virtual_server": { "path": "/virtual/combined-tools", "server_name": "Combined Context7 and CurrentTime Tools", "description": "Virtual server aggregating documentation search tools from Context7 and timezone tools from CurrentTime server", "is_enabled": false, "tool_count": 3 } } ``` ### List Virtual Servers ```bash # List all virtual servers uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ vs-list # List only enabled virtual servers uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ vs-list --enabled-only # Output as JSON uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ vs-list --json ``` ### Get Virtual Server Details ```bash uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ vs-get --path /virtual/combined-tools ``` **Example Output:** ``` Virtual MCP Server: /virtual/combined-tools ------------------------------------------------------------ Name: Combined Context7 and CurrentTime Tools Status: enabled Description: Virtual server aggregating documentation search tools from Context7 and timezone tools from CurrentTime server Rating: 0.0 stars Tags: documentation, time, timezone, libraries, combined Transports: streamable-http Required Scopes: None Tool Mappings (3): - resolve-library-id Backend: /context7 - query-docs Backend: /context7 - current_time_by_timezone -> get-current-time Backend: /currenttime/ Created: 2026-02-17T13:35:22.803009Z Updated: 2026-02-17T13:35:41.075488Z Created By: admin ``` ### Enable or Disable a Virtual Server ```bash # Enable uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ vs-toggle --path /virtual/combined-tools --enabled true # Disable uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ vs-toggle --path /virtual/combined-tools --enabled false ``` ### Update a Virtual Server ```bash uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ vs-update --path /virtual/combined-tools --config updated-config.json ``` ### Delete a Virtual Server ```bash # With confirmation prompt uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ vs-delete --path /virtual/combined-tools # Skip confirmation uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ vs-delete --path /virtual/combined-tools --force ``` ### Rate a Virtual Server ```bash uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ vs-rate --path /virtual/combined-tools --rating 5 ``` ### Get Virtual Server Rating ```bash uv run python api/registry_management.py \ --registry-url http://localhost \ --token-file .token \ vs-rating --path /virtual/combined-tools ``` ## Scope-Based Access Control Virtual servers support fine-grained access control through scopes. Virtual servers are configured in scope definitions exactly the same way as regular MCP servers - you simply use the virtual server path (e.g., `/virtual/scoped-tools`) as the server identifier. For comprehensive documentation on how access control works, see [Virtual MCP Server Access Control](scopes.md#virtual-mcp-server-access-control) in the Fine-Grained Access Control documentation. See [Scope-Based Access Control Example](../cli/examples/virtual-server-scoped-example.json) for a virtual server configuration with scopes. ### Server-Level Scopes Use `required_scopes` to require users to have specific scopes to access the virtual server: ```json { "required_scopes": ["virtual-server/access"] } ``` ### Per-Tool Scope Overrides Use `tool_scope_overrides` to require additional scopes for specific tools: ```json { "tool_scope_overrides": [ { "tool_alias": "sensitive-tool", "required_scopes": ["virtual-server/admin"] } ] } ``` ### E2E Testing Script An end-to-end test script is provided for testing scope-based access control: ```bash # Run the E2E test (with automatic cleanup) ./tests/integration/test_virtual_server_scopes_e2e.sh \ --registry-url http://localhost \ --token-file .token # Run without cleanup (saves credentials for UI testing) ./tests/integration/test_virtual_server_scopes_e2e.sh \ --registry-url http://localhost \ --token-file .token \ --no-cleanup # View saved credentials cat /tmp/.vs-creds ``` The test script creates: - A virtual server with scope-based access control - A user group with matching scopes - An M2M service account for API testing - A regular user for UI testing See [test_virtual_server_scopes_e2e.sh](../tests/integration/test_virtual_server_scopes_e2e.sh) for details. ## Web UI Alternative All virtual server management operations can also be performed through the web UI. The UI provides a guided wizard for creating virtual servers with: - Server configuration form - Tool selection from registered backend servers - Tool aliasing and scope configuration - Real-time validation ## Environment Variables Instead of passing `--registry-url` each time, you can set environment variables: ```bash export REGISTRY_URL=http://localhost export TOKEN_FILE=.token # Then run commands without flags uv run python api/registry_management.py vs-list ``` ## Related Documentation - [Virtual MCP Server Design Document](design/virtual-mcp-server.md) - [CLI Reference](cli.md) - [Server Management](service-management.md) ================================================ FILE: frontend/.gitignore ================================================ .Jules/ ================================================ FILE: frontend/README.md ================================================ # MCP Gateway Registry Frontend React-based frontend for the MCP Gateway Registry application. ## Development Setup ### Prerequisites - Node.js 16+ and npm - Backend server running on `http://localhost:7860` (configured in `package.json` proxy) ### Installation ```bash npm install ``` Note: The postinstall script will automatically apply patches to dependencies. ### Running Development Server ```bash npm start ``` The development server will start on `http://localhost:3000`. ## Important Configuration Notes ### webpack-dev-server v5 Compatibility Patch This project uses `react-scripts` v5.0.1, which has a compatibility issue with `webpack-dev-server` v5. The project includes a patch to fix this issue. **Problem**: react-scripts v5.0.1 uses deprecated webpack-dev-server hooks (`onBeforeSetupMiddleware` and `onAfterSetupMiddleware`) that were removed in webpack-dev-server v5. **Solution**: We use `patch-package` to apply a patch that replaces the deprecated hooks with the modern `setupMiddlewares` API. **Patch Location**: `patches/react-scripts+5.0.1.patch` **How it Works**: 1. The patch modifies `node_modules/react-scripts/config/webpackDevServer.config.js` 2. Replaces deprecated hooks with `setupMiddlewares` function 3. The patch is automatically applied after `npm install` via the postinstall script **If you encounter webpack-dev-server errors**: 1. Delete `node_modules` and `package-lock.json` 2. Run `npm install` to reinstall dependencies and reapply the patch 3. If the patch fails, check the `patches/react-scripts+5.0.1.patch` file for conflicts ## Available Scripts - `npm start` - Start the development server - `npm build` - Build the production bundle - `npm test` - Run the test suite - `npm run eject` - Eject from create-react-app (not recommended) ## Tech Stack - React 18 - TypeScript - Tailwind CSS - React Router v6 - Heroicons - Axios ## Project Structure ``` frontend/ ├── src/ │ ├── components/ # Reusable React components │ ├── contexts/ # React Context providers │ ├── hooks/ # Custom React hooks │ ├── pages/ # Page components │ └── App.tsx # Main application component ├── public/ # Static assets ├── patches/ # Dependency patches (managed by patch-package) └── package.json ``` ## Dependencies Management ### Using patch-package This project uses `patch-package` to maintain patches for third-party dependencies. If you need to modify a dependency: 1. Make changes to files in `node_modules/` 2. Run `npx patch-package ` 3. Commit the generated patch file in `patches/` directory The patches will be automatically applied after `npm install` via the postinstall script. ================================================ FILE: frontend/e2e/helpers/auth.ts ================================================ import { Page, expect } from '@playwright/test'; const BASE_URL = 'http://localhost'; /** * FastAPI backend URL (bypasses nginx auth_request). */ const BACKEND_URL = 'http://localhost:7860'; /** * Headers that nginx normally sets after auth_request validation. */ const ADMIN_AUTH_HEADERS: Record = { 'X-User': 'admin', 'X-Username': 'admin', 'X-Scopes': 'mcp-registry-admin,mcp-servers-unrestricted/read,mcp-servers-unrestricted/execute,federation/peers', 'X-Auth-Method': 'oauth2', 'X-Client-Id': '', }; /** * Mock response for /api/auth/me. */ const ADMIN_ME_RESPONSE = { username: 'admin', email: 'admin@local', auth_method: 'oauth2', provider: 'oauth2', scopes: ['mcp-registry-admin'], groups: ['mcp-registry-admin'], can_modify_servers: true, is_admin: true, ui_permissions: { list_service: ['all'], register_service: ['all'], health_check_service: ['all'], toggle_service: ['all'], modify_service: ['all'], list_agents: ['all'], get_agent: ['all'], publish_agent: ['all'], modify_agent: ['all'], delete_agent: ['all'], }, accessible_servers: ['*'], accessible_services: ['all'], accessible_agents: ['all'], }; /** * Default mock responses for API endpoints. * Used when the backend returns 500 (e.g. MongoDB auth issues). */ const MOCK_RESPONSES: Record = { '/api/servers': [], '/api/agents': [], '/api/skills': [], '/api/virtual-servers': [], '/api/peers': [], '/api/version': { version: '0.0.0-e2e' }, }; /** * Authenticate for e2e tests. * * Strategy: * 1. /api/auth/me is intercepted with a mock admin profile. * 2. Protected /api/* requests are proxied to the backend (port 7860) * with admin auth headers. 500 responses are replaced with safe * mock data so the SPA does not crash. * 3. Public auth endpoints flow through nginx normally. */ export async function loginAsAdmin(page: Page): Promise { // Intercept /api/auth/me with mock admin profile. await page.route('**/api/auth/me', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ADMIN_ME_RESPONSE), }); }); // Proxy protected /api/* requests to the backend. await page.route('**/api/**', async (route) => { const url = new URL(route.request().url()); // Public auth paths go through nginx normally. if (url.pathname.startsWith('/api/auth/')) { return route.fallback(); } // Build backend URL with admin auth headers. const backendUrl = `${BACKEND_URL}${url.pathname}${url.search}`; const headers: Record = { ...route.request().headers(), ...ADMIN_AUTH_HEADERS, }; // Remove host header to avoid confusing the backend. delete headers['host']; try { const response = await page.request.fetch(backendUrl, { method: route.request().method(), headers, data: route.request().postDataBuffer() ?? undefined, }); const status = response.status(); if (status >= 400) { // Use mock response if we have one, otherwise return empty. const mock = MOCK_RESPONSES[url.pathname]; if (mock !== undefined) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mock), }); } else { // Generic fallback: array for plural endpoints, object otherwise. const body = url.pathname.match(/\/[a-z-]+s(\/)?$/) ? '[]' : '{}'; await route.fulfill({ status: 200, contentType: 'application/json', body, }); } return; } await route.fulfill({ response }); } catch { // Backend unreachable - return mock data. const mock = MOCK_RESPONSES[url.pathname]; try { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mock ?? []), }); } catch { // Route was already handled; ignore. } } }); // Navigate to the app (auth is handled via route interception above). await page.goto('/'); await page.waitForLoadState('networkidle'); // Verify we landed on the Dashboard (not the login page). await expect(page).toHaveURL('/', { timeout: 15000 }); } /** * Navigate to the Settings page > Virtual MCP > Virtual Servers. * * The SPA uses relative asset paths (`./static/...`) so a direct * page.goto('/settings/virtual-mcp/servers') would fail to load JS/CSS. * Instead we click the Settings gear icon (which is a React Router Link) * then expand the Virtual MCP sidebar category. */ export async function navigateToVirtualServers(page: Page): Promise { // Ensure we start from the Dashboard (page loaded from /). const currentUrl = page.url(); if (!currentUrl.endsWith('/') && !currentUrl.endsWith('localhost')) { await page.goto('/'); await page.waitForLoadState('networkidle'); } // Click the Settings gear icon (React Router ). const settingsLink = page.locator('a[title="Settings"]'); await expect(settingsLink).toBeVisible({ timeout: 5000 }); await settingsLink.click(); await page.waitForLoadState('networkidle'); // Expand the "Virtual MCP" category in the settings sidebar. const virtualMcpCategory = page.locator('button:has-text("Virtual MCP")'); await expect(virtualMcpCategory).toBeVisible({ timeout: 10000 }); await virtualMcpCategory.click(); await page.waitForTimeout(300); // Click "Virtual Servers" under the expanded category. const virtualServersItem = page.locator('button:has-text("Virtual Servers")'); await expect(virtualServersItem).toBeVisible({ timeout: 5000 }); await virtualServersItem.click(); await page.waitForLoadState('networkidle'); // Verify the Virtual MCP Servers heading appears. await expect( page.locator('h2:has-text("Virtual MCP Servers")') ).toBeVisible({ timeout: 15000 }); } ================================================ FILE: frontend/e2e/virtual-server-accessibility.spec.ts ================================================ import { test, expect } from '@playwright/test'; import { loginAsAdmin, navigateToVirtualServers } from './helpers/auth'; /** * Accessibility tests for Virtual MCP Server UI components. * * Verifies ARIA attributes, keyboard navigation, and screen reader * compatibility for modals, toggles, and interactive elements. */ test.describe('Virtual Server Accessibility', () => { test.beforeEach(async ({ page }) => { await loginAsAdmin(page); }); test('create form modal should have correct ARIA attributes', async ({ page, }) => { await navigateToVirtualServers(page); await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Verify role="dialog" and aria-modal="true" await expect(dialog).toHaveAttribute('role', 'dialog'); await expect(dialog).toHaveAttribute('aria-modal', 'true'); // Verify aria-label is set const ariaLabel = await dialog.getAttribute('aria-label'); expect(ariaLabel).toBeTruthy(); expect(ariaLabel).toContain('Create Virtual Server'); // Clean up await page.keyboard.press('Escape'); }); test('Escape key should close the create form modal', async ({ page }) => { await navigateToVirtualServers(page); await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible({ timeout: 3000 }); }); test('toggle switches should have aria-label', async ({ page }) => { await navigateToVirtualServers(page); // Find all toggle switches in the virtual server table const toggleInputs = page.locator( 'input[type="checkbox"][aria-label^="Enable"]' ); const count = await toggleInputs.count(); if (count === 0) { // No servers exist (empty mock), so skip assertion but pass return; } // Each toggle should have a meaningful aria-label for (let i = 0; i < count; i++) { const ariaLabel = await toggleInputs.nth(i).getAttribute('aria-label'); expect(ariaLabel).toBeTruthy(); expect(ariaLabel).toMatch(/^Enable .+/); } }); test('delete confirmation dialog should have correct ARIA attributes', async ({ page, }) => { await navigateToVirtualServers(page); // Find a Delete button in the table const deleteButtons = page.locator('button:has-text("Delete")'); const hasServers = (await deleteButtons.count()) > 0; if (!hasServers) { test.skip(); return; } // Click the first Delete button await deleteButtons.first().click(); // The delete confirmation dialog should have correct ARIA const deleteDialog = page.locator( '[role="dialog"][aria-label="Delete virtual server confirmation"]' ); await expect(deleteDialog).toBeVisible({ timeout: 5000 }); await expect(deleteDialog).toHaveAttribute('role', 'dialog'); await expect(deleteDialog).toHaveAttribute('aria-modal', 'true'); // Clean up - dismiss dialog await deleteDialog.locator('button:has-text("Cancel")').click(); await expect(deleteDialog).not.toBeVisible({ timeout: 3000 }); }); test('Escape key should close the delete confirmation dialog', async ({ page, }) => { await navigateToVirtualServers(page); const deleteButtons = page.locator('button:has-text("Delete")'); const hasServers = (await deleteButtons.count()) > 0; if (!hasServers) { test.skip(); return; } await deleteButtons.first().click(); const deleteDialog = page.locator( '[role="dialog"][aria-label="Delete virtual server confirmation"]' ); await expect(deleteDialog).toBeVisible({ timeout: 5000 }); // The delete input handles Escape to close the modal const inputField = deleteDialog.locator('input[type="text"]'); await inputField.focus(); await page.keyboard.press('Escape'); await expect(deleteDialog).not.toBeVisible({ timeout: 3000 }); }); test('Dashboard Virtual MCP filter tab should be accessible', async ({ page, }) => { // The "Virtual MCP" filter button should be a proper button element const virtualTab = page.locator('button:has-text("Virtual MCP")'); await expect(virtualTab).toBeVisible({ timeout: 5000 }); // It should be focusable await virtualTab.focus(); // Pressing Enter should activate it await page.keyboard.press('Enter'); await page.waitForTimeout(500); // The filter should be applied (button state should change) // Check that the button has an active/selected visual state const buttonText = await virtualTab.textContent(); expect(buttonText).toContain('Virtual MCP'); }); }); ================================================ FILE: frontend/e2e/virtual-server-crud.spec.ts ================================================ import { test, expect } from '@playwright/test'; import { loginAsAdmin, navigateToVirtualServers } from './helpers/auth'; /** * Full CRUD lifecycle tests for Virtual MCP Servers. * * Flow: Create -> Verify in list -> Toggle enable/disable -> Delete with * name confirmation -> Verify removal. */ test.describe('Virtual Server CRUD', () => { const SERVER_NAME = `E2E Test Server ${Date.now()}`; const SERVER_DESCRIPTION = 'Created by Playwright e2e test'; test.beforeEach(async ({ page }) => { await loginAsAdmin(page); }); test('should create a virtual server via the wizard', async ({ page }) => { await navigateToVirtualServers(page); // Click "Create Virtual Server" button const createBtn = page.locator('button:has-text("Create Virtual Server")'); await expect(createBtn).toBeVisible({ timeout: 5000 }); await createBtn.click(); // The modal dialog should appear const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Step 1: Basics - fill in name and description // Placeholder is "e.g. Dev Essentials" await page.fill('input[placeholder="e.g. Dev Essentials"]', SERVER_NAME); await page.fill( 'textarea[placeholder="Describe what this virtual server provides..."]', SERVER_DESCRIPTION ); // The path should auto-generate from the name const pathInput = page.locator('input[placeholder="/virtual/dev-essentials"]'); await expect(pathInput).not.toHaveValue(''); // Click Next to go to Tool Selection await dialog.locator('button:has-text("Next")').click(); // Step 2: Tool Selection await expect( page.locator('text=Select tools to include in this virtual server') ).toBeVisible({ timeout: 3000 }); // Click Next to go to Configuration (skip tool selection) await dialog.locator('button:has-text("Next")').click(); // Step 3: Configuration await expect(page.locator('text=Tool Aliases and Version Pins')).toBeVisible({ timeout: 3000, }); // Click Next to go to Review await dialog.locator('button:has-text("Next")').click(); // Step 4: Review - verify the name appears await expect(page.locator('text=Server Details')).toBeVisible({ timeout: 3000, }); await expect(dialog.locator(`text=${SERVER_NAME}`)).toBeVisible(); await expect(dialog.locator(`text=${SERVER_DESCRIPTION}`)).toBeVisible(); // Submit the form await dialog.locator('button:has-text("Create Virtual Server")').click(); // Wait for the modal to close await expect(dialog).not.toBeVisible({ timeout: 10000 }); // Verify the server appears in the list (or empty state message) // The API might return empty if backend is mocked }); test('should toggle a virtual server enable/disable', async ({ page }) => { await navigateToVirtualServers(page); // Find any toggle checkbox in the table (aria-label="Enable ...") const toggle = page.locator('input[type="checkbox"][aria-label^="Enable"]').first(); if (!(await toggle.isVisible({ timeout: 3000 }).catch(() => false))) { test.skip(); return; } const isChecked = await toggle.isChecked(); // Click the parent label since the checkbox is hidden (sr-only) // and a styled div overlay intercepts pointer events. const label = page.locator('label').filter({ has: toggle }).first(); await label.click(); await page.waitForTimeout(500); // Verify the toggle state flipped if (isChecked) { await expect(toggle).not.toBeChecked(); } else { await expect(toggle).toBeChecked(); } }); test('should delete a virtual server with name confirmation', async ({ page, }) => { await navigateToVirtualServers(page); // Find a Delete button in the table const deleteBtn = page.locator('button:has-text("Delete")').first(); if (!(await deleteBtn.isVisible({ timeout: 3000 }).catch(() => false))) { test.skip(); return; } await deleteBtn.click(); // Delete confirmation dialog should appear const deleteDialog = page.locator( '[role="dialog"][aria-label="Delete virtual server confirmation"]' ); await expect(deleteDialog).toBeVisible({ timeout: 5000 }); // The Delete button should be disabled until we type the name const confirmDeleteBtn = deleteDialog.locator('button:has-text("Delete")'); await expect(confirmDeleteBtn).toBeDisabled(); // Type the server name from the placeholder (it shows the required name) const nameInput = deleteDialog.locator('input[type="text"]'); const placeholder = await nameInput.getAttribute('placeholder'); if (placeholder) { await nameInput.fill(placeholder); // Now the delete button should be enabled await expect(confirmDeleteBtn).toBeEnabled(); } // Cancel instead of actually deleting await deleteDialog.locator('button:has-text("Cancel")').click(); await expect(deleteDialog).not.toBeVisible({ timeout: 3000 }); }); }); ================================================ FILE: frontend/e2e/virtual-server-dashboard.spec.ts ================================================ import { test, expect } from '@playwright/test'; import { loginAsAdmin } from './helpers/auth'; /** * Dashboard integration tests for Virtual MCP Servers. * * Verifies virtual server visibility and interactions on the Dashboard. */ test.describe('Virtual Server Dashboard', () => { test.beforeEach(async ({ page }) => { await loginAsAdmin(page); }); test('should display the Virtual MCP filter tab on the Dashboard', async ({ page, }) => { // The Dashboard should have a "Virtual MCP" filter button const virtualTab = page.locator('button:has-text("Virtual MCP")'); await expect(virtualTab).toBeVisible({ timeout: 5000 }); }); test('should show virtual server section when Virtual MCP tab is clicked', async ({ page, }) => { // Click the "Virtual MCP" filter tab const virtualTab = page.locator('button:has-text("Virtual MCP")'); await expect(virtualTab).toBeVisible({ timeout: 5000 }); await virtualTab.click(); await page.waitForTimeout(500); // After clicking Virtual MCP tab, the "Virtual MCP Servers" heading // should appear. If no servers exist, an empty state is shown. const heading = page.locator('text=Virtual MCP Servers'); const emptyState = page.locator( 'text=No virtual servers found' ); const hasHeading = await heading.isVisible().catch(() => false); const hasEmptyState = await emptyState.isVisible().catch(() => false); // Either a heading or empty state should be visible expect(hasHeading || hasEmptyState).toBeTruthy(); }); test('should show empty state when Virtual MCP filter has no servers', async ({ page, }) => { // Click "Virtual MCP" filter tab const virtualTab = page.locator('button:has-text("Virtual MCP")'); await expect(virtualTab).toBeVisible({ timeout: 5000 }); await virtualTab.click(); await page.waitForTimeout(500); // With mocked empty data, we should see a "no results" state or empty list const noServersMsg = page.locator( 'text=No virtual servers configured' ); const noResultsMsg = page.locator('text=No servers found'); const virtualBadges = page.locator('text=VIRTUAL'); const hasNoServers = await noServersMsg.isVisible().catch(() => false); const hasNoResults = await noResultsMsg.isVisible().catch(() => false); const hasBadges = (await virtualBadges.count()) > 0; // One of these states should be true expect(hasNoServers || hasNoResults || hasBadges).toBeTruthy(); }); }); ================================================ FILE: frontend/e2e/virtual-server-e2e-full.spec.ts ================================================ import { test, expect } from '@playwright/test'; import { loginAsAdmin, navigateToVirtualServers } from './helpers/auth'; /** * Comprehensive E2E test suite for Virtual MCP Servers. * * Covers: Dashboard tab, Settings list view, full CRUD lifecycle, * form validation / wizard navigation, and multi-backend inspection. */ // --------------------------------------------------------------------------- // 1. Dashboard Virtual MCP tab // --------------------------------------------------------------------------- test.describe('Dashboard Virtual MCP tab', () => { test.beforeEach(async ({ page }) => { await loginAsAdmin(page); }); test('should render Virtual MCP filter tab on Dashboard', async ({ page }) => { const virtualTab = page.locator('button:has-text("Virtual MCP")'); await expect(virtualTab).toBeVisible({ timeout: 5000 }); }); test('should show virtual server cards when Virtual MCP tab is clicked', async ({ page, }) => { const virtualTab = page.locator('button:has-text("Virtual MCP")'); await expect(virtualTab).toBeVisible({ timeout: 5000 }); await virtualTab.click(); await page.waitForTimeout(500); // After clicking the tab we should see either virtual server cards // (with names and VIRTUAL badges) or an appropriate heading/empty state. const heading = page.locator('text=Virtual MCP Servers'); const virtualBadges = page.locator('text=VIRTUAL'); const emptyState = page.locator('text=No virtual servers'); const hasHeading = await heading.isVisible().catch(() => false); const hasBadges = (await virtualBadges.count()) > 0; const hasEmpty = await emptyState.isVisible().catch(() => false); expect(hasHeading || hasBadges || hasEmpty).toBeTruthy(); }); test('should display status badges on virtual server cards', async ({ page, }) => { const virtualTab = page.locator('button:has-text("Virtual MCP")'); await expect(virtualTab).toBeVisible({ timeout: 5000 }); await virtualTab.click(); await page.waitForTimeout(500); // Look for Enabled / Disabled status text inside the card footer const enabledBadge = page.locator('text=Enabled'); const disabledBadge = page.locator('text=Disabled'); const hasEnabled = (await enabledBadge.count()) > 0; const hasDisabled = (await disabledBadge.count()) > 0; // At least one status badge should be visible if there are servers const virtualBadges = page.locator('text=VIRTUAL'); const hasBadges = (await virtualBadges.count()) > 0; if (hasBadges) { expect(hasEnabled || hasDisabled).toBeTruthy(); } }); }); // --------------------------------------------------------------------------- // 2. Settings list view // --------------------------------------------------------------------------- test.describe('Settings list view', () => { test.beforeEach(async ({ page }) => { await loginAsAdmin(page); await navigateToVirtualServers(page); }); test('should display Virtual MCP Servers heading and table', async ({ page, }) => { await expect( page.locator('h2:has-text("Virtual MCP Servers")') ).toBeVisible({ timeout: 5000 }); // The table (or empty state) should be present const table = page.locator('table'); const emptyState = page.locator('text=No virtual servers configured'); const hasTable = await table.isVisible().catch(() => false); const hasEmpty = await emptyState.isVisible().catch(() => false); expect(hasTable || hasEmpty).toBeTruthy(); }); test('should show server rows with name, path, tools, backends, status columns', async ({ page, }) => { const rows = page.locator('table tbody tr'); const rowCount = await rows.count(); if (rowCount === 0) { // No servers - empty state is fine return; } // Verify at least the first row has cells for name, path, tools, backends, status const firstRow = rows.first(); const cells = firstRow.locator('td'); // Table has 6 columns: Name, Path, Tools, Backends, Status, Actions expect(await cells.count()).toBeGreaterThanOrEqual(5); }); test('should filter servers using the search input', async ({ page }) => { const searchInput = page.locator('input[placeholder="Search virtual servers..."]'); await expect(searchInput).toBeVisible({ timeout: 5000 }); // Type a search query that should not match anything await searchInput.fill('xyznonexistent'); await page.waitForTimeout(300); // Should show "No matching virtual servers" empty state const noMatch = page.locator('text=No matching virtual servers'); await expect(noMatch).toBeVisible({ timeout: 3000 }); // Clear search await searchInput.fill(''); await page.waitForTimeout(300); // Original servers should reappear (or default empty state) const table = page.locator('table'); const emptyState = page.locator('text=No virtual servers configured'); const hasTable = await table.isVisible().catch(() => false); const hasEmpty = await emptyState.isVisible().catch(() => false); expect(hasTable || hasEmpty).toBeTruthy(); }); test('should filter and find "Time Only" server by name', async ({ page, }) => { const searchInput = page.locator('input[placeholder="Search virtual servers..."]'); await expect(searchInput).toBeVisible({ timeout: 5000 }); await searchInput.fill('Time Only'); await page.waitForTimeout(300); // "Time Only" should still be visible in the table const timeOnly = page.locator('td:has-text("Time Only")'); const count = await timeOnly.count(); if (count === 0) { // Server might not exist in this environment - acceptable return; } expect(count).toBeGreaterThanOrEqual(1); }); }); // --------------------------------------------------------------------------- // 3. Full CRUD lifecycle (serial - tests depend on order) // --------------------------------------------------------------------------- test.describe.serial('Full CRUD lifecycle', () => { const SERVER_NAME = `Playwright Full Test ${Date.now()}`; const SERVER_DESCRIPTION = 'Full E2E lifecycle test by Playwright'; const UPDATED_DESCRIPTION = 'Updated description by Playwright E2E'; test('should create a virtual server via the wizard', async ({ page }) => { await loginAsAdmin(page); await navigateToVirtualServers(page); // Click "Create Virtual Server" const createBtn = page.locator('button:has-text("Create Virtual Server")'); await expect(createBtn).toBeVisible({ timeout: 5000 }); await createBtn.click(); // Dialog should appear const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Step 1: Basics await page.fill('input[placeholder="e.g. Dev Essentials"]', SERVER_NAME); await page.fill( 'textarea[placeholder="Describe what this virtual server provides..."]', SERVER_DESCRIPTION ); // Path should auto-generate const pathInput = page.locator('input[placeholder="/virtual/dev-essentials"]'); await expect(pathInput).not.toHaveValue(''); // Next -> Step 2: Tool Selection await dialog.locator('button:has-text("Next")').click(); await expect( page.locator('text=Select tools to include in this virtual server') ).toBeVisible({ timeout: 3000 }); // Next -> Step 3: Configuration await dialog.locator('button:has-text("Next")').click(); await expect( page.locator('text=Tool Aliases and Version Pins') ).toBeVisible({ timeout: 3000 }); // Next -> Step 4: Review await dialog.locator('button:has-text("Next")').click(); await expect(page.locator('text=Server Details')).toBeVisible({ timeout: 3000, }); // Verify review shows our name and description await expect(dialog.locator(`text=${SERVER_NAME}`)).toBeVisible(); await expect(dialog.locator(`text=${SERVER_DESCRIPTION}`)).toBeVisible(); // Submit await dialog.locator('button:has-text("Create Virtual Server")').click(); await expect(dialog).not.toBeVisible({ timeout: 10000 }); }); test('should verify the created server appears in the list', async ({ page, }) => { await loginAsAdmin(page); await navigateToVirtualServers(page); // Search for our server const searchInput = page.locator('input[placeholder="Search virtual servers..."]'); await expect(searchInput).toBeVisible({ timeout: 5000 }); await searchInput.fill(SERVER_NAME); await page.waitForTimeout(500); // The server should appear in the table const serverCell = page.locator(`td:has-text("${SERVER_NAME}")`); const count = await serverCell.count(); expect(count).toBeGreaterThanOrEqual(1); }); test('should toggle a virtual server enable/disable', async ({ page }) => { await loginAsAdmin(page); await navigateToVirtualServers(page); // Find any toggle checkbox in the table const toggle = page.locator( 'input[type="checkbox"][aria-label^="Enable"]' ).first(); if (!(await toggle.isVisible({ timeout: 3000 }).catch(() => false))) { test.skip(); return; } const isCheckedBefore = await toggle.isChecked(); // Click the parent label (checkbox is sr-only) and wait for the // toggle POST + list refetch GET to complete. const label = page.locator('label').filter({ has: toggle }).first(); await Promise.all([ page.waitForResponse( (resp) => resp.url().includes('/api/virtual-servers') && resp.request().method() === 'GET', { timeout: 10000 }, ), label.click(), ]); // Re-locate after possible re-render and verify the state flipped const toggleAfter = page .locator('input[type="checkbox"][aria-label^="Enable"]') .first(); const isCheckedAfter = await toggleAfter.isChecked(); // The state should have changed expect(isCheckedAfter).not.toBe(isCheckedBefore); // Toggle back to restore original state const labelAfter = page.locator('label').filter({ has: toggleAfter }).first(); await Promise.all([ page.waitForResponse( (resp) => resp.url().includes('/api/virtual-servers') && resp.request().method() === 'GET', { timeout: 10000 }, ), labelAfter.click(), ]); }); test('should delete the created server with name confirmation', async ({ page, }) => { await loginAsAdmin(page); await navigateToVirtualServers(page); // Search for our server const searchInput = page.locator('input[placeholder="Search virtual servers..."]'); await expect(searchInput).toBeVisible({ timeout: 5000 }); await searchInput.fill(SERVER_NAME); await page.waitForTimeout(500); // Click the Delete button in the matching row const deleteBtn = page.locator('button:has-text("Delete")').first(); if (!(await deleteBtn.isVisible({ timeout: 3000 }).catch(() => false))) { test.skip(); return; } await deleteBtn.click(); // Delete dialog should appear const deleteDialog = page.locator( '[role="dialog"][aria-label="Delete virtual server confirmation"]' ); await expect(deleteDialog).toBeVisible({ timeout: 5000 }); // Confirm button should be disabled initially const confirmDeleteBtn = deleteDialog.locator('button:has-text("Delete")'); await expect(confirmDeleteBtn).toBeDisabled(); // Type the server name from the placeholder const nameInput = deleteDialog.locator('input[type="text"]'); const placeholder = await nameInput.getAttribute('placeholder'); expect(placeholder).toBeTruthy(); await nameInput.fill(placeholder!); // Now the delete button should be enabled await expect(confirmDeleteBtn).toBeEnabled(); // Actually delete await confirmDeleteBtn.click(); // Dialog should close await expect(deleteDialog).not.toBeVisible({ timeout: 10000 }); }); test('should verify the deleted server is removed from the list', async ({ page, }) => { await loginAsAdmin(page); await navigateToVirtualServers(page); // Search for our server const searchInput = page.locator('input[placeholder="Search virtual servers..."]'); await expect(searchInput).toBeVisible({ timeout: 5000 }); await searchInput.fill(SERVER_NAME); await page.waitForTimeout(500); // The server should no longer appear const serverCell = page.locator(`td:has-text("${SERVER_NAME}")`); const count = await serverCell.count(); // Either 0 rows or the "No matching" empty state const noMatch = page.locator('text=No matching virtual servers'); const hasNoMatch = await noMatch.isVisible().catch(() => false); expect(count === 0 || hasNoMatch).toBeTruthy(); }); }); // --------------------------------------------------------------------------- // 4. Form validation and wizard navigation // --------------------------------------------------------------------------- test.describe('Form validation and wizard navigation', () => { test.beforeEach(async ({ page }) => { await loginAsAdmin(page); await navigateToVirtualServers(page); }); test('should show error when name is empty and Next is clicked', async ({ page, }) => { await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Click Next without filling name await dialog.locator('button:has-text("Next")').click(); // Validation error should appear await expect(page.locator('text=Server name is required')).toBeVisible({ timeout: 3000, }); }); test('should auto-generate path from name', async ({ page }) => { await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); await page.fill('input[placeholder="e.g. Dev Essentials"]', 'My Test Server'); const pathInput = page.locator('input[placeholder="/virtual/dev-essentials"]'); await expect(pathInput).toHaveValue('/virtual/my-test-server'); }); test('should navigate through all 4 wizard steps forward and back', async ({ page, }) => { await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Step 1: Basics await page.fill('input[placeholder="e.g. Dev Essentials"]', 'Wizard Nav Test'); await expect(page.locator('text=Basics')).toBeVisible(); // Forward to Step 2: Tool Selection await dialog.locator('button:has-text("Next")').click(); await expect( page.locator('text=Select tools to include in this virtual server') ).toBeVisible({ timeout: 3000 }); // Forward to Step 3: Configuration await dialog.locator('button:has-text("Next")').click(); await expect(page.locator('text=Tool Aliases and Version Pins')).toBeVisible({ timeout: 3000, }); // Forward to Step 4: Review await dialog.locator('button:has-text("Next")').click(); await expect(page.locator('text=Server Details')).toBeVisible({ timeout: 3000, }); // Back to Step 3 await dialog.locator('button:has-text("Back")').click(); await expect(page.locator('text=Tool Aliases and Version Pins')).toBeVisible({ timeout: 3000, }); // Back to Step 2 await dialog.locator('button:has-text("Back")').click(); await expect( page.locator('text=Select tools to include in this virtual server') ).toBeVisible({ timeout: 3000 }); // Back to Step 1 await dialog.locator('button:has-text("Back")').click(); await expect( page.locator('input[placeholder="e.g. Dev Essentials"]') ).toBeVisible({ timeout: 3000 }); }); test('should close the form when Cancel is clicked on step 1', async ({ page, }) => { await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); await dialog.locator('button:has-text("Cancel")').click(); await expect(dialog).not.toBeVisible({ timeout: 3000 }); }); test('should close the form when Cancel is clicked on a later step', async ({ page, }) => { await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Fill name and advance to step 2 await page.fill('input[placeholder="e.g. Dev Essentials"]', 'Cancel Test'); await dialog.locator('button:has-text("Next")').click(); await expect( page.locator('text=Select tools to include in this virtual server') ).toBeVisible({ timeout: 3000 }); // Cancel on step 2 const cancelBtn = dialog.locator('button:has-text("Cancel")'); await cancelBtn.click(); await expect(dialog).not.toBeVisible({ timeout: 3000 }); }); test('should close the form when Escape key is pressed', async ({ page, }) => { await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); await page.keyboard.press('Escape'); await expect(dialog).not.toBeVisible({ timeout: 3000 }); }); }); // --------------------------------------------------------------------------- // 5. Multi-backend server inspection // --------------------------------------------------------------------------- test.describe('Multi-backend server inspection', () => { test.beforeEach(async ({ page }) => { await loginAsAdmin(page); await navigateToVirtualServers(page); }); test('should find "E2E Multi Backend" in the server list', async ({ page, }) => { const searchInput = page.locator('input[placeholder="Search virtual servers..."]'); await expect(searchInput).toBeVisible({ timeout: 5000 }); await searchInput.fill('E2E Multi Backend'); await page.waitForTimeout(500); const serverCell = page.locator('td:has-text("E2E Multi Backend")'); const count = await serverCell.count(); if (count === 0) { // Server might not exist in this environment test.skip(); return; } expect(count).toBeGreaterThanOrEqual(1); }); test('should show tool count of 4 for "E2E Multi Backend"', async ({ page, }) => { const searchInput = page.locator('input[placeholder="Search virtual servers..."]'); await expect(searchInput).toBeVisible({ timeout: 5000 }); await searchInput.fill('E2E Multi Backend'); await page.waitForTimeout(500); const serverRow = page.locator('tr').filter({ has: page.locator('td:has-text("E2E Multi Backend")'), }); const count = await serverRow.count(); if (count === 0) { test.skip(); return; } // The Tools column (3rd column, index 2) should contain "4" const toolsCell = serverRow.locator('td').nth(2); await expect(toolsCell).toHaveText('4'); }); test('should show 2 backend paths for "E2E Multi Backend"', async ({ page, }) => { const searchInput = page.locator('input[placeholder="Search virtual servers..."]'); await expect(searchInput).toBeVisible({ timeout: 5000 }); await searchInput.fill('E2E Multi Backend'); await page.waitForTimeout(500); const serverRow = page.locator('tr').filter({ has: page.locator('td:has-text("E2E Multi Backend")'), }); const count = await serverRow.count(); if (count === 0) { test.skip(); return; } // The Backends column (4th column, index 3) should show 2 backend path badges const backendsCell = serverRow.locator('td').nth(3); const backendBadges = backendsCell.locator('span'); const badgeCount = await backendBadges.count(); expect(badgeCount).toBe(2); // Verify the actual backend paths const badge1Text = await backendBadges.nth(0).textContent(); const badge2Text = await backendBadges.nth(1).textContent(); const allText = [badge1Text, badge2Text].join(' '); expect(allText).toContain('/currenttime/'); expect(allText).toContain('/realserverfaketools/'); }); }); ================================================ FILE: frontend/e2e/virtual-server-form.spec.ts ================================================ import { test, expect } from '@playwright/test'; import { loginAsAdmin, navigateToVirtualServers } from './helpers/auth'; /** * Form validation and wizard navigation tests for the Virtual Server form. * * Covers: required field validation, wizard step navigation, cancel/escape * behavior, and form auto-generation (path from name). */ test.describe('Virtual Server Form Validation', () => { test.beforeEach(async ({ page }) => { await loginAsAdmin(page); await navigateToVirtualServers(page); }); test('should show validation error when name is empty and Next is clicked', async ({ page, }) => { // Open create form await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Name field should be empty by default; click Next await dialog.locator('button:has-text("Next")').click(); // A validation error should appear await expect(page.locator('text=Server name is required')).toBeVisible({ timeout: 3000, }); }); test('should auto-generate path from name', async ({ page }) => { await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Type a name await page.fill('input[placeholder="e.g. Dev Essentials"]', 'My Test Server'); // The path should be auto-generated const pathInput = page.locator('input[placeholder="/virtual/dev-essentials"]'); await expect(pathInput).toHaveValue('/virtual/my-test-server'); }); test('should navigate through all wizard steps', async ({ page }) => { await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Step 1: Basics - fill required fields await page.fill('input[placeholder="e.g. Dev Essentials"]', 'Wizard Nav Test'); await expect(page.locator('text=Basics')).toBeVisible(); // Go to step 2: Tool Selection await dialog.locator('button:has-text("Next")').click(); await expect( page.locator('text=Select tools to include in this virtual server') ).toBeVisible({ timeout: 3000 }); // Go to step 3: Configuration await dialog.locator('button:has-text("Next")').click(); await expect(page.locator('text=Tool Aliases and Version Pins')).toBeVisible({ timeout: 3000, }); // Go to step 4: Review await dialog.locator('button:has-text("Next")').click(); await expect(page.locator('text=Server Details')).toBeVisible({ timeout: 3000, }); // Go back to step 3 (footer left button says "Back") await dialog.locator('button:has-text("Back")').click(); await expect(page.locator('text=Tool Aliases and Version Pins')).toBeVisible({ timeout: 3000, }); // Go back to step 2 await dialog.locator('button:has-text("Back")').click(); await expect( page.locator('text=Select tools to include in this virtual server') ).toBeVisible({ timeout: 3000 }); // Go back to step 1 await dialog.locator('button:has-text("Back")').click(); await expect( page.locator('input[placeholder="e.g. Dev Essentials"]') ).toBeVisible({ timeout: 3000 }); }); test('should close the form when Cancel is clicked on step 1', async ({ page, }) => { await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // On step 1, the left footer button says "Cancel" await dialog.locator('button:has-text("Cancel")').click(); // Dialog should close await expect(dialog).not.toBeVisible({ timeout: 3000 }); }); test('should close the form when Cancel is clicked on a later step', async ({ page, }) => { await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Fill name and advance to step 2 await page.fill('input[placeholder="e.g. Dev Essentials"]', 'Cancel Test'); await dialog.locator('button:has-text("Next")').click(); await expect( page.locator('text=Select tools to include in this virtual server') ).toBeVisible({ timeout: 3000 }); // On step 2+, there is a text "Cancel" button in the footer right area const cancelBtn = dialog.locator('button:has-text("Cancel")'); await cancelBtn.click(); // Dialog should close await expect(dialog).not.toBeVisible({ timeout: 3000 }); }); test('should close the form when Escape key is pressed', async ({ page, }) => { await page.click('button:has-text("Create Virtual Server")'); const dialog = page.locator('[role="dialog"]'); await expect(dialog).toBeVisible({ timeout: 5000 }); // Press Escape await page.keyboard.press('Escape'); // Dialog should close await expect(dialog).not.toBeVisible({ timeout: 3000 }); }); }); ================================================ FILE: frontend/package.json ================================================ { "name": "mcp-gateway-frontend", "version": "0.1.0", "private": true, "homepage": "/", "dependencies": { "@headlessui/react": "^1.7.17", "@heroicons/react": "^2.0.18", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.10", "@types/node": "^16.18.0", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.18", "ajv": "8.18.0", "autoprefixer": "^10.4.16", "axios": "^1.15.0", "clsx": "^2.0.0", "date-fns": "^4.1.0", "jszip": "^3.10.1", "postcss": "^8.5.12", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", "react-router-dom": "^6.30.3", "react-scripts": "5.0.1", "remark-gfm": "^4.0.0", "tailwindcss": "^3.3.6", "typescript": "^4.9.5" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "postinstall": "patch-package" }, "eslintConfig": { "extends": [ "react-app", "react-app/jest" ] }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] }, "jest": { "transformIgnorePatterns": [ "node_modules/(?!axios)/" ] }, "proxy": "http://localhost:7860", "overrides": { "nth-check": "^2.1.1", "webpack-dev-server": "^5.2.1", "resolve-url-loader": "^5.0.0", "serialize-javascript": ">=7.0.5", "svgo": ">=2.8.1", "@tootallnate/once": ">=3.0.1", "underscore": ">=1.13.8", "ajv@<6.14.0": "6.14.0", "qs": ">=6.14.2" }, "devDependencies": { "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/jest": "^30.0.0", "patch-package": "^8.0.1", "postinstall-postinstall": "^2.1.0" } } ================================================ FILE: frontend/patches/react-scripts+5.0.1.patch ================================================ diff --git a/node_modules/react-scripts/config/webpackDevServer.config.js b/node_modules/react-scripts/config/webpackDevServer.config.js index 522a81b..75d3959 100644 --- a/node_modules/react-scripts/config/webpackDevServer.config.js +++ b/node_modules/react-scripts/config/webpackDevServer.config.js @@ -109,7 +109,11 @@ module.exports = function (proxy, allowedHost) { }, // `proxy` is run between `before` and `after` `webpack-dev-server` hooks proxy, - onBeforeSetupMiddleware(devServer) { + setupMiddlewares: (middlewares, devServer) => { + if (!devServer) { + throw new Error('webpack-dev-server is not defined'); + } + // Keep `evalSourceMapMiddleware` // middlewares before `redirectServedPath` otherwise will not have any effect // This lets us fetch source contents from webpack for the error overlay @@ -119,8 +123,7 @@ module.exports = function (proxy, allowedHost) { // This registers user provided middleware for proxy reasons require(paths.proxySetup)(devServer.app); } - }, - onAfterSetupMiddleware(devServer) { + // Redirect to `PUBLIC_URL` or `homepage` from `package.json` if url not match devServer.app.use(redirectServedPath(paths.publicUrlOrPath)); @@ -130,6 +133,8 @@ module.exports = function (proxy, allowedHost) { // it used the same host and port. // https://github.com/facebook/create-react-app/issues/2272#issuecomment-302832432 devServer.app.use(noopServiceWorkerMiddleware(paths.publicUrlOrPath)); + + return middlewares; }, }; }; ================================================ FILE: frontend/playwright.config.ts ================================================ import { defineConfig, devices } from '@playwright/test'; /** * Playwright configuration for MCP Gateway Registry e2e tests. * * The app is served by nginx on port 80 (http://localhost). * Authentication uses basic auth with session cookies. */ export default defineConfig({ testDir: './e2e', fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: 1, reporter: 'html', timeout: 60_000, use: { baseURL: 'http://localhost', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, ], }); ================================================ FILE: frontend/postcss.config.js ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================ FILE: frontend/public/index.html ================================================ AI Gateway & Registry
================================================ FILE: frontend/src/App.tsx ================================================ import React from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { AuthProvider } from './contexts/AuthContext'; import { ThemeProvider } from './contexts/ThemeContext'; import Layout from './components/Layout'; import Dashboard from './pages/Dashboard'; import TokenGeneration from './pages/TokenGeneration'; import RegisterPage from './pages/RegisterPage'; import Login from './pages/Login'; import Logout from './pages/Logout'; import OAuthCallback from './pages/OAuthCallback'; import ProtectedRoute from './components/ProtectedRoute'; import SettingsPage from './pages/SettingsPage'; // Get basename from tag for path-based routing (e.g., /registry) const getBasename = () => { const baseTag = document.querySelector('base'); if (baseTag && baseTag.href) { const url = new URL(baseTag.href); return url.pathname.replace(/\/$/, '') || '/'; } return '/'; }; function App() { return ( } /> } /> } /> } /> } /> } /> } /> ); } export default App; ================================================ FILE: frontend/src/components/ANSBadge.tsx ================================================ import React, { useState, useEffect, useCallback } from 'react'; import { ShieldCheckIcon, ExclamationTriangleIcon, XCircleIcon, CodeBracketIcon } from '@heroicons/react/24/solid'; interface ANSFunction { id?: string; name?: string; tags?: string[] | null; } interface ANSEndpoint { type?: string; url?: string; protocol?: string; transports?: string[]; functions?: ANSFunction[]; } interface ANSLink { rel?: string; href?: string; } interface ANSMetadata { ans_agent_id: string; status: 'verified' | 'expired' | 'revoked' | 'not_found' | 'pending'; domain?: string; organization?: string; ans_name?: string; ans_display_name?: string; ans_description?: string; ans_version?: string; registered_with_ans_at?: string; certificate?: { not_after?: string; not_before?: string; subject_dn?: string; issuer_dn?: string; serial_number?: string; }; endpoints?: ANSEndpoint[]; links?: ANSLink[]; raw_ans_response?: Record; last_verified?: string; } interface ANSBadgeProps { ansMetadata: ANSMetadata | null | undefined; compact?: boolean; } const STATUS_CONFIG = { verified: { label: 'ANS VERIFIED', Icon: ShieldCheckIcon, badgeClasses: 'bg-gradient-to-r from-emerald-100 to-green-100 text-emerald-700 ' + 'dark:from-emerald-900/30 dark:to-green-900/30 dark:text-emerald-300 ' + 'border border-emerald-200 dark:border-emerald-600', iconColor: 'text-emerald-600 dark:text-emerald-400', modalBadgeClasses: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300', }, expired: { label: 'ANS EXPIRED', Icon: ExclamationTriangleIcon, badgeClasses: 'bg-gradient-to-r from-yellow-100 to-amber-100 text-yellow-700 ' + 'dark:from-yellow-900/30 dark:to-amber-900/30 dark:text-yellow-300 ' + 'border border-yellow-200 dark:border-yellow-600', iconColor: 'text-yellow-600 dark:text-yellow-400', modalBadgeClasses: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300', }, revoked: { label: 'ANS REVOKED', Icon: XCircleIcon, badgeClasses: 'bg-gradient-to-r from-red-100 to-rose-100 text-red-700 ' + 'dark:from-red-900/30 dark:to-rose-900/30 dark:text-red-300 ' + 'border border-red-200 dark:border-red-600', iconColor: 'text-red-600 dark:text-red-400', modalBadgeClasses: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', }, not_found: { label: 'ANS NOT FOUND', Icon: ExclamationTriangleIcon, badgeClasses: 'bg-gradient-to-r from-gray-100 to-slate-100 text-gray-700 ' + 'dark:from-gray-900/30 dark:to-slate-900/30 dark:text-gray-300 ' + 'border border-gray-200 dark:border-gray-600', iconColor: 'text-gray-600 dark:text-gray-400', modalBadgeClasses: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300', }, pending: { label: 'ANS PENDING', Icon: ShieldCheckIcon, badgeClasses: 'bg-gradient-to-r from-blue-100 to-indigo-100 text-blue-700 ' + 'dark:from-blue-900/30 dark:to-indigo-900/30 dark:text-blue-300 ' + 'border border-blue-200 dark:border-blue-600', iconColor: 'text-blue-600 dark:text-blue-400', modalBadgeClasses: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', }, }; const LINK_LABELS: Record = { 'self': 'ANS Agent API', 'server-certificates': 'Server Certificates', 'identity-certificates': 'Identity Certificates', 'agent-details': 'Agent Details', }; export const ANSBadge: React.FC = ({ ansMetadata }) => { const [showModal, setShowModal] = useState(false); if (!ansMetadata) return null; const config = STATUS_CONFIG[ansMetadata.status] || STATUS_CONFIG.pending; const { label, Icon, badgeClasses, iconColor } = config; return ( <> setShowModal(true)} > {label} {showModal && ( setShowModal(false)} /> )} ); }; interface ANSCertificateModalProps { ansMetadata: ANSMetadata; onClose: () => void; } const ANSCertificateModal: React.FC = ({ ansMetadata, onClose }) => { const [showRawJson, setShowRawJson] = useState(false); const config = STATUS_CONFIG[ansMetadata.status] || STATUS_CONFIG.pending; const { label, Icon, iconColor, modalBadgeClasses } = config; // Close on ESC key const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } }, [onClose]); useEffect(() => { document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [handleKeyDown]); const hasCertDetails = ansMetadata.certificate && ( ansMetadata.certificate.subject_dn || ansMetadata.certificate.issuer_dn || ansMetadata.certificate.not_after ); const hasEndpoints = ansMetadata.endpoints && ansMetadata.endpoints.length > 0; const hasLinks = ansMetadata.links && ansMetadata.links.length > 0; // Collect all unique functions across endpoints const allFunctions = (ansMetadata.endpoints || []) .flatMap(ep => ep.functions || []) .filter(fn => fn && fn.id); return (
e.stopPropagation()}> {/* Header */}

ANS Certificate Details

{ansMetadata.raw_ans_response && ( )}
{/* Raw JSON View */} {showRawJson && ansMetadata.raw_ans_response && (
              {JSON.stringify(ansMetadata.raw_ans_response, null, 2)}
            
)} {/* Normal View */} {!showRawJson && (
{/* Status */}
Status
{label}
{/* ANS Display Name */} {ansMetadata.ans_display_name && (
ANS Registered Name
{ansMetadata.ans_display_name}
)} {/* Agent ID */}
Agent ID
{ansMetadata.ans_agent_id}
{/* Domain */} {ansMetadata.domain && ( )} {/* Agent Card URL */} {ansMetadata.domain && ( )} {/* Organization */} {ansMetadata.organization && (
Organization
{ansMetadata.organization}
)} {/* Version */} {ansMetadata.ans_version && (
ANS Version
{ansMetadata.ans_version}
)} {/* ANS Registration Date */} {ansMetadata.registered_with_ans_at && (
Registered with ANS
{new Date(ansMetadata.registered_with_ans_at).toLocaleString()}
)} {/* Certificate Section */} {hasCertDetails && (
Certificate
{ansMetadata.certificate?.subject_dn && (
Subject:{' '} {ansMetadata.certificate.subject_dn}
)} {ansMetadata.certificate?.issuer_dn && (
Issuer:{' '} {ansMetadata.certificate.issuer_dn}
)} {ansMetadata.certificate?.not_after && (
Expires:{' '} {new Date(ansMetadata.certificate.not_after).toLocaleDateString()}
)} {ansMetadata.certificate?.serial_number && (
Serial:{' '} {ansMetadata.certificate.serial_number}
)}
)} {/* Endpoints Section */} {hasEndpoints && (
Endpoints
{ansMetadata.endpoints!.map((ep, idx) => (
{ep.type || 'HTTP'} {ep.url} {ep.protocol && ( {ep.protocol} )}
{ep.transports && ep.transports.length > 0 && (
Transport: {ep.transports.map((t, ti) => ( {t} ))}
)}
))}
)} {/* Functions/Skills Section */} {allFunctions.length > 0 && (
Functions
{allFunctions.map((fn, idx) => (
{fn.name || fn.id} {fn.tags && fn.tags.length > 0 && (
{fn.tags.map((tag, ti) => ( {tag} ))}
)}
))}
)} {/* ANS API Links Section */} {hasLinks && (
ANS API Links
{ansMetadata.links!.map((link, idx) => (
{LINK_LABELS[link.rel || ''] || link.rel}: {link.href}
))}
)} {/* Description from ANS */} {ansMetadata.ans_description && (
ANS Description

{ansMetadata.ans_description}

)} {/* Last Verified */} {ansMetadata.last_verified && (
Last Verified: {new Date(ansMetadata.last_verified).toLocaleString()}
)}
)} {/* Close button */}
); }; export default ANSBadge; ================================================ FILE: frontend/src/components/AddRegistryEntryModal.tsx ================================================ import React, { useState } from 'react'; import axios from 'axios'; import DetailsModal from './DetailsModal'; /** * Source types supported by this modal. */ export type RegistrySourceType = 'aws_registry' | 'anthropic' | 'asor'; /** * Props for the AddRegistryEntryModal component. */ interface AddRegistryEntryModalProps { isOpen: boolean; onClose: () => void; sourceType: RegistrySourceType; onSuccess: () => void; onShowToast: (message: string, type: 'success' | 'error' | 'info') => void; } /** * Form data for AWS Registry source. */ interface AwsRegistryFormData { registry_id: string; aws_account_id: string; aws_region: string; assume_role_arn: string; descriptor_types: string[]; sync_status_filter: string; } /** * All available descriptor types for AWS Registry. */ const ALL_DESCRIPTOR_TYPES = ['MCP', 'A2A', 'CUSTOM', 'AGENT_SKILLS']; /** * Source type display labels. */ const SOURCE_TITLES: Record = { aws_registry: 'Add AWS Agent Registry', anthropic: 'Add Anthropic Server', asor: 'Add ASOR Agent', }; /** * CSS classes for form inputs. */ const INPUT_CLASS = 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg ' + 'bg-white dark:bg-gray-900 text-gray-900 dark:text-white ' + 'focus:ring-2 focus:ring-purple-500 focus:border-transparent ' + 'placeholder-gray-400 dark:placeholder-gray-500 text-sm'; /** * CSS classes for form labels. */ const LABEL_CLASS = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1'; /** * Default form data for AWS Registry. */ function _defaultAwsFormData(): AwsRegistryFormData { return { registry_id: '', aws_account_id: '', aws_region: '', assume_role_arn: '', descriptor_types: [...ALL_DESCRIPTOR_TYPES], sync_status_filter: 'APPROVED', }; } /** * Modal for adding a new entry to any federation source. * * Renders different form fields based on sourceType: * - aws_registry: multi-field form for AWS Agent Registry * - anthropic: single field for server name * - asor: single field for agent ID */ const AddRegistryEntryModal: React.FC = ({ isOpen, onClose, sourceType, onSuccess, onShowToast, }) => { // Simple string fields for Anthropic/ASOR const [simpleValue, setSimpleValue] = useState(''); // Multi-field form for AWS Registry const [awsForm, setAwsForm] = useState(_defaultAwsFormData()); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); /** * Reset all form state when closing. */ const handleClose = () => { setSimpleValue(''); setAwsForm(_defaultAwsFormData()); setErrors({}); setIsSubmitting(false); onClose(); }; /** * Extract region and account ID from an ARN string. * ARN format: arn:aws:bedrock-agentcore:::registry/... * Returns extracted values as soon as enough colon-separated parts are present. */ const extractFromArn = (arn: string): { region: string; accountId: string } | null => { const trimmed = arn.trim(); if (!trimmed.startsWith('arn:')) return null; const parts = trimmed.split(':'); // parts[3] = region, parts[4] = account_id const region = parts.length > 3 ? parts[3] : ''; const accountId = parts.length > 4 ? parts[4] : ''; // Only return if we have at least one useful value if (region || accountId) { return { region, accountId }; } return null; }; /** * Handle changes to AWS Registry form fields. * Auto-populates region and account ID when registry_id is an ARN. */ const handleAwsChange = (e: React.ChangeEvent) => { const { name, value } = e.target; if (name === 'registry_id') { const extracted = extractFromArn(value); setAwsForm((prev) => ({ ...prev, registry_id: value, aws_region: extracted?.region ?? prev.aws_region, aws_account_id: extracted?.accountId ?? prev.aws_account_id, })); } else { setAwsForm((prev) => ({ ...prev, [name]: value })); } if (errors[name]) { setErrors((prev) => ({ ...prev, [name]: '' })); } }; /** * Toggle a descriptor type checkbox. */ const handleDescriptorToggle = (dtype: string) => { setAwsForm((prev) => { const current = prev.descriptor_types; const updated = current.includes(dtype) ? current.filter((d) => d !== dtype) : [...current, dtype]; return { ...prev, descriptor_types: updated }; }); }; /** * Validate the form before submission. */ const validateForm = (): boolean => { const newErrors: Record = {}; if (sourceType === 'anthropic') { if (!simpleValue.trim()) { newErrors.server_name = 'Server name is required'; } } else if (sourceType === 'asor') { if (!simpleValue.trim()) { newErrors.agent_id = 'Agent ID is required'; } } else if (sourceType === 'aws_registry') { if (!awsForm.registry_id.trim()) { newErrors.registry_id = 'Registry ID is required'; } if (awsForm.descriptor_types.length === 0) { newErrors.descriptor_types = 'At least one descriptor type is required'; } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; /** * Submit the form to add a new entry. */ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!validateForm()) return; setIsSubmitting(true); try { if (sourceType === 'anthropic') { await axios.post( `/api/federation/config/default/anthropic/servers?server_name=${encodeURIComponent(simpleValue.trim())}` ); onShowToast(`Server '${simpleValue.trim()}' added`, 'success'); } else if (sourceType === 'asor') { await axios.post( `/api/federation/config/default/asor/agents?agent_id=${encodeURIComponent(simpleValue.trim())}` ); onShowToast(`Agent '${simpleValue.trim()}' added`, 'success'); } else if (sourceType === 'aws_registry') { const payload: Record = { registry_id: awsForm.registry_id.trim(), descriptor_types: awsForm.descriptor_types, sync_status_filter: awsForm.sync_status_filter, }; // Only include optional fields if filled if (awsForm.aws_account_id.trim()) { payload.aws_account_id = awsForm.aws_account_id.trim(); } if (awsForm.aws_region.trim()) { payload.aws_region = awsForm.aws_region.trim(); } if (awsForm.assume_role_arn.trim()) { payload.assume_role_arn = awsForm.assume_role_arn.trim(); } await axios.post('/api/federation/config/default/aws_registry/registries', payload); onShowToast(`Registry '${awsForm.registry_id.trim()}' added`, 'success'); } handleClose(); onSuccess(); } catch (err: any) { const detail = err?.response?.data?.detail || 'Failed to add entry'; onShowToast(detail, 'error'); } finally { setIsSubmitting(false); } }; const maxWidth = sourceType === 'aws_registry' ? 'lg' : 'md'; return (
{/* Anthropic: single server_name field */} {sourceType === 'anthropic' && (
{ setSimpleValue(e.target.value); if (errors.server_name) setErrors((prev) => ({ ...prev, server_name: '' })); }} disabled={isSubmitting} className={INPUT_CLASS} placeholder="io.github.owner/server-name" autoFocus /> {errors.server_name && (

{errors.server_name}

)}

The server identifier from the Anthropic MCP Registry

)} {/* ASOR: single agent_id field */} {sourceType === 'asor' && (
{ setSimpleValue(e.target.value); if (errors.agent_id) setErrors((prev) => ({ ...prev, agent_id: '' })); }} disabled={isSubmitting} className={INPUT_CLASS} placeholder="my_agent_id" autoFocus /> {errors.agent_id && (

{errors.agent_id}

)}

The agent identifier from the ASOR registry

)} {/* AWS Registry: multi-field form */} {sourceType === 'aws_registry' && ( <> {/* Registry ID (required) */}
{errors.registry_id && (

{errors.registry_id}

)}
{/* Two-column layout for optional fields */}

Leave empty to use the global region

{/* Assume Role ARN */}

Only needed if adding a registry from a different AWS account

{/* Descriptor Types checkboxes */}
{ALL_DESCRIPTOR_TYPES.map((dtype) => ( ))}
{errors.descriptor_types && (

{errors.descriptor_types}

)}
{/* Sync Status Filter */}
)} {/* Action buttons */}
); }; export default AddRegistryEntryModal; ================================================ FILE: frontend/src/components/AgentCard.tsx ================================================ import React, { useState, useCallback, useEffect } from 'react'; import axios from 'axios'; import { StarIcon, ArrowPathIcon, PencilIcon, ClockIcon, CheckCircleIcon, XCircleIcon, QuestionMarkCircleIcon, ShieldCheckIcon, ShieldExclamationIcon, GlobeAltIcon, LockClosedIcon, InformationCircleIcon, TrashIcon, } from '@heroicons/react/24/outline'; import AgentDetailsModal from './AgentDetailsModal'; import SecurityScanModal from './SecurityScanModal'; import StarRatingWidget from './StarRatingWidget'; import DeleteConfirmation from './DeleteConfirmation'; import StatusBadge from './StatusBadge'; import { ANSBadge } from './ANSBadge'; import { formatRelativeTime } from '../utils/dateUtils'; interface SyncMetadata { is_federated?: boolean; source_peer_id?: string; upstream_path?: string; last_synced_at?: string; is_read_only?: boolean; is_orphaned?: boolean; orphaned_at?: string; } /** * Agent interface representing an A2A agent. */ export interface Agent { name: string; path: string; url?: string; description?: string; version?: string; visibility?: 'public' | 'private' | 'group-restricted'; trust_level?: 'community' | 'verified' | 'trusted' | 'unverified'; enabled: boolean; tags?: string[]; last_checked_time?: string; usersCount?: number; rating?: number; rating_details?: Array<{ user: string; rating: number }>; status?: 'healthy' | 'healthy-auth-expired' | 'unhealthy' | 'unknown'; // Federation sync metadata sync_metadata?: SyncMetadata; // ANS verification metadata ans_metadata?: { ans_agent_id: string; status: 'verified' | 'expired' | 'revoked' | 'not_found' | 'pending'; domain?: string; organization?: string; certificate?: { not_after?: string; subject_dn?: string; issuer_dn?: string; }; last_verified?: string; }; // Lifecycle status lifecycle_status?: 'active' | 'deprecated' | 'draft' | 'beta'; source_created_at?: string; source_updated_at?: string; // Supported protocol (e.g., 'a2a', 'mcp') supported_protocol?: string | null; } /** * Props for the AgentCard component. */ interface AgentCardProps { agent: Agent & { [key: string]: any }; // Allow additional fields from full agent JSON onToggle: (path: string, enabled: boolean) => void; onEdit?: (agent: Agent) => void; canModify?: boolean; canHealthCheck?: boolean; // Whether user can run health check on this agent canToggle?: boolean; // Whether user can enable/disable this agent canDelete?: boolean; // Whether user can delete this agent onDelete?: (path: string) => Promise; // Callback to delete the agent onRefreshSuccess?: () => void; onShowToast?: (message: string, type: 'success' | 'error') => void; onAgentUpdate?: (path: string, updates: Partial) => void; authToken?: string | null; } /** * Helper function to format time since last checked. */ const formatTimeSince = (timestamp: string | null | undefined): string | null => { if (!timestamp) { return null; } try { const now = new Date(); const lastChecked = new Date(timestamp); // Check if the date is valid if (isNaN(lastChecked.getTime())) { return null; } const diffMs = now.getTime() - lastChecked.getTime(); const diffSeconds = Math.floor(diffMs / 1000); const diffMinutes = Math.floor(diffSeconds / 60); const diffHours = Math.floor(diffMinutes / 60); const diffDays = Math.floor(diffHours / 24); let result; if (diffSeconds < 0) { result = 'just now'; } else if (diffDays > 0) { result = `${diffDays}d ago`; } else if (diffHours > 0) { result = `${diffHours}h ago`; } else if (diffMinutes > 0) { result = `${diffMinutes}m ago`; } else { result = `${diffSeconds}s ago`; } return result; } catch (error) { console.error('formatTimeSince error:', error, 'for timestamp:', timestamp); return null; } }; const normalizeHealthStatus = (status?: string | null): Agent['status'] => { if (status === 'healthy' || status === 'healthy-auth-expired') { return status; } if (status === 'unhealthy') { return 'unhealthy'; } return 'unknown'; }; /** * AgentCard component for displaying A2A agents. * * Displays agent information with a distinct visual style from MCP servers, * using blue/cyan tones and robot-themed icons. */ const AgentCard: React.FC = React.memo(({ agent, onToggle, onEdit, canModify, canHealthCheck = true, canToggle = true, canDelete, onDelete, onRefreshSuccess, onShowToast, onAgentUpdate, authToken }) => { const [showDetails, setShowDetails] = useState(false); const [loadingRefresh, setLoadingRefresh] = useState(false); const [fullAgentDetails, setFullAgentDetails] = useState(null); const [loadingDetails, setLoadingDetails] = useState(false); const [showSecurityScan, setShowSecurityScan] = useState(false); const [securityScanResult, setSecurityScanResult] = useState(null); const [loadingSecurityScan, setLoadingSecurityScan] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); // Check if this is a federated agent from a peer registry using sync_metadata const isFederatedAgent = agent.sync_metadata?.is_federated === true; const peerRegistryId = isFederatedAgent && agent.sync_metadata?.source_peer_id ? agent.sync_metadata.source_peer_id : null; // Check if this agent is orphaned (no longer exists on peer registry) const isOrphanedAgent = agent.sync_metadata?.is_orphaned === true; // Fetch security scan status on mount to show correct icon color useEffect(() => { const fetchSecurityScan = async () => { try { const headers = authToken ? { Authorization: `Bearer ${authToken}` } : undefined; const response = await axios.get( `/api/agents${agent.path}/security-scan`, headers ? { headers } : undefined ); setSecurityScanResult(response.data); } catch { // Silently ignore - no scan result available } }; fetchSecurityScan(); }, [agent.path, authToken]); const getStatusIcon = () => { switch (agent.status) { case 'healthy': return ; case 'healthy-auth-expired': return ; case 'unhealthy': return ; default: return ; } }; const getTrustLevelColor = () => { switch (agent.trust_level) { case 'trusted': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 border border-green-200 dark:border-green-700'; case 'verified': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 border border-blue-200 dark:border-blue-700'; case 'community': default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-600'; } }; const getTrustLevelIcon = () => { switch (agent.trust_level) { case 'trusted': return ; case 'verified': return ; default: return null; } }; const getVisibilityIcon = () => { return agent.visibility === 'public' ? ( ) : ( ); }; const handleRefreshHealth = useCallback(async () => { if (loadingRefresh) return; setLoadingRefresh(true); try { const headers = authToken ? { Authorization: `Bearer ${authToken}` } : undefined; const response = await axios.post( `/api/agents${agent.path}/health`, undefined, headers ? { headers } : undefined ); // Update just this agent instead of triggering global refresh if (onAgentUpdate && response.data) { const updates: Partial = { status: normalizeHealthStatus(response.data.status), last_checked_time: response.data.last_checked_iso }; onAgentUpdate(agent.path, updates); } else if (onRefreshSuccess) { // Fallback to global refresh if onAgentUpdate is not provided onRefreshSuccess(); } if (onShowToast) { onShowToast('Agent health status refreshed successfully', 'success'); } } catch (error: any) { console.error('Failed to refresh agent health:', error); if (onShowToast) { onShowToast(error.response?.data?.detail || 'Failed to refresh agent health status', 'error'); } } finally { setLoadingRefresh(false); } }, [agent.path, authToken, loadingRefresh, onRefreshSuccess, onShowToast, onAgentUpdate]); const handleCopyDetails = useCallback( async (data: any) => { try { await navigator.clipboard.writeText(JSON.stringify(data, null, 2)); onShowToast?.('Full agent JSON copied to clipboard!', 'success'); } catch (error) { console.error('Failed to copy JSON:', error); onShowToast?.('Failed to copy JSON', 'error'); } }, [onShowToast] ); const handleViewSecurityScan = useCallback(async () => { if (loadingSecurityScan) return; setShowSecurityScan(true); setLoadingSecurityScan(true); try { const headers = authToken ? { Authorization: `Bearer ${authToken}` } : undefined; const response = await axios.get( `/api/agents${agent.path}/security-scan`, headers ? { headers } : undefined ); setSecurityScanResult(response.data); } catch (error: any) { if (error.response?.status !== 404) { console.error('Failed to fetch security scan:', error); if (onShowToast) { onShowToast('Failed to load security scan results', 'error'); } } setSecurityScanResult(null); } finally { setLoadingSecurityScan(false); } }, [agent.path, authToken, loadingSecurityScan, onShowToast]); const handleRescan = useCallback(async () => { const headers = authToken ? { Authorization: `Bearer ${authToken}` } : undefined; const response = await axios.post( `/api/agents${agent.path}/rescan`, undefined, headers ? { headers } : undefined ); setSecurityScanResult(response.data); }, [agent.path, authToken]); const getSecurityIconState = () => { // Gray: no scan result yet if (!securityScanResult) { return { Icon: ShieldCheckIcon, color: 'text-gray-400 dark:text-gray-500', title: 'View security scan results' }; } // Red: scan failed or any vulnerabilities found if (securityScanResult.scan_failed) { return { Icon: ShieldExclamationIcon, color: 'text-red-500 dark:text-red-400', title: 'Security scan failed' }; } const hasVulnerabilities = securityScanResult.critical_issues > 0 || securityScanResult.high_severity > 0 || securityScanResult.medium_severity > 0 || securityScanResult.low_severity > 0; if (hasVulnerabilities) { return { Icon: ShieldExclamationIcon, color: 'text-red-500 dark:text-red-400', title: 'Security issues found' }; } // Green: scan passed with no vulnerabilities return { Icon: ShieldCheckIcon, color: 'text-green-500 dark:text-green-400', title: 'Security scan passed' }; }; return ( <>
{showDeleteConfirm ? ( /* Delete Confirmation - replaces card content when active */
setShowDeleteConfirm(false)} />
) : ( /* Normal card content */ <> {/* Header */}

{agent.name}

{agent.lifecycle_status && agent.lifecycle_status !== 'active' && ( )} {/* Check if this is an ASOR agent */} {(agent.tags?.includes('asor') || (agent as any).provider === 'ASOR') && ( ASOR )} {/* A2A tag badge (for AgentCore imported agents) */} {agent.tags?.includes('a2a') && ( A2A )} {/* Supported Protocol Badge */} {agent.supported_protocol === 'a2a' && !agent.tags?.includes('a2a') && ( A2A Protocol )} {agent.trust_level && ( {getTrustLevelIcon()} {agent.trust_level.toUpperCase()} )} {agent.visibility && ( {getVisibilityIcon()} {agent.visibility.toUpperCase()} )} {/* Registry source badge - only show for federated (peer registry) items */} {isFederatedAgent && ( {peerRegistryId?.toUpperCase().replace('PEER-REGISTRY-', '').replace('PEER-', '')} )} {/* Orphaned badge - agent no longer exists on peer registry */} {isOrphanedAgent && ( ORPHANED )}
{/* ANS Verified badge on its own row to avoid overlap */} {agent.ans_metadata && (
)} {agent.path} {agent.version && ( v{agent.version} )} {agent.url && ( {agent.url} )}
{canModify && ( )} {/* Security Scan Button */} {/* Full Details Button */} {/* Delete Button */} {canDelete && ( )}
{/* Description */}

{agent.description || 'No description available'}

{/* Tags */} {agent.tags && agent.tags.length > 0 && (
{agent.tags.slice(0, 3).map((tag) => ( #{tag} ))} {agent.tags.length > 3 && ( +{agent.tags.length - 3} )}
)}
{/* Stats */}
{ // Update local agent rating when user submits rating if (onAgentUpdate) { onAgentUpdate(agent.path, { rating: newRating }); } }} />
{/* Footer */}
{/* Status Indicators */}
{agent.enabled ? 'Enabled' : 'Disabled'}
{agent.status === 'healthy' ? 'Healthy' : agent.status === 'healthy-auth-expired' ? 'Healthy (Auth Expired)' : agent.status === 'unhealthy' ? 'Unhealthy' : 'Unknown'}
{/* Controls */}
{/* Last Updated (source timestamp) */} {agent.source_updated_at && (
{formatRelativeTime(agent.source_updated_at)}
)} {/* Last Checked */} {(() => { const timeText = formatTimeSince(agent.last_checked_time); return agent.last_checked_time && timeText && !agent.source_updated_at ? (
{timeText}
) : null; })()} {/* Refresh Button - only show if user has health_check_agent permission */} {canHealthCheck && ( )} {/* Toggle Switch - only show if user has toggle_agent permission */} {canToggle && (
)}
setShowDetails(false)} loading={loadingDetails} fullDetails={fullAgentDetails} onCopy={handleCopyDetails} /> setShowSecurityScan(false)} loading={loadingSecurityScan} scanResult={securityScanResult} onRescan={canModify ? handleRescan : undefined} canRescan={canModify} onShowToast={onShowToast} /> ); }); AgentCard.displayName = 'AgentCard'; export default AgentCard; ================================================ FILE: frontend/src/components/AgentDetailsModal.tsx ================================================ import React from 'react'; import { ClipboardDocumentIcon } from '@heroicons/react/24/outline'; import DetailsModal from './DetailsModal'; interface AgentLike { name: string; path: string; description?: string; version?: string; visibility?: string; trust_level?: string; enabled: boolean; tags?: string[]; } interface AgentDetailsModalProps { agent: AgentLike & { [key: string]: any }; isOpen: boolean; onClose: () => void; loading: boolean; fullDetails?: any; onCopy?: (data: any) => Promise | void; } /** * AgentDetailsModal displays the complete agent JSON schema. * * Features: * - Uses shared DetailsModal component * - Copy to clipboard functionality * - Field reference documentation * - Loading states handled by parent DetailsModal */ const getAgentCardUrl = (agentUrl: string): string | null => { try { const origin = new URL(agentUrl).origin; return `${origin}/.well-known/agent-card.json`; } catch { return null; } }; const AgentDetailsModal: React.FC = ({ agent, isOpen, onClose, loading, fullDetails, onCopy, }) => { const dataToCopy = fullDetails || agent; const handleCopy = async () => { try { if (onCopy) { await onCopy(dataToCopy); } else { await navigator.clipboard.writeText(JSON.stringify(dataToCopy, null, 2)); } } catch (error) { console.error('Failed to copy agent JSON:', error); } }; return (

Complete Agent Schema

This is the complete A2A agent definition stored in the registry. It includes all metadata, skills, security schemes, and configuration details.

{/* A2A Agent Card URL for A2A agents */} {fullDetails?.supported_protocol === 'a2a' && fullDetails?.url && (() => { const cardUrl = getAgentCardUrl(fullDetails.url); return cardUrl ? (

A2A Agent Card:{' '} {cardUrl}

) : null; })()}

Agent JSON Schema:

            {JSON.stringify(dataToCopy, null, 2)}
          

Field Reference

Core Fields
  • protocol_version - A2A protocol version
  • name - Agent display name
  • description - Agent purpose
  • url - Agent endpoint URL
  • path - Registry path
Metadata Fields
  • skills - Agent capabilities
  • security_schemes - Auth methods
  • tags - Categorization
  • trust_level - Verification status
  • status - Lifecycle status
); }; export default AgentDetailsModal; ================================================ FILE: frontend/src/components/ApplicationLogs.tsx ================================================ import React, { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; import { ArrowDownTrayIcon, ArrowPathIcon, FunnelIcon, XMarkIcon, ChevronLeftIcon, ChevronRightIcon, ExclamationTriangleIcon, } from '@heroicons/react/24/outline'; interface LogEntry { timestamp: string; hostname: string; service: string; level: string; logger: string; filename: string; lineno: number; message: string; } interface LogQueryResponse { entries: LogEntry[]; total_count: number; limit: number; offset: number; has_next: boolean; } interface LogMetadata { services: string[]; hostnames: string[]; levels: string[]; } interface LogFilters { service: string; level: string; hostname: string; search: string; start: string; end: string; } interface ApplicationLogsProps { onShowToast: (message: string, type: 'success' | 'error' | 'info') => void; } const LEVEL_COLORS: Record = { DEBUG: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', INFO: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', WARNING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300', ERROR: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300', CRITICAL: 'bg-red-200 text-red-900 dark:bg-red-900/50 dark:text-red-200', }; const PAGE_SIZE = 50; const ApplicationLogs: React.FC = ({ onShowToast }) => { const [entries, setEntries] = useState([]); const [totalCount, setTotalCount] = useState(0); const [hasNext, setHasNext] = useState(false); const [offset, setOffset] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [metadata, setMetadata] = useState(null); const [showFilters, setShowFilters] = useState(false); const [expandedRow, setExpandedRow] = useState(null); const [fetchTrigger, setFetchTrigger] = useState(0); const [filters, setFilters] = useState({ service: '', level: '', hostname: '', search: '', start: '', end: '', }); const _buildParams = useCallback((extraOffset?: number): URLSearchParams => { const params = new URLSearchParams(); params.set('limit', PAGE_SIZE.toString()); params.set('offset', (extraOffset ?? offset).toString()); if (filters.service) params.set('service', filters.service); if (filters.level) params.set('level', filters.level); if (filters.hostname) params.set('hostname', filters.hostname); if (filters.search) params.set('search', filters.search); if (filters.start) params.set('start', new Date(filters.start).toISOString()); if (filters.end) params.set('end', new Date(filters.end).toISOString()); return params; }, [filters, offset]); const _fetchLogs = useCallback(async (currentOffset: number) => { setLoading(true); setError(null); try { const params = _buildParams(currentOffset); const response = await axios.get( `/api/admin/logs?${params.toString()}` ); setEntries(response.data.entries); setTotalCount(response.data.total_count); setHasNext(response.data.has_next); } catch (err: any) { if (err.response?.status === 403) { setError('Access denied. Admin permissions required.'); } else if (err.response?.status === 503) { setError('Centralized application logging is not enabled. Set APP_LOG_CENTRALIZED_ENABLED=true.'); } else { setError(err.response?.data?.detail || 'Failed to load application logs.'); } } finally { setLoading(false); } }, [_buildParams]); const _fetchMetadata = useCallback(async () => { try { const response = await axios.get('/api/admin/logs/metadata'); setMetadata(response.data); } catch { // Metadata is optional; silently ignore } }, []); useEffect(() => { _fetchLogs(offset); }, [offset, fetchTrigger, _fetchLogs]); useEffect(() => { _fetchMetadata(); }, [_fetchMetadata]); const handleApplyFilters = () => { setExpandedRow(null); setOffset(0); setFetchTrigger((prev) => prev + 1); }; const handleClearFilters = () => { setFilters({ service: '', level: '', hostname: '', search: '', start: '', end: '' }); setExpandedRow(null); setOffset(0); setFetchTrigger((prev) => prev + 1); }; const handleExport = useCallback(() => { const params = _buildParams(0); params.set('limit', '50000'); params.delete('offset'); window.open(`/api/admin/logs/export?${params.toString()}`, '_blank'); onShowToast('Log export started', 'info'); }, [_buildParams, onShowToast]); const handleRefresh = () => { setExpandedRow(null); _fetchLogs(offset); }; const handlePrevPage = () => { const newOffset = Math.max(0, offset - PAGE_SIZE); setOffset(newOffset); setExpandedRow(null); }; const handleNextPage = () => { if (hasNext) { setOffset(offset + PAGE_SIZE); setExpandedRow(null); } }; const _activeFilterCount = (): number => { let count = 0; if (filters.service) count++; if (filters.level) count++; if (filters.hostname) count++; if (filters.search) count++; if (filters.start) count++; if (filters.end) count++; return count; }; const _formatTimestamp = (ts: string): string => { try { const d = new Date(ts); return d.toLocaleString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); } catch { return ts; } }; const _truncateMessage = (msg: string, maxLen: number = 120): string => { const firstLine = msg.split('\n')[0]; if (firstLine.length <= maxLen) return firstLine; return firstLine.substring(0, maxLen) + '...'; }; const currentPage = Math.floor(offset / PAGE_SIZE) + 1; const totalPages = Math.ceil(totalCount / PAGE_SIZE); const filterCount = _activeFilterCount(); return (
{/* Header */}

Application Logs

View and download centralized application logs from all services.

{/* Filter Toggle */}
{/* Filter Panel */} {showFilters && (
{/* Service */}
{/* Level */}
{/* Hostname */}
{/* Search */}
setFilters({ ...filters, search: e.target.value })} onKeyDown={(e) => { if (e.key === 'Enter') handleApplyFilters(); }} placeholder="e.g. timeout, connection refused" className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
{/* Start time */}
setFilters({ ...filters, start: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
{/* End time */}
setFilters({ ...filters, end: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
{/* Filter actions */}
{filterCount > 0 && ( )}
)} {/* Error State */} {error && (

{error}

)} {/* Summary bar */} {!error && (
{totalCount.toLocaleString()} log entries {filterCount > 0 && ' (filtered)'} Page {currentPage} of {totalPages || 1}
)} {/* Log Table */} {!error && (
{loading && entries.length === 0 ? (
) : entries.length === 0 ? (
No log entries found.
) : (
{entries.map((entry, idx) => ( setExpandedRow(expandedRow === idx ? null : idx)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpandedRow(expandedRow === idx ? null : idx); } }} > {expandedRow === idx && ( )} ))}
Timestamp Level Service Source Message
{_formatTimestamp(entry.timestamp)} {entry.level} {entry.service} {entry.filename}:{entry.lineno} {_truncateMessage(entry.message)}
Hostname: {entry.hostname} Logger: {entry.logger}
                                {entry.message}
                              
)} {/* Pagination */} {entries.length > 0 && (
Showing {offset + 1}-{Math.min(offset + entries.length, totalCount)} of {totalCount.toLocaleString()}
{currentPage} / {totalPages}
)}
)}
); }; export default ApplicationLogs; ================================================ FILE: frontend/src/components/AuditEventDetail.tsx ================================================ import React, { useState } from 'react'; import { XMarkIcon, ClipboardDocumentIcon, CheckIcon, } from '@heroicons/react/24/outline'; import { AuditEvent } from './AuditLogTable'; interface AuditEventDetailProps { event: AuditEvent; onClose: () => void; } const AuditEventDetail: React.FC = ({ event, onClose }) => { const [copied, setCopied] = useState(false); const isMcpEvent = event.log_type === 'mcp_server_access'; const handleCopy = async () => { try { await navigator.clipboard.writeText(JSON.stringify(event, null, 2)); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (err) { console.error('Failed to copy to clipboard:', err); } }; const formatJson = (obj: unknown): string => { return JSON.stringify(obj, null, 2); }; const getStatusColor = (statusCode: number): string => { if (statusCode >= 200 && statusCode < 300) return 'text-green-600 dark:text-green-400'; if (statusCode >= 400 && statusCode < 500) return 'text-yellow-600 dark:text-yellow-400'; if (statusCode >= 500) return 'text-red-600 dark:text-red-400'; return 'text-gray-600 dark:text-gray-400'; }; const getMcpStatusColor = (status: string): string => { switch (status?.toLowerCase()) { case 'success': return 'text-green-600 dark:text-green-400'; case 'error': return 'text-red-600 dark:text-red-400'; case 'timeout': return 'text-yellow-600 dark:text-yellow-400'; default: return 'text-gray-600 dark:text-gray-400'; } }; return (
{/* Header */}

Event Details

{event.request_id}
{/* Summary */}
Timestamp
{new Date(event.timestamp).toLocaleString()}
User
{event.identity.username} {event.identity.is_admin && ( Admin )}
Status
{isMcpEvent ? (
{event.mcp_response?.status || '-'}
) : (
{event.response?.status_code || '-'}
)}
Duration
{isMcpEvent ? `${(event.mcp_response?.duration_ms || 0).toFixed(2)} ms` : `${(event.response?.duration_ms || 0).toFixed(2)} ms` }
{/* MCP-specific summary row */} {isMcpEvent && (
Server
{event.mcp_server?.name || '-'}
Method
{event.mcp_request?.method || '-'}
Tool
{event.mcp_request?.tool_name || event.mcp_request?.resource_uri || '-'}
Transport
{event.mcp_request?.transport || '-'}
)} {/* JSON Content */}
          {formatJson(event)}
        
); }; export default AuditEventDetail; ================================================ FILE: frontend/src/components/AuditFilterBar.tsx ================================================ import React, { useState, useEffect, useRef } from 'react'; import SearchableSelect, { SelectOption } from './SearchableSelect'; import axios from 'axios'; import { FunnelIcon, XMarkIcon, ArrowPathIcon, } from '@heroicons/react/24/outline'; export interface AuditFilters { stream: 'registry_api' | 'mcp_access'; from?: string; to?: string; username?: string; operation?: string; resourceType?: string; statusMin?: number; statusMax?: number; } interface AuditFilterBarProps { filters: AuditFilters; onFilterChange: (filters: AuditFilters) => void; onRefresh?: () => void; loading?: boolean; } const REGISTRY_OPERATION_OPTIONS = [ { value: '', label: 'All Operations' }, { value: 'create', label: 'Create' }, { value: 'read', label: 'Read' }, { value: 'update', label: 'Update' }, { value: 'delete', label: 'Delete' }, { value: 'list', label: 'List' }, { value: 'toggle', label: 'Toggle' }, { value: 'rate', label: 'Rate' }, { value: 'login', label: 'Login' }, { value: 'logout', label: 'Logout' }, { value: 'search', label: 'Search' }, ]; const MCP_OPERATION_OPTIONS = [ { value: '', label: 'All Methods' }, { value: 'initialize', label: 'Initialize' }, { value: 'tools/list', label: 'Tools List' }, { value: 'tools/call', label: 'Tools Call' }, { value: 'resources/list', label: 'Resources List' }, { value: 'resources/templates/list', label: 'Resource Templates' }, { value: 'notifications/initialized', label: 'Notifications' }, ]; const REGISTRY_RESOURCE_TYPE_OPTIONS = [ { value: '', label: 'All Resources' }, { value: 'server', label: 'Server' }, { value: 'agent', label: 'Agent' }, { value: 'auth', label: 'Auth' }, { value: 'federation', label: 'Federation' }, { value: 'health', label: 'Health' }, { value: 'search', label: 'Search' }, ]; const MCP_RESOURCE_TYPE_OPTIONS = [ { value: '', label: 'All Servers' }, ]; const STATUS_PRESETS = [ { value: '', label: 'All Status Codes' }, { value: '2xx', label: '2xx Success' }, { value: '4xx', label: '4xx Client Error' }, { value: '5xx', label: '5xx Server Error' }, { value: 'error', label: 'All Errors (4xx & 5xx)' }, ]; interface FilterOptionsCache { registry_api?: { usernames: SelectOption[]; serverNames: SelectOption[] }; mcp_access?: { usernames: SelectOption[]; serverNames: SelectOption[] }; } const AuditFilterBar: React.FC = ({ filters, onFilterChange, onRefresh, loading = false, }) => { const isMcpStream = filters.stream === 'mcp_access'; const operationOptions = isMcpStream ? MCP_OPERATION_OPTIONS : REGISTRY_OPERATION_OPTIONS; const resourceTypeOptions = isMcpStream ? MCP_RESOURCE_TYPE_OPTIONS : REGISTRY_RESOURCE_TYPE_OPTIONS; const [usernameOptions, setUsernameOptions] = useState([]); const [serverNameOptions, setServerNameOptions] = useState([]); const [optionsLoading, setOptionsLoading] = useState(false); const optionsCacheRef = useRef({}); // Prefetch both streams' filter options on mount useEffect(() => { const fetchAllOptions = async () => { setOptionsLoading(true); try { const [registryRes, mcpRes] = await Promise.all([ axios.get('/api/audit/filter-options', { params: { stream: 'registry_api' } }), axios.get('/api/audit/filter-options', { params: { stream: 'mcp_access' } }), ]); optionsCacheRef.current = { registry_api: { usernames: registryRes.data.usernames.map((u: string) => ({ value: u, label: u })), serverNames: [], }, mcp_access: { usernames: mcpRes.data.usernames.map((u: string) => ({ value: u, label: u })), serverNames: mcpRes.data.server_names.map((s: string) => ({ value: s, label: s })), }, }; // Set current stream's options const current = optionsCacheRef.current[filters.stream]; if (current) { setUsernameOptions(current.usernames); setServerNameOptions(current.serverNames); } } catch (error) { console.error('Failed to fetch filter options:', error); } finally { setOptionsLoading(false); } }; fetchAllOptions(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // When stream changes, serve from cache useEffect(() => { const cached = optionsCacheRef.current[filters.stream]; if (cached) { setUsernameOptions(cached.usernames); setServerNameOptions(cached.serverNames); } }, [filters.stream]); const handleStreamChange = (e: React.ChangeEvent) => { // Clear operation and resource type filters when switching streams onFilterChange({ ...filters, stream: e.target.value as 'registry_api' | 'mcp_access', operation: undefined, resourceType: undefined, }); }; const handleFromChange = (e: React.ChangeEvent) => { onFilterChange({ ...filters, from: e.target.value || undefined, }); }; const handleToChange = (e: React.ChangeEvent) => { onFilterChange({ ...filters, to: e.target.value || undefined, }); }; const handleUsernameSelect = (value: string) => { onFilterChange({ ...filters, username: value || undefined, }); }; const handleOperationChange = (e: React.ChangeEvent) => { onFilterChange({ ...filters, operation: e.target.value || undefined, }); }; const handleResourceTypeChange = (e: React.ChangeEvent) => { onFilterChange({ ...filters, resourceType: e.target.value || undefined, }); }; const handleServerNameSelect = (value: string) => { onFilterChange({ ...filters, resourceType: value || undefined, }); }; const handleStatusPresetChange = (e: React.ChangeEvent) => { const value = e.target.value; let statusMin: number | undefined; let statusMax: number | undefined; switch (value) { case '2xx': statusMin = 200; statusMax = 299; break; case '4xx': statusMin = 400; statusMax = 499; break; case '5xx': statusMin = 500; statusMax = 599; break; case 'error': statusMin = 400; statusMax = 599; break; default: statusMin = undefined; statusMax = undefined; } onFilterChange({ ...filters, statusMin, statusMax, }); }; const getStatusPresetValue = (): string => { const { statusMin, statusMax } = filters; if (statusMin === 200 && statusMax === 299) return '2xx'; if (statusMin === 400 && statusMax === 499) return '4xx'; if (statusMin === 500 && statusMax === 599) return '5xx'; if (statusMin === 400 && statusMax === 599) return 'error'; return ''; }; const handleClearFilters = () => { onFilterChange({ stream: filters.stream, from: undefined, to: undefined, username: undefined, operation: undefined, resourceType: undefined, statusMin: undefined, statusMax: undefined, }); }; const hasActiveFilters = !!( filters.from || filters.to || filters.username || filters.operation || filters.resourceType || filters.statusMin || filters.statusMax ); return (

Filters

{hasActiveFilters && ( )} {onRefresh && ( )}
{/* Stream Selector */}
{/* Date Range - From */}
{/* Date Range - To */}
{/* Username Filter */}
{/* Operation / MCP Method Filter */}
{/* Resource Type / Server Name Filter */}
{isMcpStream ? ( ) : ( )}
{/* Status Code Range Filter */}
); }; export default AuditFilterBar; ================================================ FILE: frontend/src/components/AuditLogTable.tsx ================================================ import React, { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; import { ChevronLeftIcon, ChevronRightIcon, ChevronDoubleLeftIcon, ChevronDoubleRightIcon, ChevronDownIcon, ChevronUpIcon, ExclamationTriangleIcon, } from '@heroicons/react/24/outline'; import { AuditFilters } from './AuditFilterBar'; export interface AuditEvent { _id?: string; timestamp: string; request_id: string; log_type: string; version?: string; correlation_id?: string; identity: { username: string; auth_method: string; provider?: string; groups?: string[]; scopes?: string[]; is_admin: boolean; credential_type: string; credential_hint?: string; }; request?: { method: string; path: string; query_params?: Record; client_ip: string; forwarded_for?: string; user_agent?: string; content_length?: number; }; response?: { status_code: number; duration_ms: number; content_length?: number; }; action?: { operation: string; resource_type: string; resource_id?: string; description?: string; }; authorization?: { decision: string; required_permission?: string; evaluated_scopes?: string[]; }; // MCP-specific fields mcp_server?: { name: string; path: string; version?: string; proxy_target: string; }; mcp_request?: { method: string; tool_name?: string; resource_uri?: string; mcp_session_id?: string; transport: string; jsonrpc_id?: string; }; mcp_response?: { status: string; duration_ms: number; error_code?: number; error_message?: string; }; } interface AuditLogTableProps { filters: AuditFilters; onEventSelect?: (event: AuditEvent) => void; selectedEventId?: string; } interface PaginationState { total: number; limit: number; offset: number; } const getStatusColor = (statusCode: number): string => { if (statusCode >= 200 && statusCode < 300) { return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'; } if (statusCode >= 300 && statusCode < 400) { return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'; } if (statusCode >= 400 && statusCode < 500) { return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'; } if (statusCode >= 500) { return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'; } return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; }; const getMethodColor = (method: string): string => { switch (method.toUpperCase()) { case 'GET': return 'text-blue-600 dark:text-blue-400'; case 'POST': return 'text-green-600 dark:text-green-400'; case 'PUT': case 'PATCH': return 'text-yellow-600 dark:text-yellow-400'; case 'DELETE': return 'text-red-600 dark:text-red-400'; default: return 'text-gray-600 dark:text-gray-400'; } }; const getMcpStatusColor = (status: string): string => { switch (status.toLowerCase()) { case 'success': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'; case 'error': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'; case 'timeout': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'; default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; } }; const formatTimestamp = (timestamp: string): string => { try { const date = new Date(timestamp); return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } catch { return timestamp; } }; const AuditLogTable: React.FC = ({ filters, onEventSelect, selectedEventId, }) => { const [events, setEvents] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [pagination, setPagination] = useState({ total: 0, limit: 50, offset: 0, }); // Sort order: -1 = descending (newest first), 1 = ascending (oldest first) const [sortOrder, setSortOrder] = useState<-1 | 1>(-1); const fetchEvents = useCallback(async (offset: number = 0, currentSortOrder: -1 | 1 = sortOrder) => { setLoading(true); setError(null); try { const params = new URLSearchParams(); params.set('stream', filters.stream); params.set('limit', pagination.limit.toString()); params.set('offset', offset.toString()); params.set('sort_order', currentSortOrder.toString()); if (filters.from) { params.set('from', new Date(filters.from).toISOString()); } if (filters.to) { params.set('to', new Date(filters.to).toISOString()); } if (filters.username) { params.set('username', filters.username); } if (filters.operation) { params.set('operation', filters.operation); } if (filters.resourceType) { params.set('resource_type', filters.resourceType); } if (filters.statusMin !== undefined) { params.set('status_min', filters.statusMin.toString()); } if (filters.statusMax !== undefined) { params.set('status_max', filters.statusMax.toString()); } const response = await axios.get(`/api/audit/events?${params.toString()}`); const data = response.data; setEvents(data.events || []); setPagination({ total: data.total || 0, limit: data.limit || 50, offset: data.offset || 0, }); } catch (err: any) { console.error('Failed to fetch audit events:', err); if (err.response?.status === 403) { setError('Access denied. Admin permissions required.'); } else { setError(err.response?.data?.detail || 'Failed to load audit events'); } setEvents([]); } finally { setLoading(false); } }, [filters, pagination.limit, sortOrder]); useEffect(() => { fetchEvents(0, sortOrder); }, [filters, sortOrder]); // eslint-disable-line react-hooks/exhaustive-deps const handlePageChange = (newOffset: number) => { fetchEvents(newOffset, sortOrder); }; const handleSortToggle = () => { const newSortOrder = sortOrder === -1 ? 1 : -1; setSortOrder(newSortOrder); }; const totalPages = Math.ceil(pagination.total / pagination.limit); const currentPage = Math.floor(pagination.offset / pagination.limit) + 1; const handleFirstPage = () => handlePageChange(0); const handlePrevPage = () => handlePageChange(Math.max(0, pagination.offset - pagination.limit)); const handleNextPage = () => handlePageChange(pagination.offset + pagination.limit); const handleLastPage = () => handlePageChange((totalPages - 1) * pagination.limit); const isMcpStream = filters.stream === 'mcp_access'; if (error) { return (
{error}
); } return (
{/* Table */}
{loading ? ( ) : events.length === 0 ? ( ) : ( events.map((event) => ( onEventSelect?.(event)} className={`cursor-pointer transition-colors ${ selectedEventId === event.request_id ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-700/50' }`} > )) )}
User {isMcpStream ? 'MCP Method' : 'Method'} {isMcpStream ? 'Tool/Resource' : 'Operation'} {isMcpStream ? 'MCP Server' : 'Resource'} Status Duration
Loading events...
No audit events found matching the current filters.
{formatTimestamp(event.timestamp)}
{event.identity.username} {event.identity.is_admin && ( Admin )}
{isMcpStream ? ( {event.mcp_request?.method || '-'} ) : ( {event.request?.method || '-'} )} {isMcpStream ? ( event.mcp_request?.tool_name || event.mcp_request?.resource_uri || '-' ) : ( event.action?.operation || '-' )} {isMcpStream ? ( event.mcp_server?.name || '-' ) : event.action ? ( {event.action.resource_type} {event.action.resource_id && ( /{event.action.resource_id} )} ) : ( - )} {isMcpStream ? ( {event.mcp_response?.status || '-'} ) : ( {event.response?.status_code || '-'} )} {isMcpStream ? `${(event.mcp_response?.duration_ms || 0).toFixed(1)} ms` : `${(event.response?.duration_ms || 0).toFixed(1)} ms` }
{/* Pagination */} {!loading && events.length > 0 && (
Showing{' '} {pagination.offset + 1} {' '}-{' '} {Math.min(pagination.offset + pagination.limit, pagination.total)} {' '}of{' '} {pagination.total} {' '}events
Page {currentPage} of {totalPages}
)}
); }; export default AuditLogTable; ================================================ FILE: frontend/src/components/AuditStatistics.tsx ================================================ import React, { useState, useEffect, useCallback, useRef } from 'react'; import axios from 'axios'; import { ChartBarIcon, ChevronDownIcon, ChevronRightIcon, ArrowPathIcon, } from '@heroicons/react/24/outline'; interface UsageSummaryItem { name: string; count: number; } interface TimeSeriesBucket { period: string; count: number; } interface StatusDistribution { status_2xx: number; status_4xx: number; status_5xx: number; } interface UserActivityItem { username: string; total: number; operations: UsageSummaryItem[]; } interface AuditStatisticsData { total_events: number; top_users: UsageSummaryItem[]; top_servers: UsageSummaryItem[]; top_operations: UsageSummaryItem[]; activity_timeline: TimeSeriesBucket[]; status_distribution: StatusDistribution; user_activity: UserActivityItem[]; } interface AuditStatisticsProps { stream: 'registry_api' | 'mcp_access'; days?: number; username?: string; } const STORAGE_KEY = 'audit-statistics-collapsed'; const BarChart: React.FC<{ items: UsageSummaryItem[]; color: string; emptyMessage?: string; }> = ({ items, color, emptyMessage = 'No data available' }) => { if (!items.length) { return

{emptyMessage}

; } const maxCount = Math.max(...items.map((i) => i.count)); return (
{items.map((item) => (
{item.name}
{item.count.toLocaleString()}
))}
); }; const StatusBar: React.FC<{ distribution: StatusDistribution }> = ({ distribution }) => { const total = distribution.status_2xx + distribution.status_4xx + distribution.status_5xx; if (total === 0) { return

No data available

; } const segments = [ { label: '2xx', count: distribution.status_2xx, color: 'bg-green-500', textColor: 'text-green-600 dark:text-green-400' }, { label: '4xx', count: distribution.status_4xx, color: 'bg-yellow-500', textColor: 'text-yellow-600 dark:text-yellow-400' }, { label: '5xx', count: distribution.status_5xx, color: 'bg-red-500', textColor: 'text-red-600 dark:text-red-400' }, ]; return (
{/* Stacked bar */}
{segments.map((seg) => seg.count > 0 ? (
) : null )}
{/* Legend */}
{segments.map((seg) => (
{seg.label}: {seg.count.toLocaleString()} ({total > 0 ? ((seg.count / total) * 100).toFixed(1) : 0}%)
))}
); }; const WEEKDAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; /** * Fill in missing days so every day in the range has an entry. * The API only returns days with events, so days with 0 events are missing. */ function _fillTimelineDays(timeline: TimeSeriesBucket[], days: number): TimeSeriesBucket[] { const countByDate = new Map(timeline.map((t) => [t.period, t.count])); const filled: TimeSeriesBucket[] = []; const now = new Date(); for (let i = days - 1; i >= 0; i--) { const d = new Date(now); d.setDate(d.getDate() - i); const key = d.toISOString().slice(0, 10); filled.push({ period: key, count: countByDate.get(key) || 0 }); } return filled; } function _formatDateLabel(period: string): string { const d = new Date(period + 'T00:00:00'); const weekday = WEEKDAY_NAMES[d.getDay()]; const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${weekday} ${month}/${day}`; } const VB_W = 600; const VB_H = 180; const PAD = { top: 20, right: 50, bottom: 32, left: 45 }; const TimelineChart: React.FC<{ timeline: TimeSeriesBucket[]; days: number }> = ({ timeline, days }) => { const [hoverIndex, setHoverIndex] = useState(null); const filled = _fillTimelineDays(timeline, days); const maxCount = Math.max(...filled.map((t) => t.count), 1); if (!filled.length) { return

No data available

; } const plotW = VB_W - PAD.left - PAD.right; const plotH = VB_H - PAD.top - PAD.bottom; const points = filled.map((b, i) => { const x = PAD.left + (filled.length > 1 ? (i / (filled.length - 1)) * plotW : plotW / 2); const y = PAD.top + plotH - (b.count / maxCount) * plotH; return { x, y, ...b }; }); const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' '); const areaPath = `${linePath} L${points[points.length - 1].x},${PAD.top + plotH} L${points[0].x},${PAD.top + plotH} Z`; const gridValues = [0, Math.round(maxCount / 2), maxCount]; const gridLines = gridValues.map((v) => ({ y: PAD.top + plotH - (v / maxCount) * plotH, label: v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v), })); return (
{/* Horizontal grid lines + Y-axis labels */} {gridLines.map((g, i) => ( {g.label} ))} {/* Area fill */} {/* Line */} {/* Data points */} {points.map((p, i) => ( 0 ? 3.5 : 2)} className={ p.count > 0 ? 'fill-blue-500 dark:fill-blue-400' : 'fill-gray-300 dark:fill-gray-600' } stroke="white" strokeWidth="1.5" /> ))} {/* Hover tooltip */} {hoverIndex !== null && points[hoverIndex] && (() => { const hp = points[hoverIndex]; const label = `${hp.count.toLocaleString()} events`; const boxW = label.length * 7 + 16; const boxH = 22; const boxX = Math.max(4, Math.min(hp.x - boxW / 2, VB_W - boxW - 4)); const boxY = Math.max(2, hp.y - boxH - 10); return ( {label} ); })()} {/* Invisible hit areas for hover */} {points.map((p, i) => ( setHoverIndex(i)} onMouseLeave={() => setHoverIndex(null)} /> ))} {/* X-axis labels */} {points.map((p) => ( {_formatDateLabel(p.period)} ))}
); }; const UserActivityTable: React.FC<{ items: UserActivityItem[] }> = ({ items }) => { if (!items.length) { return

No user activity data

; } return (
{items.map((item) => ( ))}
User Total Top Operations
{item.username} {item.total.toLocaleString()}
{item.operations.slice(0, 3).map((op) => ( {op.name} ({op.count}) ))}
); }; const AuditStatistics: React.FC = ({ stream, days = 7, username }) => { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [collapsed, setCollapsed] = useState(() => { try { const stored = localStorage.getItem(STORAGE_KEY); return stored === null ? true : stored === 'true'; } catch { return true; } }); const debounceRef = useRef | null>(null); const fetchStatistics = useCallback(async (currentStream: string, currentDays: number, currentUsername?: string) => { setLoading(true); setError(null); try { const params: Record = { stream: currentStream, days: currentDays }; if (currentUsername) { params.username = currentUsername; } const res = await axios.get('/api/audit/statistics', { params }); setData(res.data); } catch (err) { console.error('Failed to fetch audit statistics:', err); setError('Failed to load statistics'); } finally { setLoading(false); } }, []); // Debounced fetch when stream, days, or username change useEffect(() => { if (collapsed) return; if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(() => { fetchStatistics(stream, days, username); }, 300); return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } }; }, [stream, days, username, collapsed, fetchStatistics]); const toggleCollapsed = () => { const next = !collapsed; setCollapsed(next); try { localStorage.setItem(STORAGE_KEY, String(next)); } catch { // Ignore localStorage errors } }; const handleRefresh = () => { fetchStatistics(stream, days, username); }; const isMcpStream = stream === 'mcp_access'; return (
{/* Header */}
{collapsed ? ( ) : ( )}

Statistics

{data && !collapsed && ( {data.total_events.toLocaleString()} events (last {days} days){username ? ` - filtered by "${username}"` : ''} )}
{!collapsed && ( )}
{/* Content */} {!collapsed && (
{loading && !data ? (
Loading statistics...
) : error ? (

{error}

) : data ? (
{/* Top Users */}

Top Users

u.name !== 'anonymous')} color="bg-blue-500" emptyMessage="No user data" /> {(() => { const anon = data.top_users.find((u) => u.name === 'anonymous'); return anon ? (

+ {anon.count.toLocaleString()} anonymous events (unauthenticated API calls, health checks, login attempts)

) : null; })()}
{/* Top Operations */}

{isMcpStream ? 'Top MCP Methods' : 'Top Operations'}

{/* Top MCP Servers (MCP stream only) */} {isMcpStream && (

Top MCP Servers

)} {/* Status Distribution */}

Status Distribution

{/* User Activity + Activity Timeline - split panel */}
{/* Left: User Activity Table */}

User Activity Breakdown

{/* Right: Activity Timeline */}

Activity Timeline (Last {days} Days)

) : null}
)}
); }; export default AuditStatistics; ================================================ FILE: frontend/src/components/ConfigPanel.tsx ================================================ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import axios from 'axios'; import { MagnifyingGlassIcon, ArrowPathIcon, ClipboardIcon, ChevronDownIcon, ChevronRightIcon, ArrowDownTrayIcon, XMarkIcon, CheckIcon, ExclamationCircleIcon, } from '@heroicons/react/24/outline'; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ interface ConfigField { key: string; label: string; value: string; raw_value: string | null; is_masked: boolean; unit: string | null; } interface ConfigSubgroup { id: string; title: string; fields: ConfigField[]; } interface ConfigGroup { id: string; title: string; order: number; fields: ConfigField[]; subgroups?: ConfigSubgroup[]; } interface ConfigResponse { groups: ConfigGroup[]; total_groups: number; is_local_dev: boolean; } type ExportFormat = 'env' | 'json' | 'tfvars' | 'yaml'; interface ConfigPanelProps { onError?: (error: string) => void; showToast?: (message: string, type: 'success' | 'error') => void; } /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ const EXPORT_OPTIONS: { format: ExportFormat; label: string }[] = [ { format: 'env', label: '.env' }, { format: 'json', label: 'JSON' }, { format: 'tfvars', label: 'Terraform (.tfvars)' }, { format: 'yaml', label: 'YAML' }, ]; const DEFAULT_EXPANDED: Set = new Set(['deployment', 'storage']); /** * Highlight occurrences of `term` inside `text` using tags. */ function highlightMatch(text: string, term: string): React.ReactNode { if (!term) return text; const idx = text.toLowerCase().indexOf(term.toLowerCase()); if (idx === -1) return text; return ( <> {text.slice(0, idx)} {text.slice(idx, idx + term.length)} {text.slice(idx + term.length)} ); } /* ------------------------------------------------------------------ */ /* ConfigGroupPanel sub-component */ /* ------------------------------------------------------------------ */ interface ConfigGroupPanelProps { group: ConfigGroup; expanded: boolean; onToggle: () => void; searchTerm: string; copiedKey: string | null; onCopy: (key: string, value: string) => void; } /** * Render a single field row (reused in top-level fields and subgroups). */ const FieldRow: React.FC<{ field: ConfigField; searchTerm: string; copiedKey: string | null; onCopy: (key: string, value: string) => void; }> = ({ field, searchTerm, copiedKey, onCopy }) => (
{highlightMatch(field.key, searchTerm)}
{highlightMatch(field.label, searchTerm)}
{highlightMatch(field.value, searchTerm)} {field.unit && !field.is_masked && ( {field.unit} )} {!field.is_masked && field.raw_value !== null && ( )}
); const ConfigGroupPanel: React.FC = ({ group, expanded, onToggle, searchTerm, copiedKey, onCopy, }) => { const panelId = `config-group-${group.id}`; const totalFields = group.fields.length + (group.subgroups?.reduce((s, sg) => s + sg.fields.length, 0) || 0); return (
{/* Group header */} {/* Group fields and subgroups */} {expanded && (
{/* Top-level fields */} {group.fields.length > 0 && (
{group.fields.map((field) => ( ))}
)} {/* Subgroups */} {group.subgroups?.map((sg) => (
{highlightMatch(sg.title, searchTerm)} {sg.fields.length} {sg.fields.length === 1 ? 'field' : 'fields'}
{sg.fields.map((field) => ( ))}
))}
)}
); }; /* ------------------------------------------------------------------ */ /* ConfigPanel main component */ /* ------------------------------------------------------------------ */ const ConfigPanel: React.FC = ({ onError, showToast }) => { const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [expandedGroups, setExpandedGroups] = useState>(new Set(DEFAULT_EXPANDED)); const [searchTerm, setSearchTerm] = useState(''); const [copiedKey, setCopiedKey] = useState(null); const [exportOpen, setExportOpen] = useState(false); /* ---- Data fetching ---- */ const fetchConfig = useCallback(async () => { setLoading(true); setError(null); try { const res = await axios.get('/api/config/full'); setConfig(res.data); } catch (err: any) { const msg = err.response?.data?.detail || 'Failed to load configuration'; setError(msg); onError?.(msg); } finally { setLoading(false); } }, [onError]); useEffect(() => { fetchConfig(); }, [fetchConfig]); /* ---- Filtering ---- */ const filteredGroups = useMemo(() => { if (!config) return []; if (!searchTerm.trim()) return config.groups; const term = searchTerm.toLowerCase(); const matchField = (f: ConfigField) => f.key.toLowerCase().includes(term) || f.label.toLowerCase().includes(term) || f.value.toLowerCase().includes(term); return config.groups .map((group) => ({ ...group, fields: group.fields.filter(matchField), subgroups: group.subgroups?.map((sg) => ({ ...sg, fields: sg.fields.filter(matchField), })).filter((sg) => sg.fields.length > 0), })) .filter((group) => group.fields.length > 0 || (group.subgroups && group.subgroups.length > 0)); }, [config, searchTerm]); const totalMatchingFields = useMemo( () => filteredGroups.reduce((sum, g) => { const sgFields = g.subgroups?.reduce((s, sg) => s + sg.fields.length, 0) || 0; return sum + g.fields.length + sgFields; }, 0), [filteredGroups] ); /* ---- Group expand/collapse ---- */ const toggleGroup = useCallback((groupId: string) => { setExpandedGroups((prev) => { const next = new Set(prev); if (next.has(groupId)) next.delete(groupId); else next.add(groupId); return next; }); }, []); const expandAll = useCallback(() => { if (!config) return; setExpandedGroups(new Set(config.groups.map((g) => g.id))); }, [config]); const collapseAll = useCallback(() => { setExpandedGroups(new Set()); }, []); /* ---- Clipboard ---- */ const copyToClipboard = useCallback( async (key: string, value: string) => { try { await navigator.clipboard.writeText(value); setCopiedKey(key); showToast?.('Copied to clipboard', 'success'); setTimeout(() => setCopiedKey(null), 2000); } catch { showToast?.('Failed to copy', 'error'); } }, [showToast] ); /* ---- Export ---- */ const handleExport = useCallback( async (format: ExportFormat) => { setExportOpen(false); try { const res = await axios.get(`/api/config/export`, { params: { format }, responseType: 'blob', }); const disposition = res.headers['content-disposition']; let filename = `mcp-registry-config.${format}`; if (disposition) { const match = disposition.match(/filename="?([^"]+)"?/); if (match) filename = match[1]; } const url = window.URL.createObjectURL(new Blob([res.data])); const link = document.createElement('a'); link.href = url; link.setAttribute('download', filename); document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); } catch (err: any) { const msg = err.response?.data?.detail || 'Export failed'; showToast?.(msg, 'error'); } }, [showToast] ); /* ---- Skeleton loading ---- */ if (loading) { return (
{[1, 2, 3, 4].map((i) => (
))}
); } /* ---- Error state ---- */ if (error) { return (

Failed to Load Configuration

{error}

); } if (!config) return null; /* ---- Main render ---- */ return (
{/* Header row */}

System Configuration

{config.is_local_dev && ( Local Development Mode )}
{/* Export dropdown */}
{exportOpen && (
{EXPORT_OPTIONS.map((opt) => ( ))}
)}
{/* Expand / Collapse */} {/* Refresh */}
{/* Search */}
setSearchTerm(e.target.value)} placeholder="Search configuration..." aria-label="Search configuration" className="w-full pl-10 pr-10 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent" /> {searchTerm && ( )}
{/* Search results count */} {searchTerm.trim() && filteredGroups.length > 0 && (

{totalMatchingFields} {totalMatchingFields === 1 ? 'field' : 'fields'} in{' '} {filteredGroups.length} {filteredGroups.length === 1 ? 'group' : 'groups'}

)} {/* No results */} {searchTerm.trim() && filteredGroups.length === 0 && (

No configuration fields match "{searchTerm}"

)} {/* Config groups */}
{filteredGroups.map((group) => ( toggleGroup(group.id)} searchTerm={searchTerm} copiedKey={copiedKey} onCopy={copyToClipboard} /> ))}
{/* Legend */}
**** = masked sensitive value (not set) = not configured
); }; export default ConfigPanel; ================================================ FILE: frontend/src/components/ConfirmModal.tsx ================================================ import React from 'react'; import DetailsModal from './DetailsModal'; import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; /** * Props for the ConfirmModal component. */ interface ConfirmModalProps { isOpen: boolean; onClose: () => void; onConfirm: () => void; title: string; message: string; confirmLabel?: string; cancelLabel?: string; isDestructive?: boolean; isLoading?: boolean; } /** * A styled confirmation modal that replaces window.confirm(). * * Renders a centered dialog with a warning icon, message, and * Cancel / Confirm action buttons. Supports destructive (red) * and normal (purple) confirm button styles. */ const ConfirmModal: React.FC = ({ isOpen, onClose, onConfirm, title, message, confirmLabel = 'Confirm', cancelLabel = 'Cancel', isDestructive = false, isLoading = false, }) => { return (

{message}

); }; export default ConfirmModal; ================================================ FILE: frontend/src/components/DataExport.tsx ================================================ import React, { useState, useEffect, useCallback } from 'react'; import { ArrowDownTrayIcon, CheckCircleIcon, ExclamationTriangleIcon, ArrowPathIcon, } from '@heroicons/react/24/outline'; import axios from 'axios'; import JSZip from 'jszip'; interface ExportableCollection { id: string; label: string; description: string; endpoint: string; queryParams: Record; dataKey: string | null; countKey: string | null; filename: string; isPaginated: boolean; paginationLimit: number; paginationOffsetKey?: string; } const EXPORTABLE_COLLECTIONS: ExportableCollection[] = [ { id: 'servers', label: 'Servers', description: 'All registered MCP servers', endpoint: '/api/servers', queryParams: {}, dataKey: 'servers', countKey: 'total_count', filename: 'servers', isPaginated: true, paginationLimit: 500, }, { id: 'agents', label: 'Agents', description: 'All registered AI agents', endpoint: '/api/agents', queryParams: {}, dataKey: 'agents', countKey: 'total_count', filename: 'agents', isPaginated: true, paginationLimit: 500, }, { id: 'skills', label: 'Skills', description: 'All registered skills (including disabled)', endpoint: '/api/skills', queryParams: { include_disabled: 'true' }, dataKey: 'skills', countKey: 'total_count', filename: 'skills', isPaginated: true, paginationLimit: 500, }, { id: 'virtual-servers', label: 'Virtual Servers', description: 'All virtual server configurations', endpoint: '/api/virtual-servers', queryParams: {}, dataKey: null, countKey: null, filename: 'virtual-servers', isPaginated: false, paginationLimit: 500, }, { id: 'federation-peers', label: 'Federation Peers', description: 'All configured federation peers', endpoint: '/api/peers', queryParams: {}, dataKey: null, countKey: null, filename: 'federation-peers', isPaginated: false, paginationLimit: 500, }, { id: 'federation-configs', label: 'Federation Configs', description: 'Federation configuration settings', endpoint: '/api/federation/configs', queryParams: {}, dataKey: 'configs', countKey: null, filename: 'federation-configs', isPaginated: false, paginationLimit: 500, }, { id: 'registry-card', label: 'Registry Card', description: 'Registry metadata and card information', endpoint: '/api/registry/v0.1/card', queryParams: {}, dataKey: null, countKey: null, filename: 'registry-card', isPaginated: false, paginationLimit: 1, }, { id: 'iam-users', label: 'IAM Users', description: 'All users and service accounts', endpoint: '/api/management/iam/users', queryParams: {}, dataKey: 'users', countKey: null, filename: 'iam-users', isPaginated: false, paginationLimit: 500, }, { id: 'iam-groups', label: 'IAM Groups', description: 'All IAM groups and scopes', endpoint: '/api/management/iam/groups', queryParams: {}, dataKey: 'groups', countKey: null, filename: 'iam-groups', isPaginated: false, paginationLimit: 500, }, { id: 'iam-m2m-clients', label: 'IAM M2M Clients', description: 'All machine-to-machine service accounts', endpoint: '/api/iam/m2m-clients', queryParams: {}, dataKey: 'items', countKey: 'total', filename: 'iam-m2m-clients', isPaginated: true, paginationLimit: 500, paginationOffsetKey: 'skip', }, { id: 'scopes', label: 'Scopes', description: 'Authorization scopes, server access rules, and group permissions', endpoint: '/api/export/scopes', queryParams: {}, dataKey: 'scopes', countKey: 'total_count', filename: 'scopes', isPaginated: false, paginationLimit: 500, }, ]; function _buildDateSuffix(): string { return new Date().toISOString().slice(0, 10); } async function _fetchAllPages( collection: ExportableCollection, ): Promise { const { endpoint, queryParams, dataKey, isPaginated, paginationLimit } = collection; const offsetKey = collection.paginationOffsetKey || 'offset'; if (!isPaginated) { const response = await axios.get(endpoint, { params: queryParams }); const json = response.data; if (dataKey) { return json[dataKey] || []; } return Array.isArray(json) ? json : [json]; } const allRecords: any[] = []; let offset = 0; while (true) { const params = { ...queryParams, limit: String(paginationLimit), [offsetKey]: String(offset), }; const response = await axios.get(endpoint, { params }); const json = response.data; const page = dataKey ? (json[dataKey] || []) : json; allRecords.push(...page); if (page.length < paginationLimit) { break; } offset += paginationLimit; } return allRecords; } function _triggerBlobDownload( blob: Blob, filename: string, ): void { const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.setAttribute('download', filename); document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); } async function _recordAuditEvent( exportType: string, collections: string[], ): Promise { try { await axios.post('/api/export/audit-event', { export_type: exportType, collections, }); } catch { // Audit event recording is best-effort; do not block the export } } async function _fetchCount( collection: ExportableCollection, ): Promise { const { endpoint, queryParams, dataKey, countKey, isPaginated, paginationLimit } = collection; const offsetKey = collection.paginationOffsetKey || 'offset'; try { // Fast path: API returns a count field (servers, agents, skills, m2m-clients) if (countKey && isPaginated) { const response = await axios.get(endpoint, { params: { ...queryParams, limit: '1', [offsetKey]: '0' }, }); return response.data[countKey] ?? 0; } // Fallback: fetch data and count the array length const params: Record = { ...queryParams }; if (isPaginated) { params.limit = String(paginationLimit); params[offsetKey] = '0'; } const response = await axios.get(endpoint, { params }); const json = response.data; if (dataKey) { return Array.isArray(json[dataKey]) ? json[dataKey].length : 0; } return Array.isArray(json) ? json.length : 1; } catch { return 0; } } interface DataExportProps { onShowToast: (message: string, type: 'success' | 'error' | 'info') => void; } const DataExport: React.FC = ({ onShowToast }) => { const [counts, setCounts] = useState>({}); const [downloading, setDownloading] = useState>({}); const [downloadingAll, setDownloadingAll] = useState(false); const [completedInZip, setCompletedInZip] = useState>(new Set()); const [loadingCounts, setLoadingCounts] = useState(true); const fetchCounts = useCallback(async () => { setLoadingCounts(true); const results = await Promise.allSettled( EXPORTABLE_COLLECTIONS.map(async (col) => { const count = await _fetchCount(col); return { id: col.id, count }; }) ); const newCounts: Record = {}; // Promise.allSettled preserves input order, so index maps to collection for (let i = 0; i < results.length; i++) { const result = results[i]; const collectionId = EXPORTABLE_COLLECTIONS[i].id; if (result.status === 'fulfilled') { newCounts[collectionId] = result.value.count; } else { newCounts[collectionId] = null; } } setCounts(newCounts); setLoadingCounts(false); }, []); useEffect(() => { fetchCounts(); }, [fetchCounts]); const handleDownload = useCallback(async (collection: ExportableCollection) => { setDownloading((prev) => ({ ...prev, [collection.id]: true })); try { const data = await _fetchAllPages(collection); const dateSuffix = _buildDateSuffix(); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); _triggerBlobDownload(blob, `${collection.filename}-export-${dateSuffix}.json`); await _recordAuditEvent('single', [collection.id]); onShowToast(`Downloaded ${collection.label} (${data.length} records)`, 'success'); } catch (err: any) { onShowToast(`Failed to download ${collection.label}: ${err.message}`, 'error'); } finally { setDownloading((prev) => ({ ...prev, [collection.id]: false })); } }, [onShowToast]); const handleDownloadAll = useCallback(async () => { setDownloadingAll(true); setCompletedInZip(new Set()); const zip = new JSZip(); const dateSuffix = _buildDateSuffix(); const failedIds: string[] = []; for (const collection of EXPORTABLE_COLLECTIONS) { try { const data = await _fetchAllPages(collection); const jsonStr = JSON.stringify(data, null, 2); zip.file(`${collection.filename}-export-${dateSuffix}.json`, jsonStr); setCompletedInZip((prev) => new Set(prev).add(collection.id)); } catch (err: any) { failedIds.push(collection.id); } } try { const blob = await zip.generateAsync({ type: 'blob' }); _triggerBlobDownload(blob, `registry-export-${dateSuffix}.zip`); const exportedIds = EXPORTABLE_COLLECTIONS .filter((c) => !failedIds.includes(c.id)) .map((c) => c.id); await _recordAuditEvent('all', exportedIds); if (failedIds.length > 0) { const failedLabels = EXPORTABLE_COLLECTIONS .filter((c) => failedIds.includes(c.id)) .map((c) => c.label); onShowToast( `ZIP downloaded with errors. Failed: ${failedLabels.join(', ')}`, 'error', ); } else { onShowToast('All collections downloaded as ZIP', 'success'); } } catch (err: any) { onShowToast(`Failed to create ZIP: ${err.message}`, 'error'); } finally { setDownloadingAll(false); } }, [onShowToast]); const isAnyDownloading = downloadingAll || Object.values(downloading).some(Boolean); return (
{/* Page header */}

Data Export

Download registry data as JSON for debugging and auditing purposes.

{/* Sensitive data warning banner */}

Exported data may contain sensitive information such as email addresses, client IDs, and configuration details. Handle exported files with care.

{/* Collection table */}
{EXPORTABLE_COLLECTIONS.map((collection) => ( ))}
Collection Description Records Action
{collection.label} {collection.description} {loadingCounts ? ( ) : ( counts[collection.id] ?? '—' )}
{downloadingAll && completedInZip.has(collection.id) && ( )}
{/* Download All button */}
); }; export default DataExport; ================================================ FILE: frontend/src/components/DeleteConfirmation.tsx ================================================ import React, { useState } from 'react'; import { ArrowPathIcon } from '@heroicons/react/24/outline'; /** * Props for the DeleteConfirmation component. */ export interface DeleteConfirmationProps { entityType: 'server' | 'agent' | 'group' | 'user' | 'm2m'; entityName: string; entityPath: string; onConfirm: (path: string) => Promise; onCancel: () => void; } /** * DeleteConfirmation component provides an inline confirmation UI for delete operations. * * Displays a red-tinted container with warning text, requiring users to type the entity * name exactly before the delete button becomes enabled. Shows loading state during * API calls and displays error messages on failure. * * Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8 */ const DeleteConfirmation: React.FC = ({ entityType, entityName, entityPath, onConfirm, onCancel, }) => { const [typedName, setTypedName] = useState(''); const [isDeleting, setIsDeleting] = useState(false); const [error, setError] = useState(null); const isConfirmed = typedName === entityName; const handleDelete = async () => { if (!isConfirmed || isDeleting) return; setIsDeleting(true); setError(null); try { await onConfirm(entityPath); onCancel(); // Close on success - parent handles list refresh + toast } catch (err: any) { setError( err.response?.data?.detail || err.response?.data?.reason || `Failed to delete ${entityType}` ); } finally { setIsDeleting(false); } }; const entityTypeLabels: Record = { server: 'Server', agent: 'Agent', group: 'Group', user: 'User', m2m: 'M2M Account', }; const entityTypeLabel = entityTypeLabels[entityType] || entityType; return (

Delete {entityTypeLabel}

This action is irreversible. This will permanently delete the {entityType}{' '} "{entityName}" and remove it from the registry.

Type {entityName} to confirm:

setTypedName(e.target.value)} className="w-full px-3 py-2 border border-red-300 dark:border-red-700 rounded mb-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white" placeholder={entityName} disabled={isDeleting} /> {error && (

{error}

)}
); }; export default DeleteConfirmation; ================================================ FILE: frontend/src/components/DeploymentModeIndicator.tsx ================================================ import React from 'react'; import { useRegistryConfig } from '../hooks/useRegistryConfig'; export const DeploymentModeIndicator: React.FC = () => { const { config } = useRegistryConfig(); if (!config || config.deployment_mode === 'with-gateway') { return null; } return ( Registry Only ); }; ================================================ FILE: frontend/src/components/DetailsModal.tsx ================================================ import React from 'react'; import useEscapeKey from '../hooks/useEscapeKey'; interface DetailsModalProps { title: string; isOpen: boolean; onClose: () => void; loading?: boolean; error?: string | null; children: React.ReactNode; maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'; } const MAX_WIDTH_CLASSES = { sm: 'max-w-sm', md: 'max-w-md', lg: 'max-w-lg', xl: 'max-w-xl', '2xl': 'max-w-2xl', '3xl': 'max-w-3xl', '4xl': 'max-w-4xl', }; /** * Shared DetailsModal component with loading and error states. * * Features: * - Backdrop with blur effect * - Escape key handler * - Configurable max width * - Built-in loading spinner * - Built-in error display * - Dark mode support * * Usage: * ```tsx * * * * ``` */ const DetailsModal: React.FC = ({ title, isOpen, onClose, loading = false, error = null, children, maxWidth = '4xl', }) => { useEscapeKey(onClose, isOpen); if (!isOpen) { return null; } return (
{/* Header */}

{title}

{/* Loading State */} {loading && (

Loading details...

)} {/* Error State */} {!loading && error && (

Error Loading Details

{error}

)} {/* Content */} {!loading && !error && children}
); }; export default DetailsModal; ================================================ FILE: frontend/src/components/DiscoverListRow.tsx ================================================ import React, { useState } from 'react'; import { StarIcon, WrenchScrewdriverIcon, ChevronDownIcon, ChevronUpIcon, } from '@heroicons/react/24/solid'; import { ServerIcon, CpuChipIcon, SparklesIcon, Square3Stack3DIcon, GlobeAltIcon, } from '@heroicons/react/24/outline'; import ServerCard from './ServerCard'; import type { Server } from './ServerCard'; import AgentCard from './AgentCard'; import SkillCard from './SkillCard'; import type { Skill } from '../types/skill'; import VirtualServerCard from './VirtualServerCard'; import type { VirtualServerInfo } from '../types/virtualServer'; type ItemType = 'server' | 'agent' | 'skill' | 'virtual'; interface DiscoverListRowProps { type: ItemType; item: Server | Skill | VirtualServerInfo; onToggle: (path: string, enabled: boolean) => void; onEdit?: (item: any) => void; onDelete?: (path: string) => any; onShowToast?: (message: string, type: 'success' | 'error') => void; authToken?: string | null; } /** * Get average rating from rating_details array. */ function _getAverageRating( ratingDetails: Array<{ user: string; rating: number }> | undefined ): number { if (!ratingDetails || ratingDetails.length === 0) { return 0; } const sum = ratingDetails.reduce((acc, r) => acc + r.rating, 0); return sum / ratingDetails.length; } /** * Get type badge styling by item type. */ function _getTypeBadge(type: ItemType) { if (type === 'server') { return { bg: 'bg-indigo-500/15 text-indigo-300', icon: ServerIcon, label: 'Server', }; } if (type === 'virtual') { return { bg: 'bg-teal-500/15 text-teal-300', icon: Square3Stack3DIcon, label: 'Virtual', }; } if (type === 'agent') { return { bg: 'bg-cyan-500/15 text-cyan-300', icon: CpuChipIcon, label: 'Agent', }; } return { bg: 'bg-amber-500/15 text-amber-300', icon: SparklesIcon, label: 'Skill', }; } /** * Get the source registry name for a server or agent, if it comes from * a federated peer or an external registry. */ function _getServerRegistrySource(server: Server): string | null { // Federated peer registry if (server.sync_metadata?.is_federated && server.sync_metadata?.source_peer_id) { return server.sync_metadata.source_peer_id; } // External registry identified by tags const tags = server.tags || []; const externalTags = ['anthropic-registry', 'workday-asor', 'asor', 'federated']; const match = tags.find(t => externalTags.includes(t)); if (match) { return match; } return null; } /** * Extract display fields from any item type in a uniform way. */ function _extractDisplayFields( type: ItemType, item: Server | Skill | VirtualServerInfo ) { if (type === 'virtual') { const vs = item as VirtualServerInfo; return { name: vs.server_name, description: vs.description || '', tags: vs.tags || [], rating: _getAverageRating(vs.rating_details), ratingCount: vs.rating_details?.length || 0, toolCount: vs.tool_count || 0, registrySource: null as string | null, }; } if (type === 'skill') { const skill = item as Skill; const source = skill.registry_name && skill.registry_name !== 'local' ? skill.registry_name : null; return { name: skill.name, description: skill.description || '', tags: skill.tags || [], rating: skill.num_stars || 0, ratingCount: 0, toolCount: 0, registrySource: source, }; } // server or agent const server = item as Server; return { name: server.name, description: (server as any).description || '', tags: (server as any).tags || [], rating: _getAverageRating(server.rating_details), ratingCount: server.rating_details?.length || 0, toolCount: (server as any).num_tools || 0, registrySource: _getServerRegistrySource(server), }; } const DiscoverListRow: React.FC = ({ type, item, onToggle, onEdit, onDelete, onShowToast, authToken, }) => { const [expanded, setExpanded] = useState(false); const badge = _getTypeBadge(type); const TypeIcon = badge.icon; const fields = _extractDisplayFields(type, item); return (
{/* Compact row */}
setExpanded(!expanded)} data-testid={`list-row-${type}-${item.path}`} > {/* Type badge */} {badge.label} {/* Registry source label */} {fields.registrySource && ( {fields.registrySource} )} {/* Name */} {fields.name} {/* Separator */} {fields.description && ( · )} {/* Description */} {fields.description} {/* Tags (up to 2) */} {fields.tags.length > 0 && (
{fields.tags.slice(0, 2).map((tag: string) => ( #{tag} ))} {fields.tags.length > 2 && ( +{fields.tags.length - 2} )}
)} {/* Tool count */} {fields.toolCount > 0 && ( {fields.toolCount} )} {/* Rating */} {fields.rating > 0 && ( {fields.rating.toFixed(1)} {fields.ratingCount > 0 && ( ({fields.ratingCount}) )} )} {/* Expand chevron */} {expanded ? ( ) : ( )}
{/* Expanded detail: full card */} {expanded && (
{type === 'server' && ( )} {type === 'agent' && ( )} {type === 'skill' && ( )} {type === 'virtual' && ( )}
)}
); }; export default DiscoverListRow; ================================================ FILE: frontend/src/components/DiscoverTab.tsx ================================================ import React, { useState, useMemo, useCallback } from 'react'; import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { useSemanticSearch } from '../hooks/useSemanticSearch'; import SemanticSearchResults from './SemanticSearchResults'; import DiscoverListRow from './DiscoverListRow'; import type { Server } from './ServerCard'; import type { Skill } from '../types/skill'; import type { VirtualServerInfo } from '../types/virtualServer'; // Path for the built-in AI Registry Tools server const AI_REGISTRY_TOOLS_PATH = '/airegistry-tools/'; // Maximum featured items per category const MAX_FEATURED = 4; interface DiscoverTabProps { servers: Server[]; agents: Server[]; skills: Skill[]; virtualServers: VirtualServerInfo[]; externalServers: Server[]; externalAgents: Server[]; loading: boolean; onServerToggle: (path: string, enabled: boolean) => void; onServerEdit?: (server: Server) => void; onServerDelete?: (path: string) => Promise; onAgentToggle: (path: string, enabled: boolean) => void; onAgentEdit?: (agent: Server) => void; onAgentDelete?: (path: string) => Promise; onSkillToggle: (path: string, enabled: boolean) => void; onSkillEdit?: (skill: Skill) => void; onSkillDelete?: (path: string) => void; onVirtualServerToggle: (path: string, enabled: boolean) => void; onVirtualServerEdit?: (vs: VirtualServerInfo) => void; onVirtualServerDelete?: (path: string) => void; onShowToast?: (message: string, type: 'success' | 'error') => void; authToken?: string | null; } /** * Compute average rating from rating_details array. */ function _getAverageRating( ratingDetails: Array<{ user: string; rating: number }> | undefined ): number { if (!ratingDetails || ratingDetails.length === 0) { return 0; } const sum = ratingDetails.reduce((acc, r) => acc + r.rating, 0); return sum / ratingDetails.length; } /** * Sort servers by average rating (descending), then alphabetically by name. */ function _sortServersByRating(servers: Server[]): Server[] { return [...servers].sort((a, b) => { const ratingDiff = _getAverageRating(b.rating_details) - _getAverageRating(a.rating_details); if (ratingDiff !== 0) return ratingDiff; return a.name.localeCompare(b.name); }); } /** * Sort skills by num_stars (descending), then alphabetically by name. */ function _sortSkillsByStars(skills: Skill[]): Skill[] { return [...skills].sort((a, b) => { const ratingDiff = (b.num_stars || 0) - (a.num_stars || 0); if (ratingDiff !== 0) return ratingDiff; return a.name.localeCompare(b.name); }); } /** * Sort virtual servers by rating then name. */ function _sortVirtualServersByRating(vs: VirtualServerInfo[]): VirtualServerInfo[] { return [...vs].sort((a, b) => { const ratingDiff = _getAverageRating(b.rating_details) - _getAverageRating(a.rating_details); if (ratingDiff !== 0) return ratingDiff; return a.server_name.localeCompare(b.server_name); }); } /** * Check if an item matches a keyword search query. * Searches name, description, path, and tags. */ function _matchesKeyword( item: { name: string; description?: string; path: string; tags?: string[] }, query: string ): boolean { const q = query.toLowerCase(); return ( item.name.toLowerCase().includes(q) || (item.description || '').toLowerCase().includes(q) || item.path.toLowerCase().includes(q) || (item.tags || []).some(tag => tag.toLowerCase().includes(q)) ); } /** * Build a count fragment like "4 servers". */ function _countFragment( count: number, label: string ): string { const plural = count !== 1 ? 's' : ''; return `${count} ${label}${plural}`; } /** * Build the summary text showing counts per category. * Default: "18 servers, 2 virtual, 8 agents, 4 skills, 3 external" * Searching: "3 servers" (only matched counts, no totals) */ function _buildSummaryText( totals: { servers: number; virtual: number; agents: number; skills: number; external: number }, matched: { servers: number; virtual: number; agents: number; skills: number; external: number }, isSearching: boolean ): string { const parts: string[] = []; // When searching, only show categories that have matches // When not searching, show all categories that have items const categories = [ { total: totals.servers, match: matched.servers, label: 'server' }, { total: totals.virtual, match: matched.virtual, label: 'virtual' }, { total: totals.agents, match: matched.agents, label: 'agent' }, { total: totals.skills, match: matched.skills, label: 'skill' }, { total: totals.external, match: matched.external, label: 'external' }, ]; for (const cat of categories) { if (isSearching && cat.match > 0) { parts.push(_countFragment(cat.match, cat.label)); } else if (!isSearching && cat.total > 0) { parts.push(_countFragment(cat.total, cat.label)); } } if (parts.length === 0) { return isSearching ? 'No matches' : 'No items registered'; } const prefix = isSearching ? 'Showing ' : ''; return prefix + parts.join(', '); } /** * Check if a virtual server matches a keyword search query. */ function _virtualServerMatchesKeyword( vs: VirtualServerInfo, query: string ): boolean { const q = query.toLowerCase(); return ( vs.server_name.toLowerCase().includes(q) || (vs.description || '').toLowerCase().includes(q) || vs.path.toLowerCase().includes(q) || (vs.tags || []).some(tag => tag.toLowerCase().includes(q)) ); } /** * Get featured items for the Discover landing page. * AI Registry Tools always first among servers if it exists. * Returns sorted, enabled items up to the max per category. */ function _getFeaturedItems( servers: Server[], agents: Server[], skills: Skill[], virtualServers: VirtualServerInfo[], externalServers: Server[], externalAgents: Server[], keywordFilter: string ) { // Filter enabled items const enabledServers = servers.filter(s => s.enabled); const enabledAgents = agents.filter(a => a.enabled); const enabledSkills = skills.filter(s => s.is_enabled); const enabledVirtual = virtualServers.filter(vs => vs.is_enabled); const enabledExtServers = externalServers.filter(s => s.enabled); const enabledExtAgents = externalAgents.filter(a => a.enabled); // Apply keyword filter if present const hasFilter = keywordFilter.length > 0; const filteredServers = hasFilter ? enabledServers.filter(s => _matchesKeyword(s, keywordFilter)) : enabledServers; const filteredAgents = hasFilter ? enabledAgents.filter(a => _matchesKeyword(a, keywordFilter)) : enabledAgents; const filteredSkills = hasFilter ? enabledSkills.filter(s => _matchesKeyword({ name: s.name, description: s.description, path: s.path, tags: s.tags, }, keywordFilter)) : enabledSkills; const filteredVirtual = hasFilter ? enabledVirtual.filter(vs => _virtualServerMatchesKeyword(vs, keywordFilter)) : enabledVirtual; const filteredExtServers = hasFilter ? enabledExtServers.filter(s => _matchesKeyword(s, keywordFilter)) : enabledExtServers; const filteredExtAgents = hasFilter ? enabledExtAgents.filter(a => _matchesKeyword(a, keywordFilter)) : enabledExtAgents; // Sort and pick top items // AI Registry Tools goes first if it's in the filtered list const aiRegistryTools = filteredServers.find(s => s.path === AI_REGISTRY_TOOLS_PATH); const otherServers = filteredServers.filter(s => s.path !== AI_REGISTRY_TOOLS_PATH); const sortedOther = _sortServersByRating(otherServers); const featuredServers: Server[] = []; if (aiRegistryTools) { featuredServers.push(aiRegistryTools); } featuredServers.push(...sortedOther.slice(0, MAX_FEATURED - featuredServers.length)); const featuredAgents = _sortServersByRating(filteredAgents).slice(0, MAX_FEATURED); const featuredSkills = _sortSkillsByStars(filteredSkills).slice(0, MAX_FEATURED); const featuredVirtual = _sortVirtualServersByRating(filteredVirtual).slice(0, MAX_FEATURED); const featuredExtServers = _sortServersByRating(filteredExtServers).slice(0, MAX_FEATURED); const featuredExtAgents = _sortServersByRating(filteredExtAgents).slice(0, MAX_FEATURED); return { featuredServers, featuredAgents, featuredSkills, featuredVirtual, featuredExtServers, featuredExtAgents, // Total enabled counts (before keyword filter + before MAX_FEATURED cap) totalServers: enabledServers.length, totalVirtual: enabledVirtual.length, totalAgents: enabledAgents.length, totalSkills: enabledSkills.length, totalExternal: enabledExtServers.length + enabledExtAgents.length, // Filtered counts (after keyword filter, before MAX_FEATURED cap) matchedServers: filteredServers.length, matchedVirtual: filteredVirtual.length, matchedAgents: filteredAgents.length, matchedSkills: filteredSkills.length, matchedExternal: filteredExtServers.length + filteredExtAgents.length, matchedExtServers: filteredExtServers.length, matchedExtAgents: filteredExtAgents.length, }; } const DiscoverTab: React.FC = ({ servers, agents, skills, virtualServers, externalServers, externalAgents, loading, onServerToggle, onServerEdit, onServerDelete, onAgentToggle, onAgentEdit, onAgentDelete, onSkillToggle, onSkillEdit, onSkillDelete, onVirtualServerToggle, onVirtualServerEdit, onVirtualServerDelete, onShowToast, authToken, }) => { const [searchTerm, setSearchTerm] = useState(''); const [committedQuery, setCommittedQuery] = useState(''); // Semantic search (only fires when committedQuery is set via Enter) const { results: searchResults, loading: searchLoading, error: searchError, } = useSemanticSearch(committedQuery, { enabled: committedQuery.length >= 2, }); const isSemanticActive = committedQuery.length >= 2; // Compute featured items with keyword filtering const { featuredServers, featuredAgents, featuredSkills, featuredVirtual, featuredExtServers, featuredExtAgents, totalServers, totalVirtual, totalAgents, totalSkills, totalExternal, matchedServers, matchedVirtual, matchedAgents, matchedSkills, matchedExternal, matchedExtServers, matchedExtAgents, } = useMemo( () => _getFeaturedItems( servers, agents, skills, virtualServers, externalServers, externalAgents, isSemanticActive ? '' : searchTerm ), [servers, agents, skills, virtualServers, externalServers, externalAgents, searchTerm, isSemanticActive] ); const totalFeatured = featuredServers.length + featuredAgents.length + featuredSkills.length + featuredVirtual.length + featuredExtServers.length + featuredExtAgents.length; const handleSemanticSearch = useCallback(() => { if (searchTerm.trim().length >= 2) { setCommittedQuery(searchTerm.trim()); } }, [searchTerm]); const handleClearSearch = useCallback(() => { setSearchTerm(''); setCommittedQuery(''); }, []); return (
{/* Header: title + search bar - always at top */}

Discover MCP Servers, Agents & Skills

{/* Search Input */}
{ setSearchTerm(e.target.value); if (committedQuery) { setCommittedQuery(''); } }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSemanticSearch(); } }} /> {searchTerm && ( )}
{/* Summary counts + hint */} {!isSemanticActive && (

{_buildSummaryText( { servers: totalServers, virtual: totalVirtual, agents: totalAgents, skills: totalSkills, external: totalExternal }, { servers: matchedServers, virtual: matchedVirtual, agents: matchedAgents, skills: matchedSkills, external: matchedExternal }, searchTerm.length > 0 )} {searchTerm && ( {' '}· press Enter for semantic search )}

)}
{/* Content Area */} {isSemanticActive ? ( /* Semantic Search Results */
) : ( /* Featured List Rows */
{loading ? (
Loading featured items...
) : totalFeatured === 0 ? (
{searchTerm ? `No items matching "${searchTerm}"` : 'No items registered yet. Register your first MCP server, agent, or skill!'}
) : (
{/* MCP Servers section */} {featuredServers.length > 0 && (

MCP Servers {matchedServers > featuredServers.length && ( (showing {featuredServers.length} of {matchedServers}) )}

{featuredServers.map(server => ( ))}
)} {/* Virtual MCP Servers section */} {featuredVirtual.length > 0 && (

Virtual MCP Servers {matchedVirtual > featuredVirtual.length && ( (showing {featuredVirtual.length} of {matchedVirtual}) )}

{featuredVirtual.map(vs => ( ))}
)} {/* Agents section */} {featuredAgents.length > 0 && (

Agents {matchedAgents > featuredAgents.length && ( (showing {featuredAgents.length} of {matchedAgents}) )}

{featuredAgents.map(agent => ( ))}
)} {/* Skills section */} {featuredSkills.length > 0 && (

Skills {matchedSkills > featuredSkills.length && ( (showing {featuredSkills.length} of {matchedSkills}) )}

{featuredSkills.map(skill => ( ))}
)} {/* External Servers section */} {featuredExtServers.length > 0 && (

External Registry Servers {matchedExtServers > featuredExtServers.length && ( (showing {featuredExtServers.length} of {matchedExtServers}) )}

{featuredExtServers.map(server => ( ))}
)} {/* External Agents section */} {featuredExtAgents.length > 0 && (

External Registry Agents {matchedExtAgents > featuredExtAgents.length && ( (showing {featuredExtAgents.length} of {matchedExtAgents}) )}

{featuredExtAgents.map(agent => ( ))}
)} {/* Bottom padding so fade gradient doesn't cover last row */}
)}
{/* Fade gradient at bottom to hint more content */}
)}
); }; export default DiscoverTab; ================================================ FILE: frontend/src/components/ExternalRegistries.tsx ================================================ import React, { useState, useEffect, useCallback } from 'react'; import axios from 'axios'; import { ArrowPathIcon, CheckCircleIcon, ExclamationCircleIcon, CloudIcon, ServerStackIcon, CpuChipIcon, SparklesIcon, PlusIcon, XMarkIcon, } from '@heroicons/react/24/outline'; import AddRegistryEntryModal, { RegistrySourceType } from './AddRegistryEntryModal'; import ConfirmModal from './ConfirmModal'; /** * Props for the ExternalRegistries component. */ interface ExternalRegistriesProps { onShowToast: (message: string, type: 'success' | 'error' | 'info') => void; } /** * Anthropic server config shape. */ interface AnthropicServerConfig { name: string; } /** * Anthropic federation config shape. */ interface AnthropicConfig { enabled: boolean; endpoint: string; sync_on_startup: boolean; servers: AnthropicServerConfig[]; } /** * ASOR agent config shape. */ interface AsorAgentConfig { id: string; } /** * ASOR federation config shape. */ interface AsorConfig { enabled: boolean; endpoint: string; auth_env_var: string | null; sync_on_startup: boolean; agents: AsorAgentConfig[]; } /** * AgentCore registry config shape. */ interface AgentCoreRegistryConfig { registry_id: string; aws_account_id: string | null; aws_region: string | null; assume_role_arn: string | null; descriptor_types: string[]; sync_status_filter: string; } /** * AgentCore federation config shape. */ interface AgentCoreConfig { enabled: boolean; aws_region: string; sync_on_startup: boolean; sync_interval_minutes: number; sync_timeout_seconds: number; max_concurrent_fetches: number; registries: AgentCoreRegistryConfig[]; } /** * Root federation config shape. */ interface FederationConfig { anthropic: AnthropicConfig; asor: AsorConfig; aws_registry: AgentCoreConfig; } /** * Sync result shape from /api/federation/sync. */ interface SyncResults { anthropic: { count: number; servers: string[] }; asor: { count: number; agents: string[] }; aws_registry: { count: number; servers: string[]; agents: string[]; skills: string[] }; } /** * Format a relative time string from an ISO timestamp. */ function _formatRelativeTime(dateString: string | null | undefined): string { if (!dateString) return 'Never'; const date = new Date(dateString); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / (1000 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffMins < 1) return 'Just now'; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } /** * Truncate a string (like an ARN) for display. */ function _truncateArn(arn: string, maxLen: number = 60): string { if (arn.length <= maxLen) return arn; return arn.slice(0, maxLen - 3) + '...'; } /** * ExternalRegistries displays the federation configuration for * Anthropic, AWS Agent Registry, and ASOR external registries. * * Shows config details, sync status, and provides a Sync Now button. */ const ExternalRegistries: React.FC = ({ onShowToast }) => { const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [syncing, setSyncing] = useState(null); const [lastSyncTime, setLastSyncTime] = useState(null); const [lastSyncResults, setLastSyncResults] = useState(null); const [addModalSource, setAddModalSource] = useState(null); const [deletingItem, setDeletingItem] = useState(null); const [confirmDelete, setConfirmDelete] = useState<{ source: 'aws_registry' | 'anthropic' | 'asor'; identifier: string; } | null>(null); /** * Fetch federation config from API. */ const fetchConfig = useCallback(async () => { setLoading(true); setError(null); try { const response = await axios.get('/api/federation/config'); setConfig(response.data); } catch (err: any) { if (err?.response?.status === 404) { setConfig(null); setError(null); } else { setError('Failed to load federation configuration'); } } finally { setLoading(false); } }, []); useEffect(() => { fetchConfig(); }, [fetchConfig]); /** * Trigger a federation sync for a specific source. */ const handleSync = async (source: string) => { setSyncing(source); try { const response = await axios.post(`/api/federation/sync?source=${source}`); const data = response.data; const totalSynced = data.total_synced || 0; setLastSyncTime(new Date().toISOString()); setLastSyncResults(data.results || null); onShowToast(`Sync completed: ${totalSynced} items synced from ${source}`, 'success'); } catch (err: any) { const detail = err?.response?.data?.detail || 'Sync failed'; onShowToast(`Sync failed for ${source}: ${detail}`, 'error'); } finally { setSyncing(null); } }; /** * Trigger sync for all enabled sources. */ const handleSyncAll = async () => { setSyncing('all'); try { const response = await axios.post('/api/federation/sync'); const data = response.data; const totalSynced = data.total_synced || 0; setLastSyncTime(new Date().toISOString()); setLastSyncResults(data.results || null); onShowToast(`Sync completed: ${totalSynced} total items synced`, 'success'); } catch (err: any) { const detail = err?.response?.data?.detail || 'Sync failed'; onShowToast(`Sync failed: ${detail}`, 'error'); } finally { setSyncing(null); } }; /** * Show the confirm modal before deleting an entry. */ const handleDeleteEntry = ( source: 'aws_registry' | 'anthropic' | 'asor', identifier: string, ) => { setConfirmDelete({ source, identifier }); }; /** * Execute the deletion after user confirms via modal. */ const executeDelete = async () => { if (!confirmDelete) return; const { source, identifier } = confirmDelete; setDeletingItem(identifier); try { if (source === 'anthropic') { await axios.delete( `/api/federation/config/default/anthropic/servers/${encodeURIComponent(identifier)}` ); } else if (source === 'asor') { await axios.delete( `/api/federation/config/default/asor/agents/${encodeURIComponent(identifier)}` ); } else if (source === 'aws_registry') { await axios.delete( `/api/federation/config/default/aws_registry/registries/${encodeURIComponent(identifier)}` ); } onShowToast(`Removed "${identifier}"`, 'success'); fetchConfig(); } catch (err: any) { const detail = err?.response?.data?.detail || 'Failed to remove entry'; onShowToast(detail, 'error'); } finally { setDeletingItem(null); setConfirmDelete(null); } }; /** * Called after successfully adding a new entry via the modal. */ const handleAddSuccess = () => { fetchConfig(); }; // Loading state if (loading) { return (
); } // Error state if (error) { return (

{error}

); } // No config state if (!config) { return (

No Federation Configuration

Federation configuration has not been set up yet. Use the CLI or API to create a federation config.

); } // Count enabled sources const enabledSources: string[] = []; if (config.anthropic.enabled) enabledSources.push('anthropic'); if (config.aws_registry.enabled) enabledSources.push('aws_registry'); if (config.asor.enabled) enabledSources.push('asor'); return (
{/* Header */}

External Registries

{enabledSources.length} source{enabledSources.length !== 1 ? 's' : ''} configured {lastSyncTime && ( | Last sync: {_formatRelativeTime(lastSyncTime)} )}

{/* Registry cards */}
{/* AWS Agent Registry */} {_renderAgentCoreCard( config.aws_registry, syncing, lastSyncResults, handleSync, () => setAddModalSource('aws_registry'), (id) => handleDeleteEntry('aws_registry', id), deletingItem, )} {/* Anthropic */} {_renderAnthropicCard( config.anthropic, syncing, lastSyncResults, handleSync, () => setAddModalSource('anthropic'), (name) => handleDeleteEntry('anthropic', name), deletingItem, )} {/* ASOR */} {_renderAsorCard( config.asor, syncing, lastSyncResults, handleSync, () => setAddModalSource('asor'), (id) => handleDeleteEntry('asor', id), deletingItem, )}
{/* Add Entry Modal */} {addModalSource && ( setAddModalSource(null)} sourceType={addModalSource} onSuccess={handleAddSuccess} onShowToast={onShowToast} /> )} {/* Delete Confirmation Modal */} {confirmDelete && ( setConfirmDelete(null)} onConfirm={executeDelete} title="Remove Entry" message={`Are you sure you want to remove "${confirmDelete.identifier}"? Any servers, agents, and skills synced from this source will also be deregistered.`} confirmLabel="Remove" isDestructive={true} isLoading={deletingItem !== null} /> )}
); }; /** * Render the AWS Agent Registry card. */ function _renderAgentCoreCard( agentcore: AgentCoreConfig, syncing: string | null, lastSyncResults: SyncResults | null, onSync: (source: string) => void, onAdd: () => void, onRemove: (registryId: string) => void, deletingItem: string | null, ): React.ReactNode { return (
{/* Card header */}

AWS Agent Registry

{agentcore.enabled ? 'Enabled' : 'Disabled'} {agentcore.sync_on_startup && ( Sync on startup )}
{agentcore.enabled && (
)}
{/* Config details */} {agentcore.enabled && (
Region: {agentcore.aws_region}
Sync interval: {agentcore.sync_interval_minutes} min
Timeout: {agentcore.sync_timeout_seconds}s
Concurrency: {agentcore.max_concurrent_fetches}
{/* Registry list */} {agentcore.registries.length > 0 && (

Registries ({agentcore.registries.length})

{agentcore.registries.map((reg, idx) => (
{reg.registry_id}
{reg.aws_region && ( {reg.aws_region} )} {reg.aws_account_id && ( Account: {reg.aws_account_id} )} Status: {reg.sync_status_filter} {reg.descriptor_types.map((dt) => ( {dt} ))}
{reg.assume_role_arn && (
Role: {_truncateArn(reg.assume_role_arn)}
)}
))}
)} {/* Last sync results */} {lastSyncResults?.aws_registry && lastSyncResults.aws_registry.count > 0 && (
Last sync: {lastSyncResults.aws_registry.count} items
{lastSyncResults.aws_registry.servers.length > 0 && ( Servers: {lastSyncResults.aws_registry.servers.length} )} {lastSyncResults.aws_registry.agents.length > 0 && ( Agents: {lastSyncResults.aws_registry.agents.length} )} {lastSyncResults.aws_registry.skills.length > 0 && ( Skills: {lastSyncResults.aws_registry.skills.length} )}
)}
)}
); } /** * Render the Anthropic registry card. */ function _renderAnthropicCard( anthropic: AnthropicConfig, syncing: string | null, lastSyncResults: SyncResults | null, onSync: (source: string) => void, onAdd: () => void, onRemove: (serverName: string) => void, deletingItem: string | null, ): React.ReactNode { return (
{/* Card header */}

Anthropic

{anthropic.enabled ? 'Enabled' : 'Disabled'} {anthropic.sync_on_startup && ( Sync on startup )}
{anthropic.enabled && (
)}
{/* Config details */} {anthropic.enabled && (
Endpoint: {anthropic.endpoint}
{/* Server list */} {anthropic.servers.length > 0 && (

Servers ({anthropic.servers.length})

{anthropic.servers.map((srv) => ( {srv.name} ))}
)} {/* Last sync results */} {lastSyncResults?.anthropic && lastSyncResults.anthropic.count > 0 && (
Last sync: {lastSyncResults.anthropic.count} servers
)}
)}
); } /** * Render the ASOR registry card. */ function _renderAsorCard( asor: AsorConfig, syncing: string | null, lastSyncResults: SyncResults | null, onSync: (source: string) => void, onAdd: () => void, onRemove: (agentId: string) => void, deletingItem: string | null, ): React.ReactNode { return (
{/* Card header */}

ASOR

{asor.enabled ? 'Enabled' : 'Disabled'} {asor.sync_on_startup && ( Sync on startup )}
{asor.enabled && (
)}
{/* Config details */} {asor.enabled && (
{asor.endpoint && (
Endpoint: {asor.endpoint}
)} {asor.agents.length > 0 && (

Agents ({asor.agents.length})

{asor.agents.map((agent) => ( {agent.id} ))}
)} {/* Last sync results */} {lastSyncResults?.asor && lastSyncResults.asor.count > 0 && (
Last sync: {lastSyncResults.asor.count} agents
)}
)}
); } /** * Simple globe icon wrapper (GlobeAltIcon from heroicons). */ function GlobeIcon(props: React.ComponentProps<'svg'>) { return ( ); } export default ExternalRegistries; ================================================ FILE: frontend/src/components/FederationPeerForm.tsx ================================================ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { ArrowLeftIcon, ArrowPathIcon, ExclamationCircleIcon, } from '@heroicons/react/24/outline'; import { useFederationPeer, createPeer, updatePeer, PeerFormData, } from '../hooks/useFederationPeers'; /** * Props for the FederationPeerForm component. */ interface FederationPeerFormProps { peerId?: string; onShowToast: (message: string, type: 'success' | 'error' | 'info') => void; } /** * Form validation errors interface. */ interface FormErrors { peer_id?: string; name?: string; endpoint?: string; federation_token?: string; sync_interval_minutes?: string; whitelist?: string; tag_filters?: string; } /** * FederationPeerForm component for adding or editing a peer registry. * * Provides a form with validation for configuring peer connection settings, * authentication, and sync options. */ const FederationPeerForm: React.FC = ({ peerId, onShowToast, }) => { const navigate = useNavigate(); const isEditMode = !!peerId; const { peer, isLoading: isLoadingPeer, error: loadError } = useFederationPeer(peerId); // Form state const [formData, setFormData] = useState({ peer_id: '', name: '', endpoint: '', enabled: true, sync_mode: 'all', whitelist_servers: [], whitelist_agents: [], tag_filters: [], sync_interval_minutes: 60, federation_token: '', }); // Whitelist and tags as comma-separated strings for easier editing const [whitelistText, setWhitelistText] = useState(''); const [tagFiltersText, setTagFiltersText] = useState(''); // Form state const [errors, setErrors] = useState({}); const [isSubmitting, setIsSubmitting] = useState(false); // Populate form in edit mode useEffect(() => { if (peer) { setFormData({ peer_id: peer.peer_id, name: peer.name, endpoint: peer.endpoint, enabled: peer.enabled, sync_mode: peer.sync_mode, whitelist_servers: peer.whitelist_servers || [], whitelist_agents: peer.whitelist_agents || [], tag_filters: peer.tag_filters || [], sync_interval_minutes: peer.sync_interval_minutes, federation_token: '', // Don't populate token for security }); // Combine whitelists for display const whitelistItems = [ ...(peer.whitelist_servers || []).map((s) => `server:${s}`), ...(peer.whitelist_agents || []).map((a) => `agent:${a}`), ]; setWhitelistText(whitelistItems.join(', ')); setTagFiltersText((peer.tag_filters || []).join(', ')); } }, [peer]); /** * Handle input field changes. */ const handleChange = ( e: React.ChangeEvent ) => { const { name, value, type } = e.target; const newValue = type === 'checkbox' ? (e.target as HTMLInputElement).checked : value; setFormData((prev) => ({ ...prev, [name]: name === 'sync_interval_minutes' ? parseInt(value) || 60 : newValue, })); // Clear error for this field if (errors[name as keyof FormErrors]) { setErrors((prev) => ({ ...prev, [name]: undefined })); } }; /** * Validate form data. */ const validateForm = (): boolean => { const newErrors: FormErrors = {}; // Peer ID validation if (!formData.peer_id.trim()) { newErrors.peer_id = 'Peer ID is required'; } else if (!/^[a-zA-Z0-9-_]+$/.test(formData.peer_id)) { newErrors.peer_id = 'Peer ID must be alphanumeric with dashes or underscores only'; } // Name validation if (!formData.name.trim()) { newErrors.name = 'Display name is required'; } // Endpoint validation if (!formData.endpoint.trim()) { newErrors.endpoint = 'Endpoint URL is required'; } else if (!formData.endpoint.startsWith('http://') && !formData.endpoint.startsWith('https://')) { newErrors.endpoint = 'Endpoint must be a valid HTTP or HTTPS URL'; } // Token validation (required for new peers) if (!isEditMode && !formData.federation_token?.trim()) { newErrors.federation_token = 'Federation token is required'; } // Sync interval validation if (formData.sync_interval_minutes < 5 || formData.sync_interval_minutes > 1440) { newErrors.sync_interval_minutes = 'Sync interval must be between 5 and 1440 minutes'; } // Whitelist validation when sync_mode is 'whitelist' if (formData.sync_mode === 'whitelist') { const items = whitelistText.split(',').map((s) => s.trim()).filter(Boolean); if (items.length === 0) { newErrors.whitelist = 'At least one whitelist item is required'; } } // Tag filter validation when sync_mode is 'tag_filter' if (formData.sync_mode === 'tag_filter') { const tags = tagFiltersText.split(',').map((s) => s.trim()).filter(Boolean); if (tags.length === 0) { newErrors.tag_filters = 'At least one tag is required'; } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; /** * Handle form submission. */ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!validateForm()) { return; } setIsSubmitting(true); try { // Parse whitelist items const whitelistItems = whitelistText.split(',').map((s) => s.trim()).filter(Boolean); const whitelistServers: string[] = []; const whitelistAgents: string[] = []; for (const item of whitelistItems) { if (item.startsWith('server:')) { whitelistServers.push(item.substring(7)); } else if (item.startsWith('agent:')) { whitelistAgents.push(item.substring(6)); } else { // Default to server if no prefix whitelistServers.push(item); } } // Parse tag filters const tagFilters = tagFiltersText.split(',').map((s) => s.trim()).filter(Boolean); const payload: PeerFormData = { ...formData, whitelist_servers: whitelistServers, whitelist_agents: whitelistAgents, tag_filters: tagFilters, }; // Don't send empty token on edit (keep existing) if (isEditMode && !payload.federation_token) { delete payload.federation_token; } if (isEditMode) { await updatePeer(peerId!, payload); onShowToast(`Peer "${formData.name}" has been updated`, 'success'); } else { await createPeer(payload); onShowToast(`Peer "${formData.name}" has been added`, 'success'); } navigate('/settings/federation/peers'); } catch (err: any) { const errorMessage = err.response?.data?.detail || err.message || `Failed to ${isEditMode ? 'update' : 'create'} peer`; onShowToast(errorMessage, 'error'); } finally { setIsSubmitting(false); } }; // Loading state for edit mode if (isEditMode && isLoadingPeer) { return (
{[1, 2, 3, 4, 5].map((i) => (
))}
); } // Error state for edit mode if (isEditMode && loadError) { return (

Failed to Load Peer

{loadError}

); } return (
{/* Header */}

{isEditMode ? 'Edit Peer' : 'Add Peer'}

{isEditMode ? 'Update peer registry configuration' : 'Configure a new peer registry for federation'}

{/* Basic Information */}

Basic Information

{/* Peer ID */}
{errors.peer_id && (

{errors.peer_id}

)}

Unique identifier for this peer (alphanumeric, dashes, underscores)

{/* Display Name */}
{errors.name && (

{errors.name}

)}
{/* Endpoint URL */}
{errors.endpoint && (

{errors.endpoint}

)}

Base URL of the peer registry API

{/* Enabled toggle */}
{/* Authentication */}

Authentication

{/* Federation Token */}
{errors.federation_token && (

{errors.federation_token}

)}

{isEditMode ? 'Leave blank to keep existing token, or enter a new value to update' : 'The FEDERATION_STATIC_TOKEN value from the peer registry'}

{/* Sync Configuration */}

Sync Configuration

{/* Sync Mode */}
{/* Whitelist (shown when sync_mode is 'whitelist') */} {formData.sync_mode === 'whitelist' && (
Groups that can access this server when visibility is group-restricted

Backend Authentication

Configure credentials the gateway uses when proxying requests to this backend server.
Only fill in to update the credential. Leave blank to keep the current one.
The HTTP header name used to send the API key (default: X-API-Key)
Cancel
================================================ FILE: registry/templates/index.html ================================================ MCP Gateway - Servers (Test)
{# Button Moved Back To Header #}
{% if username %} {# Check if user has register_service permission for any service #} {% if 'register_service' in user_context.ui_permissions and user_context.ui_permissions.register_service %} {% endif %} {# Add Generate Token button for all authenticated users #} 🔑 Generate API Token {% endif %}
{{ username }} {% if user_context.is_admin %} 🔑 Admin Access {% elif user_context.can_modify_servers %} ⚙️ Modify Access {% else %} 👁️ Read-only Access {% endif %} {% if user_context.auth_method == 'oauth2' %} ({{ user_context.provider | title }}) {% endif %} {% if not user_context.is_admin %} Access: {{ user_context.accessible_servers | length }} server(s) {% endif %}
{% if services %} {% for service in services %}

{{ service.display_name }}

official ☁️ 💻 {# Refresh button - only show if user has health_check_service permission #} {% if can_perform_action('health_check_service', service.display_name) %} {% endif %}

Path: {{ service.path }}

{% for tag in service.tags %} {{ tag }} {% endfor %}

{{ service.description | default('No description provided.') }}

{# --- Moved Status Badge Logic Here --- #} {% set initial_status = service.health_status %} {# Determine initial class and text, avoiding 'checking' text #} {% set status_class = 'status-unknown' %} {% set display_text = 'unknown' %} {% if initial_status == 'healthy' %} {% set status_class = 'status-healthy' %} {% set display_text = 'healthy' %} {% elif initial_status.startswith('unhealthy') %} {% set status_class = 'status-unhealthy' %} {% set display_text = initial_status.split('(')[0].strip() %} {% elif initial_status.startswith('error') %} {% set status_class = 'status-error' %} {% set display_text = initial_status.split('(')[0].strip() %} {% elif initial_status == 'disabled' %} {% set status_class = 'status-disabled' %} {% set display_text = 'disabled' %} {% elif initial_status == 'checking' %} {# If checking initially, display as 'unknown' until first WS update #} {# Spinner will be shown by JS if needed #} {% set status_class = 'status-unknown' %} {% set display_text = 'unknown' %} {% endif %} {# Generate IDs - REMOVE leading slash BEFORE replacing #} {% set safe_path = service.path | replace('/', '', 1) | replace('/', '_') | replace(':', '_') %} {% set badge_id = 'status-badge-' + safe_path %} {% set spinner_id = 'spinner-for-' + safe_path %} {% set last_checked_id = 'last-checked-' + safe_path %} {# Render badge with determined initial text/class #}
{{ display_text }} {# Always render spinner, hide initially with inline style #}
{# Check if user has modify_service permission for this specific service #} {% if can_perform_action('modify_service', service.display_name) %} Modify {% endif %} {# Check if user has toggle_service permission for this specific service #} {% if can_perform_action('toggle_service', service.display_name) %} {# Add ID to form if needed, but action URL is sufficient #}
{# Add ID to label span #} {{ 'Enabled' if service.is_enabled else 'Disabled' }}
{% elif can_perform_action('list_service', service.display_name) %} {# Users who can list but not toggle: show read-only status #}
📖 {{ 'Enabled' if service.is_enabled else 'Disabled' }} (Read-only)
{% endif %}
{% endfor %} {% else %}

No services found matching your query, or none registered. Try rescanning.

{% endif %}
{% endblock %} ================================================ FILE: registry/templates/token_generation.html ================================================ Generate API Token - MCP Registry
{{ username }}
Logout

Your Current Permissions

Current Scopes:
{% for scope in user_scopes %} {{ scope }} {% endfor %}

Generated tokens can have the same or fewer permissions than your current scopes.

Token Lifetime Configuration

Note: Tokens have a default short lifetime (typically 5-15 minutes) for security.

If you need longer-lived tokens for automation or extended use:

  • Ask your administrator to increase the access token timeout in Keycloak
  • Navigate to: Keycloak Admin Console → Realm Settings → Tokens → Access Token Lifespan
  • This approach is more secure than using refresh tokens

Token Configuration

Scope Configuration

Enter a JSON array of scope names. Must be a subset of your current scopes.

✅ Token Generated Successfully

Access Token:

📋 Usage Instructions

Use this token in your API requests:

Authorization: Bearer YOUR_TOKEN_HERE

Replace YOUR_TOKEN_HERE with the token above.

⚠️ Important: This token will not be shown again. Save it securely!

================================================ FILE: registry/utils/__init__.py ================================================ """Utility modules for the MCP Registry.""" ================================================ FILE: registry/utils/agent_validator.py ================================================ """ Agent Card validator for A2A (Agent-to-Agent) protocol. This module validates Agent Cards according to the A2A protocol specification, ensuring compliance with required fields, URL formats, skill definitions, and security schemes. Based on: docs/design/a2a-protocol-integration.md """ import logging import re from typing import Any import httpx from pydantic import BaseModel from registry.schemas.agent_models import ( AgentCard, SecurityScheme, Skill, ) # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) class ValidationResult(BaseModel): """Result of agent card validation.""" is_valid: bool errors: list[str] warnings: list[str] def _validate_agent_url( url: str, ) -> bool: """ Validate agent URL format. Allows both HTTP and HTTPS for flexibility in local/development environments, though HTTPS is required for production per A2A specification. Args: url: Agent endpoint URL to validate Returns: True if URL is valid, False otherwise """ if not url: return False url_str = str(url) if not (url_str.startswith("http://") or url_str.startswith("https://")): return False url_pattern = ( r"^https?://([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*" r"[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" r"(:\d+)?(/[^\s]*)?$" ) return bool(re.match(url_pattern, url_str)) def _validate_skills( skills: list[Skill], ) -> list[str]: """ Validate agent skills. Ensures each skill has required fields and proper format. Args: skills: List of skills to validate Returns: List of error messages (empty if valid) """ errors: list[str] = [] if not isinstance(skills, list): errors.append("Skills must be a list") return errors for idx, skill in enumerate(skills): if not skill.id: errors.append(f"Skill {idx}: ID cannot be empty") if not skill.name: errors.append(f"Skill {idx}: name cannot be empty") if not skill.description: errors.append(f"Skill {idx}: description cannot be empty") return errors def _validate_security_schemes( security_schemes: dict[str, SecurityScheme | dict[str, Any]], ) -> list[str]: """ Validate security schemes configuration. Ensures schemes are properly configured with required fields. Supports both standard A2A SecurityScheme objects and alternative formats like Bedrock AgentCore httpAuthSecurityScheme dicts. Args: security_schemes: Dictionary of security schemes to validate Returns: List of error messages (empty if valid) """ errors: list[str] = [] if not isinstance(security_schemes, dict): errors.append("Security schemes must be a dictionary") return errors for scheme_name, scheme in security_schemes.items(): if not scheme_name: errors.append("Security scheme name cannot be empty") # Raw dicts (e.g. Bedrock AgentCore httpAuthSecurityScheme format) # are accepted without further field-level validation if isinstance(scheme, dict): continue if not scheme.type: errors.append(f"Scheme '{scheme_name}': type is required") valid_types = ["apiKey", "http", "oauth2", "openIdConnect"] if scheme.type not in valid_types: errors.append(f"Scheme '{scheme_name}': invalid type '{scheme.type}'") if scheme.type == "apiKey": if not scheme.in_: errors.append(f"Scheme '{scheme_name}': 'in' is required for apiKey") if not scheme.name: errors.append(f"Scheme '{scheme_name}': 'name' is required for apiKey") if scheme.type == "http": if not scheme.scheme: errors.append(f"Scheme '{scheme_name}': 'scheme' is required for http") if scheme.type == "oauth2": if not scheme.flows: errors.append(f"Scheme '{scheme_name}': 'flows' is required for oauth2") if scheme.type == "openIdConnect": if not scheme.openid_connect_url: errors.append(f"Scheme '{scheme_name}': openIdConnect URL required") return errors def _validate_tags( tags: list[str], ) -> list[str]: """ Validate agent tags. Ensures tags are non-empty strings. Args: tags: List of tags to validate Returns: List of error messages (empty if valid) """ errors: list[str] = [] if not isinstance(tags, list): errors.append("Tags must be a list") return errors for idx, tag in enumerate(tags): if not isinstance(tag, str): errors.append(f"Tag {idx}: must be a string, got {type(tag).__name__}") if isinstance(tag, str) and not tag.strip(): errors.append(f"Tag {idx}: cannot be empty") return errors def _check_endpoint_reachability( url: str, ) -> tuple[bool, str | None]: """ Check if agent endpoint is reachable. Attempts HTTP GET request to the well-known endpoint. Does not block validation if unreachable. Args: url: Agent endpoint URL to check Returns: Tuple of (is_reachable, error_message) """ try: well_known_url = f"{url}/.well-known/agent-card.json" response = httpx.get( well_known_url, timeout=5.0, ) if response.status_code == 200: return (True, None) return (False, f"Endpoint returned status {response.status_code}") except httpx.TimeoutException: logger.warning(f"Endpoint timeout for {url}") return (False, "Endpoint request timed out") except Exception as e: logger.warning(f"Could not reach endpoint {url}: {e}") return (False, str(e)) def _validate_agent_card( agent_card: AgentCard, ) -> tuple[bool, list[str]]: """ Validate agent card structure and content. Performs core validation on required fields and references. Args: agent_card: AgentCard instance to validate Returns: Tuple of (is_valid, error_messages) """ errors: list[str] = [] if not agent_card.name or not agent_card.name.strip(): errors.append("Agent name cannot be empty") if not agent_card.description or not agent_card.description.strip(): errors.append("Agent description cannot be empty") # Path is optional - auto-generated if not provided if agent_card.path and not agent_card.path.strip(): errors.append("Agent path cannot be empty if provided") if not _validate_agent_url(str(agent_card.url)): errors.append("Agent URL must be HTTP or HTTPS and properly formatted") if agent_card.protocol_version: if not re.match(r"^\d+\.\d+(\.\d+)?$", agent_card.protocol_version): errors.append("Protocol version must be in format X.Y or X.Y.Z") from registry.utils.visibility import VALID_VISIBILITY_VALUES if agent_card.visibility not in VALID_VISIBILITY_VALUES: errors.append(f"Invalid visibility: {agent_card.visibility}") if agent_card.trust_level not in [ "unverified", "community", "verified", "trusted", ]: errors.append(f"Invalid trust level: {agent_card.trust_level}") skill_errors = _validate_skills(agent_card.skills) errors.extend(skill_errors) scheme_errors = _validate_security_schemes(agent_card.security_schemes) errors.extend(scheme_errors) tag_errors = _validate_tags(agent_card.tags) errors.extend(tag_errors) is_valid = len(errors) == 0 return (is_valid, errors) def validate_agent_card( agent_card: AgentCard, check_reachability: bool = False, ) -> ValidationResult: """ Validate an agent card. Main entry point for agent card validation. Performs structure and content validation, with optional reachability checks. Args: agent_card: AgentCard instance to validate check_reachability: If True, attempt to reach agent endpoint Returns: ValidationResult with validation status and messages """ is_valid, errors = _validate_agent_card(agent_card) warnings: list[str] = [] if check_reachability and agent_card.url: reachable, error_msg = _check_endpoint_reachability(str(agent_card.url)) if not reachable: warnings.append(f"Agent endpoint unreachable: {error_msg}") logger.warning(f"Agent {agent_card.name} endpoint unreachable: {error_msg}") if errors: logger.error(f"Agent card validation failed: {errors}") else: logger.info(f"Agent card '{agent_card.name}' validated successfully") if warnings: logger.warning(f"Agent card '{agent_card.name}' has warnings: {warnings}") return ValidationResult( is_valid=is_valid, errors=errors, warnings=warnings, ) class AgentValidator: """Service for validating A2A agent cards.""" async def validate_agent_card( self, agent_card: AgentCard, verify_endpoint: bool = False, ) -> ValidationResult: """ Async wrapper for validating an agent card. Args: agent_card: AgentCard instance to validate verify_endpoint: If True, attempt to verify endpoint Returns: ValidationResult with validation status and messages """ return validate_agent_card( agent_card=agent_card, check_reachability=verify_endpoint, ) # Global validator instance agent_validator = AgentValidator() ================================================ FILE: registry/utils/auth0_manager.py ================================================ """Auth0 Management API manager for user and role operations. This module provides async functions for managing users and roles in Auth0 using the Auth0 Management API. Note: Auth0 uses "roles" terminology, but we map them to "groups" for consistency with the MCP Gateway IAM interface. """ import logging import os from typing import Any import httpx logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Configuration from environment AUTH0_DOMAIN: str = os.environ.get("AUTH0_DOMAIN", "") AUTH0_M2M_CLIENT_ID: str = os.environ.get("AUTH0_M2M_CLIENT_ID", "") AUTH0_M2M_CLIENT_SECRET: str = os.environ.get("AUTH0_M2M_CLIENT_SECRET", "") AUTH0_MANAGEMENT_API_TOKEN: str = os.environ.get("AUTH0_MANAGEMENT_API_TOKEN", "") async def _get_management_api_token() -> str: """Get Auth0 Management API access token using M2M credentials. Returns: Access token for Management API Raises: ValueError: If credentials are not configured or token request fails """ # If static management API token is provided, use it if AUTH0_MANAGEMENT_API_TOKEN: return AUTH0_MANAGEMENT_API_TOKEN # Otherwise, get token using M2M client credentials if not AUTH0_M2M_CLIENT_ID or not AUTH0_M2M_CLIENT_SECRET: raise ValueError( "Auth0 Management API access not configured. " "Set AUTH0_M2M_CLIENT_ID and AUTH0_M2M_CLIENT_SECRET, " "or AUTH0_MANAGEMENT_API_TOKEN environment variables." ) domain = AUTH0_DOMAIN.replace("https://", "").rstrip("/") token_url = f"https://{domain}/oauth/token" token_data = { "client_id": AUTH0_M2M_CLIENT_ID, "client_secret": AUTH0_M2M_CLIENT_SECRET, "audience": f"https://{domain}/api/v2/", "grant_type": "client_credentials", } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(token_url, json=token_data) if response.status_code != 200: error_msg = f"Failed to get Auth0 Management API token: {response.text}" logger.error(error_msg) raise ValueError(error_msg) token_response = response.json() return token_response.get("access_token", "") async def _get_api_headers() -> dict[str, str]: """Get headers for Auth0 Management API requests.""" token = await _get_management_api_token() return { "Authorization": f"Bearer {token}", "Accept": "application/json", "Content-Type": "application/json", } def _get_base_url() -> str: """Get Auth0 Management API base URL.""" domain = AUTH0_DOMAIN.replace("https://", "").rstrip("/") return f"https://{domain}/api/v2" def _check_rate_limit(response: httpx.Response) -> None: """Check for Auth0 rate limiting and raise appropriate error. Args: response: HTTP response to check Raises: ValueError: If rate limited, includes retry delay info """ if response.status_code == 429: retry_after = int(response.headers.get("Retry-After", 60)) rate_limit_remaining = response.headers.get("X-RateLimit-Remaining", "0") logger.warning( f"Auth0 rate limit exceeded. " f"Remaining: {rate_limit_remaining}, Retry after: {retry_after}s" ) raise ValueError( f"Auth0 API rate limited. Retry after {retry_after} seconds. " f"Consider reducing request frequency." ) async def list_auth0_users( search: str | None = None, max_results: int = 500, include_groups: bool = True, ) -> list[dict[str, Any]]: """List users from Auth0. Args: search: Optional search filter (email or username) max_results: Maximum number of results to return include_groups: Whether to include role (group) memberships Returns: List of user dictionaries """ base_url = _get_base_url() headers = await _get_api_headers() params: dict[str, Any] = {"per_page": min(max_results, 100), "page": 0} if search: params["q"] = f'email:"{search}*" OR username:"{search}*"' params["search_engine"] = "v3" users: list[dict[str, Any]] = [] async with httpx.AsyncClient(timeout=10.0) as client: while len(users) < max_results: response = await client.get(f"{base_url}/users", headers=headers, params=params) _check_rate_limit(response) response.raise_for_status() page_users = response.json() if not page_users: break users.extend(page_users) params["page"] += 1 # Transform to common format result = [] for user in users[:max_results]: user_data: dict[str, Any] = { "id": user.get("user_id"), "username": user.get("username") or user.get("email", "").split("@")[0], "email": user.get("email"), "first_name": user.get("given_name", ""), "last_name": user.get("family_name", ""), "status": "active" if not user.get("blocked") else "blocked", "created": user.get("created_at"), "groups": [], } if include_groups: # Get user's roles (which we map to groups) roles_url = f"{base_url}/users/{user['user_id']}/roles" roles_response = await client.get(roles_url, headers=headers) if roles_response.status_code == 200: user_data["groups"] = [r.get("name") for r in roles_response.json()] result.append(user_data) logger.info(f"Retrieved {len(result)} users from Auth0") return result async def create_auth0_human_user( username: str, email: str, first_name: str, last_name: str, groups: list[str], password: str | None = None, ) -> dict[str, Any]: """Create a human user in Auth0. Args: username: Username for the account email: Email address first_name: First name last_name: Last name groups: List of role names to assign (mapped to groups terminology) password: Optional initial password Returns: Dictionary with created user details """ base_url = _get_base_url() headers = await _get_api_headers() user_data: dict[str, Any] = { "email": email, "given_name": first_name, "family_name": last_name, "name": f"{first_name} {last_name}", "connection": "Username-Password-Authentication", # Auth0 default database connection "email_verified": False, } if password: user_data["password"] = password async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{base_url}/users", headers=headers, json=user_data, ) if response.status_code >= 400: try: error_body = response.json() except Exception: error_body = response.text logger.error(f"Auth0 user creation failed ({response.status_code}): {error_body}") raise ValueError(f"Auth0 user creation failed: {error_body}") created_user = response.json() user_id = created_user.get("user_id") # Assign to roles (groups) if groups: # Get all roles to find IDs roles_response = await client.get(f"{base_url}/roles", headers=headers) roles_response.raise_for_status() all_roles = {r.get("name"): r.get("id") for r in roles_response.json()} # Assign user to matching roles role_ids = [all_roles[group] for group in groups if group in all_roles] if role_ids: await client.post( f"{base_url}/users/{user_id}/roles", headers=headers, json={"roles": role_ids}, ) logger.info(f"Created Auth0 user: {username}") return { "id": user_id, "username": username, "email": email, "groups": groups, } async def delete_auth0_user(username_or_id: str) -> bool: """Delete a user from Auth0. Args: username_or_id: Username (email) or user ID Returns: True if successful Raises: ValueError: If user not found """ base_url = _get_base_url() headers = await _get_api_headers() async with httpx.AsyncClient(timeout=10.0) as client: # If it looks like an email, search for user if "@" in username_or_id: response = await client.get( f"{base_url}/users-by-email", headers=headers, params={"email": username_or_id}, ) if response.status_code == 200: users = response.json() if users: user_id = users[0].get("user_id") else: raise ValueError(f"User not found: {username_or_id}") else: raise ValueError(f"User not found: {username_or_id}") else: user_id = username_or_id # Delete user delete_response = await client.delete( f"{base_url}/users/{user_id}", headers=headers, ) delete_response.raise_for_status() logger.info(f"Deleted Auth0 user: {username_or_id}") return True async def list_auth0_groups() -> list[dict[str, Any]]: """List all roles from Auth0 (mapped to groups terminology). Returns: List of role dictionaries with id, name, description """ base_url = _get_base_url() headers = await _get_api_headers() roles: list[dict[str, Any]] = [] async with httpx.AsyncClient(timeout=10.0) as client: params: dict[str, Any] = {"per_page": 100, "page": 0} while True: response = await client.get(f"{base_url}/roles", headers=headers, params=params) response.raise_for_status() page_roles = response.json() if not page_roles: break roles.extend(page_roles) params["page"] += 1 result = [ { "id": r.get("id"), "name": r.get("name"), "description": r.get("description", ""), "type": "AUTH0_ROLE", "path": f"/{r.get('name')}", } for r in roles ] logger.info(f"Retrieved {len(result)} roles (groups) from Auth0") return result async def create_auth0_group( group_name: str, description: str = "", ) -> dict[str, Any]: """Create a role in Auth0 (mapped to group terminology). Args: group_name: Name of the role description: Optional description Returns: Dictionary with created role details """ base_url = _get_base_url() headers = await _get_api_headers() role_data = { "name": group_name, "description": description, } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{base_url}/roles", headers=headers, json=role_data, ) response.raise_for_status() created_role = response.json() logger.info(f"Created Auth0 role (group): {group_name}") return { "id": created_role.get("id"), "name": group_name, "description": description, } async def delete_auth0_group(group_name_or_id: str) -> bool: """Delete a role from Auth0 by name or ID. Args: group_name_or_id: Role name or ID Returns: True if successful Raises: ValueError: If role not found """ base_url = _get_base_url() headers = await _get_api_headers() async with httpx.AsyncClient(timeout=10.0) as client: # If not an ID format, search by name if not group_name_or_id.startswith("rol_"): response = await client.get( f"{base_url}/roles", headers=headers, params={"name_filter": group_name_or_id}, ) response.raise_for_status() roles = response.json() role_id = None for r in roles: if r.get("name") == group_name_or_id: role_id = r.get("id") break if not role_id: raise ValueError(f"Role (group) not found: {group_name_or_id}") else: role_id = group_name_or_id delete_response = await client.delete( f"{base_url}/roles/{role_id}", headers=headers, ) delete_response.raise_for_status() logger.info(f"Deleted Auth0 role (group): {group_name_or_id}") return True async def create_auth0_service_account( client_id_name: str, group_names: list[str], description: str | None = None, ) -> dict[str, Any]: """Create an M2M application (service account) in Auth0. Creates an M2M application with client_credentials grant type. Note: Auth0 M2M applications don't directly have roles - roles are assigned to users, not applications. Args: client_id_name: Name for the M2M application group_names: List of role names (for documentation - not directly assigned) description: Optional description Returns: Dictionary with client_id and client_secret """ base_url = _get_base_url() headers = await _get_api_headers() app_data = { "name": client_id_name, "description": description or f"M2M service account for {client_id_name}", "app_type": "non_interactive", # M2M application "grant_types": ["client_credentials"], "token_endpoint_auth_method": "client_secret_post", } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{base_url}/clients", headers=headers, json=app_data, ) response.raise_for_status() created_app = response.json() client_id = created_app.get("client_id") client_secret = created_app.get("client_secret") logger.info(f"Created Auth0 M2M application: {client_id_name}") logger.warning( f"Auth0 M2M applications don't have roles. " f"Configure API permissions in Auth0 dashboard for {client_id_name}." ) return { "client_id": client_id, "client_secret": client_secret, "groups": group_names, "auth0_client_id": client_id, } async def update_auth0_user_groups( username_or_id: str, groups: list[str], ) -> dict[str, Any]: """Update role memberships for an Auth0 user. Replaces the user's current role (group) memberships with the specified roles. Args: username_or_id: Username (email) or user ID groups: List of role names to assign Returns: Dictionary with updated user info """ base_url = _get_base_url() headers = await _get_api_headers() async with httpx.AsyncClient(timeout=10.0) as client: # Resolve user ID if "@" in username_or_id: response = await client.get( f"{base_url}/users-by-email", headers=headers, params={"email": username_or_id}, ) if response.status_code == 200: users = response.json() if users: user_id = users[0].get("user_id") else: raise ValueError(f"User not found: {username_or_id}") else: raise ValueError(f"User not found: {username_or_id}") else: user_id = username_or_id # Get current roles current_roles_resp = await client.get( f"{base_url}/users/{user_id}/roles", headers=headers, ) current_roles_resp.raise_for_status() current_role_ids = [r.get("id") for r in current_roles_resp.json()] # Get all available roles all_roles_resp = await client.get( f"{base_url}/roles", headers=headers, ) all_roles_resp.raise_for_status() all_roles = {r.get("name"): r.get("id") for r in all_roles_resp.json()} target_role_ids = [all_roles[group] for group in groups if group in all_roles] # Remove current roles if current_role_ids: await client.delete( f"{base_url}/users/{user_id}/roles", headers=headers, json={"roles": current_role_ids}, ) # Add target roles if target_role_ids: await client.post( f"{base_url}/users/{user_id}/roles", headers=headers, json={"roles": target_role_ids}, ) logger.info(f"Updated roles (groups) for Auth0 user {username_or_id}: {groups}") return {"username": username_or_id, "groups": groups} async def update_auth0_group( group_name_or_id: str, description: str = "", ) -> dict[str, Any]: """Update a role's properties in Auth0. Args: group_name_or_id: Role name or ID description: New description for the role Returns: Dictionary with updated role info Raises: ValueError: If role not found """ base_url = _get_base_url() headers = await _get_api_headers() async with httpx.AsyncClient(timeout=10.0) as client: # Resolve role ID if needed if not group_name_or_id.startswith("rol_"): response = await client.get( f"{base_url}/roles", headers=headers, params={"name_filter": group_name_or_id}, ) response.raise_for_status() matched = [r for r in response.json() if r.get("name") == group_name_or_id] if not matched: raise ValueError(f"Role (group) not found: {group_name_or_id}") role_id = matched[0].get("id") role_name = group_name_or_id else: role_id = group_name_or_id # Get current role name role_resp = await client.get(f"{base_url}/roles/{role_id}", headers=headers) role_resp.raise_for_status() role_name = role_resp.json().get("name") update_resp = await client.patch( f"{base_url}/roles/{role_id}", headers=headers, json={"description": description}, ) update_resp.raise_for_status() logger.info(f"Updated Auth0 role (group): {group_name_or_id}") return {"name": role_name, "description": description} ================================================ FILE: registry/utils/credential_encryption.py ================================================ """ Backend MCP server credential encryption utilities. Provides Fernet-based encryption and decryption for backend server auth credentials (Bearer tokens, API keys) stored in server configurations. Uses the application SECRET_KEY (via PBKDF2 key derivation) for encryption. Follows the same pattern as federation_encryption.py but derives the Fernet key from SECRET_KEY instead of requiring a separate environment variable. """ import base64 import hashlib import logging from datetime import UTC, datetime from cryptography.fernet import Fernet, InvalidToken logger = logging.getLogger(__name__) # Salt for PBKDF2 key derivation (purpose-specific to avoid key reuse) _KEY_DERIVATION_SALT: bytes = b"mcp-gateway-credential-encryption" # PBKDF2 iteration count _KEY_DERIVATION_ITERATIONS: int = 100_000 # Field names in server config dicts PLAINTEXT_FIELD: str = "auth_credential" ENCRYPTED_FIELD: str = "auth_credential_encrypted" def _derive_fernet_key( secret_key: str, ) -> bytes: """Derive a Fernet-compatible key from the application SECRET_KEY using PBKDF2. Args: secret_key: Application SECRET_KEY string. Returns: 32-byte url-safe base64-encoded key suitable for Fernet. """ derived = hashlib.pbkdf2_hmac( "sha256", secret_key.encode(), _KEY_DERIVATION_SALT, _KEY_DERIVATION_ITERATIONS, ) return base64.urlsafe_b64encode(derived) def _get_fernet() -> Fernet | None: """Get a Fernet instance derived from the application SECRET_KEY. Returns: Fernet instance, or None if SECRET_KEY is not available. """ try: from ..core.config import settings secret_key = settings.secret_key except Exception as e: logger.error(f"Could not load SECRET_KEY from settings: {e}") return None if not secret_key: return None try: key = _derive_fernet_key(secret_key) return Fernet(key) except Exception as e: logger.error(f"Failed to derive Fernet key from SECRET_KEY: {e}") return None def encrypt_credential( credential: str, ) -> str: """Encrypt a backend server credential for storage. Args: credential: Plaintext credential (Bearer token or API key). Returns: Fernet-encrypted credential string (base64-encoded). Raises: ValueError: If SECRET_KEY is not configured or encryption fails. """ fernet = _get_fernet() if not fernet: raise ValueError( "SECRET_KEY is not configured. Cannot encrypt credentials. " "Set SECRET_KEY in your environment or .env file." ) encrypted = fernet.encrypt(credential.encode()) return encrypted.decode() def decrypt_credential( encrypted_credential: str, ) -> str | None: """Decrypt a backend server credential from storage. Args: encrypted_credential: Fernet-encrypted credential string. Returns: Plaintext credential, or None if decryption fails. """ fernet = _get_fernet() if not fernet: logger.error("SECRET_KEY not configured. Cannot decrypt server credential.") return None try: decrypted = fernet.decrypt(encrypted_credential.encode()) return decrypted.decode() except InvalidToken: logger.error( "Failed to decrypt server credential. " "SECRET_KEY may have changed since the credential was stored. " "Re-register the server with a new credential." ) return None except Exception as e: logger.error(f"Unexpected error decrypting server credential: {e}") return None def encrypt_credential_in_server_dict( server_dict: dict, ) -> dict: """Encrypt auth_credential in a server dict before storage. If auth_credential is present and non-empty, encrypts it into auth_credential_encrypted and removes the plaintext field. Also sets credential_updated_at timestamp. Args: server_dict: Server config dictionary. Returns: Modified dict with encrypted credential (original dict is mutated). Raises: ValueError: If credential is present but encryption fails. """ credential = server_dict.get(PLAINTEXT_FIELD) if not credential: server_dict.pop(PLAINTEXT_FIELD, None) return server_dict encrypted = encrypt_credential(credential) server_dict[ENCRYPTED_FIELD] = encrypted server_dict["credential_updated_at"] = datetime.now(UTC).isoformat() # Remove plaintext from storage dict server_dict.pop(PLAINTEXT_FIELD, None) logger.info( f"Server credential encrypted for storage (path: {server_dict.get('path', 'unknown')})" ) return server_dict def strip_credentials_from_dict( server_dict: dict, ) -> dict: """Remove encrypted credentials from a server dict before returning in API responses. Args: server_dict: Server config dictionary. Returns: Modified dict with credentials removed (original dict is mutated). """ server_dict.pop(ENCRYPTED_FIELD, None) server_dict.pop(PLAINTEXT_FIELD, None) return server_dict def _migrate_auth_type_to_auth_scheme( server_dict: dict, ) -> dict: """Migrate legacy auth_type to auth_scheme on read. Converts old auth_type values to the new auth_scheme enum values. Does nothing if auth_scheme already exists. Args: server_dict: Server info dictionary from storage. Returns: Modified dict with auth_scheme populated from auth_type if needed. """ if "auth_scheme" in server_dict: return server_dict auth_type = server_dict.get("auth_type") if not auth_type: return server_dict migration_map = { "none": "none", "oauth": "bearer", "api-key": "api_key", "api_key": "api_key", "custom": "bearer", } server_dict["auth_scheme"] = migration_map.get(auth_type, "none") return server_dict ================================================ FILE: registry/utils/entra_manager.py ================================================ """ Microsoft Entra ID group and user management utilities. This module provides functions to manage users and groups in Entra ID via the Microsoft Graph API. It handles authentication, user/group CRUD operations, and integrates with the registry. """ import asyncio import logging import os import re import secrets import string from typing import Any import httpx # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Environment variables for Entra ID management ENTRA_TENANT_ID: str = os.environ.get("ENTRA_TENANT_ID", "") ENTRA_CLIENT_ID: str = os.environ.get("ENTRA_CLIENT_ID", "") ENTRA_CLIENT_SECRET: str = os.environ.get("ENTRA_CLIENT_SECRET", "") GRAPH_BASE_URL: str = "https://graph.microsoft.com/v1.0" class EntraAdminError(RuntimeError): """Raised when Entra ID Graph API operations fail.""" def _is_guid(value: str) -> bool: """Check if a string looks like a GUID.""" guid_pattern = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I ) return bool(guid_pattern.match(value)) def _generate_temp_password() -> str: """Generate a temporary password meeting Entra ID requirements.""" # Entra ID password requirements: 8+ chars, 3 of 4 categories # (upper, lower, digit, special) alphabet = string.ascii_letters + string.digits + "!@#$%^&*()" password = "".join(secrets.choice(alphabet) for _ in range(16)) return password def _auth_headers(token: str) -> dict[str, str]: """Build auth headers for Graph API calls.""" return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} def _build_prefix_odata_filter( prefixes: list[str], ) -> str: """ Build an OData $filter expression for multiple displayName prefixes. For a single prefix: startswith(displayName,'mcp-') For multiple: startswith(displayName,'mcp-') or startswith(displayName,'ai-') Args: prefixes: List of prefix strings (already validated) Returns: OData $filter expression string """ conditions = [f"startswith(displayName,'{prefix}')" for prefix in prefixes] return " or ".join(conditions) async def _get_entra_admin_token() -> str: """ Get admin access token from Entra ID for Graph API calls. Uses client credentials flow with the app registration credentials. Returns: Access token string for Graph API Raises: EntraAdminError: If authentication fails """ if not ENTRA_CLIENT_SECRET: raise EntraAdminError("ENTRA_CLIENT_SECRET environment variable not set") if not ENTRA_TENANT_ID: raise EntraAdminError("ENTRA_TENANT_ID environment variable not set") if not ENTRA_CLIENT_ID: raise EntraAdminError("ENTRA_CLIENT_ID environment variable not set") token_url = f"https://login.microsoftonline.com/{ENTRA_TENANT_ID}/oauth2/v2.0/token" data = { "grant_type": "client_credentials", "client_id": ENTRA_CLIENT_ID, "client_secret": ENTRA_CLIENT_SECRET, "scope": "https://graph.microsoft.com/.default", } try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( token_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"} ) response.raise_for_status() token_data = response.json() access_token = token_data.get("access_token") if not access_token: raise EntraAdminError("No access token in Entra ID response") logger.info("Successfully obtained Entra ID Graph API admin token") return access_token except httpx.HTTPStatusError as e: logger.error(f"Failed to authenticate with Entra ID: HTTP {e.response.status_code}") raise EntraAdminError( f"Entra ID authentication failed: HTTP {e.response.status_code}" ) from e except Exception as e: logger.error(f"Error getting Entra ID admin token: {e}") raise EntraAdminError(f"Failed to authenticate with Entra ID: {e}") from e async def _get_default_domain(token: str) -> str: """Get the default verified domain for the tenant.""" async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get( f"{GRAPH_BASE_URL}/organization", headers=_auth_headers(token), params={"$select": "verifiedDomains"}, ) response.raise_for_status() data = response.json() orgs = data.get("value", []) if orgs: domains = orgs[0].get("verifiedDomains", []) for domain in domains: if domain.get("isDefault"): return domain.get("name", "") if domains: return domains[0].get("name", "") raise EntraAdminError("Unable to determine default domain for tenant") async def _find_group_id_by_name( client: httpx.AsyncClient, token: str, group_name: str ) -> str | None: """Find a group's object ID by display name.""" response = await client.get( f"{GRAPH_BASE_URL}/groups", headers=_auth_headers(token), params={"$filter": f"displayName eq '{group_name}'", "$select": "id"}, ) response.raise_for_status() data = response.json() groups = data.get("value", []) if groups: return groups[0].get("id") return None async def _find_user_id( client: httpx.AsyncClient, token: str, username_or_email: str ) -> str | None: """Find a user's object ID by userPrincipalName or email. Args: client: HTTP client token: Admin token username_or_email: User principal name or email address Returns: User's object ID if found, None otherwise """ # Try to find by userPrincipalName first response = await client.get( f"{GRAPH_BASE_URL}/users", headers=_auth_headers(token), params={ "$filter": f"userPrincipalName eq '{username_or_email}'", "$select": "id", }, ) if response.status_code == 200: data = response.json() users = data.get("value", []) if users: return users[0].get("id") # Try by mail if not found by UPN response = await client.get( f"{GRAPH_BASE_URL}/users", headers=_auth_headers(token), params={ "$filter": f"mail eq '{username_or_email}'", "$select": "id", }, ) if response.status_code == 200: data = response.json() users = data.get("value", []) if users: return users[0].get("id") return None async def _get_user_groups(client: httpx.AsyncClient, token: str, user_id: str) -> list[str]: """Fetch group names for a user in Entra ID.""" try: response = await client.get( f"{GRAPH_BASE_URL}/users/{user_id}/memberOf", headers=_auth_headers(token), params={"$select": "id,displayName"}, ) response.raise_for_status() data = response.json() groups = data.get("value", []) # Return group display names return [ g.get("displayName", "") for g in groups if g.get("@odata.type") == "#microsoft.graph.group" ] except Exception as e: logger.warning(f"Failed to get groups for user {user_id}: {e}") return [] async def _add_user_to_group_by_name( client: httpx.AsyncClient, token: str, user_id: str, group_name: str ) -> None: """Add a user to a group by group display name.""" group_id = await _find_group_id_by_name(client, token, group_name) if not group_id: raise EntraAdminError(f"Group '{group_name}' not found") payload = {"@odata.id": f"{GRAPH_BASE_URL}/directoryObjects/{user_id}"} response = await client.post( f"{GRAPH_BASE_URL}/groups/{group_id}/members/$ref", headers=_auth_headers(token), json=payload, ) # 204 = success, 400 with "already exist" = also acceptable if response.status_code not in (204, 400): raise EntraAdminError( f"Failed to add user to group '{group_name}' (HTTP {response.status_code})" ) async def _remove_user_from_group_by_name( client: httpx.AsyncClient, token: str, user_id: str, group_name: str ) -> None: """Remove a user from a group by group display name.""" group_id = await _find_group_id_by_name(client, token, group_name) if not group_id: logger.warning(f"Group '{group_name}' not found, skipping removal") return response = await client.delete( f"{GRAPH_BASE_URL}/groups/{group_id}/members/{user_id}/$ref", headers=_auth_headers(token), ) # 204 = success, 404 = user not in group (also acceptable) if response.status_code not in (204, 404): raise EntraAdminError( f"Failed to remove user from group '{group_name}' (HTTP {response.status_code})" ) async def _add_service_principal_to_group( client: httpx.AsyncClient, token: str, sp_id: str, group_name: str ) -> None: """Add a service principal to a group by group display name. Includes retry logic to handle Entra ID eventual consistency where the service principal may not be immediately available after creation. """ logger.debug(f"Looking up group '{group_name}' for SP assignment") group_id = await _find_group_id_by_name(client, token, group_name) if not group_id: logger.warning(f"Group '{group_name}' not found in Entra ID, skipping assignment") return logger.debug(f"Found group '{group_name}' with ID: {group_id}") payload = {"@odata.id": f"{GRAPH_BASE_URL}/directoryObjects/{sp_id}"} # Retry logic for eventual consistency - SP may not be available immediately max_retries = 5 retry_delay = 2.0 for attempt in range(max_retries): response = await client.post( f"{GRAPH_BASE_URL}/groups/{group_id}/members/$ref", headers=_auth_headers(token), json=payload, ) # 204 = success, 400 with "already exist" = also acceptable if response.status_code == 204: logger.info(f"Successfully added service principal {sp_id} to group '{group_name}'") return if response.status_code == 400: # Check if already a member (acceptable) error_data = response.json() error_msg = error_data.get("error", {}).get("message", "") if "already exist" in error_msg.lower(): logger.info( f"Service principal {sp_id} is already a member of group '{group_name}'" ) return logger.warning(f"Failed to add SP to group '{group_name}': {error_msg}") return if response.status_code == 404: # Could be eventual consistency - SP not yet propagated error_data = response.json() error_msg = error_data.get("error", {}).get("message", "") logger.debug(f"HTTP 404 response: {error_msg}") if attempt < max_retries - 1: logger.warning( f"Service principal not yet available for group assignment " f"(attempt {attempt + 1}/{max_retries}), retrying in {retry_delay}s..." ) await asyncio.sleep(retry_delay) retry_delay *= 1.5 continue # Other error status codes logger.warning( f"Failed to add service principal to group '{group_name}': HTTP {response.status_code}" ) try: error_detail = response.json() logger.debug(f"Error details: {error_detail}") except Exception as e: logger.warning(f"Could not parse error response from Entra: {e}") return logger.warning( f"Failed to add service principal {sp_id} to group '{group_name}' " f"after {max_retries} retries" ) # ==================== USER MANAGEMENT ==================== async def list_entra_users( search: str | None = None, max_results: int = 500, include_groups: bool = True ) -> list[dict[str, Any]]: """ List users in Entra ID tenant. Args: search: Optional search filter (filters on displayName, userPrincipalName) max_results: Maximum number of results to return include_groups: Whether to include group memberships (slower) Returns: List of user dictionaries with id, username, email, etc. """ admin_token = await _get_entra_admin_token() async with httpx.AsyncClient(timeout=30.0) as client: # Build query parameters params: dict[str, Any] = { "$top": max_results, "$select": "id,displayName,userPrincipalName,mail,givenName,surname,accountEnabled", } if search: # Graph API filter syntax params["$filter"] = ( f"startswith(displayName,'{search}') or startswith(userPrincipalName,'{search}')" ) response = await client.get( f"{GRAPH_BASE_URL}/users", headers=_auth_headers(admin_token), params=params ) response.raise_for_status() data = response.json() users = data.get("value", []) # Transform to match Keycloak format result = [] for user in users: user_entry = { "id": user.get("id", ""), "username": user.get("userPrincipalName", ""), "email": user.get("mail"), "firstName": user.get("givenName"), "lastName": user.get("surname"), "enabled": user.get("accountEnabled", True), "groups": [], } # Optionally fetch group memberships if include_groups: user_entry["groups"] = await _get_user_groups(client, admin_token, user["id"]) result.append(user_entry) return result async def create_entra_human_user( username: str, email: str, first_name: str, last_name: str, groups: list[str], password: str | None = None, ) -> dict[str, Any]: """ Create a human user in Entra ID. Args: username: User principal name (must include @domain.com) email: Email address first_name: Given name last_name: Surname groups: List of group display names to add user to password: Initial password (if None, a random password is generated) Returns: User dictionary with id, username, etc. """ admin_token = await _get_entra_admin_token() # Entra ID requires userPrincipalName to include domain # If username doesn't have @, append the default domain if "@" not in username: # Get default domain from tenant default_domain = await _get_default_domain(admin_token) username = f"{username}@{default_domain}" user_payload = { "accountEnabled": True, "displayName": f"{first_name} {last_name}", "givenName": first_name, "surname": last_name, "userPrincipalName": username, "mail": email, "mailNickname": username.split("@")[0], "passwordProfile": { "forceChangePasswordNextSignIn": password is None, "password": password or _generate_temp_password(), }, } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{GRAPH_BASE_URL}/users", headers=_auth_headers(admin_token), json=user_payload ) if response.status_code == 409: raise EntraAdminError(f"User '{username}' already exists") response.raise_for_status() user_data = response.json() # Add user to groups user_id = user_data["id"] for group_name in groups: await _add_user_to_group_by_name(client, admin_token, user_id, group_name) return { "id": user_data.get("id"), "username": user_data.get("userPrincipalName"), "email": user_data.get("mail"), "firstName": user_data.get("givenName"), "lastName": user_data.get("surname"), "enabled": user_data.get("accountEnabled", True), "groups": groups, } async def delete_entra_user(username_or_id: str) -> bool: """ Delete a user from Entra ID. Args: username_or_id: User principal name or object ID Returns: True if successful """ admin_token = await _get_entra_admin_token() async with httpx.AsyncClient(timeout=10.0) as client: response = await client.delete( f"{GRAPH_BASE_URL}/users/{username_or_id}", headers=_auth_headers(admin_token) ) if response.status_code == 404: raise EntraAdminError(f"User '{username_or_id}' not found") if response.status_code != 204: raise EntraAdminError(f"Failed to delete user (HTTP {response.status_code})") logger.info(f"Deleted Entra ID user: {username_or_id}") return True # ==================== GROUP MANAGEMENT ==================== async def list_entra_groups() -> list[dict[str, Any]]: """ List groups in Entra ID tenant. When IDP_GROUP_FILTER_PREFIX is set, uses Microsoft Graph API OData $filter for server-side filtering (more efficient than client-side for large tenants). When not set, all groups are returned (backward compatible). Returns: List of group dictionaries """ from .iam_manager import IDP_GROUP_FILTER_PREFIXES admin_token = await _get_entra_admin_token() params: dict[str, str] = { "$select": "id,displayName,description,securityEnabled", } if IDP_GROUP_FILTER_PREFIXES: params["$filter"] = _build_prefix_odata_filter(IDP_GROUP_FILTER_PREFIXES) logger.info( "Filtering Entra ID groups by prefixes (server-side): %s", IDP_GROUP_FILTER_PREFIXES, ) async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get( f"{GRAPH_BASE_URL}/groups", headers=_auth_headers(admin_token), params=params, ) response.raise_for_status() data = response.json() groups = data.get("value", []) logger.info( "Retrieved %d groups from Entra ID%s", len(groups), f" (prefix filter: {IDP_GROUP_FILTER_PREFIXES})" if IDP_GROUP_FILTER_PREFIXES else "", ) return [ { "id": g.get("id", ""), "name": g.get("displayName", ""), "path": f"/{g.get('displayName', '')}", # Emulate Keycloak path format "attributes": { "description": [g.get("description", "")], "securityEnabled": g.get("securityEnabled", True), }, } for g in groups ] async def create_entra_group(group_name: str, description: str = "") -> dict[str, Any]: """ Create a security group in Entra ID. Args: group_name: Display name for the group description: Optional description Returns: Group dictionary with id, name, path """ admin_token = await _get_entra_admin_token() group_payload = { "displayName": group_name, "description": description, "mailEnabled": False, "mailNickname": group_name.replace(" ", "-").lower(), "securityEnabled": True, } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{GRAPH_BASE_URL}/groups", headers=_auth_headers(admin_token), json=group_payload ) if response.status_code == 400: error_data = response.json() error_msg = error_data.get("error", {}).get("message", "") if "already exists" in error_msg.lower(): raise EntraAdminError(f"Group '{group_name}' already exists") raise EntraAdminError(f"Failed to create group: {error_msg}") response.raise_for_status() group_data = response.json() logger.info(f"Created Entra ID group: {group_name}") return { "id": group_data.get("id", ""), "name": group_data.get("displayName", ""), "path": f"/{group_data.get('displayName', '')}", "attributes": {"description": [description]}, } async def delete_entra_group(group_name_or_id: str) -> bool: """ Delete a group from Entra ID. Args: group_name_or_id: Group display name or object ID Returns: True if successful """ admin_token = await _get_entra_admin_token() async with httpx.AsyncClient(timeout=10.0) as client: # If it looks like a name (not a GUID), find the group ID first group_id = group_name_or_id if not _is_guid(group_name_or_id): group_id = await _find_group_id_by_name(client, admin_token, group_name_or_id) if not group_id: raise EntraAdminError(f"Group '{group_name_or_id}' not found") response = await client.delete( f"{GRAPH_BASE_URL}/groups/{group_id}", headers=_auth_headers(admin_token) ) if response.status_code == 404: raise EntraAdminError(f"Group '{group_name_or_id}' not found") if response.status_code != 204: raise EntraAdminError(f"Failed to delete group (HTTP {response.status_code})") logger.info(f"Deleted Entra ID group: {group_name_or_id}") return True async def update_entra_group( group_name_or_id: str, description: str, ) -> dict[str, Any]: """ Update a group's description in Entra ID. Args: group_name_or_id: Group display name or object ID description: New description for the group Returns: Dictionary with updated group info (id, name, description) Raises: EntraAdminError: If group not found or update fails """ admin_token = await _get_entra_admin_token() async with httpx.AsyncClient(timeout=10.0) as client: # If it looks like a name (not a GUID), find the group ID first group_id = group_name_or_id group_display_name = group_name_or_id if not _is_guid(group_name_or_id): found_id = await _find_group_id_by_name(client, admin_token, group_name_or_id) if not found_id: raise EntraAdminError(f"Group '{group_name_or_id}' not found") group_id = found_id else: # If GUID provided, fetch the display name get_response = await client.get( f"{GRAPH_BASE_URL}/groups/{group_id}", headers=_auth_headers(admin_token), params={"$select": "displayName"}, ) if get_response.status_code == 404: raise EntraAdminError(f"Group '{group_name_or_id}' not found") get_response.raise_for_status() group_data = get_response.json() group_display_name = group_data.get("displayName", group_name_or_id) # Update group description via PATCH request update_payload = {"description": description} response = await client.patch( f"{GRAPH_BASE_URL}/groups/{group_id}", headers=_auth_headers(admin_token), json=update_payload, ) if response.status_code == 404: raise EntraAdminError(f"Group '{group_name_or_id}' not found") if response.status_code != 204: raise EntraAdminError(f"Failed to update group (HTTP {response.status_code})") logger.info(f"Updated Entra ID group: {group_display_name}") return { "id": group_id, "name": group_display_name, "description": description, } # ==================== SERVICE PRINCIPAL (M2M) MANAGEMENT ==================== async def create_service_principal_client( client_id_name: str, group_names: list[str], description: str | None = None ) -> dict[str, Any]: """ Create or update a service principal (app registration) with group assignments. For Entra ID M2M authentication, this creates: 1. An App Registration 2. A Service Principal 3. A client secret 4. Assigns app roles or groups Args: client_id_name: Name for the application group_names: List of group names to assign (via app roles or group membership) description: Optional description Returns: Dictionary with client_id, client_secret, groups """ admin_token = await _get_entra_admin_token() async with httpx.AsyncClient(timeout=30.0) as client: # 1. Create App Registration app_payload = { "displayName": client_id_name, "description": description or f"Service account for {client_id_name}", "signInAudience": "AzureADMyOrg", "api": {"requestedAccessTokenVersion": 2}, } app_response = await client.post( f"{GRAPH_BASE_URL}/applications", headers=_auth_headers(admin_token), json=app_payload ) if app_response.status_code == 400: error_data = app_response.json() error_msg = error_data.get("error", {}).get("message", "") raise EntraAdminError(f"Failed to create app registration: {error_msg}") app_response.raise_for_status() app_data = app_response.json() app_id = app_data["appId"] # This is the client_id app_object_id = app_data["id"] # Object ID for managing the app # 2. Create Service Principal for the app (with retry for eventual consistency) sp_payload = {"appId": app_id} sp_max_retries = 5 sp_retry_delay = 2.0 sp_object_id = None for sp_attempt in range(sp_max_retries): sp_response = await client.post( f"{GRAPH_BASE_URL}/servicePrincipals", headers=_auth_headers(admin_token), json=sp_payload, ) if sp_response.status_code in (200, 201): sp_data = sp_response.json() sp_object_id = sp_data["id"] logger.info(f"Created service principal: {sp_object_id}") break if sp_response.status_code == 400: error_data = sp_response.json() error_msg = error_data.get("error", {}).get("message", "") # Check if it's an eventual consistency issue if "does not reference a valid application object" in error_msg: if sp_attempt < sp_max_retries - 1: logger.warning( f"App not yet propagated for SP creation " f"(attempt {sp_attempt + 1}/{sp_max_retries}), " f"retrying in {sp_retry_delay}s..." ) await asyncio.sleep(sp_retry_delay) sp_retry_delay *= 1.5 continue # Check if SP already exists logger.warning(f"Service principal creation returned 400: {error_msg}") find_sp_response = await client.get( f"{GRAPH_BASE_URL}/servicePrincipals", headers=_auth_headers(admin_token), params={"$filter": f"appId eq '{app_id}'"}, ) find_sp_response.raise_for_status() find_sp_data = find_sp_response.json() existing_sps = find_sp_data.get("value", []) if existing_sps: sp_object_id = existing_sps[0]["id"] logger.info(f"Found existing service principal: {sp_object_id}") break raise EntraAdminError(f"Failed to create service principal: {error_msg}") sp_response.raise_for_status() if not sp_object_id: raise EntraAdminError( f"Failed to create service principal after {sp_max_retries} retries" ) # 3. Create client secret (with retry for eventual consistency) secret_payload = { "passwordCredential": { "displayName": f"{client_id_name}-secret", "endDateTime": "2099-12-31T23:59:59Z", # Long-lived for M2M } } # Retry logic for eventual consistency in Entra ID max_retries = 3 retry_delay = 2.0 client_secret = None for attempt in range(max_retries): secret_response = await client.post( f"{GRAPH_BASE_URL}/applications/{app_object_id}/addPassword", headers=_auth_headers(admin_token), json=secret_payload, ) if secret_response.status_code == 200: secret_data = secret_response.json() client_secret = secret_data["secretText"] break elif secret_response.status_code == 404 and attempt < max_retries - 1: # App not yet available due to eventual consistency logger.warning( f"App not ready for password creation (attempt {attempt + 1}/{max_retries}), " f"retrying in {retry_delay}s..." ) await asyncio.sleep(retry_delay) retry_delay *= 2 # Exponential backoff else: secret_response.raise_for_status() if not client_secret: raise EntraAdminError("Failed to create client secret after retries") # 4. Add service principal to groups for group_name in group_names: await _add_service_principal_to_group(client, admin_token, sp_object_id, group_name) logger.info(f"Created Entra ID service principal: {client_id_name}") return { "client_id": app_id, "client_uuid": app_object_id, "service_principal_id": sp_object_id, "client_secret": client_secret, "groups": group_names, } async def update_entra_user_groups( username_or_id: str, groups: list[str], ) -> dict[str, Any]: """ Update group memberships for an Entra ID user or service principal. Calculates the diff between current and desired groups, then adds/removes groups as needed. Args: username_or_id: User principal name, email, or object ID groups: List of group names the user should belong to Returns: Dict with username and updated groups list Raises: EntraAdminError: If user not found or group operations fail """ admin_token = await _get_entra_admin_token() async with httpx.AsyncClient(timeout=30.0) as client: # Try to find as a regular user first user_id = await _find_user_id(client, admin_token, username_or_id) is_service_principal = False if not user_id: # Try to find as a service principal by display name sp_response = await client.get( f"{GRAPH_BASE_URL}/servicePrincipals", headers=_auth_headers(admin_token), params={"$filter": f"displayName eq '{username_or_id}'"}, ) if sp_response.status_code == 200: sp_data = sp_response.json() sp_list = sp_data.get("value", []) if sp_list: user_id = sp_list[0].get("id") is_service_principal = True if not user_id: raise EntraAdminError(f"User or service principal '{username_or_id}' not found") # Get current groups current_groups_data = await _get_user_groups(client, admin_token, user_id) current_groups = set(current_groups_data) desired_groups = set(groups) # Calculate diff groups_to_add = desired_groups - current_groups groups_to_remove = current_groups - desired_groups # Apply changes for group_name in groups_to_add: if is_service_principal: await _add_service_principal_to_group(client, admin_token, user_id, group_name) else: await _add_user_to_group_by_name(client, admin_token, user_id, group_name) for group_name in groups_to_remove: await _remove_user_from_group_by_name(client, admin_token, user_id, group_name) logger.info( "Updated groups for %s '%s': added=%s, removed=%s", "service principal" if is_service_principal else "user", username_or_id, list(groups_to_add), list(groups_to_remove), ) return { "username": username_or_id, "groups": list(desired_groups), "added": list(groups_to_add), "removed": list(groups_to_remove), } ================================================ FILE: registry/utils/federation_encryption.py ================================================ """ Federation token encryption utilities. Provides Fernet-based encryption and decryption for federation static tokens stored in peer registry configurations (MongoDB/file). Uses the FEDERATION_ENCRYPTION_KEY environment variable as the encryption key. The encryption key must be a valid Fernet key (32 url-safe base64-encoded bytes). Generate one with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" """ import logging import os from cryptography.fernet import Fernet, InvalidToken logger = logging.getLogger(__name__) # Environment variable name for the encryption key FEDERATION_ENCRYPTION_KEY_ENV: str = "FEDERATION_ENCRYPTION_KEY" # Field names in peer config dicts PLAINTEXT_FIELD: str = "federation_token" ENCRYPTED_FIELD: str = "federation_token_encrypted" def _get_fernet() -> Fernet | None: """Get a Fernet instance from the FEDERATION_ENCRYPTION_KEY env var. Returns: Fernet instance, or None if key is not configured. """ key = os.environ.get(FEDERATION_ENCRYPTION_KEY_ENV) if not key: return None try: return Fernet(key.encode()) except Exception as e: logger.error( f"Invalid {FEDERATION_ENCRYPTION_KEY_ENV}: {e}. " "Generate a valid key with: python3 -c " '"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"' ) return None def encrypt_federation_token( token: str, ) -> str: """Encrypt a federation token for storage. Args: token: Plaintext federation token. Returns: Fernet-encrypted token string (base64-encoded). Raises: ValueError: If FEDERATION_ENCRYPTION_KEY is not set or invalid. """ fernet = _get_fernet() if not fernet: raise ValueError( f"{FEDERATION_ENCRYPTION_KEY_ENV} environment variable is not set or invalid. " "Cannot encrypt federation token for storage. " "Generate a key with: python3 -c " '"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"' ) encrypted = fernet.encrypt(token.encode()) return encrypted.decode() def decrypt_federation_token( encrypted_token: str, ) -> str | None: """Decrypt a federation token from storage. Args: encrypted_token: Fernet-encrypted token string. Returns: Plaintext federation token, or None if decryption fails. """ fernet = _get_fernet() if not fernet: logger.error( f"{FEDERATION_ENCRYPTION_KEY_ENV} not set. Cannot decrypt federation token. " "Peer sync will fail for peers using federation static tokens." ) return None try: decrypted = fernet.decrypt(encrypted_token.encode()) return decrypted.decode() except InvalidToken: logger.error( "Failed to decrypt federation token. The encryption key may have changed " "since the token was stored. Re-add the peer with the correct token." ) return None except Exception as e: logger.error(f"Unexpected error decrypting federation token: {e}") return None def encrypt_token_in_peer_dict( peer_dict: dict, ) -> dict: """Encrypt federation_token in a peer config dict before storage. If federation_token is present and non-empty, encrypts it into federation_token_encrypted and removes the plaintext field. If FEDERATION_ENCRYPTION_KEY is not set but a token is present, raises ValueError to prevent storing plaintext secrets. Args: peer_dict: Peer config dictionary (from model_dump). Returns: Modified dict with encrypted token (original dict is mutated). Raises: ValueError: If token is present but encryption key is not configured. """ token = peer_dict.get(PLAINTEXT_FIELD) if not token: # No token to encrypt, remove plaintext field if present peer_dict.pop(PLAINTEXT_FIELD, None) return peer_dict # Encrypt the token encrypted = encrypt_federation_token(token) peer_dict[ENCRYPTED_FIELD] = encrypted # Remove plaintext from storage dict peer_dict.pop(PLAINTEXT_FIELD, None) logger.info("Federation token encrypted for storage") return peer_dict def decrypt_token_in_peer_dict( peer_dict: dict, ) -> dict: """Decrypt federation_token_encrypted in a peer config dict after loading. If federation_token_encrypted is present, decrypts it into federation_token for use by PeerRegistryClient. Args: peer_dict: Peer config dictionary (from MongoDB/file). Returns: Modified dict with decrypted token (original dict is mutated). """ encrypted_token = peer_dict.get(ENCRYPTED_FIELD) if not encrypted_token: return peer_dict # Decrypt the token decrypted = decrypt_federation_token(encrypted_token) if decrypted: peer_dict[PLAINTEXT_FIELD] = decrypted else: logger.warning( "Could not decrypt federation token. Peer sync will fall back to global OAuth2 auth." ) # Remove encrypted field from the dict before constructing PeerRegistryConfig # (PeerRegistryConfig doesn't have a federation_token_encrypted field) peer_dict.pop(ENCRYPTED_FIELD, None) return peer_dict ================================================ FILE: registry/utils/iam_manager.py ================================================ """ IAM Manager factory for multi-provider support. This module provides a unified interface for IAM operations across different identity providers (Keycloak, Entra ID, Okta, Auth0). """ import logging import os import re from typing import ( Any, Protocol, runtime_checkable, ) # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) AUTH_PROVIDER: str = os.environ.get("AUTH_PROVIDER", "keycloak") # IdP group filtering -- applies to all identity providers IDP_GROUP_FILTER_PREFIX: str = os.environ.get("IDP_GROUP_FILTER_PREFIX", "") # Parse comma-separated prefixes and validate each one to prevent injection IDP_GROUP_FILTER_PREFIXES: list[str] = [] if IDP_GROUP_FILTER_PREFIX: IDP_GROUP_FILTER_PREFIXES = [p.strip() for p in IDP_GROUP_FILTER_PREFIX.split(",") if p.strip()] for _prefix in IDP_GROUP_FILTER_PREFIXES: if not re.match(r"^[a-zA-Z0-9\-_ ]+$", _prefix): raise ValueError( f"IDP_GROUP_FILTER_PREFIX contains invalid characters in " f"prefix '{_prefix}'. " f"Only alphanumeric, hyphens, underscores, and spaces are allowed." ) logger.info("IdP group filter prefixes: %s", IDP_GROUP_FILTER_PREFIXES) def _filter_groups_by_prefix( groups: list[dict[str, Any]], prefixes: list[str], ) -> list[dict[str, Any]]: """ Filter groups by display name prefix (client-side fallback). Used when the IdP API does not support server-side prefix filtering. Args: groups: List of group dictionaries with a 'name' key prefixes: List of allowed prefixes Returns: Filtered list of groups whose name starts with any prefix """ if not prefixes: return groups return [g for g in groups if any(g.get("name", "").startswith(prefix) for prefix in prefixes)] @runtime_checkable class IAMManager(Protocol): """Protocol defining the IAM manager interface.""" async def list_users( self, search: str | None = None, max_results: int = 500, include_groups: bool = True ) -> list[dict[str, Any]]: """ List users from the identity provider. Args: search: Optional search filter max_results: Maximum number of results to return include_groups: Whether to include group memberships Returns: List of user dictionaries """ ... async def create_human_user( self, username: str, email: str, first_name: str, last_name: str, groups: list[str], password: str | None = None, ) -> dict[str, Any]: """ Create a human user account. Args: username: Username for the account email: Email address first_name: First name last_name: Last name groups: List of group names to assign password: Optional initial password Returns: User dictionary with created user details """ ... async def delete_user(self, username: str) -> bool: """ Delete a user by username. Args: username: Username or identifier of the user to delete Returns: True if successful """ ... async def list_groups(self) -> list[dict[str, Any]]: """ List all groups from the identity provider. Returns: List of group dictionaries """ ... async def create_group(self, group_name: str, description: str = "") -> dict[str, Any]: """ Create a group in the identity provider. Args: group_name: Name of the group to create description: Optional description Returns: Group dictionary with created group details """ ... async def delete_group(self, group_name: str) -> bool: """ Delete a group from the identity provider. Args: group_name: Name or identifier of the group to delete Returns: True if successful """ ... async def group_exists(self, group_name: str) -> bool: """ Check if a group exists in the identity provider. Args: group_name: Name of the group to check Returns: True if group exists, False otherwise """ ... async def create_service_account( self, client_id: str, groups: list[str], description: str | None = None ) -> dict[str, Any]: """ Create a service account (M2M) in the identity provider. Args: client_id: Client ID for the service account groups: List of group names to assign description: Optional description Returns: Dictionary with client_id, client_secret, and groups """ ... async def update_user_groups(self, username: str, groups: list[str]) -> dict[str, Any]: """ Update group memberships for a user or service account. Args: username: Username or client ID of the user/service account groups: List of group names the user should belong to Returns: Dictionary with username, groups, added, and removed lists """ ... async def update_group( self, group_name: str, description: str = "", ) -> dict[str, Any]: """ Update a group's properties in the identity provider. Args: group_name: Name of the group to update description: New description for the group Returns: Dictionary with updated group details (id, name, path, attributes) """ ... class KeycloakIAMManager: """Keycloak IAM manager implementation.""" async def list_users( self, search: str | None = None, max_results: int = 500, include_groups: bool = True ) -> list[dict[str, Any]]: """List users from Keycloak.""" from .keycloak_manager import list_keycloak_users return await list_keycloak_users( search=search, max_results=max_results, include_groups=include_groups ) async def create_human_user( self, username: str, email: str, first_name: str, last_name: str, groups: list[str], password: str | None = None, ) -> dict[str, Any]: """Create a human user in Keycloak.""" from .keycloak_manager import create_human_user_account return await create_human_user_account( username=username, email=email, first_name=first_name, last_name=last_name, groups=groups, password=password, ) async def delete_user(self, username: str) -> bool: """Delete a user from Keycloak.""" from .keycloak_manager import delete_keycloak_user return await delete_keycloak_user(username=username) async def list_groups(self) -> list[dict[str, Any]]: """List groups from Keycloak, filtered by IDP_GROUP_FILTER_PREFIX if set.""" from .keycloak_manager import list_keycloak_groups groups = await list_keycloak_groups() return _filter_groups_by_prefix(groups, IDP_GROUP_FILTER_PREFIXES) async def create_group(self, group_name: str, description: str = "") -> dict[str, Any]: """Create a group in Keycloak.""" from .keycloak_manager import create_keycloak_group return await create_keycloak_group(group_name=group_name, description=description) async def delete_group(self, group_name: str) -> bool: """Delete a group from Keycloak.""" from .keycloak_manager import delete_keycloak_group return await delete_keycloak_group(group_name=group_name) async def group_exists(self, group_name: str) -> bool: """Check if a group exists in Keycloak.""" from .keycloak_manager import group_exists_in_keycloak return await group_exists_in_keycloak(group_name) async def create_service_account( self, client_id: str, groups: list[str], description: str | None = None ) -> dict[str, Any]: """Create a service account client in Keycloak.""" from .keycloak_manager import create_service_account_client return await create_service_account_client( client_id=client_id, group_names=groups, description=description ) async def update_user_groups(self, username: str, groups: list[str]) -> dict[str, Any]: """Update group memberships for a Keycloak user or service account.""" from .keycloak_manager import update_keycloak_user_groups return await update_keycloak_user_groups(username=username, groups=groups) async def update_group( self, group_name: str, description: str = "", ) -> dict[str, Any]: """Update a group's properties in Keycloak.""" from .keycloak_manager import update_keycloak_group return await update_keycloak_group(group_name=group_name, description=description) class EntraIAMManager: """Entra ID IAM manager implementation.""" async def list_users( self, search: str | None = None, max_results: int = 500, include_groups: bool = True ) -> list[dict[str, Any]]: """List users from Entra ID.""" from .entra_manager import list_entra_users return await list_entra_users( search=search, max_results=max_results, include_groups=include_groups ) async def create_human_user( self, username: str, email: str, first_name: str, last_name: str, groups: list[str], password: str | None = None, ) -> dict[str, Any]: """Create a human user in Entra ID.""" from .entra_manager import create_entra_human_user return await create_entra_human_user( username=username, email=email, first_name=first_name, last_name=last_name, groups=groups, password=password, ) async def delete_user(self, username: str) -> bool: """Delete a user from Entra ID.""" from .entra_manager import delete_entra_user return await delete_entra_user(username_or_id=username) async def list_groups(self) -> list[dict[str, Any]]: """List all groups from Entra ID.""" from .entra_manager import list_entra_groups return await list_entra_groups() async def create_group(self, group_name: str, description: str = "") -> dict[str, Any]: """Create a group in Entra ID.""" from .entra_manager import create_entra_group return await create_entra_group(group_name=group_name, description=description) async def delete_group(self, group_name: str) -> bool: """Delete a group from Entra ID.""" from .entra_manager import delete_entra_group return await delete_entra_group(group_name_or_id=group_name) async def group_exists(self, group_name: str) -> bool: """Check if a group exists in Entra ID.""" from .entra_manager import list_entra_groups try: groups = await list_entra_groups() return any(g.get("name", "").lower() == group_name.lower() for g in groups) except Exception: return False async def create_service_account( self, client_id: str, groups: list[str], description: str | None = None ) -> dict[str, Any]: """Create a service principal (app registration) in Entra ID.""" from .entra_manager import create_service_principal_client return await create_service_principal_client( client_id_name=client_id, group_names=groups, description=description ) async def update_user_groups(self, username: str, groups: list[str]) -> dict[str, Any]: """Update group memberships for an Entra ID user or service principal.""" from .entra_manager import update_entra_user_groups return await update_entra_user_groups(username_or_id=username, groups=groups) async def update_group( self, group_name: str, description: str = "", ) -> dict[str, Any]: """Update a group's properties in Entra ID.""" from .entra_manager import update_entra_group return await update_entra_group(group_name_or_id=group_name, description=description) class OktaIAMManager: """Okta IAM manager implementation.""" async def list_users( self, search: str | None = None, max_results: int = 500, include_groups: bool = True ) -> list[dict[str, Any]]: """List users from Okta.""" from .okta_manager import list_okta_users return await list_okta_users( search=search, max_results=max_results, include_groups=include_groups ) async def create_human_user( self, username: str, email: str, first_name: str, last_name: str, groups: list[str], password: str | None = None, ) -> dict[str, Any]: """Create a human user in Okta.""" from .okta_manager import create_okta_human_user return await create_okta_human_user( username=username, email=email, first_name=first_name, last_name=last_name, groups=groups, password=password, ) async def delete_user(self, username: str) -> bool: """Delete a user from Okta.""" from .okta_manager import delete_okta_user return await delete_okta_user(username_or_id=username) async def list_groups(self) -> list[dict[str, Any]]: """List groups from Okta, filtered by IDP_GROUP_FILTER_PREFIX if set.""" from .okta_manager import list_okta_groups groups = await list_okta_groups() return _filter_groups_by_prefix(groups, IDP_GROUP_FILTER_PREFIXES) async def create_group(self, group_name: str, description: str = "") -> dict[str, Any]: """Create a group in Okta.""" from .okta_manager import create_okta_group return await create_okta_group(group_name=group_name, description=description) async def delete_group(self, group_name: str) -> bool: """Delete a group from Okta.""" from .okta_manager import delete_okta_group return await delete_okta_group(group_name_or_id=group_name) async def group_exists(self, group_name: str) -> bool: """Check if a group exists in Okta.""" from .okta_manager import list_okta_groups try: groups = await list_okta_groups() return any(g.get("name", "").lower() == group_name.lower() for g in groups) except Exception: return False async def create_service_account( self, client_id: str, groups: list[str], description: str | None = None ) -> dict[str, Any]: """Create an OAuth2 service application in Okta.""" from .okta_manager import create_okta_service_account return await create_okta_service_account( client_id_name=client_id, group_names=groups, description=description ) async def update_user_groups(self, username: str, groups: list[str]) -> dict[str, Any]: """Update group memberships for an Okta user.""" from .okta_manager import update_okta_user_groups return await update_okta_user_groups(username_or_id=username, groups=groups) async def update_group( self, group_name: str, description: str = "", ) -> dict[str, Any]: """Update a group's properties in Okta.""" from .okta_manager import update_okta_group return await update_okta_group(group_name_or_id=group_name, description=description) class Auth0IAMManager: """Auth0 IAM manager implementation.""" async def list_users( self, search: str | None = None, max_results: int = 500, include_groups: bool = True ) -> list[dict[str, Any]]: """List users from Auth0.""" from .auth0_manager import list_auth0_users return await list_auth0_users( search=search, max_results=max_results, include_groups=include_groups ) async def create_human_user( self, username: str, email: str, first_name: str, last_name: str, groups: list[str], password: str | None = None, ) -> dict[str, Any]: """Create a human user in Auth0.""" from .auth0_manager import create_auth0_human_user return await create_auth0_human_user( username=username, email=email, first_name=first_name, last_name=last_name, groups=groups, password=password, ) async def delete_user(self, username: str) -> bool: """Delete a user from Auth0.""" from .auth0_manager import delete_auth0_user return await delete_auth0_user(username_or_id=username) async def list_groups(self) -> list[dict[str, Any]]: """List roles (groups) from Auth0, filtered by IDP_GROUP_FILTER_PREFIX if set.""" from .auth0_manager import list_auth0_groups groups = await list_auth0_groups() return _filter_groups_by_prefix(groups, IDP_GROUP_FILTER_PREFIXES) async def create_group(self, group_name: str, description: str = "") -> dict[str, Any]: """Create a role (group) in Auth0.""" from .auth0_manager import create_auth0_group return await create_auth0_group(group_name=group_name, description=description) async def delete_group(self, group_name: str) -> bool: """Delete a role (group) from Auth0.""" from .auth0_manager import delete_auth0_group return await delete_auth0_group(group_name_or_id=group_name) async def group_exists(self, group_name: str) -> bool: """Check if a role (group) exists in Auth0.""" from .auth0_manager import list_auth0_groups try: groups = await list_auth0_groups() return any(g.get("name", "").lower() == group_name.lower() for g in groups) except Exception: return False async def create_service_account( self, client_id: str, groups: list[str], description: str | None = None ) -> dict[str, Any]: """Create an M2M application (service account) in Auth0.""" from .auth0_manager import create_auth0_service_account return await create_auth0_service_account( client_id_name=client_id, group_names=groups, description=description ) async def update_user_groups(self, username: str, groups: list[str]) -> dict[str, Any]: """Update role (group) memberships for an Auth0 user.""" from .auth0_manager import update_auth0_user_groups return await update_auth0_user_groups(username_or_id=username, groups=groups) async def update_group( self, group_name: str, description: str = "", ) -> dict[str, Any]: """Update a role's (group's) properties in Auth0.""" from .auth0_manager import update_auth0_group return await update_auth0_group(group_name_or_id=group_name, description=description) def get_iam_manager() -> IAMManager: """ Factory function to get the appropriate IAM manager based on AUTH_PROVIDER. Returns: IAMManager implementation for the configured provider """ provider = AUTH_PROVIDER.lower() if provider == "keycloak": logger.debug("Using Keycloak IAM manager") return KeycloakIAMManager() elif provider == "entra": logger.debug("Using Entra ID IAM manager") return EntraIAMManager() elif provider == "okta": logger.debug("Using Okta IAM manager") return OktaIAMManager() elif provider == "auth0": logger.debug("Using Auth0 IAM manager") return Auth0IAMManager() else: logger.warning(f"Unknown AUTH_PROVIDER '{provider}', defaulting to Keycloak") return KeycloakIAMManager() ================================================ FILE: registry/utils/keycloak_manager.py ================================================ """ Keycloak group management utilities. This module provides functions to manage groups in Keycloak via the Admin REST API. It handles authentication, group CRUD operations, and integrates with the registry. """ import logging import os from typing import Any import httpx logger = logging.getLogger(__name__) KEYCLOAK_ADMIN_URL: str = os.environ.get("KEYCLOAK_URL", "http://keycloak:8080") KEYCLOAK_REALM: str = os.environ.get("KEYCLOAK_REALM", "mcp-gateway") KEYCLOAK_ADMIN: str = os.environ.get("KEYCLOAK_ADMIN", "admin") KEYCLOAK_ADMIN_PASSWORD: str | None = os.environ.get("KEYCLOAK_ADMIN_PASSWORD") class KeycloakAdminError(RuntimeError): """Raised when Keycloak admin API operations fail.""" async def _get_keycloak_admin_token() -> str: """ Get admin access token from Keycloak for Admin API calls. Returns: Admin access token string Raises: Exception: If authentication fails """ if not KEYCLOAK_ADMIN_PASSWORD: raise Exception("KEYCLOAK_ADMIN_PASSWORD environment variable not set") token_url = f"{KEYCLOAK_ADMIN_URL}/realms/master/protocol/openid-connect/token" data = { "username": KEYCLOAK_ADMIN, "password": KEYCLOAK_ADMIN_PASSWORD, "grant_type": "password", "client_id": "admin-cli", } headers = {"Content-Type": "application/x-www-form-urlencoded"} try: async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(token_url, data=data, headers=headers) response.raise_for_status() token_data = response.json() access_token = token_data.get("access_token") if not access_token: raise Exception("No access token in Keycloak response") logger.info("Successfully obtained Keycloak admin token") return access_token except httpx.HTTPStatusError as e: logger.error(f"Failed to authenticate with Keycloak: HTTP {e.response.status_code}") raise Exception(f"Keycloak authentication failed: HTTP {e.response.status_code}") from e except Exception as e: logger.error(f"Error getting Keycloak admin token: {e}") raise Exception(f"Failed to authenticate with Keycloak: {e}") from e def _auth_headers(token: str, content_type: str | None = "application/json") -> dict[str, str]: """Build auth headers for Keycloak admin API.""" headers = {"Authorization": f"Bearer {token}"} if content_type: headers["Content-Type"] = content_type return headers async def _get_group_name_map( client: httpx.AsyncClient, token: str, ) -> dict[str, str]: """Return mapping of Keycloak group name to ID.""" groups_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/groups" response = await client.get(groups_url, headers=_auth_headers(token, None)) response.raise_for_status() groups = response.json() return {group.get("name"): group.get("id") for group in groups if group.get("id")} async def _find_client_uuid( client: httpx.AsyncClient, token: str, client_id: str, ) -> str | None: """Look up a client UUID by clientId.""" clients_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/clients" response = await client.get( clients_url, headers=_auth_headers(token, None), params={"clientId": client_id}, ) response.raise_for_status() clients = response.json() if clients: return clients[0].get("id") return None def _extract_resource_id(location_header: str | None) -> str | None: """Extract trailing resource ID from a Location header.""" if not location_header: return None return location_header.rstrip("/").split("/")[-1] async def create_keycloak_group(group_name: str, description: str = "") -> dict[str, Any]: """ Create a group in Keycloak. Args: group_name: Name of the group to create description: Optional description for the group Returns: Dict containing group information including ID Raises: Exception: If group creation fails """ logger.info(f"Creating Keycloak group: {group_name}") try: # Get admin token admin_token = await _get_keycloak_admin_token() # Prepare group data group_data = { "name": group_name, "attributes": {"description": [description] if description else []}, } # Create group via Admin API groups_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/groups" headers = {"Authorization": f"Bearer {admin_token}", "Content-Type": "application/json"} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post(groups_url, json=group_data, headers=headers) if response.status_code == 201: logger.info(f"Successfully created Keycloak group: {group_name}") # Get the created group's details group_info = await get_keycloak_group(group_name) return group_info elif response.status_code == 409: logger.warning(f"Group already exists in Keycloak: {group_name}") raise Exception(f"Group '{group_name}' already exists in Keycloak") else: logger.error( f"Failed to create group: HTTP {response.status_code} - {response.text}" ) raise Exception(f"Failed to create group in Keycloak: HTTP {response.status_code}") except Exception as e: logger.error(f"Error creating Keycloak group '{group_name}': {e}") raise async def delete_keycloak_group(group_name: str) -> bool: """ Delete a group from Keycloak. Args: group_name: Name of the group to delete Returns: True if successful Raises: Exception: If group deletion fails """ logger.info(f"Deleting Keycloak group: {group_name}") try: # Get admin token admin_token = await _get_keycloak_admin_token() # First, get the group ID group_info = await get_keycloak_group(group_name) group_id = group_info.get("id") if not group_id: raise Exception(f"Group '{group_name}' not found in Keycloak") # Delete group via Admin API delete_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/groups/{group_id}" headers = {"Authorization": f"Bearer {admin_token}"} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.delete(delete_url, headers=headers) if response.status_code == 204: logger.info(f"Successfully deleted Keycloak group: {group_name}") return True elif response.status_code == 404: logger.warning(f"Group not found in Keycloak: {group_name}") raise Exception(f"Group '{group_name}' not found in Keycloak") else: logger.error( f"Failed to delete group: HTTP {response.status_code} - {response.text}" ) raise Exception( f"Failed to delete group from Keycloak: HTTP {response.status_code}" ) except Exception as e: logger.error(f"Error deleting Keycloak group '{group_name}': {e}") raise async def get_keycloak_group(group_name: str) -> dict[str, Any]: """ Get a group's details from Keycloak by name. Args: group_name: Name of the group to retrieve Returns: Dict containing group information (id, name, path, attributes, etc.) Raises: Exception: If group retrieval fails or group not found """ logger.info(f"Getting Keycloak group: {group_name}") try: # Get admin token admin_token = await _get_keycloak_admin_token() # List all groups and find the one with matching name groups_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/groups" headers = {"Authorization": f"Bearer {admin_token}"} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(groups_url, headers=headers) response.raise_for_status() groups = response.json() # Find group by name for group in groups: if group.get("name") == group_name: logger.info(f"Found group: {group_name} with ID: {group.get('id')}") return group # Group not found raise Exception(f"Group '{group_name}' not found in Keycloak") except httpx.HTTPStatusError as e: logger.error(f"HTTP error getting group: {e.response.status_code}") raise Exception(f"Failed to get group from Keycloak: HTTP {e.response.status_code}") from e except Exception as e: logger.error(f"Error getting Keycloak group '{group_name}': {e}") raise async def list_keycloak_groups() -> list[dict[str, Any]]: """ List all groups in Keycloak realm. Returns: List of dicts containing group information Raises: Exception: If listing groups fails """ logger.info("Listing all Keycloak groups") try: # Get admin token admin_token = await _get_keycloak_admin_token() # List all groups groups_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/groups" headers = {"Authorization": f"Bearer {admin_token}"} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(groups_url, headers=headers) response.raise_for_status() groups = response.json() logger.info(f"Retrieved {len(groups)} groups from Keycloak") return groups except httpx.HTTPStatusError as e: logger.error(f"HTTP error listing groups: {e.response.status_code}") raise Exception( f"Failed to list groups from Keycloak: HTTP {e.response.status_code}" ) from e except Exception as e: logger.error(f"Error listing Keycloak groups: {e}") raise async def group_exists_in_keycloak(group_name: str) -> bool: """ Check if a group exists in Keycloak. Args: group_name: Name of the group to check Returns: True if group exists, False otherwise """ try: await get_keycloak_group(group_name) return True except Exception: return False async def update_keycloak_group(group_name: str, description: str) -> dict[str, Any]: """ Update a group's description in Keycloak. Args: group_name: Name of the group to update description: New description for the group Returns: Dict containing updated group information (id, name, path, attributes) Raises: Exception: If group update fails or group not found """ logger.info(f"Updating Keycloak group: {group_name}") try: # Get admin token admin_token = await _get_keycloak_admin_token() async with httpx.AsyncClient(timeout=10.0) as client: # Find the group by name to get its ID name_map = await _get_group_name_map(client, admin_token) group_id = name_map.get(group_name) if not group_id: raise Exception(f"Group '{group_name}' not found in Keycloak") # Prepare updated group data group_data = { "name": group_name, "attributes": {"description": [description] if description else []}, } # Update group via Admin API update_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/groups/{group_id}" headers = _auth_headers(admin_token) response = await client.put(update_url, json=group_data, headers=headers) if response.status_code == 204: logger.info(f"Successfully updated Keycloak group: {group_name}") # Get the updated group's details group_info = await get_keycloak_group(group_name) return { "id": group_info.get("id"), "name": group_info.get("name"), "path": group_info.get("path"), "attributes": group_info.get("attributes", {}), } elif response.status_code == 404: logger.warning(f"Group not found in Keycloak: {group_name}") raise Exception(f"Group '{group_name}' not found in Keycloak") else: logger.error( f"Failed to update group: HTTP {response.status_code} - {response.text}" ) raise Exception(f"Failed to update group in Keycloak: HTTP {response.status_code}") except Exception as e: logger.error(f"Error updating Keycloak group '{group_name}': {e}") raise def _normalize_group_list(groups: list[str]) -> list[str]: """Clean and validate incoming group list.""" normalized = [group.strip() for group in groups if group and group.strip()] if not normalized: raise KeycloakAdminError("At least one group must be provided") return normalized async def _assign_user_to_groups_by_name( client: httpx.AsyncClient, token: str, user_id: str, groups: list[str], ) -> None: """Assign a Keycloak user/service account to a set of groups.""" if not groups: return name_map = await _get_group_name_map(client, token) for group_name in groups: group_id = name_map.get(group_name) if not group_id: raise KeycloakAdminError(f"Group '{group_name}' not found in Keycloak") assign_url = ( f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{user_id}/groups/{group_id}" ) response = await client.put(assign_url, headers=_auth_headers(token, None)) if response.status_code not in (204, 409): logger.error( "Failed assigning user %s to group %s: %s", user_id, group_name, response.text ) raise KeycloakAdminError( f"Failed to assign group '{group_name}' (HTTP {response.status_code})" ) async def _remove_user_from_groups_by_name( client: httpx.AsyncClient, token: str, user_id: str, groups: list[str], ) -> None: """Remove a Keycloak user from a set of groups.""" if not groups: return name_map = await _get_group_name_map(client, token) for group_name in groups: group_id = name_map.get(group_name) if not group_id: logger.warning("Group '%s' not found in Keycloak, skipping removal", group_name) continue remove_url = ( f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{user_id}/groups/{group_id}" ) response = await client.delete(remove_url, headers=_auth_headers(token, None)) if response.status_code not in (204, 404): logger.error( "Failed removing user %s from group %s: %s", user_id, group_name, response.text ) raise KeycloakAdminError( f"Failed to remove group '{group_name}' (HTTP {response.status_code})" ) async def _get_user_groups( client: httpx.AsyncClient, token: str, user_id: str, ) -> list[str]: """Fetch group names for a given Keycloak user.""" groups_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{user_id}/groups" response = await client.get(groups_url, headers=_auth_headers(token, None)) response.raise_for_status() groups = response.json() return [group.get("name") for group in groups if group.get("name")] async def _get_user_by_username( client: httpx.AsyncClient, token: str, username: str, ) -> dict[str, Any] | None: """Look up a user in Keycloak by username.""" users_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users" response = await client.get( users_url, headers=_auth_headers(token, None), params={"username": username}, ) response.raise_for_status() matches = response.json() for user in matches: if user.get("username") == username: return user return None async def _get_user_by_id( client: httpx.AsyncClient, token: str, user_id: str, ) -> dict[str, Any]: """Fetch a user document by ID.""" user_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{user_id}" response = await client.get(user_url, headers=_auth_headers(token, None)) response.raise_for_status() return response.json() async def _ensure_client( client: httpx.AsyncClient, token: str, client_id: str, description: str | None, ) -> str: """Create the client if it does not yet exist and return UUID.""" existing_uuid = await _find_client_uuid(client, token, client_id) if existing_uuid: return existing_uuid payload = { "clientId": client_id, "name": client_id, "description": description or f"Service account for {client_id}", "enabled": True, "clientAuthenticatorType": "client-secret", "serviceAccountsEnabled": True, "standardFlowEnabled": False, "directAccessGrantsEnabled": False, "publicClient": False, "bearerOnly": False, "protocol": "openid-connect", } clients_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/clients" response = await client.post(clients_url, headers=_auth_headers(token), json=payload) if response.status_code not in (201, 204): logger.error("Failed to create client %s: %s", client_id, response.text) raise KeycloakAdminError( f"Failed to create service account client '{client_id}' (HTTP {response.status_code})" ) created_id = _extract_resource_id(response.headers.get("Location")) if created_id: return created_id client_uuid = await _find_client_uuid(client, token, client_id) if not client_uuid: raise KeycloakAdminError(f"Unable to resolve client ID for '{client_id}' after creation") return client_uuid async def _ensure_groups_mapper( client: httpx.AsyncClient, token: str, client_uuid: str, ) -> None: """Ensure the standard groups protocol mapper exists for the client.""" mapper_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/clients/{client_uuid}/protocol-mappers/models" response = await client.get(mapper_url, headers=_auth_headers(token, None)) response.raise_for_status() mappers = response.json() if any(mapper.get("name") == "groups" for mapper in mappers): return mapper_payload = { "name": "groups", "protocol": "openid-connect", "protocolMapper": "oidc-group-membership-mapper", "consentRequired": False, "config": { "full.path": "false", "id.token.claim": "true", "access.token.claim": "true", "claim.name": "groups", "userinfo.token.claim": "true", }, } create_response = await client.post( mapper_url, headers=_auth_headers(token), json=mapper_payload ) if create_response.status_code not in (201, 409): logger.error( "Failed to create groups mapper for client %s: %s", client_uuid, create_response.text, ) raise KeycloakAdminError( f"Failed to create groups mapper (HTTP {create_response.status_code})" ) async def _get_service_account_user_id( client: httpx.AsyncClient, token: str, client_uuid: str, ) -> str: """Return the user ID of the service account backing a client.""" sa_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/clients/{client_uuid}/service-account-user" response = await client.get(sa_url, headers=_auth_headers(token, None)) response.raise_for_status() data = response.json() user_id = data.get("id") if not user_id: raise KeycloakAdminError("Unable to determine service account user ID") return user_id async def _get_client_secret_value( client: httpx.AsyncClient, token: str, client_uuid: str, ) -> str: """Fetch the client secret value for the specified client.""" secret_url = ( f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/clients/{client_uuid}/client-secret" ) response = await client.get(secret_url, headers=_auth_headers(token, None)) response.raise_for_status() data = response.json() secret_value = data.get("value") if not secret_value: raise KeycloakAdminError("Keycloak did not return a client secret value") return secret_value async def _set_initial_password( client: httpx.AsyncClient, token: str, user_id: str, password: str, temporary: bool = False, ) -> None: """Set the initial password for a created user.""" password_url = ( f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{user_id}/reset-password" ) payload = { "type": "password", "value": password, "temporary": temporary, } response = await client.put(password_url, headers=_auth_headers(token), json=payload) if response.status_code != 204: logger.error("Failed to set initial password for user %s: %s", user_id, response.text) raise KeycloakAdminError(f"Failed to set password (HTTP {response.status_code})") async def create_service_account_client( client_id: str, group_names: list[str], description: str | None = None, ) -> dict[str, Any]: """ Create or update a service account client with group assignments. Returns: Dict with client_id, client_uuid, service_account_user_id, client_secret, and groups. """ normalized_groups = _normalize_group_list(group_names) admin_token = await _get_keycloak_admin_token() async with httpx.AsyncClient(timeout=10.0) as client: client_uuid = await _ensure_client(client, admin_token, client_id, description) await _ensure_groups_mapper(client, admin_token, client_uuid) service_account_user_id = await _get_service_account_user_id( client, admin_token, client_uuid ) await _assign_user_to_groups_by_name( client, admin_token, service_account_user_id, normalized_groups ) client_secret = await _get_client_secret_value(client, admin_token, client_uuid) logger.info( "Configured service account client '%s' with groups: %s", client_id, normalized_groups ) return { "client_id": client_id, "client_uuid": client_uuid, "service_account_user_id": service_account_user_id, "client_secret": client_secret, "groups": normalized_groups, } async def create_human_user_account( username: str, email: str, first_name: str, last_name: str, groups: list[str], password: str | None = None, ) -> dict[str, Any]: """ Create a human Keycloak user and assign groups. """ normalized_groups = _normalize_group_list(groups) admin_token = await _get_keycloak_admin_token() async with httpx.AsyncClient(timeout=10.0) as client: existing = await _get_user_by_username(client, admin_token, username) if existing: raise KeycloakAdminError(f"User '{username}' already exists") user_payload = { "username": username, "email": email, "firstName": first_name, "lastName": last_name, "enabled": True, "emailVerified": False, } users_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users" response = await client.post( users_url, headers=_auth_headers(admin_token), json=user_payload ) if response.status_code not in (201, 204): logger.error("Failed to create user %s: %s", username, response.text) raise KeycloakAdminError( f"Failed to create user '{username}' (HTTP {response.status_code})" ) created_id = _extract_resource_id(response.headers.get("Location")) if not created_id: new_user = await _get_user_by_username(client, admin_token, username) if not new_user: raise KeycloakAdminError(f"Unable to resolve new user ID for '{username}'") created_id = new_user.get("id") if password: await _set_initial_password(client, admin_token, created_id, password) await _assign_user_to_groups_by_name(client, admin_token, created_id, normalized_groups) user_doc = await _get_user_by_id(client, admin_token, created_id) user_doc["groups"] = normalized_groups logger.info("Created Keycloak user '%s' with groups: %s", username, normalized_groups) return user_doc async def delete_keycloak_user(username: str) -> bool: """ Delete a Keycloak user or M2M service account by username. This function handles both: - Human users: deleted via the users endpoint - M2M service accounts: deleted via the clients endpoint (they are Keycloak clients) """ admin_token = await _get_keycloak_admin_token() async with httpx.AsyncClient(timeout=10.0) as client: # Try to find as a regular user first user = await _get_user_by_username(client, admin_token, username) if user: # It's a human user - delete via users endpoint user_id = user.get("id") delete_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{user_id}" response = await client.delete(delete_url, headers=_auth_headers(admin_token, None)) if response.status_code != 204: logger.error("Failed to delete user %s: %s", username, response.text) raise KeycloakAdminError( f"Failed to delete user '{username}' (HTTP {response.status_code})" ) logger.info("Deleted Keycloak user '%s'", username) return True # Not found as user - try to find as a client (M2M service account) client_uuid = await _find_client_uuid(client, admin_token, username) if client_uuid: # It's an M2M service account - delete via clients endpoint delete_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/clients/{client_uuid}" response = await client.delete(delete_url, headers=_auth_headers(admin_token, None)) if response.status_code != 204: logger.error("Failed to delete M2M client %s: %s", username, response.text) raise KeycloakAdminError( f"Failed to delete M2M client '{username}' (HTTP {response.status_code})" ) logger.info("Deleted Keycloak M2M service account (client) '%s'", username) return True # Not found as either user or client raise KeycloakAdminError(f"User or M2M account '{username}' not found") async def list_keycloak_users( search: str | None = None, max_results: int = 500, include_groups: bool = True, ) -> list[dict[str, Any]]: """ List users in the Keycloak realm. This includes both: - Human users (regular Keycloak users) - M2M service accounts (service account clients) M2M accounts are returned with their clientId as the username and are marked with serviceAccountsEnabled=True for identification. """ admin_token = await _get_keycloak_admin_token() async with httpx.AsyncClient(timeout=10.0) as client: # Fetch human users params: dict[str, Any] = {"max": max_results} if search: params["search"] = search users_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users" response = await client.get( users_url, headers=_auth_headers(admin_token, None), params=params ) response.raise_for_status() users = response.json() # Fetch M2M service account clients clients_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/clients" response = await client.get(clients_url, headers=_auth_headers(admin_token, None)) response.raise_for_status() all_clients = response.json() # Filter to only service account clients and convert to user-like format service_accounts = [] for keycloak_client in all_clients: if not keycloak_client.get("serviceAccountsEnabled"): continue client_id = keycloak_client.get("clientId", "") # Apply search filter if specified if search and search.lower() not in client_id.lower(): continue # Get the service account user to retrieve groups service_account_user_id = None groups = [] if include_groups: try: sa_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/clients/{keycloak_client['id']}/service-account-user" sa_response = await client.get(sa_url, headers=_auth_headers(admin_token, None)) if sa_response.status_code == 200: sa_user = sa_response.json() service_account_user_id = sa_user.get("id") if service_account_user_id: groups = await _get_user_groups( client, admin_token, service_account_user_id ) except Exception as e: logger.warning("Failed to get groups for M2M account %s: %s", client_id, e) # Format M2M account as a user entry service_account_entry = { "id": keycloak_client.get("id", ""), "username": client_id, "enabled": keycloak_client.get("enabled", True), "serviceAccountsEnabled": True, # Mark as M2M account "firstName": "M2M", "lastName": "Service Account", "email": f"{client_id}@service-account.local", "groups": groups, } service_accounts.append(service_account_entry) # Add groups to human users if requested if include_groups: for user in users: user_id = user.get("id") if not user_id: user["groups"] = [] continue user["groups"] = await _get_user_groups(client, admin_token, user_id) # Combine human users and M2M service accounts all_users = users + service_accounts # Apply max_results limit to combined list return all_users[:max_results] async def update_keycloak_user_groups( username: str, groups: list[str], ) -> dict[str, Any]: """ Update group memberships for a Keycloak user or service account. Calculates the diff between current and desired groups, then adds/removes groups as needed. Args: username: Username of the human user or clientId of service account groups: List of group names the user should belong to Returns: Dict with username and updated groups list Raises: KeycloakAdminError: If user not found or group operations fail """ admin_token = await _get_keycloak_admin_token() async with httpx.AsyncClient(timeout=10.0) as client: # Try to find as a human user first user = await _get_user_by_username(client, admin_token, username) user_id = None is_service_account = False if user: user_id = user.get("id") else: # Try to find as a service account client client_uuid = await _find_client_uuid(client, admin_token, username) if client_uuid: # Get the service account user ID sa_url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/clients/{client_uuid}/service-account-user" sa_response = await client.get(sa_url, headers=_auth_headers(admin_token, None)) if sa_response.status_code == 200: sa_user = sa_response.json() user_id = sa_user.get("id") is_service_account = True if not user_id: raise KeycloakAdminError(f"User or service account '{username}' not found") # Get current groups current_groups = set(await _get_user_groups(client, admin_token, user_id)) desired_groups = set(groups) # Calculate diff groups_to_add = desired_groups - current_groups groups_to_remove = current_groups - desired_groups # Apply changes if groups_to_add: await _assign_user_to_groups_by_name(client, admin_token, user_id, list(groups_to_add)) if groups_to_remove: await _remove_user_from_groups_by_name( client, admin_token, user_id, list(groups_to_remove) ) logger.info( "Updated groups for %s '%s': added=%s, removed=%s", "service account" if is_service_account else "user", username, list(groups_to_add), list(groups_to_remove), ) return { "username": username, "groups": list(desired_groups), "added": list(groups_to_add), "removed": list(groups_to_remove), } ================================================ FILE: registry/utils/logging_setup.py ================================================ """Shared logging configuration for registry and auth-server. Configures three output destinations: 1. Console (stdout/stderr) - always enabled 2. RotatingFileHandler - rotated log file 3. MongoDBLogHandler - optional, writes to MongoDB application_logs collection """ import logging from logging.handlers import RotatingFileHandler from pathlib import Path LOG_FORMAT = "%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s" def setup_logging( service_name: str, log_file: Path | None = None, ) -> Path | None: """Configure root logger with console, file, and optional MongoDB handlers. Args: service_name: Identifies this process in MongoDB log documents (e.g. "registry", "auth-server"). log_file: Explicit log file path. When ``None`` the path is derived from settings (``settings.log_dir / f"{service_name}.log"``). Returns: The resolved log file path, or None if file logging was skipped. """ from ..core.config import settings level = getattr(logging, settings.app_log_level.upper(), logging.INFO) root = logging.getLogger() root.setLevel(level) for handler in root.handlers[:]: root.removeHandler(handler) formatter = logging.Formatter(LOG_FORMAT) # 1. Console handler console = logging.StreamHandler() console.setLevel(level) console.setFormatter(formatter) root.addHandler(console) # 2. RotatingFileHandler resolved_log_file: Path | None = None if log_file is not None: resolved_log_file = log_file else: resolved_log_file = settings.log_dir / f"{service_name}.log" if resolved_log_file is not None: try: resolved_log_file.parent.mkdir(parents=True, exist_ok=True) file_handler = RotatingFileHandler( filename=str(resolved_log_file), maxBytes=settings.app_log_max_bytes, backupCount=settings.app_log_backup_count, encoding="utf-8", ) file_handler.setLevel(level) file_handler.setFormatter(formatter) root.addHandler(file_handler) except PermissionError: root.warning( f"Cannot write to log file {resolved_log_file}, " "continuing with console logging only" ) resolved_log_file = None # 3. Centralized log handler (optional, writes to MongoDB/DocumentDB) if settings.app_log_centralized_enabled and settings.storage_backend in ( "documentdb", "mongodb-ce", ): try: from .mongodb_log_handler import MongoDBLogHandler excluded = frozenset( name.strip() for name in settings.app_log_excluded_loggers.split(",") if name.strip() ) mongo_handler = MongoDBLogHandler( service_name=service_name, buffer_size=settings.app_log_mongodb_buffer_size, flush_interval=settings.app_log_mongodb_flush_interval_seconds, ttl_days=settings.app_log_centralized_ttl_days, excluded_loggers=excluded, ) mongo_handler.setLevel(level) mongo_handler.setFormatter(formatter) root.addHandler(mongo_handler) except Exception as exc: root.warning(f"Failed to initialize MongoDB log handler: {exc}") return resolved_log_file ================================================ FILE: registry/utils/metadata.py ================================================ """Shared metadata utilities for keyword search.""" from typing import Any def flatten_metadata_to_text(metadata: dict[str, Any]) -> str: """Flatten a metadata dict into a searchable text string. Handles nested lists and dicts by joining their string values. Example: {"team": "myteam", "langs": ["python", "go"]} becomes: "team myteam langs python go" """ if not isinstance(metadata, dict) or not metadata: return "" parts = [] for key, value in metadata.items(): parts.append(str(key)) if isinstance(value, list): parts.extend(str(item) for item in value) elif isinstance(value, dict): parts.extend(str(v) for v in value.values()) else: parts.append(str(value)) return " ".join(parts) ================================================ FILE: registry/utils/mongodb_connection.py ================================================ """Shared MongoDB connection string builder. Provides a single source of truth for building MongoDB/DocumentDB connection strings and TLS options, used by both the async motor client and the synchronous MongoDBLogHandler. """ from typing import Any def build_connection_string() -> str: """Build a MongoDB/DocumentDB connection string from registry settings. Handles three authentication modes: - IAM (MONGODB-AWS) for DocumentDB with AWS credentials - Username/password (SCRAM-SHA-256 for MongoDB CE, SCRAM-SHA-1 for DocumentDB) - No authentication (local development) """ from ..core.config import settings if settings.documentdb_use_iam: import boto3 session = boto3.Session() credentials = session.get_credentials() if not credentials: raise ValueError("AWS credentials not found for DocumentDB IAM auth") return ( f"mongodb://{credentials.access_key}:{credentials.secret_key}@" f"{settings.documentdb_host}:{settings.documentdb_port}/" f"{settings.documentdb_database}?" f"authSource=$external&authMechanism=MONGODB-AWS" ) if settings.documentdb_username and settings.documentdb_password: if settings.storage_backend == "mongodb-ce": auth_mechanism = "SCRAM-SHA-256" else: auth_mechanism = "SCRAM-SHA-1" return ( f"mongodb://{settings.documentdb_username}:{settings.documentdb_password}@" f"{settings.documentdb_host}:{settings.documentdb_port}/" f"{settings.documentdb_database}?authMechanism={auth_mechanism}&authSource=admin" ) return ( f"mongodb://{settings.documentdb_host}:{settings.documentdb_port}/" f"{settings.documentdb_database}" ) def build_tls_kwargs() -> dict[str, Any]: """Build TLS keyword arguments for MongoDB client.""" from ..core.config import settings kwargs: dict[str, Any] = {} if settings.documentdb_use_tls: kwargs["tls"] = True if settings.documentdb_tls_ca_file: kwargs["tlsCAFile"] = settings.documentdb_tls_ca_file return kwargs def build_client_options() -> dict[str, Any]: """Build common client options for MongoDB connections.""" from ..core.config import settings options: dict[str, Any] = {"retryWrites": False} if settings.documentdb_direct_connection: options["directConnection"] = True return options ================================================ FILE: registry/utils/mongodb_log_handler.py ================================================ """Custom logging handler that writes log records to MongoDB. Uses synchronous PyMongo in a background thread to avoid blocking the async event loop. Records are buffered and flushed periodically or when the buffer reaches a configurable size. """ import atexit import logging import socket import threading import time from datetime import UTC, datetime from typing import Any from pymongo import MongoClient from pymongo.errors import PyMongoError from .mongodb_connection import build_client_options, build_connection_string, build_tls_kwargs EXCLUDED_LOGGERS_DEFAULT = frozenset( { "pymongo", "motor", "registry.utils.mongodb_log_handler", "registry.utils.logging_setup", "uvicorn.access", "httpx", } ) class MongoDBLogHandler(logging.Handler): """Logging handler that buffers records and flushes them to MongoDB. A daemon thread periodically flushes the buffer. The handler also flushes when the buffer reaches ``buffer_size`` records. The target collection is ``application_logs_{namespace}`` with a TTL index on the ``created_at`` field. """ def __init__( self, service_name: str, buffer_size: int = 50, flush_interval: float = 5.0, ttl_days: int = 7, excluded_loggers: frozenset[str] | None = None, ): super().__init__() from ..core.config import settings self._service_name = service_name self._hostname = socket.gethostname() self._buffer: list[dict[str, Any]] = [] self._buffer_lock = threading.Lock() self._buffer_size = buffer_size self._flush_interval = flush_interval self._ttl_days = ttl_days self._excluded_loggers = excluded_loggers or EXCLUDED_LOGGERS_DEFAULT self._flush_failure_count = 0 self._closed = False namespace = settings.documentdb_namespace self._collection_name = f"application_logs_{namespace}" self._client: MongoClient | None = None self._collection = None self._connect_error_logged = False self._flush_thread = threading.Thread( target=self._periodic_flush, daemon=True, name="mongodb-log-flusher", ) self._flush_thread.start() atexit.register(self.close) def _ensure_connection(self) -> bool: """Lazily connect to MongoDB and ensure TTL index exists.""" if self._collection is not None: return True try: from ..core.config import settings self._client = MongoClient( build_connection_string(), serverSelectionTimeoutMS=5000, **build_client_options(), **build_tls_kwargs(), ) db = self._client[settings.documentdb_database] self._collection = db[self._collection_name] self._collection.create_index( "created_at", expireAfterSeconds=self._ttl_days * 86400, background=True, ) self._collection.create_index( [("service", 1), ("level_no", -1), ("timestamp", -1)], background=True, ) self._collection.create_index( [("hostname", 1), ("timestamp", -1)], background=True, ) self._connect_error_logged = False return True except Exception as exc: if not self._connect_error_logged: import sys print( f"MongoDBLogHandler: failed to connect - {exc}", file=sys.stderr, ) self._connect_error_logged = True return False def _is_excluded(self, logger_name: str) -> bool: for excluded in self._excluded_loggers: if logger_name == excluded or logger_name.startswith(excluded + "."): return True return False @property def flush_failure_count(self) -> int: return self._flush_failure_count def emit(self, record: logging.LogRecord) -> None: if self._closed: return if self._is_excluded(record.name): return try: now = datetime.fromtimestamp(record.created, tz=UTC) doc = { "timestamp": now, "hostname": self._hostname, "service": self._service_name, "level": record.levelname, "level_no": record.levelno, "logger": record.name, "filename": record.filename, "lineno": record.lineno, "process": record.process, "message": self.format(record), "created_at": now, } with self._buffer_lock: self._buffer.append(doc) should_flush = len(self._buffer) >= self._buffer_size if should_flush: self._flush() except Exception: pass def _flush(self) -> None: """Flush buffered records to MongoDB.""" with self._buffer_lock: if not self._buffer: return batch = self._buffer[:] self._buffer.clear() if not self._ensure_connection(): return try: self._collection.insert_many(batch, ordered=False) except PyMongoError: self._flush_failure_count += 1 try: from ..core.metrics import APP_LOG_FLUSH_FAILURES APP_LOG_FLUSH_FAILURES.labels(service=self._service_name).inc() except Exception: pass def _periodic_flush(self) -> None: """Background thread: flush buffer every ``flush_interval`` seconds.""" while not self._closed: time.sleep(self._flush_interval) try: self._flush() except Exception: pass def close(self) -> None: if self._closed: return self._closed = True self._flush() if self._client is not None: try: self._client.close() except Exception: pass super().close() ================================================ FILE: registry/utils/okta_manager.py ================================================ """Okta Admin API manager for user and group operations. This module provides async functions for managing users and groups in Okta using the Okta Admin API. """ import logging import os from typing import Any import httpx logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Configuration from environment OKTA_DOMAIN: str = os.environ.get("OKTA_DOMAIN", "") OKTA_API_TOKEN: str = os.environ.get("OKTA_API_TOKEN", "") def _get_api_headers() -> dict[str, str]: """Get headers for Okta Admin API requests.""" if not OKTA_API_TOKEN: raise ValueError( "OKTA_API_TOKEN is not set. " "Create an API token in Okta Admin Console → Security → API → Tokens." ) return { "Authorization": f"SSWS {OKTA_API_TOKEN}", "Accept": "application/json", "Content-Type": "application/json", } def _get_base_url() -> str: """Get Okta Admin API base URL.""" domain = OKTA_DOMAIN.replace("https://", "").rstrip("/") return f"https://{domain}/api/v1" def _check_rate_limit(response: httpx.Response) -> None: """Check for Okta rate limiting and raise appropriate error. Args: response: HTTP response to check Raises: ValueError: If rate limited, includes retry delay info """ if response.status_code == 429: retry_after = int(response.headers.get("Retry-After", 60)) rate_limit_remaining = response.headers.get("X-Rate-Limit-Remaining", "0") logger.warning( f"Okta rate limit exceeded. " f"Remaining: {rate_limit_remaining}, Retry after: {retry_after}s" ) raise ValueError( f"Okta API rate limited. Retry after {retry_after} seconds. " f"Consider reducing request frequency." ) async def list_okta_users( search: str | None = None, max_results: int = 500, include_groups: bool = True, ) -> list[dict[str, Any]]: """List users from Okta. Args: search: Optional search filter max_results: Maximum number of results to return include_groups: Whether to include group memberships Returns: List of user dictionaries """ base_url = _get_base_url() headers = _get_api_headers() params: dict[str, Any] = {"limit": min(max_results, 200)} if search: params["search"] = f'profile.login sw "{search}" or profile.email sw "{search}"' users: list[dict[str, Any]] = [] async with httpx.AsyncClient(timeout=10.0) as client: url: str | None = f"{base_url}/users" while url and len(users) < max_results: response = await client.get(url, headers=headers, params=params) _check_rate_limit(response) response.raise_for_status() page_users = response.json() users.extend(page_users) url = response.links.get("next", {}).get("url") params = {} # Transform to common format result = [] for user in users[:max_results]: user_data: dict[str, Any] = { "id": user.get("id"), "username": user.get("profile", {}).get("login"), "email": user.get("profile", {}).get("email"), "first_name": user.get("profile", {}).get("firstName"), "last_name": user.get("profile", {}).get("lastName"), "status": user.get("status"), "created": user.get("created"), "groups": [], } if include_groups: groups_url = f"{base_url}/users/{user['id']}/groups" groups_response = await client.get(groups_url, headers=headers) if groups_response.status_code == 200: user_data["groups"] = [ g.get("profile", {}).get("name") for g in groups_response.json() ] result.append(user_data) logger.info(f"Retrieved {len(result)} users from Okta") return result async def create_okta_human_user( username: str, email: str, first_name: str, last_name: str, groups: list[str], password: str | None = None, ) -> dict[str, Any]: """Create a human user in Okta. Args: username: Username (login) for the account email: Email address first_name: First name last_name: Last name groups: List of group names to assign password: Optional initial password Returns: Dictionary with created user details """ base_url = _get_base_url() headers = _get_api_headers() user_data: dict[str, Any] = { "profile": { "login": username, "email": email, "firstName": first_name, "lastName": last_name, } } if password: user_data["credentials"] = {"password": {"value": password}} async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{base_url}/users", headers=headers, json=user_data, params={"activate": "true" if password else "false"}, ) if response.status_code >= 400: try: error_body = response.json() except Exception: error_body = response.text logger.error(f"Okta user creation failed ({response.status_code}): {error_body}") raise ValueError(f"Okta user creation failed: {error_body}") created_user = response.json() # Assign to groups for group_name in groups: groups_response = await client.get( f"{base_url}/groups", headers=headers, params={"q": group_name}, ) groups_response.raise_for_status() matching_groups = groups_response.json() for group in matching_groups: if group.get("profile", {}).get("name") == group_name: await client.put( f"{base_url}/groups/{group['id']}/users/{created_user['id']}", headers=headers, ) break logger.info(f"Created Okta user: {username}") return { "id": created_user.get("id"), "username": username, "email": email, "groups": groups, } async def delete_okta_user(username_or_id: str) -> bool: """Delete a user from Okta (deactivate then delete). Args: username_or_id: Username (login) or user ID Returns: True if successful Raises: ValueError: If user not found """ base_url = _get_base_url() headers = _get_api_headers() async with httpx.AsyncClient(timeout=10.0) as client: # If it looks like a login, resolve to user ID if "@" in username_or_id or "." in username_or_id: response = await client.get( f"{base_url}/users/{username_or_id}", headers=headers, ) if response.status_code == 200: user_id = response.json().get("id") else: raise ValueError(f"User not found: {username_or_id}") else: user_id = username_or_id # Deactivate user first (required before deletion) await client.post( f"{base_url}/users/{user_id}/lifecycle/deactivate", headers=headers, ) # Delete user delete_response = await client.delete( f"{base_url}/users/{user_id}", headers=headers, ) delete_response.raise_for_status() logger.info(f"Deleted Okta user: {username_or_id}") return True async def list_okta_groups() -> list[dict[str, Any]]: """List all groups from Okta. Returns: List of group dictionaries with id, name, description, type """ base_url = _get_base_url() headers = _get_api_headers() groups: list[dict[str, Any]] = [] async with httpx.AsyncClient(timeout=10.0) as client: url: str | None = f"{base_url}/groups" params: dict[str, Any] = {"limit": 200} while url: response = await client.get(url, headers=headers, params=params) response.raise_for_status() page_groups = response.json() groups.extend(page_groups) url = response.links.get("next", {}).get("url") params = {} result = [ { "id": g.get("id"), "name": g.get("profile", {}).get("name"), "description": g.get("profile", {}).get("description", ""), "type": g.get("type"), } for g in groups ] logger.info(f"Retrieved {len(result)} groups from Okta") return result async def create_okta_group( group_name: str, description: str = "", ) -> dict[str, Any]: """Create a group in Okta. Args: group_name: Name of the group description: Optional description Returns: Dictionary with created group details """ base_url = _get_base_url() headers = _get_api_headers() group_data = { "profile": { "name": group_name, "description": description, } } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{base_url}/groups", headers=headers, json=group_data, ) response.raise_for_status() created_group = response.json() logger.info(f"Created Okta group: {group_name}") return { "id": created_group.get("id"), "name": group_name, "description": description, } async def delete_okta_group(group_name_or_id: str) -> bool: """Delete a group from Okta by name or ID. Resolves group name to ID if needed before deletion. Args: group_name_or_id: Group name or ID Returns: True if successful Raises: ValueError: If group not found """ base_url = _get_base_url() headers = _get_api_headers() async with httpx.AsyncClient(timeout=10.0) as client: # If not a UUID-like string, search by name if "-" not in group_name_or_id or len(group_name_or_id) < 20: response = await client.get( f"{base_url}/groups", headers=headers, params={"q": group_name_or_id}, ) response.raise_for_status() groups = response.json() group_id = None for g in groups: if g.get("profile", {}).get("name") == group_name_or_id: group_id = g.get("id") break if not group_id: raise ValueError(f"Group not found: {group_name_or_id}") else: group_id = group_name_or_id delete_response = await client.delete( f"{base_url}/groups/{group_id}", headers=headers, ) delete_response.raise_for_status() logger.info(f"Deleted Okta group: {group_name_or_id}") return True async def create_okta_service_account( client_id_name: str, group_names: list[str], description: str | None = None, ) -> dict[str, Any]: """Create an OAuth2 service application (service account) in Okta. Creates an OIDC service app with client_credentials grant type and assigns it to the specified groups. Args: client_id_name: Name for the OAuth2 application group_names: List of group names to assign description: Optional description Returns: Dictionary with client_id and client_secret """ base_url = _get_base_url() headers = _get_api_headers() app_data = { "name": "oidc_client", "label": client_id_name, "signOnMode": "OPENID_CONNECT", "credentials": { "oauthClient": { "token_endpoint_auth_method": "client_secret_basic", } }, "settings": { "oauthClient": { "client_uri": None, "logo_uri": None, "redirect_uris": [], "response_types": ["token"], "grant_types": ["client_credentials"], "application_type": "service", } }, } async with httpx.AsyncClient(timeout=10.0) as client: response = await client.post( f"{base_url}/apps", headers=headers, json=app_data, ) response.raise_for_status() created_app = response.json() client_id = created_app.get("credentials", {}).get("oauthClient", {}).get("client_id") client_secret = ( created_app.get("credentials", {}).get("oauthClient", {}).get("client_secret") ) # Assign application to groups for group_name in group_names: groups_response = await client.get( f"{base_url}/groups", headers=headers, params={"q": group_name}, ) groups_response.raise_for_status() for group in groups_response.json(): if group.get("profile", {}).get("name") == group_name: await client.put( f"{base_url}/apps/{created_app['id']}/groups/{group['id']}", headers=headers, ) break logger.info(f"Created Okta OAuth2 application: {client_id_name}") return { "client_id": client_id, "client_secret": client_secret, "groups": group_names, "okta_app_id": created_app.get("id"), # Include Okta app ID } async def update_okta_user_groups( username_or_id: str, groups: list[str], ) -> dict[str, Any]: """Update group memberships for an Okta user. Replaces the user's current group memberships with the specified groups. Args: username_or_id: Username (login) or user ID groups: List of group names to assign Returns: Dictionary with updated user info """ base_url = _get_base_url() headers = _get_api_headers() async with httpx.AsyncClient(timeout=10.0) as client: # Resolve user ID if "@" in username_or_id or "." in username_or_id: response = await client.get( f"{base_url}/users/{username_or_id}", headers=headers, ) if response.status_code == 200: user_id = response.json().get("id") else: raise ValueError(f"User not found: {username_or_id}") else: user_id = username_or_id # Get current groups current_groups_resp = await client.get( f"{base_url}/users/{user_id}/groups", headers=headers, ) current_groups_resp.raise_for_status() current_groups = { g.get("profile", {}).get("name"): g.get("id") for g in current_groups_resp.json() if g.get("type") == "OKTA_GROUP" } # Resolve target group names to IDs all_groups_resp = await client.get( f"{base_url}/groups", headers=headers, params={"limit": 200}, ) all_groups_resp.raise_for_status() all_groups = {g.get("profile", {}).get("name"): g.get("id") for g in all_groups_resp.json()} target_names = set(groups) # Remove from groups not in target for name, gid in current_groups.items(): if name not in target_names: await client.delete( f"{base_url}/groups/{gid}/users/{user_id}", headers=headers, ) # Add to groups in target but not current for name in target_names: if name not in current_groups and name in all_groups: await client.put( f"{base_url}/groups/{all_groups[name]}/users/{user_id}", headers=headers, ) logger.info(f"Updated groups for Okta user {username_or_id}: {groups}") return {"username": username_or_id, "groups": groups} async def update_okta_group( group_name_or_id: str, description: str = "", ) -> dict[str, Any]: """Update a group's properties in Okta. Args: group_name_or_id: Group name or ID description: New description for the group Returns: Dictionary with updated group info Raises: ValueError: If group not found """ base_url = _get_base_url() headers = _get_api_headers() async with httpx.AsyncClient(timeout=10.0) as client: # Resolve group ID if needed if "-" not in group_name_or_id or len(group_name_or_id) < 20: response = await client.get( f"{base_url}/groups", headers=headers, params={"q": group_name_or_id}, ) response.raise_for_status() matched = [ g for g in response.json() if g.get("profile", {}).get("name") == group_name_or_id ] if not matched: raise ValueError(f"Group not found: {group_name_or_id}") group_id = matched[0].get("id") group_name = group_name_or_id else: group_id = group_name_or_id group_name = group_name_or_id update_resp = await client.put( f"{base_url}/groups/{group_id}", headers=headers, json={"profile": {"name": group_name, "description": description}}, ) update_resp.raise_for_status() logger.info(f"Updated Okta group: {group_name_or_id}") return {"name": group_name, "description": description} ================================================ FILE: registry/utils/path_utils.py ================================================ """ Utility functions for path handling. Extracted to avoid code duplication across routes. """ import re def normalize_skill_path( path: str, ) -> str: """Normalize skill path, ensuring /skills/ prefix. Args: path: Raw path string Returns: Normalized path with /skills/ prefix """ # Remove leading/trailing whitespace path = path.strip() # Remove duplicate slashes path = re.sub(r"/+", "/", path) # Ensure /skills/ prefix if not path.startswith("/skills/"): # Remove leading slash if present path = path.lstrip("/") path = f"/skills/{path}" return path def extract_skill_name( path: str, ) -> str: """Extract skill name from path. Args: path: Skill path (e.g., /skills/pdf-processing) Returns: Skill name (e.g., pdf-processing) """ normalized = normalize_skill_path(path) return normalized.replace("/skills/", "").strip("/") def validate_skill_name( name: str, ) -> bool: """Validate skill name follows Agent Skills spec. Args: name: Skill name to validate Returns: True if valid, False otherwise """ pattern = r"^[a-z0-9]+(-[a-z0-9]+)*$" return bool(re.match(pattern, name)) ================================================ FILE: registry/utils/request_utils.py ================================================ """ Shared request utilities for extracting client information. Provides validated, safe extraction of client IP from proxied requests. """ import ipaddress import logging from fastapi import Request logger = logging.getLogger(__name__) def get_client_ip(request: Request) -> str: """ Extract the client IP from a request, preferring X-Forwarded-For when present. Validates that the extracted value is a well-formed IP address to prevent log injection or XSS via crafted headers. Args: request: FastAPI Request object Returns: A validated IP address string, or "unknown" if unavailable. """ forwarded_for = request.headers.get("X-Forwarded-For") if forwarded_for: candidate = forwarded_for.split(",")[0].strip() try: ipaddress.ip_address(candidate) return candidate except ValueError: logger.warning("Malformed IP in X-Forwarded-For header, ignoring") if request.client: return request.client.host return "unknown" ================================================ FILE: registry/utils/scopes_manager.py ================================================ """ DEPRECATED: This module is deprecated. Use registry.services.scope_service instead. This module is kept for backward compatibility only. All functions are thin wrappers around the new scope_service module with deprecation warnings. The old implementation has been preserved in scopes_manager_old.py for reference. """ import logging from typing import ( Any, ) from ..services.scope_service import ( add_server_to_groups as _add_server_to_groups, ) from ..services.scope_service import ( create_group as _create_group, ) from ..services.scope_service import ( delete_group as _delete_group, ) from ..services.scope_service import ( group_exists as _group_exists, ) from ..services.scope_service import ( list_groups as _list_groups, ) from ..services.scope_service import ( remove_server_from_groups as _remove_server_from_groups, ) from ..services.scope_service import ( remove_server_scopes as _remove_server_scopes, ) from ..services.scope_service import ( trigger_auth_server_reload as _trigger_auth_server_reload, ) from ..services.scope_service import ( update_server_scopes as _update_server_scopes, ) logger = logging.getLogger(__name__) async def update_server_scopes( server_path: str, server_name: str, tools: list[str], ) -> bool: """ DEPRECATED: Use registry.services.scope_service.update_server_scopes instead. Update scopes for a server (add or update) and reload auth server. Args: server_path: The server's path (e.g., '/example-server') server_name: The server's display name tools: List of tool names the server provides Returns: True if successful, False otherwise """ logger.warning( "scopes_manager.update_server_scopes is deprecated, " "use scope_service.update_server_scopes instead" ) return await _update_server_scopes(server_path, server_name, tools) async def remove_server_scopes( server_path: str, ) -> bool: """ DEPRECATED: Use registry.services.scope_service.remove_server_scopes instead. Remove scopes for a server and reload auth server. Args: server_path: The server's path (e.g., '/example-server') Returns: True if successful, False otherwise """ logger.warning( "scopes_manager.remove_server_scopes is deprecated, " "use scope_service.remove_server_scopes instead" ) return await _remove_server_scopes(server_path) async def add_server_to_groups( server_path: str, group_names: list[str], ) -> bool: """ DEPRECATED: Use registry.services.scope_service.add_server_to_groups instead. Add a server and all its known tools/methods to specific groups in scopes.yml. Args: server_path: The server's path (e.g., '/example-server') group_names: List of group names to add the server to Returns: True if successful, False otherwise """ logger.warning( "scopes_manager.add_server_to_groups is deprecated, " "use scope_service.add_server_to_groups instead" ) return await _add_server_to_groups(server_path, group_names) async def remove_server_from_groups( server_path: str, group_names: list[str], ) -> bool: """ DEPRECATED: Use registry.services.scope_service.remove_server_from_groups instead. Remove a server from specific groups in scopes.yml. Args: server_path: The server's path (e.g., '/example-server') group_names: List of group names to remove the server from Returns: True if successful, False otherwise """ logger.warning( "scopes_manager.remove_server_from_groups is deprecated, " "use scope_service.remove_server_from_groups instead" ) return await _remove_server_from_groups(server_path, group_names) async def create_group_in_scopes( group_name: str, description: str = "", ) -> bool: """ DEPRECATED: Use registry.services.scope_service.create_group instead. Create a new group entry in scopes.yml and add it to group_mappings. Args: group_name: Name of the group (e.g., 'mcp-servers-custom/read') description: Optional description Returns: True if successful, False otherwise """ logger.warning( "scopes_manager.create_group_in_scopes is deprecated, " "use scope_service.create_group instead" ) return await _create_group(group_name, description) async def delete_group_from_scopes( group_name: str, remove_from_mappings: bool = True, ) -> bool: """ DEPRECATED: Use registry.services.scope_service.delete_group instead. Delete a group from scopes.yml and optionally from group_mappings. Args: group_name: Name of the group to delete remove_from_mappings: Whether to remove from group_mappings section Returns: True if successful, False otherwise """ logger.warning( "scopes_manager.delete_group_from_scopes is deprecated, " "use scope_service.delete_group instead" ) return await _delete_group(group_name, remove_from_mappings) async def list_groups_from_scopes() -> dict[str, Any]: """ DEPRECATED: Use registry.services.scope_service.list_groups instead. List all groups defined in scopes.yml. Returns: Dict with group information including server counts and mappings """ logger.warning( "scopes_manager.list_groups_from_scopes is deprecated, " "use scope_service.list_groups instead" ) return await _list_groups() async def group_exists_in_scopes( group_name: str, ) -> bool: """ DEPRECATED: Use registry.services.scope_service.group_exists instead. Check if a group exists in scopes.yml. Args: group_name: Name of the group to check Returns: True if group exists, False otherwise """ logger.warning( "scopes_manager.group_exists_in_scopes is deprecated, " "use scope_service.group_exists instead" ) return await _group_exists(group_name) async def trigger_auth_server_reload() -> bool: """ DEPRECATED: Use registry.services.scope_service.trigger_auth_server_reload instead. Trigger the auth server to reload its scopes configuration. Returns: True if successful, False otherwise """ logger.warning( "scopes_manager.trigger_auth_server_reload is deprecated, " "use scope_service.trigger_auth_server_reload instead" ) return await _trigger_auth_server_reload() ================================================ FILE: registry/utils/scopes_manager_old.py ================================================ """ Utility functions for managing scopes.yml file updates when servers are registered or removed. """ import logging from pathlib import Path from typing import Any import httpx import yaml logger = logging.getLogger(__name__) def _get_scopes_file_path() -> Path: """Get the path to the scopes.yml file.""" # This is the mounted volume location in the container return Path("/app/auth_server/scopes.yml") def _read_scopes_file() -> dict[str, Any]: """Read the current scopes.yml file.""" scopes_file = _get_scopes_file_path() if not scopes_file.exists(): logger.error(f"Scopes file not found at {scopes_file}") raise FileNotFoundError(f"Scopes file not found at {scopes_file}") with open(scopes_file) as f: return yaml.safe_load(f) def _write_scopes_file(scopes_data: dict[str, Any]) -> None: """Write the updated scopes data to the file.""" scopes_file = _get_scopes_file_path() # Direct write to the file (can't use atomic replacement with mounted volumes) # Create a backup first for safety backup_file = scopes_file.with_suffix(".backup") try: # Make a backup copy import shutil shutil.copy2(scopes_file, backup_file) # Write directly to the file with open(scopes_file, "w") as f: # Create a custom YAML dumper that doesn't generate anchors/aliases class NoAnchorDumper(yaml.SafeDumper): def ignore_aliases(self, data): return True yaml.dump( scopes_data, f, default_flow_style=False, sort_keys=False, Dumper=NoAnchorDumper ) logger.info(f"Successfully updated scopes file at {scopes_file}") # Remove backup after successful write if backup_file.exists(): backup_file.unlink() except Exception as e: logger.error(f"Failed to write scopes file: {e}") # Try to restore from backup if write failed if backup_file.exists(): shutil.copy2(backup_file, scopes_file) logger.info("Restored scopes file from backup") raise def _create_server_entry(server_path: str, tools: list[str]) -> dict[str, Any]: """Create a server entry for scopes.yml.""" # Remove leading slash from server path server_name = server_path.lstrip("/") return { "server": server_name, "methods": [ "initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list", ], "tools": tools, } async def add_server_to_scopes(server_path: str, server_name: str, tools: list[str]) -> bool: """ Add a server to all appropriate scope sections in scopes.yml. Args: server_path: The server's path (e.g., '/example-server') server_name: The server's display name tools: List of tool names the server provides Returns: True if successful, False otherwise """ try: # Read current scopes scopes_data = _read_scopes_file() # Create the server entry server_entry = _create_server_entry(server_path, tools) # Add to unrestricted scope sections only sections = ["mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute"] modified = False for section in sections: if section in scopes_data: # Check if server already exists in this section existing = [ s for s in scopes_data[section] if s.get("server") == server_entry["server"] ] if existing: # Update existing entry idx = scopes_data[section].index(existing[0]) scopes_data[section][idx] = server_entry.copy() logger.info(f"Updated existing server {server_path} in section {section}") else: # Add new entry scopes_data[section].append(server_entry.copy()) logger.info(f"Added server {server_path} to section {section}") modified = True else: logger.warning(f"Scope section {section} not found in scopes.yml") if modified: # Write back the updated scopes _write_scopes_file(scopes_data) logger.info(f"Successfully added server {server_path} to scopes.yml") return True else: logger.warning(f"No sections were modified for server {server_path}") return False except Exception as e: logger.error(f"Failed to add server {server_path} to scopes: {e}") return False async def remove_server_from_scopes(server_path: str) -> bool: """ Remove a server from all scope sections in scopes.yml. Args: server_path: The server's path (e.g., '/example-server') Returns: True if successful, False otherwise """ try: # Read current scopes scopes_data = _read_scopes_file() # Remove leading slash from server path server_name = server_path.lstrip("/") # Remove from all standard scope sections sections = [ "mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute", "mcp-servers-restricted/read", "mcp-servers-restricted/execute", ] modified = False for section in sections: if section in scopes_data: original_length = len(scopes_data[section]) scopes_data[section] = [ s for s in scopes_data[section] if s.get("server") != server_name ] if len(scopes_data[section]) < original_length: logger.info(f"Removed server {server_path} from section {section}") modified = True if modified: # Write back the updated scopes _write_scopes_file(scopes_data) logger.info(f"Successfully removed server {server_path} from scopes.yml") return True else: logger.warning(f"Server {server_path} not found in any scope sections") return False except Exception as e: logger.error(f"Failed to remove server {server_path} from scopes: {e}") return False async def trigger_auth_server_reload() -> bool: """ Trigger the auth server to reload its scopes configuration. Uses JWT Bearer token signed with the shared SECRET_KEY for authentication. Returns: True if successful, False otherwise """ try: from ..auth.internal import generate_internal_token token = generate_internal_token( subject="registry-service", purpose="reload-scopes", ) async with httpx.AsyncClient() as client: response = await client.post( "http://auth-server:8888/internal/reload-scopes", headers={"Authorization": f"Bearer {token}"}, timeout=10.0, ) if response.status_code == 200: logger.info("Successfully triggered auth server scope reload") return True else: logger.error( f"Failed to reload auth server scopes: {response.status_code} - {response.text}" ) return False except Exception as e: logger.error(f"Failed to trigger auth server reload: {e}") # Non-fatal - scopes will be picked up on next restart return False async def update_server_scopes(server_path: str, server_name: str, tools: list[str]) -> bool: """ Update scopes for a server (add or update) and reload auth server. This is a convenience function that combines adding/updating scopes and triggering the auth server reload. Args: server_path: The server's path (e.g., '/example-server') server_name: The server's display name tools: List of tool names the server provides Returns: True if successful, False otherwise """ # Add/update server in scopes.yml if not await add_server_to_scopes(server_path, server_name, tools): return False # Trigger auth server reload await trigger_auth_server_reload() return True async def remove_server_scopes(server_path: str) -> bool: """ Remove scopes for a server and reload auth server. This is a convenience function that combines removing scopes and triggering the auth server reload. Args: server_path: The server's path (e.g., '/example-server') Returns: True if successful, False otherwise """ # Remove server from scopes.yml if not await remove_server_from_scopes(server_path): return False # Trigger auth server reload await trigger_auth_server_reload() return True async def add_server_to_groups(server_path: str, group_names: list[str]) -> bool: """ Add a server and all its known tools/methods to specific groups in scopes.yml. Gets the server's tools from the last health check and adds them to the specified groups using the same format as other servers. Args: server_path: The server's path (e.g., '/example-server') group_names: List of group names to add the server to (e.g., ['mcp-servers-restricted/read']) Returns: True if successful, False otherwise """ try: # First, get the server info to find its tools from ..services.server_service import server_service server_info = server_service.get_server_info(server_path) if not server_info: logger.error(f"Server {server_path} not found in registry") return False # Get the tools from the last health check tool_list = server_info.get("tool_list", []) tool_names = [ tool["name"] for tool in tool_list if isinstance(tool, dict) and "name" in tool ] logger.info(f"Found {len(tool_names)} tools for server {server_path}: {tool_names}") # Read current scopes scopes_data = _read_scopes_file() # Create the server entry with discovered tools server_entry = _create_server_entry(server_path, tool_names) modified = False for group_name in group_names: if group_name in scopes_data: # Check if server already exists in this group existing = [ s for s in scopes_data[group_name] if s.get("server") == server_entry["server"] ] if existing: # Update existing entry idx = scopes_data[group_name].index(existing[0]) scopes_data[group_name][idx] = server_entry.copy() logger.info(f"Updated existing server {server_path} in group {group_name}") else: # Add new entry scopes_data[group_name].append(server_entry.copy()) logger.info(f"Added server {server_path} to group {group_name}") modified = True else: logger.warning(f"Group {group_name} not found in scopes.yml") if modified: # Update UI-Scopes to include this server in list_service for each group if "UI-Scopes" not in scopes_data: scopes_data["UI-Scopes"] = {} # Use the actual server_name from server_info for UI-Scopes server_name = server_info.get("server_name", server_path.lstrip("/").rstrip("/")) for group_name in group_names: if group_name in scopes_data: # Only update if group exists # Ensure UI-Scopes has an entry for this group if group_name not in scopes_data["UI-Scopes"]: scopes_data["UI-Scopes"][group_name] = {"list_service": []} # Ensure list_service exists if "list_service" not in scopes_data["UI-Scopes"][group_name]: scopes_data["UI-Scopes"][group_name]["list_service"] = [] # Add server to list_service if not already there if server_name not in scopes_data["UI-Scopes"][group_name]["list_service"]: scopes_data["UI-Scopes"][group_name]["list_service"].append(server_name) logger.info(f"Added {server_name} to UI-Scopes[{group_name}].list_service") # Write back the updated scopes _write_scopes_file(scopes_data) logger.info(f"Successfully added server {server_path} to groups: {group_names}") # Trigger auth server reload await trigger_auth_server_reload() return True else: logger.warning(f"No groups were modified for server {server_path}") return False except Exception as e: logger.error(f"Failed to add server {server_path} to groups {group_names}: {e}") return False async def remove_server_from_groups(server_path: str, group_names: list[str]) -> bool: """ Remove a server from specific groups in scopes.yml. Args: server_path: The server's path (e.g., '/example-server') group_names: List of group names to remove the server from Returns: True if successful, False otherwise """ try: # Get server info to get the actual server_name from ..services.server_service import server_service server_info = server_service.get_server_info(server_path) # Read current scopes scopes_data = _read_scopes_file() # Remove leading slash from server path (used for matching in scopes sections) server_name = server_path.lstrip("/") # Get the display name for UI-Scopes (from server_info if available) server_display_name = ( server_info.get("server_name", server_path.lstrip("/").rstrip("/")) if server_info else server_path.lstrip("/").rstrip("/") ) modified = False for group_name in group_names: if group_name in scopes_data: original_length = len(scopes_data[group_name]) scopes_data[group_name] = [ s for s in scopes_data[group_name] if s.get("server") != server_name ] if len(scopes_data[group_name]) < original_length: logger.info(f"Removed server {server_path} from group {group_name}") modified = True else: logger.warning(f"Group {group_name} not found in scopes.yml") if modified: # Also remove from UI-Scopes list_service (using display name) if "UI-Scopes" in scopes_data: for group_name in group_names: if group_name in scopes_data["UI-Scopes"]: if "list_service" in scopes_data["UI-Scopes"][group_name]: if ( server_display_name in scopes_data["UI-Scopes"][group_name]["list_service"] ): scopes_data["UI-Scopes"][group_name]["list_service"].remove( server_display_name ) logger.info( f"Removed {server_display_name} from UI-Scopes[{group_name}].list_service" ) # Write back the updated scopes _write_scopes_file(scopes_data) logger.info(f"Successfully removed server {server_path} from groups: {group_names}") # Trigger auth server reload await trigger_auth_server_reload() return True else: logger.warning(f"Server {server_path} not found in any of the specified groups") return False except Exception as e: logger.error(f"Failed to remove server {server_path} from groups {group_names}: {e}") return False async def create_group_in_scopes(group_name: str, description: str = "") -> bool: """ Create a new group entry in scopes.yml and add it to group_mappings. Args: group_name: Name of the group (e.g., 'mcp-servers-custom/read') description: Optional description Returns: True if successful, False otherwise """ try: # Read current scopes scopes_data = _read_scopes_file() # Check if group already exists if group_name in scopes_data: logger.warning(f"Group {group_name} already exists in scopes.yml") return False # Create new empty group entry scopes_data[group_name] = [] logger.info(f"Created new group entry: {group_name}") # Add to group_mappings if it doesn't exist if "group_mappings" not in scopes_data: scopes_data["group_mappings"] = {} # Add self-mapping: the group maps to itself if group_name not in scopes_data["group_mappings"]: scopes_data["group_mappings"][group_name] = [group_name] logger.info(f"Added {group_name} to group_mappings (self-mapping)") # Add to UI-Scopes for web interface visibility if "UI-Scopes" not in scopes_data: scopes_data["UI-Scopes"] = {} if group_name not in scopes_data["UI-Scopes"]: # Add UI permissions for the new group # list_service will be dynamically populated as servers are added to the group scopes_data["UI-Scopes"][group_name] = { "list_service": [] # Will be populated when servers are added } logger.info(f"Added {group_name} to UI-Scopes with empty list_service") # Write back the updated scopes _write_scopes_file(scopes_data) logger.info( f"Successfully added group {group_name} to scopes.yml, group_mappings, and UI-Scopes" ) # Trigger auth server reload await trigger_auth_server_reload() return True except Exception as e: logger.error(f"Failed to create group {group_name} in scopes: {e}") return False async def delete_group_from_scopes(group_name: str, remove_from_mappings: bool = True) -> bool: """ Delete a group from scopes.yml and optionally from group_mappings. Args: group_name: Name of the group to delete remove_from_mappings: Whether to remove from group_mappings section Returns: True if successful, False otherwise """ try: # Read current scopes scopes_data = _read_scopes_file() # Check if group exists if group_name not in scopes_data: logger.warning(f"Group {group_name} not found in scopes.yml") return False # Check if group has servers assigned if isinstance(scopes_data[group_name], list) and len(scopes_data[group_name]) > 0: server_count = len(scopes_data[group_name]) logger.warning(f"Group {group_name} has {server_count} servers assigned") # Still allow deletion - servers will lose this group access # Remove the group del scopes_data[group_name] logger.info(f"Removed group {group_name} from scopes.yml") # Optionally remove from group_mappings if remove_from_mappings and "group_mappings" in scopes_data: modified_mappings = False for mapped_group, mapped_scopes in scopes_data["group_mappings"].items(): if group_name in mapped_scopes: scopes_data["group_mappings"][mapped_group].remove(group_name) logger.info(f"Removed {group_name} from group_mappings[{mapped_group}]") modified_mappings = True if modified_mappings: logger.info("Updated group_mappings after group deletion") # Write back the updated scopes _write_scopes_file(scopes_data) logger.info(f"Successfully deleted group {group_name} from scopes.yml") # Trigger auth server reload await trigger_auth_server_reload() return True except Exception as e: logger.error(f"Failed to delete group {group_name} from scopes: {e}") return False async def list_groups_from_scopes() -> dict[str, Any]: """ List all groups defined in scopes.yml. Returns: Dict with group information including server counts and mappings """ try: # Read current scopes scopes_data = _read_scopes_file() groups = {} # Find all scope groups (those with server lists) for key, value in scopes_data.items(): # Skip UI-Scopes and group_mappings sections if key in ["UI-Scopes", "group_mappings"]: continue # Check if this is a scope group (has list of servers) if isinstance(value, list): server_count = len(value) server_names = [s.get("server", "unknown") for s in value if isinstance(s, dict)] groups[key] = { "name": key, "server_count": server_count, "servers": server_names, "in_mappings": [], } # Check which groups are in group_mappings if "group_mappings" in scopes_data: for mapped_group, mapped_scopes in scopes_data["group_mappings"].items(): for scope in mapped_scopes: if scope in groups: groups[scope]["in_mappings"].append(mapped_group) logger.info(f"Found {len(groups)} groups in scopes.yml") return {"total_count": len(groups), "groups": groups} except Exception as e: logger.error(f"Failed to list groups from scopes: {e}") return {"total_count": 0, "groups": {}, "error": str(e)} async def group_exists_in_scopes(group_name: str) -> bool: """ Check if a group exists in scopes.yml. Args: group_name: Name of the group to check Returns: True if group exists, False otherwise """ try: scopes_data = _read_scopes_file() return group_name in scopes_data except Exception as e: logger.error(f"Error checking if group exists in scopes: {e}") return False ================================================ FILE: registry/utils/url_utils.py ================================================ """ URL utilities for GitHub URL translation and handling. Provides functions to translate GitHub URLs to raw content URLs, supporting both github.com and enterprise GitHub instances. """ import logging import re from urllib.parse import urlparse # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def _is_github_hostname( hostname: str, ) -> bool: """Check if hostname is a GitHub instance (public or enterprise). Args: hostname: The hostname to check Returns: True if the hostname appears to be a GitHub instance """ hostname_lower = hostname.lower() # Public GitHub if hostname_lower in ("github.com", "raw.githubusercontent.com"): return True # Enterprise GitHub typically contains 'github' in the hostname # Examples: github.mycompany.com, mycompany-github.com if "github" in hostname_lower: return True return False def _is_raw_github_url( hostname: str, ) -> bool: """Check if hostname is already a raw GitHub URL. Args: hostname: The hostname to check Returns: True if the hostname is a raw content URL """ hostname_lower = hostname.lower() # Public GitHub raw URL if hostname_lower == "raw.githubusercontent.com": return True # Enterprise GitHub raw URLs typically have 'raw' subdomain # Examples: raw.github.mycompany.com if hostname_lower.startswith("raw.") and "github" in hostname_lower: return True return False def _map_to_base_hostname( hostname: str, ) -> str: """Map a raw or regular GitHub hostname to the base GitHub hostname. Args: hostname: Lowercase hostname to map Returns: Base GitHub hostname for constructing repository URLs Examples: >>> _map_to_base_hostname("raw.githubusercontent.com") 'github.com' >>> _map_to_base_hostname("raw.github.mycompany.com") 'github.mycompany.com' >>> _map_to_base_hostname("github.com") 'github.com' """ if hostname == "raw.githubusercontent.com": return "github.com" # Enterprise raw URLs: strip "raw." prefix if hostname.startswith("raw.") and "github" in hostname: return hostname[4:] # Already a base hostname (github.com, github.mycompany.com, etc.) return hostname def _translate_github_url_to_raw( url: str, ) -> str: """Translate a GitHub blob URL to a raw content URL. Handles both public GitHub and enterprise GitHub instances. Examples: - github.com/owner/repo/blob/main/path/SKILL.md -> raw.githubusercontent.com/owner/repo/refs/heads/main/path/SKILL.md - github.mycompany.com/owner/repo/blob/main/path/SKILL.md -> raw.github.mycompany.com/owner/repo/refs/heads/main/path/SKILL.md Args: url: GitHub URL to translate Returns: Raw content URL """ parsed = urlparse(url) hostname = parsed.hostname or "" path = parsed.path # Pattern: /owner/repo/blob/branch/path/to/file blob_pattern = re.compile(r"^/([^/]+)/([^/]+)/blob/([^/]+)/(.+)$") match = blob_pattern.match(path) if not match: # If not a blob URL, return as-is logger.debug(f"URL path doesn't match blob pattern, returning as-is: {url}") return url owner, repo, branch, file_path = match.groups() # Construct raw URL based on hostname type hostname_lower = hostname.lower() if hostname_lower == "github.com": # Public GitHub: use raw.githubusercontent.com raw_url = ( f"https://raw.githubusercontent.com/{owner}/{repo}/refs/heads/{branch}/{file_path}" ) else: # Enterprise GitHub: prepend 'raw.' to hostname # If hostname already starts with something, replace it # e.g., github.mycompany.com -> raw.github.mycompany.com raw_hostname = f"raw.{hostname_lower}" raw_url = f"https://{raw_hostname}/{owner}/{repo}/refs/heads/{branch}/{file_path}" logger.debug(f"Translated GitHub URL: {url} -> {raw_url}") return raw_url def translate_skill_url( url: str, ) -> tuple[str, str]: """Translate a skill URL to both user-provided and raw formats. This function handles: 1. GitHub URLs (github.com/...) - translated to raw.githubusercontent.com 2. Already raw GitHub URLs (raw.githubusercontent.com) - kept as-is 3. Enterprise GitHub URLs (*.github.* domains) - translated to raw.*.github.* 4. Non-GitHub URLs - used as-is for both fields Args: url: The URL provided by the user Returns: Tuple of (user_provided_url, raw_url) - user_provided_url: The original URL as provided - raw_url: The URL for fetching raw content """ url = url.strip() parsed = urlparse(url) hostname = parsed.hostname or "" if not hostname: logger.warning(f"URL has no hostname: {url}") return (url, url) # Check if it's a GitHub-related hostname if not _is_github_hostname(hostname): # Non-GitHub URL: use same URL for both logger.debug(f"Non-GitHub URL, using as-is: {url}") return (url, url) # Already a raw URL: keep as-is if _is_raw_github_url(hostname): logger.debug(f"Already a raw GitHub URL: {url}") return (url, url) # GitHub URL: translate to raw raw_url = _translate_github_url_to_raw(url) return (url, raw_url) def extract_repository_url( url: str, ) -> str | None: """Extract the GitHub repository URL from a SKILL.md URL. Given a URL pointing to a file in a GitHub repository (either a blob URL or a raw content URL), this function extracts the base repository URL in the form https://{hostname}/{owner}/{repo}. Handles: - github.com blob URLs - raw.githubusercontent.com URLs - Enterprise GitHub URLs (github.mycompany.com, raw.github.mycompany.com) Args: url: URL to extract repository from Returns: Repository URL string, or None if not a GitHub URL or malformed Examples: >>> extract_repository_url("https://github.com/anthropics/skills/blob/main/SKILL.md") 'https://github.com/anthropics/skills' >>> extract_repository_url("https://raw.githubusercontent.com/anthropics/skills/refs/heads/main/SKILL.md") 'https://github.com/anthropics/skills' >>> extract_repository_url("https://example.com/file.md") None """ if not url or not url.strip(): return None url = url.strip() try: parsed = urlparse(url) except Exception: return None hostname = parsed.hostname or "" if not hostname: return None # Only handle GitHub hostnames if not _is_github_hostname(hostname): return None # Extract path segments (skip leading empty segment from leading slash) path_segments = [s for s in parsed.path.split("/") if s] if len(path_segments) < 2: return None owner = path_segments[0] repo = path_segments[1] # Map the hostname back to the base GitHub hostname hostname_lower = hostname.lower() base_hostname = _map_to_base_hostname(hostname_lower) return f"https://{base_hostname}/{owner}/{repo}" def derive_resource_url(skill_md_url: str, resource_path: str) -> str: """Derive a resource URL by replacing the filename in a SKILL.md URL. Works by stripping the filename from the base URL and appending the requested resource path. """ if "/SKILL.md" in skill_md_url: base = skill_md_url.rsplit("/SKILL.md", 1)[0] return f"{base}/{resource_path}" base = skill_md_url.rsplit("/", 1)[0] return f"{base}/{resource_path}" ================================================ FILE: registry/utils/visibility.py ================================================ """Shared visibility normalization utilities. Provides a single source of truth for valid visibility values and normalizes backward-compatible aliases (e.g. "internal" -> "private"). """ # Canonical visibility values used across agents, servers, and skills VALID_VISIBILITY_VALUES: list[str] = ["public", "private", "group-restricted"] # Aliases that are silently normalized to canonical values _VISIBILITY_ALIASES: dict[str, str] = { "internal": "private", "group": "group-restricted", } def _normalize_visibility( value: str, ) -> str: """Normalize a visibility value to its canonical form. Accepts backward-compatible aliases: - "internal" -> "private" - "group" -> "group-restricted" Case-insensitive: input is lowercased before normalization. Args: value: The visibility value to normalize. Returns: The canonical visibility value (lowercased). """ lowered = value.lower() return _VISIBILITY_ALIASES.get(lowered, lowered) def validate_visibility( value: str, ) -> str: """Normalize and validate a visibility value. Args: value: The visibility value to normalize and validate. Returns: The canonical visibility value. Raises: ValueError: If the value is not a valid visibility after normalization. """ normalized = _normalize_visibility(value) if normalized not in VALID_VISIBILITY_VALUES: raise ValueError(f"Visibility must be one of: {', '.join(VALID_VISIBILITY_VALUES)}") return normalized ================================================ FILE: registry/version.py ================================================ """ Version management for MCP Gateway Registry. Version can be set via BUILD_VERSION environment variable (for Docker builds) or determined from git tags at runtime (for local development). """ import logging import os import subprocess # nosec B404 from pathlib import Path logger = logging.getLogger(__name__) DEFAULT_VERSION = "1.0.0" def _get_git_version() -> str: """ Get version from git describe. Returns version in format: v1.0.7 or v1.0.7-3-g1234abc (if commits after tag) Returns: Version string from git, or None if not in a git repository """ try: # Get the repository root repo_root = Path(__file__).parent.parent # Run git describe to get version result = subprocess.run( # nosec B603 B607 - hardcoded git command with static args ["git", "describe", "--tags", "--always"], cwd=repo_root, capture_output=True, text=True, timeout=5, check=False, ) if result.returncode == 0: version_str = result.stdout.strip() # Remove 'v' prefix if present if version_str.startswith("v"): version_str = version_str[1:] logger.info(f"Version from git: {version_str}") return version_str else: logger.debug(f"Git describe failed: {result.stderr.strip()}") return None except FileNotFoundError: logger.debug("Git command not found") return None except subprocess.TimeoutExpired: logger.debug("Git describe timed out") return None except Exception as e: logger.debug(f"Error getting git version: {e}") return None def get_version() -> str: """ Get application version. Priority order: 1. BUILD_VERSION environment variable (set at Docker build time) 2. Git tags (for local development) 3. DEFAULT_VERSION fallback Returns: Version string (e.g., "1.0.7" or "1.0.0") """ # First check for build-time version (Docker builds) build_version = os.getenv("BUILD_VERSION") if build_version: logger.info(f"Using build version: {build_version}") return build_version # Try git for local development git_version = _get_git_version() if git_version: return git_version # Fall back to default logger.info(f"Using default version: {DEFAULT_VERSION}") return DEFAULT_VERSION # Module-level version constant __version__ = get_version() ================================================ FILE: release-notes/DISCLAIMER.md ================================================ # Release Notes Disclaimer The release notes in this directory describe features and capabilities at the time of release. Terms like "production-ready" or "enterprise-grade" describe design intent and should not be construed as warranties or guarantees. Users should perform their own testing and validation before deploying to their environments. ================================================ FILE: release-notes/v1.0.10.md ================================================ # Release v1.0.10 - A2A Discovery, OAuth2 Providers & Enhanced Search **January 2026** --- ## Major Features ### A2A Agent Discovery and Invocation Pattern Complete implementation of Agent-to-Agent (A2A) communication workflow enabling agents to discover and collaborate with other agents: - **Registry Discovery Client**: Semantic search and skill-based agent discovery - **Remote Agent Client**: A2A protocol communication with discovered agents - **Travel Assistant + Flight Booking Agents**: Reference implementation demonstrating the pattern - **Agent Caching**: Efficient caching of discovered agents for reuse [PR #344](https://github.com/agentic-community/mcp-gateway-registry/pull/344) | [Issue #198](https://github.com/agentic-community/mcp-gateway-registry/issues/198) ### OAuth2 Provider Configuration Flexible OAuth2 provider enablement through environment variables: - **Dynamic Provider Selection**: Enable/disable Keycloak, Cognito, Entra ID, GitHub, and Google via environment variables - **Nginx Route Configuration**: Added OAuth2 callback routes for Entra, GitHub, and Google providers - **Backward Compatibility**: Keycloak enabled by default to match previous behavior [PR #353](https://github.com/agentic-community/mcp-gateway-registry/pull/353) | [PR #354](https://github.com/agentic-community/mcp-gateway-registry/pull/354) ### Enhanced Semantic Search Improved search capabilities with hybrid keyword matching: - **Hybrid Search**: Combines semantic similarity with keyword matching - **Tool Discovery**: Better discovery of MCP server tools and agent skills - **Query Tokenization**: Improved handling of multi-word search queries [PR #352](https://github.com/agentic-community/mcp-gateway-registry/pull/352) --- ## What's New ### Authentication & OAuth2 - Make OAuth2 provider enablement configurable via environment variables (#353) - Add OAuth2 nginx routes for Entra, GitHub, and Google providers (#354) - Fix outdated placeholder checks in registry-entrypoint.sh (#355) - Fix boolean conversion in `substitute_env_vars` for OAuth2 provider enablement ### A2A Agent Registry - Complete A2A agent discovery and invocation pattern (#344) - Web-based UI for A2A agent management (#349) - Closes issue #198: Agent-to-Agent Communication Workflow ### Search & Discovery - Improve semantic search with hybrid keyword matching (#352) - Better agent tool discovery through enhanced search ### Security & UI - Add security scan results popup to ServerCard and AgentCard (#341) - Support `server_name` field in JSON upload for server registration ### MongoDB/DocumentDB - MongoDB deployment and configuration improvements (#343) - Update index creation for mongodb-ce (#342) - Update MongoDB init to support SCRAM-SHA-256 and custom replicaset (#337) - Fix MongoDB authentication compatibility for DocumentDB (#335) ### Frontend - Frontend performance optimizations with webpack-dev-server v5 fix (#339) - Bump react-router and react-router-dom dependencies (#345) ### Documentation - Clarify AUTH_SERVER_EXTERNAL_URL configuration in macOS setup guide (#338) --- ## Configuration Changes ### New Environment Variables ```bash # OAuth2 Provider Enablement (all default to false except KEYCLOAK_ENABLED) KEYCLOAK_ENABLED=true # Default: true (for backward compatibility) COGNITO_ENABLED=false ENTRA_ENABLED=false GITHUB_ENABLED=false GOOGLE_ENABLED=false ``` ### OAuth2 Provider Setup To enable additional OAuth2 providers: 1. **Entra ID (Microsoft)**: ```bash ENTRA_ENABLED=true ENTRA_TENANT_ID=your-tenant-id ENTRA_CLIENT_ID=your-client-id ENTRA_CLIENT_SECRET=your-client-secret ``` 2. **GitHub**: ```bash GITHUB_ENABLED=true GITHUB_CLIENT_ID=your-github-client-id GITHUB_CLIENT_SECRET=your-github-client-secret ``` 3. **Google**: ```bash GOOGLE_ENABLED=true GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret ``` --- ## Upgrade Instructions ### For Docker Compose Deployments 1. **Pull the latest changes:** ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.10 ``` 2. **Update environment configuration** (if enabling OAuth2 providers): ```bash # Add OAuth2 provider configuration to .env # See Configuration Changes section above ``` 3. **Rebuild and restart:** ```bash ./build_and_run.sh ``` ### For AWS ECS Deployment 1. **Update Terraform variables** for any new OAuth2 providers 2. **Apply Terraform changes:** ```bash cd terraform/aws-ecs terraform plan terraform apply ``` --- ## Bug Fixes - Fix boolean conversion in `substitute_env_vars` for OAuth2 provider enablement (environment variables return strings, not booleans) - Fix outdated placeholder checks in registry-entrypoint.sh (#355) - Fix MongoDB authentication compatibility for DocumentDB (#335) - Frontend performance optimizations with webpack-dev-server v5 fix (#339) --- ## Pull Requests Included | PR | Title | |----|-------| | #353 | Registry-Auth: Make OAuth2 provider enablement configurable via environment variables | | #354 | Add OAuth2 nginx routes for Entra, GitHub, and Google providers | | #355 | Fix outdated placeholder checks in registry-entrypoint.sh | | #352 | Improve semantic search with hybrid keyword matching and agent tool discovery | | #349 | Web-based UI for A2A agent management | | #345 | chore(deps): bump react-router and react-router-dom in /frontend | | #344 | A2A Agent Discovery and Invocation Pattern | | #343 | MongoDB deployment and configuration | | #342 | Update index creation for mongodb-ce | | #341 | Add security scan results popup to ServerCard and AgentCard | | #339 | Frontend Performance Optimizations with webpack-dev-server v5 Fix | | #338 | docs: clarify AUTH_SERVER_EXTERNAL_URL config in macOS setup guide | | #337 | Update mongodb init to support SCRAM-SHA-256, support auth and custom replicaset | | #335 | Fix MongoDB authentication compatibility for DocumentDB | --- ## Issues Closed - [#198](https://github.com/agentic-community/mcp-gateway-registry/issues/198) - Implement Agent-to-Agent Communication Workflow --- ## Resources ### Documentation - [A2A Agents README](agents/a2a/README.md) - A2A agent setup and usage - [OAuth2 Configuration](.env.example) - Environment variable reference - [macOS Setup Guide](docs/podman-setup.md) - Updated with AUTH_SERVER_EXTERNAL_URL clarification --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.9...v1.0.10](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.9...v1.0.10) ================================================ FILE: release-notes/v1.0.12.md ================================================ # Release v1.0.12 - Multi-Provider IAM, DocumentDB Storage & Well-Known Health Fix **January 2026** --- ## Major Features ### Multi-Provider IAM Support for Keycloak and Microsoft Entra ID Full IAM support enabling both Keycloak AND Microsoft Entra ID through a unified API: - **Harmonized API**: Same user and group management experience regardless of IdP - **Self-Signed JWT Tokens**: Human users can generate tokens for CLI tools and AI coding assistants - **M2M Service Accounts**: AI agent identity with OAuth2 Client Credentials flow - **Fine-Grained Access Control**: Scopes define exactly which MCP servers, methods, tools, and agents each user can access [PR #378](https://github.com/agentic-community/mcp-gateway-registry/pull/378) ### CloudFront HTTPS Support for AWS ECS Production-ready AWS deployment with CloudFront for HTTPS termination: - **CDN Caching**: Global edge distribution for improved latency - **Three Deployment Modes**: Flexible configurations to match requirements - **SSL/TLS Termination**: Secure connections without managing certificates on ECS [PR #363](https://github.com/agentic-community/mcp-gateway-registry/pull/363) | [Issue #293](https://github.com/agentic-community/mcp-gateway-registry/issues/293) ### Well-Known Discovery Health Status Fix The `/.well-known/mcp-servers` endpoint now returns actual health status instead of hardcoded "healthy": - **Accurate Status Reporting**: Servers show real health status (healthy, unhealthy, disabled, unknown) - **Status Normalization**: Detailed messages like "unhealthy: timeout" normalized to "unhealthy" for client consumption - **Comprehensive Tests**: 457 lines of new tests for the well-known routes [PR #384](https://github.com/agentic-community/mcp-gateway-registry/pull/384) | [Issue #375](https://github.com/agentic-community/mcp-gateway-registry/issues/375) --- ## What's New ### Authentication & IAM - Multi-Provider IAM Support for Keycloak and Microsoft Entra ID (#378) - JWT token scopes improvements (#383) ### Infrastructure & Docker - Add lightweight Dockerfile for simple MCP servers (Dockerfile.mcp-server-light) - Synchronize docker-compose files for consistency - Reference official mongo8:2 image in docker-compose.prebuilt.yml (#364) - Update images and add scope.yml to mongo setup job (#360) ### AWS ECS Deployment - CloudFront HTTPS support for AWS ECS deployment (#363) - Deployment mode fixes and security group rules limit (#374) - AWS ECS deployment improvements and script hardening (#365) ### Bug Fixes - Fix well-known endpoint returning hardcoded health status (#384) - Quick Start docs, MongoDB auth, and JWT token scopes fixes (#383) ### Documentation - Mark #232 and #297 as completed in roadmap - Add HuggingFace CLI explanation and installation link (#371) - Mark MCP server description as required (#362) --- ## Configuration Changes ### New Dockerfile for Simple MCP Servers A lightweight Dockerfile (`docker/Dockerfile.mcp-server-light`) is now available for simple MCP servers that don't need PyTorch or the registry module: ```yaml # docker-compose.yml example currenttime-server: build: context: . dockerfile: docker/Dockerfile.mcp-server-light args: SERVER_DIR: servers/currenttime ``` Benefits: - Smaller image size (no PyTorch dependencies) - Faster builds - Suitable for: currenttime, fininfo, realserverfaketools servers --- ## Upgrade Instructions ### For Docker Compose Deployments 1. **Pull the latest changes:** ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.12 ``` 2. **Rebuild and restart:** ```bash ./build_and_run.sh ``` ### For AWS ECS Deployment 1. **Update Terraform variables** for CloudFront configuration if desired 2. **Apply Terraform changes:** ```bash cd terraform/aws-ecs terraform plan terraform apply ``` --- ## Bug Fixes - Fix `/.well-known/mcp-servers` endpoint returning hardcoded "healthy" status for all servers (#384) - Fix Quick Start documentation and MongoDB authentication issues (#383) - Fix JWT token scopes handling (#383) - Fix deployment mode issues and security group rules limit in AWS ECS (#374) - Synchronize docker-compose files for consistency --- ## Pull Requests Included | PR | Title | |----|-------| | #384 | fix: retrieve actual health status in well-known discovery endpoint | | #383 | fix: Quick Start docs, MongoDB auth, and JWT token scopes | | #378 | feat: Multi-Provider IAM Support for Keycloak and Microsoft Entra ID | | #374 | fix: deployment mode fixes, security group rules limit, and documentation improvements | | #371 | docs: add HuggingFace CLI explanation and installation link | | #365 | fix: AWS ECS deployment improvements and script hardening for v1.0.10 | | #364 | Changed line 365 of docker-compose.prebuilt.yml to reference the official mongo8:2 image | | #363 | feat: Add CloudFront HTTPS support for AWS ECS deployment | | #362 | mark mcp server description as required | | #360 | update images and add scope.yml to mongo setup job | --- ## Issues Closed - [#375](https://github.com/agentic-community/mcp-gateway-registry/issues/375) - Bug: /.well-known/mcp-servers endpoint returns hardcoded "healthy" status - [#293](https://github.com/agentic-community/mcp-gateway-registry/issues/293) - Add CloudFront HTTPS support for AWS ECS deployment --- ## Contributors Thank you to our amazing contributors for this release: - Omri Shiv - Viviana Luccioli - Andreas Feldmann - Wallace Printz - Gaurav Rele - cxhello - Gabriel Rojas --- ## Resources ### Documentation - [Storage Architecture](docs/design/storage-architecture-mongodb-documentdb.md) - MongoDB/DocumentDB storage design - [IdP Provider Support](docs/design/idp-provider-support.md) - Multi-provider IAM documentation - [Authentication Design](docs/design/authentication-design.md) - Authentication architecture --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.10...v1.0.12](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.10...v1.0.12) ================================================ FILE: release-notes/v1.0.13.md ================================================ # Release v1.0.13 - Federated Registry, Agent Skills & Audit Logging **February 2026** --- ## Major Features ### Federated Registry ![Federated Registry](../docs/img/federated-registries.png) Connect multiple MCP Gateway registries together with bi-directional synchronization: - **Peer Registry Management**: Add, configure, and manage peer registries through the UI or CLI - **Automatic Sync**: Servers and agents sync between registries with configurable filters (whitelist, tag-based) - **Chain Prevention**: Prevents A->B->C sync loops for clean federation topology - **Orphan Detection**: Identifies and manages orphaned items when peer registries are removed - **Security Scan Sync**: Security scan results propagate across federated registries - **Visibility Control**: Configure which servers/agents are exported to peers (public, internal, private) [PR #422](https://github.com/agentic-community/mcp-gateway-registry/pull/422) | [Federation Guide](docs/federation.md) ### Agent Skills Registry Register, discover, and manage agent skills with health monitoring and ratings: - **Skill Registration**: Register individual agent skills with metadata and SKILL.md documentation - **Health Checks**: Automatic health monitoring for registered skills - **Skill Ratings**: Community-driven 5-star rating system for skills - **Semantic Search**: Skills are indexed for semantic search alongside servers and agents - **UI Integration**: Browse, rate, and view skill documentation from the registry UI [PR #451](https://github.com/agentic-community/mcp-gateway-registry/pull/451) | Multiple skill-related commits ### Audit Logging & Compliance ![Audit Logs](../docs/img/audit-log.png) Comprehensive audit logging for API and MCP access tracking: - **MongoDB Storage**: All audit events stored in MongoDB for scalability - **API & MCP Logging**: Track both REST API calls and MCP tool invocations - **Admin UI**: View, filter, and sort audit logs from the Settings menu - **Compliance Ready**: Designed for enterprise compliance requirements [PR #449](https://github.com/agentic-community/mcp-gateway-registry/pull/449) ### MCP Server Version Routing Route requests to specific server versions using HTTP headers: - **Header-Based Routing**: Use `X-MCP-Server-Version` header to target specific versions - **Version Management**: Register multiple versions of the same server - **Seamless Upgrades**: Test new versions without affecting production traffic [PR #407](https://github.com/agentic-community/mcp-gateway-registry/pull/407) --- ## What's New ### Federation & Sync - Federated Registry with peer management and bi-directional sync (#422) - Federation export API with visibility controls (#422) - Sync metadata for tracking federated items (#422) - Chain prevention for multi-hop federation scenarios (#422) - Orphan detection and cleanup when peers are deleted (#422) - Security scan sync across federated registries (#422) ### Agent Skills - Agent Skills registry entity with backend implementation - Skill health checks and monitoring - Skill ratings with 5-star widget - Skills included in semantic search - SKILL.md viewer in UI ### Audit & Compliance - Audit logging with MongoDB storage (#449) - API and MCP access tracking - Admin-only Audit Logs viewer in Settings - Clickable sort toggles for log filtering ### Security Improvements - SSRF protection for redirect validation (CWE-918) (#453) - SQL injection prevention in metrics-service retention subsystem (#451) - Information exposure fix for exceptions (#453) - Static token auth for Registry API (#420) ### Authentication & Authorization - Microsoft Entra ID support in Helm charts (#458) - Bearer token support for /api/auth/me endpoint (#454, #431) - Check mcp-registry-admin in both groups and scopes (#456) - Registry client implementation for skill API (#455) ### Infrastructure - Docker build workflows with release tagging (#464, #432) - High availability Pod scaling in Kubernetes (#437) - Lexical fallback search when embedding model unavailable (#415) - Docker Hardened Images (DHI) support as optional overlay (#414) - Lightweight Dockerfile improvements ### UI/UX Improvements - Federated registry UI with collapsible sections - Delete functionality for servers and agents in UI (#439) - Settings navigation improvements (#444) - Ratings popup fix for card cutoff (#422) - Dashboard UX improvements --- ## Configuration Changes ### Federation Environment Variables New environment variables for federation support: ```bash FEDERATION_ENABLED=true FEDERATION_SYNC_INTERVAL_SECONDS=300 FEDERATION_TOKEN_ENCRYPTION_KEY=your-32-byte-key ``` ### Audit Logging Enable audit logging with: ```bash AUDIT_LOGGING_ENABLED=true AUDIT_LOG_RETENTION_DAYS=90 ``` --- ## Upgrade Instructions ### For Docker Compose Deployments 1. **Pull the latest changes:** ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.13 ``` 2. **Rebuild and restart:** ```bash ./build_and_run.sh ``` ### For Kubernetes/Helm Deployments 1. **Update chart values** for Entra ID and federation if needed 2. **Apply changes:** ```bash helm upgrade mcp-gateway ./charts/mcp-gateway -f your-values.yaml ``` --- ## Bug Fixes - Fix MongoDB replica set initialization race condition (#440) - Fix token masking behavior in tests (#444) - Fix MCP URL format in tests (#449) - Fix security group rules limit in AWS ECS - Fix ratings popup cutoff in server/agent cards - Fix hybrid search scoring and HNSW recall (#415) - Fix auth server returning 500 instead of 401 (#423) --- ## Pull Requests Included | PR | Title | |----|-------| | #464 | Add release image workflow and tagging | | #463 | feat: Improve test-mcp-client.sh with verbose mode and required parameters | | #458 | Add Entra ID group mapping support in Helm charts | | #456 | fix: Check mcp-registry-admin in both groups and scopes | | #455 | fix: Add registry client implementation for skill API | | #454 | fix: Add nginx location blocks for /api/auth/me Bearer token support | | #453 | Potential fix for code scanning alerts (SSRF, exception exposure) | | #451 | fix: Prevent SQL injection in metrics-service retention subsystem | | #450 | Switch scopes to JSON configuration | | #449 | feat: Add audit compliance logging with API/MCP access tracking | | #448 | Update Docker builds | | #444 | feat: Add Settings navigation and improve Dashboard UX | | #442 | Add demo video to Federation Operational Guide | | #440 | Fix MongoDB replica set initialization race condition | | #439 | Add delete functionality for servers and agents in UI | | #437 | Add scaling and high availability section to charts | | #432 | Add Docker build workflows | | #431 | fix: Use nginx_proxied_auth for /api/auth/me | | #425 | Add inbound CIDR restrictions | | #423 | fix: Return correct 4xx status codes from auth server | | #422 | feat: Federated Registry with peer management and sync | | #421 | feat: Unified deploy script and CodeQL fix | | #420 | feat: Add static token auth for Registry API | | #417 | Dynamically generate shared secretKey in charts | | #415 | Improve hybrid search scoring and lexical fallback | | #414 | Add Docker Hardened Images (DHI) support | | #407 | feat: MCP server version routing | --- ## Contributors Thank you to our amazing contributors for this release: - **Amit Arora** ([@aarora79](https://github.com/aarora79)) - **Omri Shiv** ([@omrishiv](https://github.com/omrishiv)) - **Dheeraj Oruganty** ([@dheerajoruganty](https://github.com/dheerajoruganty)) - **Bren Whyte** ([@brenwhyte](https://github.com/brenwhyte)) - **Andreas Feldmann** ([@ndrsfel](https://github.com/ndrsfel)) - **Abhishek Singh** - **Gaurav Rele** - **kanghengliu** --- ## Resources ### Documentation - [Federation Guide](docs/federation.md) - Federated registry setup and operations - [Audit Logging](docs/audit-logging.md) - Compliance and audit trail documentation - [Agent Skills](docs/skills.md) - Skills registry documentation - [Server Versioning](docs/design/server-versioning.md) - MCP server version routing --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.12...v1.0.13](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.12...v1.0.13) ================================================ FILE: release-notes/v1.0.14.md ================================================ # Release v1.0.14 - Registry-Only Deployment Mode **February 2026** --- ## Major Features ### Registry-Only Deployment Mode Deploy the registry as a standalone catalog/discovery service without gateway integration: - **New `DEPLOYMENT_MODE` parameter**: Choose between `with-gateway` (default) or `registry-only` - **Works with `REGISTRY_MODE`**: Combine deployment mode with registry mode (`full`, `skills-only`, `mcp-servers-only`, `agents-only`) - **Lightweight deployments**: Run registry without nginx dynamic location block generation for catalog-only use cases - **Auto-correction**: Invalid combinations (e.g., `with-gateway` + `skills-only`) are automatically corrected with warnings - **Prometheus metrics**: Monitor deployment mode via `registry_deployment_mode_info` gauge and track skipped nginx updates via `registry_nginx_updates_skipped_total` counter - **Health check enhancement**: `/health` endpoint now includes `deployment_mode`, `registry_mode`, and `nginx_updates_enabled` fields - **Configuration API**: New `/api/config` endpoint exposes deployment mode and feature flags to the frontend - **Frontend awareness**: UI adapts to deployment mode — shows direct server URLs and hides gateway auth instructions in registry-only mode [PR #478](https://github.com/agentic-community/mcp-gateway-registry/pull/478) --- ## Configuration ### Environment Variables ```bash # Deployment mode: with-gateway (default) or registry-only DEPLOYMENT_MODE=with-gateway # Registry mode: full (default), skills-only, mcp-servers-only, agents-only REGISTRY_MODE=full ``` ### Example: Skills-Only Registry ```bash DEPLOYMENT_MODE=registry-only REGISTRY_MODE=skills-only ``` ### Auto-Correction Behavior If an invalid combination is detected at startup, the registry auto-corrects and logs a warning banner: | Configuration | Auto-Corrected To | |---|---| | `with-gateway` + `skills-only` | `registry-only` + `skills-only` | All other combinations are valid and pass through unchanged. ### Helm Chart ```yaml registry: deployment_mode: with-gateway registry_mode: full ``` ### Terraform ```hcl deployment_mode = "with-gateway" registry_mode = "full" ``` --- ## What's Changed - New `DeploymentMode` and `RegistryMode` enums in configuration - `nginx_updates_enabled` property on Settings controls nginx behavior - Nginx service methods (`generate_config`, `generate_config_async`, `reload_nginx`) return early in registry-only mode - Prometheus metrics for deployment mode info and skipped nginx operations - `/api/config` endpoint for frontend deployment mode awareness - `/health` endpoint enhanced with deployment mode fields - Docker entrypoint logs deployment mode at container startup - `useRegistryConfig` React hook for frontend components - `DeploymentModeIndicator` badge component for registry-only mode - `ServerConfigModal` uses `proxy_pass_url` in registry-only mode instead of constructed gateway URL - Helm chart, Terraform, and `.env.example` updated with new variables --- ## Upgrade Instructions ### For Docker Compose Deployments 1. **Pull the latest changes:** ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.14 ``` 2. **Rebuild and restart:** ```bash ./build_and_run.sh ``` No configuration changes required — defaults to `with-gateway` + `full` for backward compatibility. ### For Kubernetes/Helm Deployments 1. **Update chart values** if you want registry-only mode: ```yaml registry: deployment_mode: registry-only registry_mode: skills-only # or full, mcp-servers-only, agents-only ``` 2. **Apply changes:** ```bash helm upgrade mcp-gateway ./charts/mcp-gateway -f your-values.yaml ``` --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.13...v1.0.14](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.13...v1.0.14) ================================================ FILE: release-notes/v1.0.15.md ================================================ # Release v1.0.15 - Virtual MCP Servers, IAM Settings, Skill Security Scanning & Internal JWT Auth **February 2026** --- ## Upgrading from v1.0.13 This section covers everything you need to know to upgrade from v1.0.13 to v1.0.15. ### Breaking Changes **Helm Chart Dependency Removal (EKS/Helm users only)** The `bitnami/common` chart dependency has been **removed** from both the `registry` and `auth-server` sub-charts. If you are upgrading Helm charts from v1.0.13, you **must** rebuild dependencies before upgrading: ```bash # Required before helm upgrade cd charts/mcp-gateway-registry-stack helm dependency build helm dependency update ``` Without this step, `helm upgrade` will fail because the old Chart.lock references a dependency that no longer exists. **Internal Service-to-Service Auth Changed to JWT (#533)** Internal communication between the registry and auth-server now uses self-signed JWTs instead of Basic Auth. This change is transparent -- no configuration is needed -- but the `SECRET_KEY` environment variable is now used for both JWT token signing and internal service authentication. Ensure your `SECRET_KEY` is set consistently across registry and auth-server containers. ### New Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `DEPLOYMENT_MODE` | `with-gateway` | `with-gateway` or `registry-only` | | `REGISTRY_MODE` | `full` | `full`, `skills-only`, `mcp-servers-only`, `agents-only` | | `OAUTH_STORE_TOKENS_IN_SESSION` | `false` | Store OAuth tokens in session cookie (disable for Entra ID) | | `SKILL_SECURITY_SCAN_ENABLED` | `true` | Enable skill security scanning on registration | | `SKILL_SECURITY_ANALYZERS` | `yara,spec,heuristic` | Comma-separated list of skill analyzers | ### Upgrade Instructions #### Docker Compose ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.15 # Review new env vars in .env.example and update your .env if needed # Then rebuild and restart: ./build_and_run.sh ``` #### Kubernetes / Helm (EKS) ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.15 # REQUIRED: Rebuild dependencies (bitnami/common was removed) cd charts/mcp-gateway-registry-stack helm dependency build helm dependency update # Update values.yaml if needed for new features (deployment mode, node selectors, etc.) # Then upgrade: helm upgrade mcp-gateway . -f your-values.yaml ``` #### Terraform / ECS ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.15 # Update your .tfvars with any new variables you want to configure # New Terraform variables available: deployment_mode, registry_mode, oauth_store_tokens_in_session cd terraform/aws-ecs terraform plan terraform apply ``` #### DockerHub Images Pre-built images are available: ```bash docker pull mcpgateway/registry:v1.0.15 docker pull mcpgateway/auth-server:v1.0.15 docker pull mcpgateway/currenttime-server:v1.0.15 docker pull mcpgateway/realserverfaketools-server:v1.0.15 docker pull mcpgateway/mcpgw-server:v1.0.15 docker pull mcpgateway/fininfo-server:v1.0.15 docker pull mcpgateway/metrics-service:v1.0.15 ``` --- ## Major Features ### Virtual MCP Servers Aggregate tools from multiple backend MCP servers into a single virtual endpoint: - **Virtual Server Management**: Create virtual servers that combine tools from multiple real backend servers into one unified endpoint - **Lua-Based Router**: High-performance nginx Lua router handles MCP protocol routing to backend servers - **Streamable HTTP Compliance**: Full MCP Streamable HTTP protocol support for virtual server endpoints - **Semantic Search Integration**: Virtual server tools are indexed and searchable via semantic search - **Scope-Based Access Control**: Virtual servers integrate with the existing IAM/scopes system - **Rating Support**: Virtual servers support the same 5-star rating system as regular servers - **CLI Commands**: Register, list, and manage virtual servers from the command line - **E2E Test Coverage**: Playwright E2E tests, MCP protocol compliance tests, and stress tests included [PR #501](https://github.com/agentic-community/mcp-gateway-registry/pull/501) ### IAM Settings UI Full IAM management interface in the Settings page: - **Groups Management**: Create, edit, and delete IAM groups with server/tool/agent access dropdowns - **Users Management**: View and manage user accounts and group assignments - **M2M Client Management**: Manage machine-to-machine OAuth clients - **Searchable Selectors**: Scalable searchable dropdowns for servers and agents (handles large lists) - **Virtual Server Permissions**: Auto-populate `list_virtual_server` permission for virtual servers - **Agent Access Control**: Full CRUD operations for IAM groups with agent access [PR #494](https://github.com/agentic-community/mcp-gateway-registry/pull/494) ### Skill Security Scanning Integrate Cisco AI Defense Skill Scanner for automated skill security analysis: - **Automated Scanning**: Skills are scanned on registration using configurable analyzers (YARA, spec, heuristic, LLM, endpoint) - **Security Scan API**: New `/api/skills/{path}/security-scan` endpoints for triggering and viewing scan results - **Frontend Integration**: Security scan results displayed on SkillCard component - **CLI Commands**: `skill-scan` and `skill-scan-result` CLI commands for scripting - **Property-Based Tests**: Comprehensive test coverage including property-based tests for schemas and repository layer [PR #510](https://github.com/agentic-community/mcp-gateway-registry/pull/510) | [Issue #495](https://github.com/agentic-community/mcp-gateway-registry/issues/495) ### System Configuration Viewer Admin configuration viewer in the Settings page: - **Configuration Groups**: 11 groups covering deployment, storage, auth, embeddings, health checks, websockets, security scanning, audit, federation, and discovery - **Export Formats**: Export configuration as `.env`, JSON, Terraform `.tfvars`, or YAML - **Sensitive Value Masking**: Passwords, tokens, and API keys are automatically masked - **Search and Filter**: Search across all configuration parameters - **Rate Limited**: 10 requests per minute per user [PR #508](https://github.com/agentic-community/mcp-gateway-registry/pull/508) | [Issue #492](https://github.com/agentic-community/mcp-gateway-registry/issues/492) ### Internal JWT Authentication (#533) Service-to-service communication now uses self-signed JWTs instead of Basic Auth: - **JWT-Based Auth**: Registry signs JWTs with `SECRET_KEY` when calling auth-server internal endpoints - **Configurable Auth Server URL**: `AUTH_SERVER_URL` setting replaces hardcoded `http://auth-server:8888` for EKS compatibility - **Single Source of Truth**: JWT issuer and audience constants defined once in `registry/auth/internal.py` [PR #533](https://github.com/agentic-community/mcp-gateway-registry/pull/533) | [Issue #515](https://github.com/agentic-community/mcp-gateway-registry/issues/515) --- ## What's New ### Deployment Modes - Registry-only deployment mode without nginx integration (#485, #486) - Skills-only registry mode for lightweight deployment (#493) - Deployment and registry mode added to Helm stack chart (#497) - Auto-correction for invalid mode combinations (e.g., `with-gateway` + `skills-only`) ### Helm Chart Improvements - Node selector support for all pods including Keycloak, Postgres, and MongoDB (#514) - Option to disable Keycloak ingress patch for service-mesh environments (#516) - Keycloak auth for registry API endpoints in Helm (#517) - Federation environment variables added to charts (#474) - Disable Keycloak when using Entra ID (#482) - Git hash/tag pushed to images for version tracking (#480, #481) - Removed unneeded `bitnami/common` chart dependency (#483) - Helm install examples added to README (#484) ### Security Fixes - Strip newlines from X-Body header to prevent scope validation bypass (#529) - Normalize leading slashes in scope server name matching (#529) - Recognize `registry-admins` group in `can_modify_servers` check - Move security-scan routes before catch-all path route ### Audit Logging Fixes - Audit composite key index fix for concurrent MCPServerAccessRecord and RegistryApiAccessRecord events (#530) - Handle duplicate audit event inserts gracefully (#513) - Case-insensitive regex for username filtering in audit logs - Stream-aware filters for audit queries ### OAuth and Authentication - Default `OAUTH_STORE_TOKENS_IN_SESSION` to `false` to prevent cookie size issues with Entra ID (#528) - OAuth token storage control surfaced in system config panel (#528) - Terraform support for `OAUTH_STORE_TOKENS_IN_SESSION` variable (#528) ### Infrastructure - Docker build optimizations for faster image builds (#473) - Preserve client IP address in logs/audit (#476) - `REGISTRY_ROOT_PATH` support for path-based API hosting (#472) - ECR-based container image references (#479) - Consistent Keycloak fallback behavior (#482) - CI parallel test execution with `-n auto` (#501) ### Frontend Improvements - Roo Code IDE option with streamable-http format and copy feedback - Auto-populate JWT token in MCP configuration modal - Virtual MCP Servers tab with rating support - Searchable select component for IAM server/agent dropdowns - Skill security scan display on SkillCard component ### Documentation - Virtual MCP server design document and operations guide - IAM Settings UI documentation - Registry deployment modes documentation - System Configuration Viewer documentation - Claude Code skills for development workflow --- ## Bug Fixes - Nginx config failed to load on startup due to excessive variables in the file (#512) - Audit composite key allowing only one event type per request (#530) - Duplicate audit event insert errors on concurrent writes (#513) - X-Body header newline injection in scope validation (#529) - Leading slash normalization in scope server name matching (#529) - `registry-admins` group not recognized in `can_modify_servers` check - Skill toggle sending query parameter instead of JSON body - Path mismatch in skill toggle causing UI not to update - Disabled skills excluded from API requests - Dashboard sections not rendering when feature enabled - Admin toggle for servers/agents/skills requiring explicit UI permission - Semantic search results not filtered by registry mode - MongoDB `nodeSelector` config not wrapping statefulset spec correctly (#514) - Security-scan routes shadowed by catch-all path route - Virtual server search returning incorrect tool results - JWT token extraction from API response --- ## Pull Requests Included | PR | Title | |----|-------| | #533 | Sign SECRET_KEY as JWT for internal communication | | #530 | fix: audit composite key index and stream-aware filters | | #529 | fix: strip newlines from X-Body header to prevent scope validation bypass | | #528 | feat: OAuth token session storage control with Terraform and config panel support | | #517 | Bug: Keycloak auth required for Registry API endpoints missing from Helm Chart | | #516 | Feature: Allow Helm Chart to not deploy the keycloak-ingress-patch | | #514 | Feature: Allow setting nodeSelector for pods in Helm charts | | #513 | bug: Handle duplicate inserts into the audit events DB | | #512 | fix: Nginx config would not load on startup due to variable count | | #510 | feat: Skill scanner integration (Issue #495) | | #509 | chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder in /cli | | #508 | feat: Add System Configuration Viewer with documentation | | #507 | Add Claude Code skills for development workflow | | #506 | docs: Remove remaining production-grade instances | | #505 | docs: Use more precise language in documentation | | #503 | chore(deps): bump ajv from 8.14.0 to 8.18.0 in /frontend | | #501 | feat: Virtual MCP Server - Aggregate tools from multiple backend servers | | #497 | Add registry/deployment modes to mcp gateway registry stack chart | | #494 | feat: IAM Settings UI with Groups, Users, and M2M Management | | #493 | feat: Add skills-only registry mode for lightweight deployment | | #486 | fix: Registry-only mode nginx config and terraform updates | | #485 | feat: Add Registry-Only Deployment Mode (#478) | | #484 | Add helm install examples | | #483 | Remove unneeded chart dependency | | #482 | Disable keycloak if Entra ID | | #481 | Add git hash to helm deployment | | #480 | Set git hash/tag to BUILD_VERSION in images | | #479 | Update images to use ECR | | #478 | feat: add registry-only deployment mode | | #477 | chore(deps): bump jsonpath from 1.2.0 to 1.2.1 in /frontend | | #476 | Preserve client IP in logs | | #474 | Add federation env vars to charts | | #473 | Docker build optimizations | | #472 | Add REGISTRY_ROOT_PATH for path-based API hosting | | #471 | Fix 1.0.13 chart image tags | | #468 | chore(deps): bump langchain-core from 1.2.5 to 1.2.11 | | #467 | chore(deps): bump cryptography from 46.0.3 to 46.0.5 | | #466 | chore(deps): bump cryptography from 46.0.3 to 46.0.5 in /agents/a2a | | #462 | chore(deps): bump axios from 1.13.2 to 1.13.5 in /frontend | --- ## Security Dependency Updates | Package | Previous | Updated | Scope | |---------|----------|---------|-------| | cryptography | 46.0.3 | 46.0.5 | registry, agents/a2a | | axios | 1.13.2 | 1.13.5 | frontend | | ajv | 8.14.0 | 8.18.0 | frontend | | langchain-core | 1.2.5 | 1.2.11 | registry | | jsonpath | 1.2.0 | 1.2.1 | frontend | | fast-xml-parser | - | latest | cli | --- ## Contributors Thank you to all contributors for this release: - **Amit Arora** ([@aarora79](https://github.com/aarora79)) - **Omri Shiv** ([@omrishiv](https://github.com/omrishiv)) - **Geoffrey Norman** ([@gknorman](https://github.com/gknorman)) - **Dheeraj Oruganty** ([@dheerajoruganty](https://github.com/dheerajoruganty)) - **snorlaX-sleeps** ([@snorlaX-sleeps](https://github.com/snorlaX-sleeps)) - **Abhishek Singh** ([@abkrsinh](https://github.com/abkrsinh)) - **Andreas Feldmann** ([@ndrsfel](https://github.com/ndrsfel)) --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.13...v1.0.15](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.13...v1.0.15) ================================================ FILE: release-notes/v1.0.16.md ================================================ # Release v1.0.16 - mcpgw Rewrite, macOS Setup Skill, Security Hardening & Observability **March 2026** --- ## Upgrading from v1.0.15 This section covers everything you need to know to upgrade from v1.0.15 to v1.0.16. ### Breaking Changes **Helm Chart Dependencies Added (EKS/Helm users only)** New `mcpgw` and `mcpgw-configure` sub-chart dependencies have been added to the `mcp-gateway-registry-stack` chart. If you are upgrading Helm charts from v1.0.15, you **must** rebuild dependencies before upgrading: ```bash # Required before helm upgrade cd charts/mcp-gateway-registry-stack helm dependency build helm dependency update ``` Without this step, `helm upgrade` will fail because the new dependencies are not available locally. **SECRET_KEY Now Used for Credential Encryption (#562)** The `SECRET_KEY` environment variable is now used for encrypting backend MCP server credentials (Bearer tokens, API keys) in addition to JWT token signing and session security. **Changing this key will invalidate all existing encrypted credentials stored in the database.** If you need to rotate the secret key: 1. Export all server configurations 2. Update SECRET_KEY 3. Re-register servers with authentication credentials ### New Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `GRAFANA_ADMIN_PASSWORD` | (required) | Grafana admin password for metrics dashboard. Generate with: `python3 -c "import secrets; print(secrets.token_urlsafe(24))"` | | `WORKDAY_TOKEN_URL` | (optional) | Workday ASOR federation token endpoint (required only for Workday ASOR integration) | **Note**: The `SECRET_KEY` variable documentation has been updated to include its new role in encrypting backend server credentials. This is not a new variable but its usage has expanded. ### Upgrade Instructions #### Docker Compose ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.16 # Review new env vars in .env.example and update your .env # Set GRAFANA_ADMIN_PASSWORD if using observability # Then rebuild and restart: ./build_and_run.sh ``` #### Kubernetes / Helm (EKS) ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.16 # REQUIRED: Rebuild dependencies (mcpgw sub-charts added) cd charts/mcp-gateway-registry-stack helm dependency build helm dependency update # Update values.yaml for new mcpgw charts if needed, then upgrade: helm upgrade mcp-gateway . -f your-values.yaml ``` #### Terraform / ECS ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.16 # Update your .tfvars with GRAFANA_ADMIN_PASSWORD if using observability cd terraform/aws-ecs terraform plan terraform apply ``` #### DockerHub Images Pre-built images are available: ```bash docker pull mcpgateway/registry:v1.0.16 docker pull mcpgateway/auth-server:v1.0.16 docker pull mcpgateway/currenttime-server:v1.0.16 docker pull mcpgateway/realserverfaketools-server:v1.0.16 docker pull mcpgateway/mcpgw-server:v1.0.16 docker pull mcpgateway/fininfo-server:v1.0.16 docker pull mcpgateway/metrics-service:v1.0.16 ``` --- ## Major Features ### mcpgw MCP Server Rewrite Complete architectural rewrite of the AI Registry Tools (mcpgw) MCP server to eliminate technical debt and modernize the implementation: - **Registry HTTP API Integration**: Replaces direct DocumentDB/MongoDB access with calls to Registry HTTP APIs for improved security and maintainability - **FastMCP 2.0 Upgrade**: Migrates from MCP 1.x to FastMCP 2.0 for better protocol compliance and performance - **Secure Host Binding**: Implements environment-based host binding (defaults to 127.0.0.1, uses 0.0.0.0 only when explicitly configured) - **Auto-Registration**: Server automatically registers itself on registry startup with immediate health checks and security scans - **Helm Chart Support**: New mcpgw sub-chart with configuration management (mcpgw-configure) - **Containerization**: Dedicated Dockerfile.mcp-server with registry module support [PR #584](https://github.com/agentic-community/mcp-gateway-registry/pull/584) | [PR #586](https://github.com/agentic-community/mcp-gateway-registry/pull/586) | [Issue #583](https://github.com/agentic-community/mcp-gateway-registry/issues/583) ### macOS Setup and Teardown Claude Skill Automated macOS installation and teardown via Claude Code skill: - **One-Command Setup**: Complete MCP Gateway & Registry installation on macOS with single skill invocation - **Interactive Configuration**: Choose between default values or interactive prompts for all settings - **Full Stack Deployment**: Installs all services (registry, auth, Keycloak, MongoDB) with proper configuration - **Verification Steps**: Automated health checks and Cloudflare docs server registration - **Complete Teardown**: Clean removal of all containers, volumes, and cloned repository - **GitHub Integration**: Can be run directly from GitHub URL without pre-cloning repository [PR #585](https://github.com/agentic-community/mcp-gateway-registry/pull/585) | [Issue #581](https://github.com/agentic-community/mcp-gateway-registry/issues/581) ### Observability Pipeline with Grafana Production-ready metrics pipeline for AWS ECS deployments: - **Amazon Managed Prometheus (AMP)**: Native integration with AWS managed Prometheus service - **Grafana Dashboards**: Pre-configured dashboards for MCP data-plane metrics - **Metrics Service**: FastAPI-based collector with OpenTelemetry export support - **Tool Execution Metrics**: Counters and duration histograms for MCP tool invocations - **System Stats**: Memory, CPU, and connection pool monitoring - **ECS Native**: Fully integrated with ECS Fargate service discovery [PR #544](https://github.com/agentic-community/mcp-gateway-registry/pull/544) | [Issue #489](https://github.com/agentic-community/mcp-gateway-registry/issues/489) ### Encrypted Backend Server Credentials Secure credential storage for backend MCP server authentication: - **auth_scheme Field**: Replaces `auth_type` with more descriptive `auth_scheme` (bearer, basic, api-key, oauth, custom) - **Encrypted Storage**: Backend server credentials (Bearer tokens, API keys) encrypted using SECRET_KEY with Fernet encryption - **Health Check Support**: Encrypted credentials automatically used for health check authentication - **Migration Path**: Old `auth_type` values still supported for backward compatibility - **UI Integration**: Auth scheme configuration in server registration forms [PR #562](https://github.com/agentic-community/mcp-gateway-registry/pull/562) | [Issue #542](https://github.com/agentic-community/mcp-gateway-registry/issues/542) ### Federation Server Reconciliation Improved federation with server reconciliation and bug fixes: - **Sync Existing Servers**: Federation now syncs servers that already exist in the database but are missing from the peer registry - **DELETE Endpoint Fix**: Corrected federation DELETE endpoint to properly remove servers - **Reconciliation Logic**: Compares local DB state with peer registry and syncs missing entries - **Generation Tracking**: Uses generation numbers to detect and handle orphaned servers [PR #576](https://github.com/agentic-community/mcp-gateway-registry/pull/576) | [Issue #539](https://github.com/agentic-community/mcp-gateway-registry/issues/539) ### Audit Log Enhancements Searchable audit logs with advanced filtering and statistics: - **Searchable Filters**: Search by username, HTTP method, status code, or audit stream - **Date Range Filtering**: Filter audit events by date range with calendar picker - **Statistics Dashboard**: View audit event counts, unique users, and timeline distributions - **Export Support**: Export filtered results to CSV/JSONL - **Performance Optimized**: Efficient queries with pagination and proper indexing [PR #575](https://github.com/agentic-community/mcp-gateway-registry/pull/575) | [Issue #572](https://github.com/agentic-community/mcp-gateway-registry/issues/572) ### System Uptime Display Registry uptime tracking with detailed system statistics: - **Uptime Display**: Shows registry uptime below version number in UI - **System Stats Tooltip**: Hover for detailed stats including memory usage, CPU, active connections - **Human-Readable Format**: Displays uptime in days, hours, minutes format - **Real-Time Updates**: Stats update on page load to show current system state [PR #567](https://github.com/agentic-community/mcp-gateway-registry/pull/567) | [Issue #566](https://github.com/agentic-community/mcp-gateway-registry/issues/566) ### OIDC SSO Logout with id_token_hint Fixed Keycloak and Entra ID SSO logout to properly terminate SSO sessions: - **Proper OIDC Logout**: Implements OIDC logout flow with `id_token_hint` parameter - **SSO Session Termination**: Clicking logout now terminates the session at the identity provider, not just locally - **id_token Storage**: Always stores id_token in session for logout (removes unused access_token/refresh_token for improved security) - **CORS Fix**: Changed frontend logout from XHR to full-page redirect to avoid cross-origin errors - **Styled Logout Page**: Professional logout success page with auto-redirect to login - **Multi-Provider Support**: Works with both Keycloak and Entra ID (Microsoft) identity providers - **Observability**: Added 4 Prometheus metrics for monitoring logout flow [PR #592](https://github.com/agentic-community/mcp-gateway-registry/pull/592) | [Issue #490](https://github.com/agentic-community/mcp-gateway-registry/issues/490) --- ## What's New ### Security Hardening - SQL injection fixes in metrics-service (#579, issue #522) - Fixed subprocess security findings (B603/B607) with hardcoded command validation (#577, issue #523) - Resolved hardcoded password findings (B105) (#571, issue #525) - Fixed import and pattern security findings (B404/B307/B310) (#568, issue #526) - Configured Bandit B101 skip for test files (#565, issue #524) - Added missing request timeouts (B113) to prevent DoS (#535, issue #518) - Suppressed B104 findings with nosec comments and env var configurability (#534, issue #520) - Replaced try-except-pass with proper error handling (B110) (#538, issue #521) - Stripped newlines from X-Body header to prevent scope validation bypass (#529) ### IAM and Authentication - Rebrand to AI Gateway & Registry with hidden local admin login (#555, issue #554) - Fixed IAM Groups tool selector empty state, path normalization, and UI permission sync (#570, issue #569) - Auth scheme screenshot added to authentication guide documentation - OIDC SSO logout with id_token_hint for Keycloak and Entra ID (#592, issue #490) ### Federation - Preserved encrypted federation tokens during peer updates (#564, issue #561) - Federation server reconciliation to sync existing DB configuration (#576, issue #539) ### Infrastructure - Resolved ECS Service Connect dual-stack DNS failures in registry entrypoint (#548, issue #547) - Fixed ROOT_PATH missing in generated nginx config (#532) - Updated CodeBuild source to point to upstream repo and main branch (#552, issue #491) - Ruff code formatting applied across codebase (#541) - mcpgw API compatibility fixes: corrected response parsing for list_services and intelligent_tool_finder (#588) - mcpgw security improvements: removed debug logging exposure of bearer tokens and eliminated SSRF vulnerability (#588) - mcpgw ECS Service Connect integration with least-privilege security group rules (#588, #590) ### Audit Logging - Fixed audit events composite key to allow both MCPServerAccessRecord and RegistryApiAccessRecord per request (#530, issue #527) - Ensured audit log timestamps include UTC timezone in API responses - Handled TTL index options conflict in mongodb-init ### Documentation - Added modern type hints (PEP 604/585) and pre-commit hook guidance to CLAUDE.md (#582) - Added comprehensive subprocess and SQL security guidelines to CLAUDE.md (#580) - More precise language in documentation (#504) ### Agent Discovery - Simplified A2A agent discovery configuration (#550) - Added discovery integration test --- ## Bug Fixes - IAM Groups: tool selector empty, UI permissions not synced, server paths inconsistently normalized (#570, issue #569) - Federation: update_peer() silently drops encrypted token on config update (#564, issue #561) - ECS Service Connect dual-stack DNS breaks Lua metrics flush and Python health checker (#548, issue #547) - Audit events composite key allowing only one event type per request (#530, issue #527) - Registry missing ROOT_PATH in generated nginx config (#532) - Handled TTL index options conflict in mongodb-init - Ensured audit log timestamps include UTC timezone in API responses - Keycloak and Entra ID SSO logout not terminating identity provider session (#592, issue #490) - mcpgw list_services returning 0 servers due to API response key mismatch (#588) - mcpgw intelligent_tool_finder returning empty results due to incorrect parsing (#588) - mcpgw Pydantic validation failing on registry API responses (#588) - mcpgw debug logging exposing bearer tokens and sensitive headers (#588) --- ## Pull Requests Included | PR | Title | |----|-------| | #592 | Fix Keycloak SSO logout with id_token_hint (issue #490) | | #590 | update mcpgw deployment and dockerfile | | #589 | chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder in /cli | | #588 | fix: mcpgw API compatibility, security hardening, and Service Connect | | #586 | add mcpgw build and charts | | #585 | feat: add macOS setup and teardown Claude skill | | #584 | Rewrite mcpgw MCP server to use registry HTTP APIs (issue #583) | | #582 | docs: add modern type hints (PEP 604/585) and pre-commit hook guidance | | #580 | docs: add comprehensive subprocess and SQL security guidelines to CLAUDE.md | | #579 | 522 address sql injection in metrics | | #578 | more ruff fixes | | #577 | 523 address bandit finding subprocess | | #576 | feat: add federation server reconciliation and fix DELETE endpoint (issue #539) | | #575 | feat: searchable audit log filters and statistics dashboard (#572) | | #571 | fix: resolve Bandit B105 findings (issue #525) | | #570 | fix(iam): tool selector, path normalization, and UI permission sync in IAM Groups | | #568 | fix: resolve Bandit B404/B307/B310 findings (issue #526) | | #567 | feat: add uptime display with system stats tooltip (#566) | | #565 | fix(security): configure Bandit B101 skip for test files | | #564 | fix: preserve encrypted federation tokens during peer updates (#561) | | #563 | chore(deps): bump awscli from 1.44.4 to 1.44.38 | | #562 | feat: Replace auth_type with auth_scheme and add encrypted credential storage for backend server authentication | | #555 | feat: hide local admin login and rebrand to AI Gateway & Registry | | #552 | fix: point CodeBuild source to upstream repo and main branch | | #551 | chore(deps): bump rollup from 2.79.2 to 2.80.0 in /frontend | | #550 | fix: simplify A2A agent discovery configuration | | #549 | chore(deps): bump langgraph-checkpoint from 3.0.1 to 4.0.0 | | #548 | fix: resolve ECS Service Connect dual-stack DNS failures in registry entrypoint | | #546 | chore(deps): bump minimatch in /frontend | | #544 | feat: observability pipeline with AMP, Grafana, and metrics-service for ECS | | #541 | ruff format code | | #538 | fix: Replace try-except-pass with proper error handling (Bandit B110) (#521) | | #535 | fix(security): add missing request timeouts (Bandit B113) | | #534 | fix: Bandit B104 findings across 11 files (#520) | | #532 | Bug: Registry - missing ROOT_PATH in generated nginx config | --- ## Security Dependency Updates | Package | Previous | Updated | Scope | |---------|----------|---------|-------| | fast-xml-parser | 5.3.6 | 5.4.1 | CLI | | @aws-sdk/xml-builder | 3.972.5 | 3.972.9 | CLI | | awscli | 1.44.4 | 1.44.38 | CLI tools | | rollup | 2.79.2 | 2.80.0 | frontend | | langgraph-checkpoint | 3.0.1 | 4.0.0 | registry | | minimatch | (various) | latest | frontend | --- ## Contributors Thank you to all contributors for this release: - **Amit Arora** ([@aarora79](https://github.com/aarora79)) - **Omri Shiv** ([@omrishiv](https://github.com/omrishiv)) - **Geoffrey Norman** ([@gknorman](https://github.com/gknorman)) - **Abhishek Singh** ([@abkrsinh](https://github.com/abkrsinh)) - **Wallace Printz** ([@printw](https://github.com/printw)) - **sazandkhalid** ([@sazandkhalid](https://github.com/sazandkhalid)) - **snorlaX-sleeps** ([@snorlaX-sleeps](https://github.com/snorlaX-sleeps)) - **dependabot[bot]** ([@dependabot](https://github.com/apps/dependabot)) --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.15...v1.0.16](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.15...v1.0.16) ================================================ FILE: release-notes/v1.0.17.md ================================================ # Release v1.0.17 - Okta Identity Provider, Security Hardening, and OTLP Metrics Export **March 2026** --- ## Upgrading from v1.0.16 This section covers everything you need to know to upgrade from v1.0.16 to v1.0.17. ### Breaking Changes **1. Local Admin Credentials Removed** The `ADMIN_USER` and `ADMIN_PASSWORD` environment variables have been removed. All authentication now requires an identity provider (Keycloak, Entra ID, Okta, or AgentCore). - **Action Required**: Remove these variables from your `.env` file - **Migration**: Use identity provider accounts for admin access **2. Registry Container Port Changes (Helm/Kubernetes Only)** The registry service now uses non-privileged ports: - HTTP: `80` → `8080` - HTTPS: `443` → `8443` - **Action Required for Kubernetes/Helm**: Update any external port references or ingress configurations - **No Action Required**: Docker Compose and Terraform/ECS deployments automatically map these ports **3. MongoDB Init Container Removed (Helm/Kubernetes Only)** The `wait-for-mongodb` init container has been removed from auth-server and registry deployments. MongoDB readiness is now handled through application-level retries and health checks. - **Action Required**: None - MongoDB connection retry logic is built into the applications - **Benefit**: Faster pod startup times and reduced security surface ### New Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `OKTA_DOMAIN` | - | Okta organization domain (e.g., dev-123456.okta.com) | | `OKTA_CLIENT_ID` | - | Okta OAuth2 application client ID | | `OKTA_CLIENT_SECRET` | - | Okta OAuth2 application client secret | | `OKTA_M2M_CLIENT_ID` | (uses `OKTA_CLIENT_ID`) | Optional: Separate M2M client ID | | `OKTA_M2M_CLIENT_SECRET` | (uses `OKTA_CLIENT_SECRET`) | Optional: Separate M2M client secret | | `OKTA_API_TOKEN` | - | Optional: Okta Admin API token for IAM operations | | `OKTA_AUTH_SERVER_ID` | (uses default) | Optional: Custom authorization server ID | | `OTEL_OTLP_ENDPOINT` | - | OTLP endpoint URL for direct metrics push (e.g., https://otlp.datadoghq.com) | | `OTEL_EXPORTER_OTLP_HEADERS` | - | OTLP headers (e.g., dd-api-key=YOUR_KEY) | | `OTEL_OTLP_EXPORT_INTERVAL_MS` | `30000` | Metrics export interval in milliseconds | | `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE` | `cumulative` | Metric temporality: `cumulative` or `delta` | ### Upgrade Instructions #### Docker Compose ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.17 # Review new env vars in .env.example and update your .env if needed # Remove ADMIN_USER and ADMIN_PASSWORD if present # Rebuild and restart: ./build_and_run.sh ``` #### Kubernetes / Helm (EKS) ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.17 # Update values.yaml if needed, then upgrade: cd charts/mcp-gateway-registry-stack helm upgrade mcp-gateway . -f your-values.yaml ``` #### Terraform / ECS ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.17 # Update your .tfvars with any new variables cd terraform/aws-ecs terraform plan terraform apply ``` #### DockerHub Images Pre-built images are available: ```bash docker pull mcpgateway/registry:v1.0.17 docker pull mcpgateway/auth-server:v1.0.17 docker pull mcpgateway/currenttime-server:v1.0.17 docker pull mcpgateway/realserverfaketools-server:v1.0.17 docker pull mcpgateway/mcpgw-server:v1.0.17 docker pull mcpgateway/fininfo-server:v1.0.17 docker pull mcpgateway/metrics-service:v1.0.17 ``` --- ## Major Features ### Okta Identity Provider Support Complete integration of Okta as a supported identity provider alongside Keycloak, Entra ID, and Amazon Bedrock AgentCore. **Key Capabilities:** - Full OAuth 2.0/OIDC authentication flow with Okta - Machine-to-machine (M2M) token generation for automated workflows - User and group synchronization via Okta API - IAM group mapping and authorization - Support for custom authorization servers - Optional separate M2M client credentials - Helm chart configuration support **Configuration:** - Set `AUTH_PROVIDER=okta` in your environment - Configure required variables: `OKTA_DOMAIN`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET` - Optional IAM features require `OKTA_API_TOKEN` [PR #644](https://github.com/agentic-community/mcp-gateway-registry/pull/644) [PR #657](https://github.com/agentic-community/mcp-gateway-registry/pull/657) ### Infrastructure Security Hardening Comprehensive security improvements across deployment methods (Docker Compose, Helm/Kubernetes, Terraform/ECS). **Security Enhancements:** - **Container Security**: Non-root user execution, dropped capabilities, read-only root filesystems - **Secrets Management**: Removed hardcoded credentials, AWS Secrets Manager integration for ECS - **Network Security**: Localhost binding for development, private IP binding for production - **Health Checks**: Liveness and readiness probes for all services - **Resource Limits**: CPU and memory constraints for all containers - **Logging**: Structured logging with AWS CloudWatch integration **Deployment-Specific Improvements:** - **Helm/Kubernetes**: SecurityContext enforcement, pod security standards compliance - **Terraform/ECS**: IAM role refinement, VPC security group tightening, ALB access logging - **Docker Compose**: TLS certificate management, nginx security headers [PR #642](https://github.com/agentic-community/mcp-gateway-registry/pull/642) ### Direct OTLP Metrics Export Push OpenTelemetry metrics directly to external observability platforms (Datadog, New Relic, Grafana Cloud, Honeycomb) via OTLP/HTTP. **Key Features:** - Parallel export to both Prometheus and OTLP endpoints - Configurable export intervals - Support for cumulative and delta metric temporality - Pre-configured examples for major platforms - No additional collector required **Supported Platforms:** - Datadog (US1/EU1 regions) - New Relic - Grafana Cloud - Honeycomb - Any OTLP-compatible platform **Configuration:** - Set `OTEL_OTLP_ENDPOINT` to your platform's OTLP endpoint - Add platform-specific headers in `OTEL_EXPORTER_OTLP_HEADERS` - Adjust temporality for Datadog: `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta` [PR #560](https://github.com/agentic-community/mcp-gateway-registry/pull/560) [PR #543](https://github.com/agentic-community/mcp-gateway-registry/pull/543) --- ## What's New ### Security Fixes - **Shell Injection Prevention**: Replaced `execSync` with `execFileSync` to prevent command injection attacks (#655) - **Semgrep Findings**: Addressed static analysis findings including SQL injection patterns, hardcoded credentials, and insecure randomness (#651) - **CSRF Protection**: Added Cross-Site Request Forgery protection with flexible token validation (#635) - **Hardcoded Secrets Removal**: Eliminated hardcoded database passwords and API tokens (#633) - **Network Binding Security**: Servers now bind to localhost in development, private IPs in production (#604) - **Subprocess Security**: Implemented hardcoded command patterns with proper validation (#577) - **SQL Injection Prevention**: Parameterized queries and allowlist validation for dynamic identifiers (#579) ### Authentication & Identity - Complete Okta identity provider integration with M2M support (#644, #657) - Removed local username/password authentication (#591) - Fixed Keycloak SSO logout with `id_token_hint` parameter (#592) - Removed old admin username/password references (#627) ### Agent Management - Fixed agent enable/disable 500 error after container restart (#621, #622) - Resolved health status race condition for enabled services (#639) - Agent enabled state now persists to repository on toggle (#622) ### Search & Discovery - Fixed FAISS search initialization and entity type handling (#646) - Improved semantic search accuracy and performance ### Deployment & Configuration - Helm charts now support Okta configuration (#657) - OpenTelemetry ConfigMap for registry metrics configuration (#638) - MongoDB credentials passed to configure job (#630) - Conditional environment variable handling (#640) - Docker security hardening and ECS Fargate production fixes (#624) ### Infrastructure - Created writable `/app/certs` directory for DocumentDB CA bundle (#632) - Fixed nginx X-Forwarded-Port mapping and proxy buffer permissions (#631) - Federation server reconciliation and DELETE endpoint fixes (#576) ### Frontend Improvements - ESC key now closes modals in the UI (#596) - Uptime display with system stats tooltip (#567) - IAM tool selector improvements and path normalization (#570) ### Documentation - Added Direct OTLP Push Export documentation (#637) - Updated roadmap with March 2026 milestones (#653) - Added modern type hints (PEP 604/585) guidance (#582) - Comprehensive subprocess and SQL security guidelines (#580) - Enterprise Security Posture documentation - AWS Show & Tell video added to demo videos --- ## Bug Fixes - Fixed FAISS search broken initialization and wrong entity types (#646) - Fixed agent enable/disable 500 error after container restart (#621) - Fixed agent enabled state persistence on toggle (#622) - Eliminated health status race condition for enabled services (#639) - Fixed writable /app/certs directory for DocumentDB CA bundle (#632) - Fixed nginx X-Forwarded-Port mapping and proxy buffer permissions (#631) - Fixed Keycloak SSO logout with id_token_hint (issue #490) (#592) - Fixed mcpgw API compatibility and Service Connect (#588) - Fixed federation server reconciliation and DELETE endpoint (issue #539) (#576) - Resolved Bandit B105 findings (issue #525) (#571) - Fixed IAM tool selector, path normalization, and UI permission sync (#570) - Resolved Bandit B404/B307/B310 findings (issue #526) (#568) - Preserved encrypted federation tokens during peer updates (#564) - Fixed Bandit B101 configuration for test files (#565) --- ## Pull Requests Included | PR | Title | |----|-------| | #657 | add okta envvars to charts | | #655 | fix: replace execSync with execFileSync to prevent shell injection | | #653 | docs: update roadmap with March 2026 milestones | | #651 | fix: implement Semgrep security findings fixes (issue #650) | | #648 | chore(deps): bump langgraph from 1.0.9 to 1.0.10rc1 | | #647 | chore(deps): bump flatted from 3.3.3 to 3.4.1 in /frontend | | #646 | fix: FAISS search broken - missing initialization and wrong entity types | | #645 | chore(deps): bump orjson from 3.11.5 to 3.11.6 | | #644 | feat: Add Okta as an Identity Provider | | #643 | chore(deps): bump black from 25.12.0 to 26.3.1 in /metrics-service | | #642 | feat: complete infrastructure security hardening implementation (issue #603) | | #640 | only set envvars if available | | #639 | fix: eliminate health status race condition for enabled services (#612) | | #638 | create otel configmap for registry and add variables in values | | #637 | docs: add Direct OTLP Push Export documentation for metrics | | #635 | fix: add CSRF protection, flexible validation, and security scan directories | | #633 | fix: remove hardcoded secret and improve credentials security | | #632 | fix: create writable /app/certs directory for DocumentDB CA bundle | | #631 | fix: nginx X-Forwarded-Port mapping and proxy buffer permissions | | #630 | pass mongodb credentials to configure job | | #629 | update helm charts for hardening PR | | #627 | remove old references to admin username/password | | #624 | Docker security hardening and ECS Fargate production fixes | | #622 | fix: persist agent enabled state to repository on toggle | | #621 | fix: agent enable/disable 500 after container restart | | #606 | remove mcpgw install script | | #604 | fix(security): address test code and network binding security findings (issue #599) | | #596 | esc now closes modals in the UI | | #592 | Fix Keycloak SSO logout with id_token_hint (issue #490) | | #591 | Remove local username password | | #590 | update mcpgw deployment and dockerfile | | #589 | chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder in /cli | | #588 | fix: mcpgw API compatibility, security hardening, and Service Connect | | #586 | add mcpgw build and charts | | #585 | feat: add macOS setup and teardown Claude skill | | #584 | Rewrite mcpgw MCP server to use registry HTTP APIs (issue #583) | | #582 | docs: add modern type hints (PEP 604/585) and pre-commit hook guidance | | #580 | docs: add comprehensive subprocess and SQL security guidelines to CLAUDE.md | | #579 | 522 address sql injection in metrics | | #578 | more ruff fixes | | #577 | 523 address bandit finding subprocess | | #576 | feat: add federation server reconciliation and fix DELETE endpoint (issue #539) | | #575 | feat: searchable audit log filters and statistics dashboard (#572) | | #571 | fix: resolve Bandit B105 findings (issue #525) | | #570 | fix(iam): tool selector, path normalization, and UI permission sync in IAM Groups | | #568 | fix: resolve Bandit B404/B307/B310 findings (issue #526) | | #567 | feat: add uptime display with system stats tooltip (#566) | | #565 | fix(security): configure Bandit B101 skip for test files | | #564 | fix: preserve encrypted federation tokens during peer updates (#561) | | #563 | chore(deps): bump awscli from 1.44.4 to 1.44.38 | --- ## Security Dependency Updates | Package | Previous | Updated | Scope | |---------|----------|---------|-------| | langgraph | 1.0.9 | 1.0.10rc1 | Python | | flatted | 3.3.3 | 3.4.1 | frontend (npm) | | black | 25.12.0 | 26.3.1 | metrics-service | | orjson | 3.11.5 | 3.11.6 | Python | | fast-xml-parser | - | (updated) | cli (npm) | | @aws-sdk/xml-builder | - | (updated) | cli (npm) | | awscli | 1.44.4 | 1.44.38 | Infrastructure | --- ## Contributors Thank you to all contributors for this release: - **Amit Arora** ([@aroraai](https://github.com/aroraai)) - **Omri Shiv** ([@omrishiv](https://github.com/omrishiv)) - **Wallace Printz** ([@printw](https://github.com/printw)) - **Harshit Kumar Gupta** ([@harshit-knit](https://github.com/harshit-knit)) - **Abhishek Singh** ([@abkrsinh](https://github.com/abkrsinh)) - **Spidershield-contrib** ([@Spidershield-contrib](https://github.com/Spidershield-contrib)) - **Prateek Sinha** ([@shekharprateek](https://github.com/shekharprateek)) - **dependabot[bot]** ([@dependabot](https://github.com/dependabot)) --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.16...v1.0.17](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.16...v1.0.17) ================================================ FILE: release-notes/v1.0.18.md ================================================ # Release v1.0.18 - Auth0 Provider, ANS Trust Verification, Telemetry, and Federation Metadata **April 2026** --- ## Upgrading from v1.0.17 This section covers everything you need to know to upgrade from v1.0.17 to v1.0.18. ### Breaking Changes There are no breaking changes in this release. ### New Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `AUTH0_DOMAIN` | - | Auth0 tenant domain (e.g., your-tenant.auth0.com) | | `AUTH0_CLIENT_ID` | - | Auth0 OAuth2 application client ID | | `AUTH0_CLIENT_SECRET` | - | Auth0 OAuth2 application client secret | | `AUTH0_AUDIENCE` | - | Optional: API audience for M2M token validation | | `AUTH0_GROUPS_CLAIM` | `https://mcp-gateway/groups` | Custom namespaced claim for group memberships | | `AUTH0_ENABLED` | `false` | Enable Auth0 as OAuth2 provider | | `AUTH0_M2M_CLIENT_ID` | - | Optional: M2M client ID for IAM management | | `AUTH0_M2M_CLIENT_SECRET` | - | Optional: M2M client secret for IAM management | | `AUTH0_MANAGEMENT_API_TOKEN` | - | Optional: Static management API token (alternative to M2M credentials) | | `ANS_INTEGRATION_ENABLED` | `false` | Enable Agent Name Service (ANS) trust verification | | `ANS_API_ENDPOINT` | `https://api.godaddy.com` | ANS API base URL | | `ANS_API_KEY` | - | GoDaddy API key (required when ANS enabled) | | `ANS_API_SECRET` | - | GoDaddy API secret (required when ANS enabled) | | `ANS_API_TIMEOUT_SECONDS` | `30` | HTTP request timeout for ANS API calls | | `ANS_SYNC_INTERVAL_HOURS` | `6` | Background re-verification interval | | `ANS_VERIFICATION_CACHE_TTL_SECONDS` | `3600` | Cache TTL for verification results | | `MCP_TELEMETRY_DISABLED` | `false` | Set to true to disable all telemetry | | `MCP_TELEMETRY_OPT_IN` | `false` | Set to true to enable daily heartbeat with aggregate counts | | `MCP_TELEMETRY_DEBUG` | `false` | Set to true to log payloads instead of sending | | `REGISTRY_NAME` | (auto-generated) | Human-readable registry name for federation | | `REGISTRY_ORGANIZATION_NAME` | `ACME Inc.` | Organization operating this registry | | `REGISTRY_DESCRIPTION` | - | Optional: Registry description for federation | | `REGISTRY_CONTACT_EMAIL` | - | Optional: Contact email for registry administrators | | `REGISTRY_CONTACT_URL` | - | Optional: Documentation or support URL | ### Upgrade Instructions #### Docker Compose ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.18 # Review new env vars in .env.example and update your .env if needed # Then rebuild and restart: ./build_and_run.sh ``` #### Kubernetes / Helm (EKS) ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.18 # Update values.yaml with Auth0/ANS/telemetry/registry card settings if needed cd charts/mcp-gateway-registry-stack helm upgrade mcp-gateway . -f your-values.yaml ``` #### Terraform / ECS ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.18 # Update your .tfvars with any new variables cd terraform/aws-ecs terraform plan terraform apply ``` #### DockerHub Images Pre-built images are available: ```bash docker pull mcpgateway/registry:v1.0.18 docker pull mcpgateway/auth-server:v1.0.18 docker pull mcpgateway/currenttime-server:v1.0.18 docker pull mcpgateway/realserverfaketools-server:v1.0.18 docker pull mcpgateway/fininfo-server:v1.0.18 docker pull mcpgateway/mcpgw-server:v1.0.18 docker pull mcpgateway/metrics-service:v1.0.18 ``` --- ## Major Features ### Auth0 Identity Provider Support Full Auth0 integration as a fourth identity provider alongside Keycloak, Entra ID, and Okta. Includes OAuth2 login, M2M client credentials flow, group enrichment via custom claims, IAM management through Auth0 Management API, and Helm chart support. [PR #708](https://github.com/agentic-community/mcp-gateway-registry/pull/708) ### Agent Name Service (ANS) Integration PKI-based trust verification for AI agents via GoDaddy ANS. Agents can link an ANS identity and receive a verified trust badge in the UI. Features include read-only "Bring Your Own ANS ID" model, background re-verification every 6 hours, circuit breaker resilience (5 failures, 1 hour cooldown), clickable badge with full certificate details, and Helm chart configuration. [PR #693](https://github.com/agentic-community/mcp-gateway-registry/pull/693) ### Server-Side Telemetry Collector Anonymous usage telemetry infrastructure with opt-out support. Collects aggregate registry metrics (asset counts, feature usage) for project health insights. Includes configurable opt-in daily heartbeat, debug mode for payload inspection, and deployment-specific configuration for Kubernetes/ECS/Docker Compose. [PR #674](https://github.com/agentic-community/mcp-gateway-registry/pull/674) ### Token Refresher and A2A Tags Automatic OAuth token refresh for MCP server connections, A2A agent tagging support, agent edit with skills management, and streamlined documentation structure. [PR #628](https://github.com/agentic-community/mcp-gateway-registry/pull/628) ### Discover Tab New Discover tab in the frontend with expandable list rows, search functionality, and asset counts across servers, agents, and skills. [PR #745](https://github.com/agentic-community/mcp-gateway-registry/pull/745) ### UUID Fields and Federation Metadata UUID identifiers and enhanced federation metadata for servers, agents, and skills. Enables cross-registry asset tracking and federation discovery with registry card configuration. [PR #676](https://github.com/agentic-community/mcp-gateway-registry/pull/676) --- ## What's New ### Authentication and IAM - Auth0 identity provider with OAuth2, M2M, and group enrichment (#708) - Okta M2M sync dual-write to `idp_m2m_clients` for group enrichment parity with Auth0 (#759) - Decouple `is_admin` from server wildcard access (#717) - Add KEYCLOAK_EXTERNAL_URL to registry service (#681) ### Agent Trust and Discovery - ANS integration with trust badges, UI components, and infrastructure config (#693) - Agent registration with Amazon Bedrock AgentCore security schemes and field pass-through (#728) - Add `supported_protocol` field, update `trust_level`/`visibility` defaults (#737) - Normalize visibility values across agents, servers, and skills (#740) ### Telemetry and Observability - Server-side telemetry collector infrastructure (#674) - Telemetry end-to-end reliability and enhancements (#702) - Fix telemetry `registry_id` being None on first startup (#714) - Usage-report Claude Code skill for telemetry reporting (#715) - Enhance usage-report skill with chart generation and styling (#727) - Add telemetry analysis script to usage-report skill (#729) ### Frontend Improvements - Discover tab with expandable list rows, search, and counts (#745) - Fix edit server blank page, add metadata to search and skill UI (#746) - Tag filtering and searching support (#668) - Fix tag filtering losing focus (#673) ### Infrastructure and Deployment - Packaging as a Python package (#669) - Helm values support for registry card (#692) - Make Nginx DNS resolver configurable via environment variable (#683) - Restore SETUID/SETGID capabilities for MongoDB after `cap_drop ALL` (#688) - MCP bug with CloudFront mode (#749) - Format KMS key policy and add role pattern comments (#754) ### Documentation - FAQ section with Entra ID group visibility and API token guides (#756) - Add QR code for repository (#757) - ANS demo video link in design doc and README (#693) - Update roadmap to April 2026 milestones (#741) --- ## Bug Fixes - Fix edit server blank page, add metadata to search and skill UI (#746) - Preserve `ans_metadata` and other fields on agent edit (#752) - MCP bug with CloudFront mode (#749) - Normalize visibility values across agents, servers, and skills (#740) - Agent registration with Amazon Bedrock AgentCore security schemes and field pass-through (#728) - Decouple `is_admin` from server wildcard access (#717) - Fix telemetry `registry_id` being None on first startup (#714) - Intelligent tool finder `top_n` parameter ignored (#703) - Telemetry end-to-end reliability and enhancements (#702) - Resolve test regressions introduced in PR #676 (#690) - Restore SETUID/SETGID capabilities for MongoDB after `cap_drop ALL` (#688) - Pin litellm to 1.82.4 to avoid compromised 1.82.8 release (#687) - Make Nginx DNS resolver configurable via environment variable (#683) - Add KEYCLOAK_EXTERNAL_URL to registry service (#681) - Fix tag filtering losing focus (#673) - Okta M2M sync dual-write to `idp_m2m_clients` collection (#759) --- ## Pull Requests Included | PR | Title | |----|-------| | #759 | fix: Okta M2M sync dual-write to idp_m2m_clients collection | | #757 | docs: add QR code for repository and qrcode dev dependency | | #756 | docs: add FAQ section with Entra ID group visibility and API token guides | | #754 | fix: format KMS key policy and add role pattern comments | | #752 | fix: preserve ans_metadata and other fields on agent edit | | #749 | MCP bug with CloudFront mode | | #746 | Fix edit server blank page, add metadata to search and skill UI | | #745 | feat: Discover tab with expandable list rows, search, and counts | | #741 | chore: update roadmap to April 2026 milestones | | #740 | fix: normalize visibility values across agents, servers, and skills | | #737 | feat: add supported_protocol field, update trust_level/visibility defaults | | #732 | chore(deps): bump lodash from 4.17.23 to 4.18.1 in /frontend | | #733 | chore(deps): bump pygments from 2.19.2 to 2.20.0 in /servers/mcpgw | | #730 | chore(deps): bump litellm from 1.82.4 to 1.83.0 | | #729 | feat: add telemetry analysis script to usage-report skill | | #728 | fix: agent registration with Bedrock AgentCore security schemes and field pass-through | | #727 | feat: enhance usage-report skill with chart generation and styling | | #725 | chore(deps): bump aiohttp from 3.13.3 to 3.13.4 in /agents/a2a | | #724 | chore(deps): bump aiohttp from 3.13.3 to 3.13.4 | | #721 | chore(deps): bump fastmcp from 3.1.0 to 3.2.0 in /servers/currenttime | | #720 | chore(deps): bump fastmcp from 3.1.0 to 3.2.0 in /servers/mcpgw | | #717 | fix: decouple is_admin from server wildcard access | | #715 | Add usage-report Claude Code skill for telemetry reporting | | #714 | Fix telemetry registry_id being None on first startup | | #713 | chore(deps): bump pygments from 2.19.2 to 2.20.0 in /servers/currenttime | | #712 | chore(deps): bump pygments from 2.19.2 to 2.20.0 in /metrics-service | | #711 | chore(deps): bump pygments from 2.19.2 to 2.20.0 | | #710 | chore(deps): bump pygments from 2.19.2 to 2.20.0 in /agents/a2a | | #709 | chore(deps): bump pygments from 2.19.2 to 2.20.0 in /agents/a2a | | #708 | Add Auth0 provider support to MCP Gateway Registry | | #707 | chore(deps): bump path-to-regexp from 0.1.12 to 0.1.13 in /frontend | | #706 | chore(deps): bump cryptography from 46.0.5 to 46.0.6 in /servers/mcpgw | | #705 | chore(deps): bump langchain-core from 1.2.11 to 1.2.22 | | #703 | fix: intelligent_tool_finder top_n parameter ignored | | #702 | fix: telemetry end-to-end reliability and enhancements | | #701 | chore(deps): bump brace-expansion from 1.1.12 to 1.1.13 in /frontend | | #700 | chore(deps): bump node-forge from 1.3.2 to 1.4.0 in /frontend | | #699 | chore(deps): bump yaml in /frontend | | #698 | chore(deps): bump requests from 2.32.5 to 2.33.0 in /metrics-service | | #697 | chore(deps): bump requests from 2.32.5 to 2.33.0 | | #696 | chore(deps): bump requests from 2.32.5 to 2.33.0 in /agents/a2a | | #694 | chore(deps): bump picomatch in /frontend | | #693 | feat: ANS integration with UI fixes and infrastructure config | | #692 | update helm values to support registry card | | #690 | fix: resolve test regressions introduced in PR #676 | | #688 | fix: restore SETUID/SETGID capabilities for MongoDB after cap_drop ALL | | #687 | fix: pin litellm to 1.82.4 to avoid compromised 1.82.8 release | | #683 | fix: make Nginx DNS resolver configurable via environment variable | | #681 | fix: Add KEYCLOAK_EXTERNAL_URL to registry service | | #680 | chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder in /cli | | #679 | chore(deps): bump pyjwt from 2.10.1 to 2.12.0 | | #676 | Add UUID fields and enhanced federation metadata for servers, agents, and skills | | #675 | chore(deps): bump jsonpath from 1.2.1 to 1.3.0 in /frontend | | #673 | fix: tag filtering losing focus | | #672 | chore(deps): bump flatted from 3.4.1 to 3.4.2 in /frontend | | #671 | chore(deps): bump pyjwt from 2.11.0 to 2.12.0 in /servers/mcpgw | | #669 | Packaging as a python package | | #668 | support tag filtering and searching | | #664 | bump image tag | | #662 | chore(deps): bump pyasn1 from 0.6.2 to 0.6.3 | | #661 | chore(deps): bump pyasn1 from 0.6.2 to 0.6.3 in /agents/a2a | | #652 | chore(deps): bump pyjwt from 2.10.1 to 2.12.0 in /agents/a2a | | #649 | chore(deps): bump pillow from 11.3.0 to 12.1.1 | | #628 | feat: token refresher, A2A tags, agent edit with skills, streamlined docs | --- ## Security Dependency Updates | Package | Previous | Updated | Scope | |---------|----------|---------|-------| | litellm | 1.82.4 | 1.83.0 | registry | | pyjwt | 2.10.1 / 2.11.0 | 2.12.0 | registry, agents/a2a, servers/mcpgw | | requests | 2.32.5 | 2.33.0 | registry, agents/a2a, metrics-service | | aiohttp | 3.13.3 | 3.13.4 | registry, agents/a2a | | pygments | 2.19.2 | 2.20.0 | registry, agents/a2a, servers/mcpgw, servers/currenttime, metrics-service | | cryptography | 46.0.5 | 46.0.6 | servers/mcpgw | | pillow | 11.3.0 | 12.1.1 | registry | | fastmcp | 3.1.0 | 3.2.0 | servers/mcpgw, servers/currenttime | | langchain-core | 1.2.11 | 1.2.22 | registry | | lodash | 4.17.23 | 4.18.1 | frontend | | node-forge | 1.3.2 | 1.4.0 | frontend | | pyasn1 | 0.6.2 | 0.6.3 | registry, agents/a2a | | path-to-regexp | 0.1.12 | 0.1.13 | frontend | | brace-expansion | 1.1.12 | 1.1.13 | frontend | | jsonpath | 1.2.1 | 1.3.0 | frontend | | flatted | 3.4.1 | 3.4.2 | frontend | --- ## Contributors Thank you to all contributors for this release: - **Amit Arora** ([@aarora79](https://github.com/aarora79)) - **Omri Shiv** ([@omrishiv](https://github.com/omrishiv)) - **Prateek Sinha** ([@prateek-sinha-godaddy](https://github.com/prateek-sinha-godaddy)) - **Abhishek Singh** ([@singhabhishek4u](https://github.com/singhabhishek4u)) - **Gaurav Rele** ([@gauravrele87](https://github.com/gauravrele87)) - **Benjamin Hsu** ([@BenjaminHsu](https://github.com/BenjaminHsu)) - **Alejandro Nunez Cabello** ([@alnu79](https://github.com/alnu79)) --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.17...v1.0.18](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.17...v1.0.18) ================================================ FILE: release-notes/v1.0.19.md ================================================ # Release v1.0.19 - GitHub Private Repo Auth, Configurable Tab Visibility, Pagination, and Lifecycle Filtering **April 2026** --- ## Upgrading from v1.0.18 This section covers everything you need to know to upgrade from v1.0.18 to v1.0.19. ### Breaking Changes **Heartbeat telemetry is now opt-out (on by default).** In v1.0.18 the daily heartbeat required `MCP_TELEMETRY_OPT_IN=1`. In v1.0.19 it runs automatically and you opt out with `MCP_TELEMETRY_OPT_OUT=1`. If you previously set `MCP_TELEMETRY_OPT_IN=1`, remove it and the heartbeat will continue as before. If you do not want heartbeat telemetry, set `MCP_TELEMETRY_OPT_OUT=1`. ### New Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `SHOW_SERVERS_TAB` | `true` | Show MCP Servers tab in UI (AND-ed with REGISTRY_MODE) | | `SHOW_VIRTUAL_SERVERS_TAB` | `true` | Show Virtual MCP Servers tab in UI (AND-ed with REGISTRY_MODE) | | `SHOW_SKILLS_TAB` | `true` | Show Skills tab in UI (AND-ed with REGISTRY_MODE) | | `SHOW_AGENTS_TAB` | `true` | Show Agents tab in UI (AND-ed with REGISTRY_MODE) | | `GITHUB_PAT` | - | GitHub Personal Access Token for private repo SKILL.md fetching | | `GITHUB_APP_ID` | - | GitHub App ID for private repo auth (enterprise) | | `GITHUB_APP_INSTALLATION_ID` | - | GitHub App installation ID | | `GITHUB_APP_PRIVATE_KEY` | - | GitHub App private key (PEM format) | | `GITHUB_EXTRA_HOSTS` | - | Comma-separated extra GitHub hosts for GHES | | `GITHUB_API_BASE_URL` | `https://api.github.com` | GitHub API base URL (override for GHES) | | `IDP_GROUP_FILTER_PREFIX` | - | Comma-separated prefixes to filter IdP groups | | `AWS_REGISTRY_FEDERATION_ENABLED` | `false` | Enable AWS Agent Registry (AgentCore) federation | | `DISABLE_AI_REGISTRY_TOOLS_SERVER` | `false` | Disable built-in AI registry tools server auto-registration | | `MCP_TELEMETRY_OPT_OUT` | - | Set to `1` to disable heartbeat telemetry (replaces OPT_IN) | | `MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES` | `1440` | Heartbeat interval in minutes (default 24h) | ### Upgrade Instructions #### Docker Compose ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.19 # Review new env vars in .env.example and update your .env if needed # Then rebuild and restart: ./build_and_run.sh ``` #### Kubernetes / Helm (EKS) ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.19 # Update values.yaml if needed, then upgrade: helm upgrade mcp-gateway . -f your-values.yaml ``` #### Terraform / ECS ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.19 # Update your .tfvars with any new variables cd terraform/aws-ecs terraform plan terraform apply ``` #### DockerHub Images Pre-built images are available: ```bash docker pull mcpgateway/registry:v1.0.19 docker pull mcpgateway/auth-server:v1.0.19 docker pull mcpgateway/currenttime-server:v1.0.19 docker pull mcpgateway/realserverfaketools-server:v1.0.19 docker pull mcpgateway/fininfo-server:v1.0.19 docker pull mcpgateway/mcpgw-server:v1.0.19 docker pull mcpgateway/metrics-service:v1.0.19 ``` --- ## Major Features ### GitHub Private Repository Authentication for Agent Skills The registry now supports authenticated access to SKILL.md files hosted in private GitHub repositories. Two authentication methods are available: Personal Access Tokens (PATs) for simple setups, and GitHub App credentials for enterprise organizations. Key capabilities: - PAT and GitHub App auth with automatic priority selection - Token caching with expiry-aware refresh for GitHub App JWT flow - GitHub Enterprise Server (GHES) support via configurable API base URL and extra hosts - Auth headers sent only to whitelisted GitHub domains for security - Full env var propagation across Docker Compose, Terraform/ECS, and Helm charts [PR #782](https://github.com/agentic-community/mcp-gateway-registry/pull/782), [PR #838](https://github.com/agentic-community/mcp-gateway-registry/pull/838) ### Configurable UI Tab Visibility Operators can now hide individual UI tabs (MCP Servers, Virtual MCP Servers, Skills, Agents) independently of REGISTRY_MODE using `SHOW_*_TAB` environment variables. Tab visibility follows AND logic: `tab_visible = REGISTRY_MODE enables feature AND SHOW_*_TAB`. This allows fine-grained control over the user-facing dashboard without affecting backend API availability. Tab visibility settings are also configurable from the System Config page in the UI. [PR #839](https://github.com/agentic-community/mcp-gateway-registry/pull/839), [PR #841](https://github.com/agentic-community/mcp-gateway-registry/pull/841) ### AWS Agent Registry (AgentCore) Federation Federate agents from Amazon Bedrock AgentCore registries into the MCP Gateway Registry. Agents discovered in AgentCore appear in the External Registries tab alongside Anthropic and Workday federation sources. Requires IAM permissions for `bedrock-agentcore:ListRegistries`, `ListRegistryRecords`, and `GetRegistryRecord`. [PR #808](https://github.com/agentic-community/mcp-gateway-registry/pull/808) ### API Pagination Support All three list endpoints now support cursor-based pagination with `limit` and `offset` query parameters: - `GET /api/agents` -- paginated agent listing - `GET /api/servers` -- paginated server listing - `GET /api/skills` -- paginated skills listing Responses include `total`, `limit`, and `offset` fields for client-side pagination controls. [PR #819](https://github.com/agentic-community/mcp-gateway-registry/pull/819), [PR #804](https://github.com/agentic-community/mcp-gateway-registry/pull/804) ### Lifecycle Status Filtering Servers, agents, and skills now support lifecycle status values (`active`, `deprecated`, `retired`). The dashboard sidebar includes a filter toggle to show or hide deprecated/retired entries. The deprecated toggle has been moved from the dashboard count line to the sidebar filter list for better UX. [PR #835](https://github.com/agentic-community/mcp-gateway-registry/pull/835) --- ## What's New ### Authentication and Authorization - Allow network-trusted auth method to generate JWT tokens (#837) - Fix Entra group management bugs and add IdP group filtering with `IDP_GROUP_FILTER_PREFIX` (#781) - Handle wildcard `*` in `accessible_servers` for non-admin server visibility (#829) ### API Improvements - Add `GET /api/servers/{path}` endpoint for single server retrieval (#802) - Replace hardcoded 3-per-type search cap with global ranking and soft caps (#804) - Fix search query stats to use lifetime max per instance instead of sum - Add deterministic sort order to paginated queries ### Frontend Improvements - Replace cog icon with labeled Connect button on MCP server cards (#797) - Hide Register button on Virtual MCP Servers and Agent Skills tabs (#794) - Fix SPA blank page on browser refresh for client-side routes (#786) - Add lifecycle status badges and sidebar filter toggle (#835) ### Infrastructure and Deployment - Make heartbeat telemetry opt-out with configurable interval (#813) - Add `DISABLE_AI_REGISTRY_TOOLS_SERVER` env var for production/GitOps deployments (#790) - Add `UV_NATIVE_TLS` for enterprise Macs with custom CA certificates (#789) - Propagate GitHub private repo auth env vars to Docker Compose, Terraform, and Helm (#838) - Update missing Helm envvars and fix Helm chart update workflow (#783, #785) - Generate secrets for postgres/keycloak passwords in Helm (#769) - Automate Helm release bump workflow (#765) - Fix warnings and add hardening improvements (#798) ### Documentation - Document JWT server management API and auth-protected server registration (#800) - Add AWS Agent Registry Federation demo video link (#812) - Add presentation slide deck PDF to docs (#811) - Migrate monolithic FAQ.md to individual FAQ articles (#771) - Add QR code image for repository (#757) - Add GitHub private repo auth configuration guide to docs/configuration.md - Add tab visibility and deployment mode sections to docs/configuration.md --- ## Bug Fixes - Fix: allow network-trusted auth method to generate JWT tokens (#837) - Fix: convert AgentCard to dict before calling `.get()` in federation service (#796) - Fix: SPA blank page on browser refresh for client-side routes (#786) - Fix: hide Register button on Virtual MCP Servers and Agent Skills tabs (#794) - Fix: Entra group management bugs and IdP group filtering (#781) - Fix: handle wildcard `*` in `accessible_servers` for non-admin server visibility (#829) - Fix: replace hardcoded 3-per-type search cap with global ranking and soft caps (#804) - Fix: correct search query stats to use lifetime max per instance instead of sum - Fix: wire agent metadata and capabilities fields through registration pipeline (#770) - Fix: Okta M2M sync dual-write to `idp_m2m_clients` collection (#759) - Fix: pin cisco-ai-a2a-scanner to commit with flexible dep ranges - Fix: relax multiple dependency versions to resolve a2a-scanner compatibility conflicts - Fix: ruff import order and test mock for GitHub auth (#827) - Fix: add deterministic sort order to paginated query - Fix: add `--limit`/`--offset` CLI args and fix skills `include_disabled` filter - Quote default values in .env.example to prevent shell errors (#762) --- ## Pull Requests Included | PR | Title | |----|-------| | #841 | fix: post-merge improvements for configurable tab visibility | | #839 | feat: Add configurable UI tab visibility independent of REGISTRY_MODE (#743) | | #838 | feat: propagate GitHub private repo auth env vars to all deployment surfaces | | #837 | fix: allow network-trusted auth method to generate JWT tokens | | #836 | chore(deps): bump langsmith from 0.6.3 to 0.7.31 | | #835 | feat: lifecycle status filtering for servers, agents, and skills | | #834 | chore(deps): bump python-multipart from 0.0.22 to 0.0.26 in /servers/currenttime | | #833 | chore(deps): bump python-multipart from 0.0.22 to 0.0.26 in /metrics-service | | #832 | chore(deps): bump python-multipart from 0.0.22 to 0.0.26 in /servers/mcpgw | | #831 | chore(deps): bump python-multipart from 0.0.22 to 0.0.26 | | #830 | chore(deps): bump python-multipart from 0.0.22 to 0.0.26 in /agents/a2a | | #829 | fix: Handle wildcard `*` in accessible_servers for non-admin server visibility (#763) | | #828 | chore(deps): bump follow-redirects from 1.15.11 to 1.16.0 in /frontend | | #827 | fix: ruff import order and test mock for github auth | | #825 | chore(deps): bump pytest from 9.0.2 to 9.0.3 | | #823 | chore(deps): bump pytest from 9.0.2 to 9.0.3 in /metrics-service | | #822 | chore(deps): bump pillow from 12.1.1 to 12.2.0 | | #819 | feat: add pagination support to GET /api/agents (#774) | | #815 | Add config propagation check to pr-review skill and update presentation | | #813 | feat: make heartbeat telemetry opt-out with configurable interval | | #812 | docs: add AWS Agent Registry Federation demo video link | | #811 | docs: add presentation slide deck PDF | | #808 | feat: add AWS Agent Registry (AgentCore) federation support | | #806 | update helm update script | | #804 | fix: replace hardcoded 3-per-type search cap with global ranking and soft caps (#803) | | #802 | feat: add GET /api/servers/{path} endpoint for single server retrieval | | #800 | docs: document JWT server management API and auth-protected server registration | | #798 | Hardening | | #797 | feat: add labeled Connect button to MCP server cards | | #796 | fix: convert AgentCard to dict in federation peer sync | | #794 | fix: hide Register button on Virtual MCP Servers and Agent Skills tabs | | #790 | feat: add DISABLE_AI_REGISTRY_TOOLS_SERVER env var (#764) | | #789 | fix: add UV_NATIVE_TLS for enterprise Macs with custom CA certificates (#784) | | #787 | chore(deps): bump axios from 1.13.5 to 1.15.0 in /frontend | | #786 | fix: SPA blank page on browser refresh for client-side routes | | #785 | fix helm chart update workflow | | #783 | update missing helm envvars | | #782 | feat: Support authenticated GitHub access for SKILL.md fetching (private repos) | | #781 | fix: Entra group management bugs and IdP group filtering (#780) | | #778 | chore(deps): bump langchain-core from 1.2.22 to 1.2.28 | | #777 | chore(deps): bump cryptography from 46.0.6 to 46.0.7 in /servers/mcpgw | | #776 | chore(deps): bump cryptography from 46.0.5 to 46.0.7 in /servers/currenttime | | #773 | chore(deps): bump cryptography from 46.0.5 to 46.0.7 in /agents/a2a | | #772 | chore(deps): bump cryptography from 46.0.5 to 46.0.7 | | #771 | docs: migrate monolithic FAQ.md to individual FAQ articles | | #770 | fix: wire agent metadata and capabilities fields through registration pipeline | | #769 | generate secret for postgres/keycloak passwords | | #767 | bump stack tag | | #766 | update helm image tags to 1.0.18 | | #765 | Automate helm release bump | | #762 | Quote default values in .env.example to prevent shell errors | | #761 | feat: add executive summary comparison and timeseries chart to usage report skill | | #760 | chore(deps): bump transformers from 4.57.3 to 5.0.0rc3 | | #759 | fix: Okta M2M sync dual-write to idp_m2m_clients collection | | #757 | docs: add QR code image for repository | | #716 | feat(charts): add existing secret support to Helm charts | --- ## Security Dependency Updates | Package | Previous | Updated | Scope | |---------|----------|---------|-------| | cryptography | 46.0.5 / 46.0.6 | 46.0.7 | registry, agents/a2a, servers/currenttime, servers/mcpgw | | python-multipart | 0.0.22 | 0.0.26 | registry, agents/a2a, metrics-service, servers/mcpgw, servers/currenttime | | axios | 1.13.5 | 1.15.0 | frontend | | follow-redirects | 1.15.11 | 1.16.0 | frontend | | langchain-core | 1.2.22 | 1.2.28 | registry | | langsmith | 0.6.3 | 0.7.31 | registry | | pillow | 12.1.1 | 12.2.0 | registry | | pytest | 9.0.2 | 9.0.3 | registry, metrics-service | | transformers | 4.57.3 | 5.0.0rc3 | registry | --- ## Contributors Thank you to all contributors for this release: - **Amit Arora** ([@aarora79](https://github.com/aarora79)) - **Vaclav Rut** ([@VaclavRut](https://github.com/VaclavRut)) - **Omri Shiv** ([@omrishiv](https://github.com/omrishiv)) - **Abhishek Singh** ([@abkrsinh](https://github.com/abkrsinh)) - **Prateek Sinha** ([@shekharprateek](https://github.com/shekharprateek)) - **Siim Talts** ([@siimtalts](https://github.com/siimtalts)) - **David Gibbons** ([@davidgibbons](https://github.com/davidgibbons)) --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.18...v1.0.19](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.18...v1.0.19) ================================================ FILE: release-notes/v1.0.20.md ================================================ # Release v1.0.20 - Registration Gate, Multi-Key API Auth, Webhooks, M2M Direct Registration, and Metadata Search **April 2026** --- ## Upgrading from v1.0.19 This section covers everything you need to know to upgrade from v1.0.19 to v1.0.20. ### Breaking Changes There are no breaking changes in this release. All new features are disabled by default or additive. ### New Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `REGISTRY_API_KEYS` | `""` | JSON map of static API keys with per-key group assignments. Each key gets only the scopes its groups resolve to. | | `REGISTRATION_WEBHOOK_URL` | `""` | Webhook URL for registration/deletion notifications. Disabled when empty. | | `REGISTRATION_WEBHOOK_AUTH_HEADER` | `Authorization` | Header name for webhook auth. If `Authorization`, Bearer is auto-prepended. | | `REGISTRATION_WEBHOOK_AUTH_TOKEN` | `""` | Webhook auth token. Leave empty for unauthenticated webhooks. | | `REGISTRATION_WEBHOOK_TIMEOUT_SECONDS` | `10` | HTTP timeout per webhook request in seconds. | | `REGISTRATION_GATE_ENABLED` | `false` | Enable registration gate admission control. | | `REGISTRATION_GATE_URL` | `""` | Gate endpoint URL. Must be set when enabled. | | `REGISTRATION_GATE_AUTH_TYPE` | `none` | Gate auth type: `none`, `api_key`, or `bearer`. | | `REGISTRATION_GATE_AUTH_CREDENTIAL` | `""` | Credential for `api_key` or `bearer` gate auth. | | `REGISTRATION_GATE_AUTH_HEADER_NAME` | `X-Api-Key` | Header name for `api_key` gate auth type. | | `REGISTRATION_GATE_TIMEOUT_SECONDS` | `5` | HTTP timeout per gate attempt in seconds. | | `REGISTRATION_GATE_MAX_RETRIES` | `2` | Retry attempts after first gate failure (exponential backoff). | | `M2M_DIRECT_REGISTRATION_ENABLED` | `true` | Enable `/api/iam/m2m-clients` admin API for direct M2M client registration. | ### Upgrade Instructions #### Docker Compose ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.20 # Review new env vars in .env.example and update your .env if needed # Then rebuild and restart: ./build_and_run.sh ``` #### Kubernetes / Helm (EKS) ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.20 # Update values.yaml with any new variables, then upgrade: helm upgrade mcp-gateway . -f your-values.yaml ``` #### Terraform / ECS ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.20 # Update your .tfvars with any new variables cd terraform/aws-ecs terraform plan terraform apply ``` #### DockerHub Images Pre-built images are available: ```bash docker pull mcpgateway/registry:v1.0.20 docker pull mcpgateway/auth-server:v1.0.20 docker pull mcpgateway/currenttime-server:v1.0.20 docker pull mcpgateway/realserverfaketools-server:v1.0.20 docker pull mcpgateway/mcpgw-server:v1.0.20 docker pull mcpgateway/fininfo-server:v1.0.20 docker pull mcpgateway/metrics-service:v1.0.20 ``` --- ## Major Features ### Registration Webhooks and Gate Two external integration points for registration lifecycle events, documented in the [Webhooks and Gate Guide](../docs/registration-webhooks.md): **Registration Gate (Admission Control)** - Call an external endpoint to approve or deny registration and update requests before they are persisted. Supports all asset types (servers, agents, skills) for both register and update operations. Fail-closed design: if the gate endpoint is unreachable after configurable retries with exponential backoff, the registration is blocked. Sensitive fields (credentials, tokens, passwords) are automatically stripped from the payload sent to the gate. Supports Bearer token, API key, or unauthenticated access. Gate returns 200 to allow, 403 to deny with a custom error message. Configured across Docker Compose, Terraform/ECS, and Helm/EKS. ([#809](https://github.com/agentic-community/mcp-gateway-registry/issues/809), [PR #881](https://github.com/agentic-community/mcp-gateway-registry/pull/881)) **Registration Webhooks** - Send HTTP POST notifications to an external URL when servers, agents, or skills are registered or deleted. Enables real-time integration with CMDBs, CI/CD pipelines, Slack, or any external system. Fire-and-forget delivery (failures are logged, never block the caller). Supports Bearer token and custom API key authentication with configurable headers and timeouts. Configured across Docker Compose, Terraform/ECS, and Helm/EKS. ([#742](https://github.com/agentic-community/mcp-gateway-registry/issues/742), [PR #878](https://github.com/agentic-community/mcp-gateway-registry/pull/878)) ### Multi-Key Static API Tokens with Per-Key Groups Replace the single `REGISTRY_API_TOKEN` with `REGISTRY_API_KEYS`, a JSON map of named API keys each scoped to specific groups. Each key resolves to only the permissions its groups grant, enabling least-privilege access for CI/CD pipelines, monitoring scripts, and service accounts. When a static token does not match any configured key, the request falls through to JWT validation instead of returning 401. [PR #876](https://github.com/agentic-community/mcp-gateway-registry/pull/876), [PR #875](https://github.com/agentic-community/mcp-gateway-registry/pull/875) ### Direct M2M Client Registration API A new `/api/iam/m2m-clients` admin API for registering machine-to-machine client IDs and their group mappings directly, without requiring an IdP Admin API token. Works with any IdP (Entra ID, Cognito, Keycloak, Okta) because it stores mappings locally in MongoDB. Enables self-service M2M onboarding without granting IdP admin access. [PR #866](https://github.com/agentic-community/mcp-gateway-registry/pull/866) ### Metadata Keyword Search for Agents, Servers, and Skills The REST API list endpoints (`GET /api/agents?query=`, `GET /api/servers?query=`, `GET /api/skills/search?q=`) now include custom metadata key-value pairs in their keyword search. Previously only name, description, tags, and skill names were searchable. The shared `flatten_metadata_to_text()` utility flattens nested metadata (lists, dicts) into a searchable string. [PR #884](https://github.com/agentic-community/mcp-gateway-registry/pull/884) --- ## What's New ### Authentication - Multi-key static API tokens with per-key group scoping (#876) - Static token auth falls through to JWT validation when token does not match (#875) ### Registration Lifecycle - Registration webhooks and gate (admission control) for all asset types (#878, #881) - Direct M2M client registration API without IdP admin access (#866) ### Search - Custom metadata included in keyword search for agents, servers, and skills (#884) - OpenAPI spec updated to clarify list endpoints use lexical substring search (#884) ### Frontend - Auto-extract repository URL from SKILL.md URL, show separate View Skill / View Repo links (#857) ### Infrastructure - Python runtime upgraded from 3.12 to 3.14 (#850) - Container base images patched to resolve openssl/zlib/musl CVEs (#861) - Post-merge fixes for Python 3.14 compatibility (#852) - Helm chart duplicate auth section fixed (#848) - M2M_DIRECT_REGISTRATION_ENABLED added to all docker-compose files (#884) ### Documentation - Group-restricted agent visibility FAQ added (#883) - Hybrid search architecture doc updated with REST API lexical search clarification (#884) --- ## Bug Fixes - Fix Helm chart duplicate auth section in values (#848) - Fix post-merge issues from Python 3.14 upgrade (#852) - Patch container base images to resolve openssl/zlib/musl CVEs (#861) --- ## Pull Requests Included | PR | Title | |----|-------| | #884 | feat(search): include custom metadata in keyword search for agents, servers, and skills | | #882 | chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder in /cli | | #881 | feat(gate): add registration gate admission control webhook (#809) | | #878 | feat(webhook): registration webhook notifications for add and delete events (#742) | | #877 | chore(skill): apply ruff formatting to usage-report skill | | #876 | feat(auth): add multi-key static tokens with per-key groups (#779) | | #875 | feat(auth): fall through to JWT validation when static token does not match (#871) | | #872 | chore(skill): add testing plan step to new-feature-design skill (v1.5) | | #870 | chore(deps): bump python-dotenv from 1.2.1 to 1.2.2 in /metrics-service | | #869 | chore(deps): bump python-dotenv from 1.2.1 to 1.2.2 | | #868 | chore(deps): bump python-dotenv from 1.2.1 to 1.2.2 in /agents/a2a | | #866 | feat: add direct M2M client registration API (#851) | | #861 | fix: patch container base images to resolve openssl/zlib/musl CVEs | | #857 | feat: auto-extract repository URL from SKILL.md URL and add View Skill/View Repo links (#846) | | #854 | chore: add internal instance tracking and fix metrics comparison in usage-report skill | | #853 | chore: update usage-report skill and telemetry scripts | | #852 | fix: post-merge fixes for Python 3.14 upgrade | | #850 | update to Python 3.14 | | #848 | fix Helm chart update duplicate auth | | #843 | chore(deps): bump authlib from 1.6.9 to 1.6.11 in /servers/mcpgw | | #842 | chore(deps): bump authlib from 1.6.9 to 1.6.11 in /servers/currenttime | --- ## Security Dependency Updates | Package | Previous | Updated | Scope | |---------|----------|---------|-------| | authlib | 1.6.9 | 1.6.11 | servers/mcpgw, servers/currenttime | | python-dotenv | 1.2.1 | 1.2.2 | root, agents/a2a, metrics-service | | fast-xml-parser / @aws-sdk/xml-builder | - | latest | cli | | Container base images | - | patched | openssl/zlib/musl CVEs | --- ## Contributors Thank you to all contributors for this release: - **Amit Arora** ([@amitarora](https://github.com/amitarora)) - **Omri Shiv** ([@omrishiv](https://github.com/omrishiv)) --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.19...v1.0.20](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.19...v1.0.20) ================================================ FILE: release-notes/v1.0.21.md ================================================ # Release v1.0.21 - Admin Tooling, Centralized Logging, and ARM64 Support **April 2026** --- ## Upgrading from v1.0.20 This section covers everything you need to know to upgrade from v1.0.20 to v1.0.21. ### Breaking Changes There are no breaking changes in this release. All new features use sensible defaults and existing deployments will continue to work without configuration changes. ### New Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `APP_LOG_MAX_BYTES` | `52428800` | Max size per log file in bytes before rotation (50 MB). | | `APP_LOG_BACKUP_COUNT` | `5` | Number of rotated backup log files to keep. | | `APP_LOG_CENTRALIZED_ENABLED` | `true` | Write application logs to MongoDB for centralized retrieval. Requires MongoDB/DocumentDB backend. | | `APP_LOG_CENTRALIZED_TTL_DAYS` | `1` | Days to retain application logs in MongoDB before TTL auto-expiry. | | `APP_LOG_MONGODB_BUFFER_SIZE` | `50` | Number of log records to buffer before flushing to MongoDB. | | `APP_LOG_MONGODB_FLUSH_INTERVAL_SECONDS` | `5.0` | Seconds between periodic flushes to MongoDB. | | `APP_LOG_LEVEL` | `INFO` | Application log level: DEBUG, INFO, WARNING, ERROR, CRITICAL. | | `APP_LOG_EXCLUDED_LOGGERS` | `uvicorn.access,httpx,pymongo,motor` | Comma-separated logger names to exclude from MongoDB log writes. | | `OIDC_ENABLED` | `false` | Enable OIDC/OAuth2 authentication for the MCPGW server. | | `OIDC_CLIENT_ID` | - | OIDC client credentials for MCPGW (used when OIDC_ENABLED=true). | | `OIDC_CLIENT_SECRET` | - | OIDC client secret for MCPGW. | | `KEYCLOAK_INTERNAL_URL` | - | Keycloak internal URL for server-to-server OIDC communication. | | `M2M_CLIENT_ID` | - | M2M client ID for MCPGW to call registry APIs. | | `M2M_CLIENT_SECRET` | - | M2M client secret for MCPGW. | | `MCPGW_BASE_URL` | - | Base URL where the MCPGW server is reachable (for OAuth redirect URIs). | ### Upgrade Instructions #### Docker Compose ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.21 # Review new env vars in .env.example and update your .env if needed # Then rebuild and restart: ./build_and_run.sh ``` #### Kubernetes / Helm (EKS) ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.21 # Update values.yaml with any new app log variables, then upgrade: helm upgrade mcp-gateway . -f your-values.yaml ``` #### Terraform / ECS ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.21 # Update your .tfvars with any new variables cd terraform/aws-ecs terraform plan terraform apply ``` #### DockerHub Images Pre-built images are available: ```bash docker pull mcpgateway/registry:v1.0.21 docker pull mcpgateway/auth-server:v1.0.21 docker pull mcpgateway/currenttime-server:v1.0.21 docker pull mcpgateway/realserverfaketools-server:v1.0.21 docker pull mcpgateway/mcpgw-server:v1.0.21 docker pull mcpgateway/fininfo-server:v1.0.21 docker pull mcpgateway/metrics-service:v1.0.21 ``` --- ## Major Features ### Admin Data Export A new Data Export section in the admin Settings page allows downloading registry data as JSON files for debugging, auditing, and backup. Supports 11 collections: Servers, Agents, Skills, Virtual Servers, Federation Peers, Federation Configs, Registry Card, IAM Users, IAM Groups, IAM M2M Clients, and Scopes. Download individual collections or use the Download All as ZIP button (powered by JSZip) with per-collection progress indicators. Includes a sensitive data warning banner and a dedicated scopes export endpoint that dumps full server_access rules. Admin-only access, not visible to non-admin users. [PR #908](https://github.com/agentic-community/mcp-gateway-registry/pull/908) ### Centralized Log Rotation, Storage, and Retrieval Production-grade application logging with RotatingFileHandler (50 MB, 5 backups) for both the registry and auth-server. Optional MongoDB storage via a non-blocking MongoDBLogHandler with buffered background writes and TTL-based auto-expiry. Admin REST API endpoints (`GET /api/admin/logs` for querying with filters, `GET /api/admin/logs/export` for JSONL download, `GET /api/admin/logs/metadata` for available services and levels) and a Settings UI Log Viewer with filtering by service, level, hostname, search text, and time range. Security includes MongoDB regex injection prevention via `re.escape()`, rate limiting (10 requests per 60 seconds per user), and max search length validation. MongoDB logging is ON by default; disable with `APP_LOG_CENTRALIZED_ENABLED=false`. File-based rotation is always active. [PR #888](https://github.com/agentic-community/mcp-gateway-registry/pull/888), [PR #900](https://github.com/agentic-community/mcp-gateway-registry/pull/900), [PR #905](https://github.com/agentic-community/mcp-gateway-registry/pull/905) ### Multi-Architecture Docker Images (ARM64) Docker images are now built for both amd64 and arm64 architectures using Docker Buildx multi-platform builds. ARM64 users (Apple Silicon Macs, AWS Graviton instances) can now pull and run images natively without emulation overhead. [PR #865](https://github.com/agentic-community/mcp-gateway-registry/pull/865) ### Per-Skill Auth Credentials and Content Drift Detection Skills now support per-skill authentication credentials (API keys, Bearer tokens) stored with the skill card. Multi-file skill support allows skills to reference multiple source files. Content drift detection compares the current skill content against the registered version and flags changes, helping operators detect when upstream skill definitions have been modified. [PR #849](https://github.com/agentic-community/mcp-gateway-registry/pull/849), [PR #898](https://github.com/agentic-community/mcp-gateway-registry/pull/898) --- ## What's New ### Admin Tooling - Admin Data Export page with 11 collection types and ZIP download (#908) - Dedicated scopes export endpoint for full server_access rule dumps (#908) ### Observability - Centralized log rotation with RotatingFileHandler for registry and auth-server (#888) - MongoDB log storage with non-blocking buffered writes and TTL auto-expiry (#888) - Admin log retrieval API with filtering, export, and metadata endpoints (#888) - Settings UI Log Viewer with service, level, hostname, and time range filters (#888) - Post-merge fixes for log handler naming, defaults, and linting (#900) - Graceful PermissionError handling in RotatingFileHandler (#905) ### Skills - Per-skill auth credentials for API key and Bearer token authentication (#849) - Multi-file skill support for referencing multiple source files (#849) - Content drift detection for upstream skill definition changes (#849) - Post-merge fixes for auth, service layering, and env docs (#898) ### Infrastructure - Multi-arch Docker images for amd64 and arm64 (#865) - Helm chart configmaps for application log settings (registry and auth-server) (#888) - YAML anchor pattern for shared app log config in stack chart (#888) - MCPGW OIDC/OAuth2 environment variable documentation in .env.example ### Performance - Remove in-memory agent registry and state cache in favor of direct repository queries (#907) - Bulk `get_all_states()` method to eliminate N+1 queries in agent state lookups (#910) - Aligned `get_state()` interface signatures across repository implementations (#910) ### Security - Scoped `add_server_scope` and `remove_server_scope` IAM actions to target group only (#909) - Skip CSRF validation for Bearer token clients on toggle endpoints (#894) ### Documentation - README roadmap updated with release-based milestones (v1.0.20, v1.0.21, v1.0.22) - What's New entries for Admin Data Export and Centralized Logging --- ## Bug Fixes - Fix CSRF validation blocking programmatic agent/skill toggle for Bearer token clients (#894) - Fix `add_server_scope` and `remove_server_scope` applying to all groups instead of target group (#909) - Fix PermissionError crash in RotatingFileHandler when log directory has restricted permissions (#905) - Fix log handler naming conventions and default values after initial logging PR merge (#900) - Fix N+1 query pattern in agent state lookups by adding bulk `get_all_states()` (#910) - Fix `get_state()` signature divergence between file and DocumentDB repository implementations (#910) --- ## Pull Requests Included | PR | Title | |----|-------| | #910 | fix(agents): add bulk get_all_states() and align get_state() signatures | | #909 | fix(scopes): scope add_server_scope and remove_server_scope to target group only | | #908 | feat(settings): add admin Data Export page for downloading registry collections | | #907 | remove in-memory agent registry and state cache | | #905 | fix(logging): handle PermissionError in RotatingFileHandler gracefully | | #900 | fix(logging): post-PR-888 follow-ups for naming, defaults, linting, and docs | | #899 | chore(skill): add internal instance identification and stickiness metrics to usage-report | | #898 | fix(skills): post-PR-849 follow-ups for auth, service layering, and env docs | | #894 | fix: skip CSRF validation for Bearer token clients on toggle endpoints | | #892 | chore(deps): bump gitpython from 3.1.45 to 3.1.47 | | #890 | chore(deps): bump postcss from 8.5.6 to 8.5.10 | | #888 | feat(logging): centralized log rotation, MongoDB storage, and retrieval API | | #887 | chore(deps): bump postcss from 8.5.6 to 8.5.12 in /frontend | | #865 | feat: build multi-arch Docker images (amd64 + arm64) | | #849 | feat(skills): add auth credentials, multi-file support, and content drift detection | --- ## Security Dependency Updates | Package | Previous | Updated | Scope | |---------|----------|---------|-------| | gitpython | 3.1.45 | 3.1.47 | root | | postcss | 8.5.6 | 8.5.12 | frontend | --- ## Contributors Thank you to all contributors for this release: - **Amit Arora** ([@amitarora](https://github.com/amitarora)) - **Daniel Y** ([@daniely](https://github.com/daniely)) - **Prateek Sinha** ([@prateeksinha](https://github.com/prateeksinha)) - **Omri Shiv** ([@omrishiv](https://github.com/omrishiv)) - **Madhu C** ([@madhuc-ghub](https://github.com/madhuc-ghub)) --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.20...v1.0.21](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.20...v1.0.21) ================================================ FILE: release-notes/v1.0.3.md ================================================ # MCP Gateway & Registry v1.0.3 **Release Date:** October 8, 2025 We're excited to announce v1.0.3 of the MCP Gateway & Registry - the enterprise-ready platform that centralizes access to AI development tools using the Model Context Protocol (MCP). ## What's New ### Amazon Bedrock AgentCore Gateway Integration Seamlessly integrate Amazon Bedrock AgentCore Gateways with the MCP Gateway Registry! This major enhancement brings enterprise-grade AI assistant capabilities to your MCP infrastructure. **Key Features:** - **Dual Authentication Flow** - Keycloak ingress authentication for gateway access + Cognito egress authentication for AgentCore - **Passthrough Token Mode** - AgentCore tokens bypass gateway validation for direct authentication with AWS Cognito - **Complete MCP Protocol Support** - Full session initialization, tool discovery, and tool execution - **Production-Ready Examples** - Customer support assistant with warranty lookup and customer profile tools **Documentation:** [Amazon Bedrock AgentCore Integration Guide](docs/agentcore.md) **Use Cases:** - Deploy customer support assistants with knowledge base integration - Access AWS Lambda functions through managed MCP endpoints - Build AI agents with enterprise authentication and audit trails ### Pre-built Docker Images - Deploy in Under 10 Minutes Get running instantly with our pre-built Docker images! No compilation required - just download and run. **Benefits:** - Instant deployment with `./build_and_run.sh --prebuilt` - Faster updates and rollbacks - Support for both EC2 and macOS deployments - All components pre-compiled and optimized **Documentation:** - [Quick Start Guide](README.md#option-a-pre-built-images-instant-setup) - [macOS Setup Guide](docs/macos-setup-guide.md) - [Pre-built Images Documentation](docs/prebuilt-images.md) ### Keycloak Identity Provider Integration Enterprise-grade authentication with complete audit trails and group-based authorization. **Features:** - Individual AI agent identity management - Group-based access control with fine-grained permissions - Service account provisioning for automation - Production-ready OAuth 2.0 flows (M2M, 2LO, 3LO) - Complete audit trail for compliance (GDPR, SOX) **Documentation:** [Keycloak Integration Guide](docs/keycloak-integration.md) ### Real-Time Metrics & Observability Comprehensive monitoring and observability platform built on industry-standard tools. **Components:** - **Grafana Dashboards** - Pre-built dashboards for server health, tool usage, and authentication - **SQLite Storage** - Efficient metrics storage with OTEL integration - **Real-Time Monitoring** - Track performance, errors, and usage patterns - **Custom Metrics** - Emit application-specific metrics from any component **Access:** http://localhost:3000 (Grafana) | http://localhost:7860 (Registry UI) **Documentation:** [Observability Guide](docs/OBSERVABILITY.md) ### Service & User Management Utilities Comprehensive CLI tools for complete lifecycle management of MCP servers and users. **Capabilities:** - Server registration and health validation - User provisioning with Keycloak integration - Group-based access control configuration - Automated testing and verification - Complete workflow examples **CLI Tools:** - `service_mgmt.sh` - Server lifecycle management - User management utilities - Group and scope configuration - Health check automation **Documentation:** [Service Management Guide](docs/service-management.md) ## Enhanced Features ### Tag-Based Tool Filtering Enhanced `intelligent_tool_finder` now supports hybrid search: - Semantic search for natural language queries - Tag-based filtering for categorical discovery - Combined search modes for precise tool selection ### Three-Legged OAuth (3LO) Support Integrate external services with user consent flows: - Atlassian (Jira, Confluence) - Google Workspace - GitHub - Custom OAuth providers ### JWT Token Vending Service Self-service token generation for automation: - Service account tokens - Time-limited access tokens - Automated credential rotation ### Automated Token Refresh Service Background token refresh maintains continuous authentication: - Automatic token renewal before expiration - Seamless credential management - Zero-downtime authentication ## Improvements ### Installation & Deployment - Eliminated sudo requirements - uses `${HOME}` instead of `/opt` - Pre-built Docker images for instant deployment - Improved EC2 and macOS compatibility - Remote desktop setup guide for easier access ### Authentication & Security - Dual authentication support (ingress + egress) - Passthrough token mode for external IdPs - Enhanced audit trails and compliance features - Fine-grained access control (FGAC) at server and tool levels ### Developer Experience - Comprehensive documentation with examples - CLI tools for automation - Complete workflow examples - Modern React frontend with TypeScript ### Observability - Real-time Grafana dashboards - OTEL-compatible metrics - Performance tracking - Usage analytics ## Bug Fixes - Fixed URL formatting for bedrock-agentcore services - Improved token validation and refresh flows - Enhanced error messages and troubleshooting guides - Corrected documentation links and anchors ## Documentation Updates - **New:** [Amazon Bedrock AgentCore Integration Guide](docs/agentcore.md) - **Updated:** [Service Management Guide](docs/service-management.md) - **Updated:** [Keycloak Integration Guide](docs/keycloak-integration.md) - **Updated:** [Observability Guide](docs/OBSERVABILITY.md) - **New:** [macOS Setup Guide](docs/macos-setup-guide.md) - **New:** [Remote Desktop Setup Guide](docs/remote-desktop-setup.md) ## Quick Start ### Option A: Pre-built Images (Recommended) ```bash # Clone and setup git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry cp .env.example .env # Configure environment export DOCKERHUB_ORG=mcpgateway # Deploy with pre-built images ./build_and_run.sh --prebuilt ``` ### Option B: Build from Source ```bash # Clone and setup git clone https://github.com/agentic-community/mcp-gateway-registry.git cd mcp-gateway-registry # Build and run ./build_and_run.sh ``` **Next Steps:** 1. Initialize Keycloak: Follow [Initial Environment Configuration](docs/complete-setup-guide.md#initialize-keycloak-configuration) 2. Create your first AI agent: [Create Your First AI Agent Account](docs/complete-setup-guide.md#create-your-first-ai-agent-account) 3. Access the registry UI: http://localhost:7860 4. Monitor with Grafana: http://localhost:3000 ## Demo Videos - [Full End-to-End Functionality](https://github.com/user-attachments/assets/5ffd8e81-8885-4412-a4d4-3339bbdba4fb) - [OAuth 3-Legged Authentication](https://github.com/user-attachments/assets/3c3a570b-29e6-4dd3-b213-4175884396cc) - [Dynamic Tool Discovery](https://github.com/user-attachments/assets/cee25b31-61e4-4089-918c-c3757f84518c) ## What's Included - **MCP Gateway** - Central gateway for all MCP traffic - **Registry Service** - Server and tool catalog with discovery - **Auth Server** - OAuth 2.0 authentication with Keycloak/Cognito - **Frontend UI** - Modern React interface for management - **Metrics Service** - OTEL-compatible observability - **CLI Tools** - Complete automation suite ## System Requirements - Docker and Docker Compose - Python 3.11+ (for development) - 4GB RAM minimum (8GB recommended) - EC2 instance or macOS system ## Community & Support - **Documentation:** [docs/](docs/) - **Issues:** [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - **Discussions:** [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md) ## Completed in This Release - #160 - Amazon Bedrock AgentCore Gateway integration documentation - #158 - Eliminate sudo requirements with ${HOME} directory usage - #111 - Standalone metrics collection service - #38 - Usage metrics and analytics system - #120 - CLI tool for MCP server registration and health validation - #119 - Well-known URL for MCP server discovery - #18 - Token vending capability - #5 - Keycloak IdP provider support ## Roadmap See our [complete roadmap](README.md#roadmap) for upcoming features including: - Multi-level registry support (federated registries) - Virtual MCP server support with intelligent routing - Microsoft Entra ID (Azure AD) authentication - OpenSearch integration for advanced vector search - Agent-as-tool dynamic MCP server generation ## License This project is licensed under the Apache-2.0 License - see the [LICENSE](LICENSE) file for details. --- **Star this repository if it helps your organization!** [Get Started](docs/installation.md) | [Documentation](docs/) | [Contribute](CONTRIBUTING.md) ================================================ FILE: release-notes/v1.0.4.md ================================================ # MCP Gateway & Registry v1.0.4 **Release Date:** October 14, 2025 We're excited to announce v1.0.4 of the MCP Gateway & Registry - featuring major enhancements for Anthropic MCP Registry integration, environment variable management, and improved documentation. ## What's New ### Anthropic MCP Registry Integration Seamlessly integrate with Anthropic's official MCP Registry to import and access curated MCP servers through your gateway! **Import Servers from Anthropic Registry** (#171) - **One-Command Import** - Import curated MCP servers with a single command - **Automatic Configuration** - Server metadata, authentication, and tags automatically configured - **Environment Variable Substitution** - API keys and credentials automatically substituted from `.env` file - **Bulk Import Support** - Import multiple servers from a list file - **Unified Access** - Access imported servers through your gateway with centralized authentication **Anthropic Registry REST API v0 Compatibility** (#178) - **Full API Compatibility** - Complete support for Anthropic's Registry REST API v0 specification - **Server Discovery** - List available servers programmatically with JWT authentication - **Version Information** - Retrieve server versions and compatibility details - **Programmatic Access** - Point your Anthropic API clients to this registry **Documentation:** - [Anthropic Registry Import Guide](docs/anthropic-registry-import.md) - Comprehensive guide for importing servers - [Registry REST API v0 Documentation](docs/anthropic_registry_api.md) - API reference and examples **Example Usage:** ```bash # Import a single server ./cli/import_from_anthropic_registry.sh ai.smithery/smithery-ai-github # Import from a curated list ./cli/import_from_anthropic_registry.sh --import-list cli/import_server_list.txt # List available servers via API curl https://your-gateway/v0/servers \ -H "Authorization: Bearer YOUR_TOKEN" ``` ### Enhanced Authentication & Environment Management **Automatic Environment Variable Substitution** (#181) - **Smart Header Processing** - Authentication headers automatically populated from environment variables - **Import-Time Substitution** - Environment variables substituted during server import, not at runtime - **Simplified Configuration** - No need to pass environment variables to Docker containers - **Auto-Load .env File** - Import script automatically sources `.env` file **Before:** ```bash # Manual environment variable management source .env export SMITHERY_API_KEY ./cli/import_from_anthropic_registry.sh server-name ``` **After:** ```bash # Automatic - just run the import ./cli/import_from_anthropic_registry.sh server-name ``` ### Bug Fixes **UI Improvements** - **Fixed proxy_pass_url Display** - UI now correctly shows upstream URLs for imported servers - **Added Missing Field** - `/servers` API endpoint now includes `proxy_pass_url` in response **Model Download Optimization** (#176) - **Removed Redundant Download** - Eliminated model download from registry entrypoint - **Faster Startup** - Registry container starts faster with pre-downloaded models - **Better User Experience** - Model download now handled by setup scripts ### Documentation Improvements **New Documentation** - **Anthropic Registry Import Guide** - Complete guide for importing servers from Anthropic's registry - **REST API v0 Documentation** - Full API reference for Anthropic registry compatibility - **Enhanced README** - More concise with better organization and navigation **README Updates** - Condensed "What's New" section (reduced from 14 to 6 key items) - Simplified deployment and infrastructure details - Added Anthropic documentation links to docs table - Removed verbose sections for better readability **macOS Setup Guide Updates** (#177) - Updated installation instructions for macOS users - Platform-specific optimizations and troubleshooting ### Roadmap Updates **Completed Features** - **#171** - Import Servers from Anthropic MCP Registry - **#37** - Multi-Level Registry Support (via Anthropic integration) These features enable federated registry support and seamless integration with the broader MCP ecosystem. ## Breaking Changes None - this release is fully backward compatible with v1.0.3. ## Upgrade Instructions ### For Existing Installations 1. **Pull the latest changes:** ```bash cd mcp-gateway-registry git pull origin main ``` 2. **Update environment configuration:** Add any new API keys to your `.env` file: ```bash # Example: Smithery API key for imported servers SMITHERY_API_KEY=your-api-key-here ``` 3. **Restart services:** ```bash ./build_and_run.sh ``` ### For Pre-built Image Users ```bash cd mcp-gateway-registry git pull origin main ./build_and_run.sh --prebuilt ``` ## Migration Notes ### Importing Servers If you want to import servers from Anthropic's registry: 1. **Add required API keys to `.env`:** ```bash # Add authentication keys for services you want to import SMITHERY_API_KEY=your-key OTHER_SERVICE_KEY=your-key ``` 2. **Create import list:** ```bash # Create cli/import_server_list.txt with desired servers echo "ai.smithery/smithery-ai-github" >> cli/import_server_list.txt echo "io.github.jgador/websharp" >> cli/import_server_list.txt ``` 3. **Run import:** ```bash ./cli/import_from_anthropic_registry.sh --import-list cli/import_server_list.txt ``` ## Known Issues - Authentication keys must be valid for successful server imports - Some Smithery servers may require specific API key permissions - Imported servers with invalid credentials will show as "auth-expired" in health checks ## Contributors Thank you to all contributors who made this release possible! - Environment variable substitution and import functionality - Anthropic Registry API compatibility - Documentation improvements - Bug fixes and UI enhancements ## What's Next Looking ahead to v1.0.5: - **#170** - Separate Gateway and Registry Containers (In Progress) - **#132** - MCP Configuration Generator in Registry UI - **#129** - Virtual MCP Server Support with Dynamic Tool Aggregation - **#128** - Microsoft Entra ID (Azure AD) Authentication Provider For the complete roadmap, see [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues). ## Resources - [Complete Setup Guide](docs/complete-setup-guide.md) - [Anthropic Registry Import Guide](docs/anthropic-registry-import.md) - [Anthropic Registry REST API Documentation](docs/anthropic_registry_api.md) - [Service Management Guide](docs/service-management.md) - [Observability Guide](docs/OBSERVABILITY.md) ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.3...v1.0.4](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.3...v1.0.4) ================================================ FILE: release-notes/v1.0.5.md ================================================ # Release v1.0.5 - Supply Chain Security & MCP Registry CLI **October 28, 2025** --- ## Major Features ### 🛡️ Supply Chain Security with Cisco AI Defence Automated security scanning for MCP servers: - **Automated scanning** on server registration - **Continuous monitoring** with periodic audits - **Dual analysis**: YARA pattern detection + LLM-powered threat analysis - **Auto-disable** servers with security issues [Security Scanner Guide](docs/security-scanner.md) | [Cisco MCP Scanner](https://github.com/cisco-ai-defense/mcp-scanner) ### 🤖 Interactive MCP Registry CLI Talk to your MCP Registry in natural language: - **Natural language discovery** - Ask questions in plain English - **Real-time token tracking** - Auth status, validity, cost monitoring - **AI-powered** - Works with Claude (Anthropic) and Amazon Bedrock - **Global command** - `registry --url ` [CLI Guide](docs/mcp-registry-cli.md) --- ## What's New - ✅ Global `registry` CLI command - ✅ Enhanced TokenStatusFooter with cost tracking - ✅ Improved app initialization and error handling - ✅ Updated README with CLI section and demo - ✅ Auto token refresh at < 10 seconds remaining --- ## Credits **Nisha Deborah Philips** [@nisha-deborah-philips](https://www.linkedin.com/in/nisha-deborah-philips/) - Cisco scanner integration, AI assistant, UI **Kangheng Liu** [@kangheng-liu](https://www.linkedin.com/in/kangheng-liu/) - AI assistant & registry UI **Abit** [@abiit](https://www.linkedin.com/in/abiit/) - Claude Code-like AI assistant concept --- ## Getting Started **Security Scanning:** ```bash ./cli/service_mgmt.sh add yara,llm ``` **CLI:** ```bash cd cli && npm install && npm link registry --url https://your-gateway.com ``` --- **Repository:** https://github.com/agentic-community/mcp-gateway-registry ================================================ FILE: release-notes/v1.0.6.md ================================================ # Release v1.0.6 - A2A Protocol, AWS ECS Production Deployment & Federation **November 2025** --- ## Major Features ### Agent-to-Agent (A2A) Protocol Support Full implementation of the A2A protocol for agent registration, discovery, and communication: - **Agent Registry API** - Complete REST API for agent lifecycle management (`/api/agents/*`) - **Semantic Agent Discovery** - Find agents using natural language queries - **Agent Health Checks** - Live `/ping` health monitoring for registered agents - **Fine-Grained Access Control** - Three-tier permissions (UI-Scopes, Group Mappings, Agent Scopes) - **Example Agents** - Travel Assistant and Flight Booking agents using Strands framework [A2A Guide](docs/a2a.md) | [Agent Management](docs/a2a-agent-management.md) ### AWS ECS Production Deployment Production-ready deployment on Amazon ECS Fargate: - **Multi-AZ Architecture** - High availability across 2 availability zones - **Auto-scaling** - Dynamic scaling based on CPU/memory utilization (2-4 tasks) - **Aurora PostgreSQL Serverless v2** - Auto-scaling database with Multi-AZ replication - **Application Load Balancers** - HTTPS/SSL termination with ACM certificates - **CloudWatch Integration** - Comprehensive monitoring, logging, and alerting - **EFS Shared Storage** - Persistent storage for models, logs, and configuration - **Complete Terraform Configuration** - Infrastructure as Code for the entire stack [ECS Deployment Guide](terraform/aws-ecs/README.md) ### Federated Registry (ASOR Integration) Multi-registry federation support: - **Workday ASOR Integration** - Import AI agents from Agent System of Record - **Visual Identification** - Clear visual tags distinguish federation sources (ANTHROPIC, ASOR) - **Automatic Sync** - Scheduled synchronization with external registries - **Centralized Management** - Single control plane for all federated servers and agents [Federation Guide](docs/federation.md) ### Microsoft Entra ID (Azure AD) Integration Enterprise SSO with Microsoft identity platform: - **Generic OIDC Support** - Flexible authentication provider configuration - **Entra ID Provider** - Native Microsoft Entra ID integration - **Group-Based Access Control** - Leverage existing Azure AD groups for permissions [Entra ID Setup Guide](docs/entra-id-setup.md) --- ## What's New ### A2A Agent Features - Agent registration, update, delete, and toggle operations - Semantic search for agent discovery (`/api/agents/discover/semantic`) - Skill-based agent discovery (`/api/agents/discover`) - Live agent health checks with `/ping` endpoint validation - Travel Assistant and Flight Booking example agents ### AWS ECS Deployment - Production architecture with ECS Fargate - Multi-account support for ALB security groups - Scopes initialization container for Keycloak setup - DockerHub publishing support for container images - Architecture diagram for ECS deployment ### UI Improvements - Dark mode as default theme - Semantic search integration in Registry UI - Agent toggle functionality (enable/disable agents) - Agent cards with health status display - Improved UX and removed redundant search button ### Developer Experience - `DEV_INSTRUCTIONS.md` - Comprehensive developer onboarding guide - `llms.txt` - LLM-friendly reference document for AI assistants - API reference documentation with OpenAPI specs - Agent management CLI (`cli/agent_mgmt.py`) - Bootstrap script for user and M2M setup ### Infrastructure - Keycloak realm-level SSL configuration - Gateway host flexibility for multi-platform support - Build configuration with `build-config.yaml` and enhanced Makefile --- ## Breaking Changes None - this release is fully backward compatible with v1.0.5. --- ## Upgrade Instructions ### For Existing Installations 1. **Pull the latest changes:** ```bash cd mcp-gateway-registry git pull origin main ``` 2. **Update environment configuration:** Add new variables to your `.env` file if using federation or Entra ID: ```bash # For ASOR federation ASOR_ACCESS_TOKEN=your_token # For Entra ID ENTRA_CLIENT_ID=your_client_id ENTRA_CLIENT_SECRET=your_client_secret ENTRA_TENANT_ID=your_tenant_id ``` 3. **Restart services:** ```bash ./build_and_run.sh ``` ### For AWS ECS Deployment See [ECS Deployment Guide](terraform/aws-ecs/README.md) for complete Terraform-based deployment instructions. --- ## Resources - [A2A Protocol Guide](docs/a2a.md) - [Agent Management Guide](docs/a2a-agent-management.md) - [AWS ECS Deployment Guide](terraform/aws-ecs/README.md) - [Federation Guide](docs/federation.md) - [Entra ID Setup Guide](docs/entra-id-setup.md) - [API Reference](docs/api-reference.md) - [Developer Instructions](DEV_INSTRUCTIONS.md) --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.5...v1.0.6](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.5...v1.0.6) ================================================ FILE: release-notes/v1.0.9-patch1.md ================================================ # Release v1.0.9-patch1 - MongoDB Authentication Compatibility **January 7, 2026** --- ## Overview This patch release addresses MongoDB authentication compatibility issues between MongoDB Community Edition and AWS DocumentDB. The changes enable the MCP Gateway Registry to work seamlessly with both MongoDB CE 8.2+ (using SCRAM-SHA-256) and AWS DocumentDB v5.0 (using SCRAM-SHA-1). **Related Issues:** - [#334](https://github.com/agentic-community/mcp-gateway-registry/issues/334) - Upgrade MongoDB authentication to SCRAM-SHA-256 - [#336](https://github.com/agentic-community/mcp-gateway-registry/issues/336) - Upgrade AWS DocumentDB authentication to SCRAM-SHA-256 (parking lot) **Pull Request:** - [#335](https://github.com/agentic-community/mcp-gateway-registry/pull/335) - Fix MongoDB authentication compatibility for DocumentDB --- ## What's Fixed ### MongoDB Authentication Compatibility The registry now automatically selects the correct authentication mechanism based on the storage backend: - **MongoDB CE 8.2+**: Uses SCRAM-SHA-256 (stronger, modern authentication) - **AWS DocumentDB v5.0**: Uses SCRAM-SHA-1 (only mechanism we could get to work with Amazon DocumentDB although the documentation claims SCRAM-SHA-256 should work, tracking it via [#336](https://github.com/agentic-community/mcp-gateway-registry/issues/336)) This is controlled by the new `STORAGE_BACKEND` environment variable: ```bash # For MongoDB Community Edition STORAGE_BACKEND=mongodb-ce # For AWS DocumentDB (default) STORAGE_BACKEND=documentdb ``` ### Pydantic Validation Fix Fixed test failures in semantic search API models by adding upper bound validation to relevance scores: ```python # Before: relevance_score: float = Field(0.0, ge=0.0) # After: relevance_score: float = Field(0.0, ge=0.0, le=1.0) ``` This ensures relevance scores are always bounded between 0.0 and 1.0, as expected. ### Federation Command Fix Fixed `populate-registry.sh` script federation command syntax: ```bash # Before (incorrect): federation-rescan --provider anthropic # After (correct): federation-sync --source anthropic ``` ### Integration Test Improvements Added skip markers to MongoDB integration tests that require MongoDB to be running, preventing false failures in CI environments where MongoDB is not available. --- ## Changed Files ### Core Authentication Changes - `registry/repositories/documentdb/client.py` - Conditional SCRAM authentication based on storage backend - `scripts/init-documentdb-indexes.py` - Added storage_backend parameter - `scripts/load-scopes.py` - Conditional SCRAM mechanism selection - `scripts/manage-documentdb.py` - Conditional SCRAM mechanism selection - `scripts/debug-scopes.py` - Conditional SCRAM mechanism selection - `registry/scripts/inspect-documentdb.py` - Conditional SCRAM mechanism selection ### Build and Deployment - `docker/Dockerfile.registry` - Added scripts directory to container - `terraform/aws-ecs/documentdb.tf` - Added STORAGE_BACKEND environment variable - `terraform/aws-ecs/keycloak-ecr.tf` - Version update - `terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf` - Added STORAGE_BACKEND to all services ### Scripts and Configuration - `api/populate-registry.sh` - Fixed federation-sync command syntax - `.env.example` - Added STORAGE_BACKEND documentation ### API and Test Fixes - `registry/api/search_routes.py` - Added upper bound validation to relevance_score fields - `tests/integration/test_mongodb_connectivity.py` - Added skip decorators to MongoDB tests --- ## Upgrade Instructions ### For Docker Compose Deployments 1. **Pull the latest changes:** ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.9-patch1 ``` 2. **Update environment configuration:** ```bash # For MongoDB CE deployments, add: echo "STORAGE_BACKEND=mongodb-ce" >> .env # For DocumentDB deployments (default), no changes needed # STORAGE_BACKEND defaults to "documentdb" ``` 3. **Rebuild and restart:** ```bash ./build_and_run.sh ``` ### For AWS ECS Deployment 1. **Update Terraform configuration:** The `STORAGE_BACKEND` environment variable is already set to `documentdb` in the Terraform configuration. No changes are required for DocumentDB deployments. 2. **Pull and deploy new images:** ```bash # Build and push updated images export AWS_REGION=us-east-1 make build-push # Force ECS service update aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --force-new-deployment # For auth server aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-auth \ --force-new-deployment ``` ### Testing the Upgrade Verify authentication is working correctly: ```bash # Check logs for authentication mechanism aws logs tail /ecs/mcp-gateway-v2-registry --follow | grep "authentication" # Expected output: # Using username/password authentication (SCRAM-SHA-1) for documentdb # Or for MongoDB CE: # Using username/password authentication (SCRAM-SHA-256) for mongodb-ce ``` --- ## Technical Details ### Authentication Mechanism Selection The conditional authentication logic in `client.py`: ```python if settings.storage_backend == "mongodb-ce": # MongoDB CE 8.2+: Use SCRAM-SHA-256 (stronger, modern authentication) auth_mechanism = "SCRAM-SHA-256" else: # AWS DocumentDB v5.0: Only supports SCRAM-SHA-1 auth_mechanism = "SCRAM-SHA-1" connection_string = ( f"mongodb://{settings.documentdb_username}:{settings.documentdb_password}@" f"{settings.documentdb_host}:{settings.documentdb_port}/" f"{settings.documentdb_database}?authMechanism={auth_mechanism}&authSource=admin" ) ``` ### Environment Variables New environment variable: - `STORAGE_BACKEND` - Controls authentication mechanism selection - `documentdb` (default) - Use SCRAM-SHA-1 for AWS DocumentDB - `mongodb-ce` - Use SCRAM-SHA-256 for MongoDB Community Edition ### Why This Change? AWS DocumentDB v5.0 only supports two authentication mechanisms: - SCRAM-SHA-1 (username/password) - MONGODB-AWS (IAM authentication) MongoDB Community Edition 8.2+ defaults to SCRAM-SHA-256 for improved security. This patch enables seamless operation with both backends without requiring code changes. --- ## Breaking Changes None. This is a backward-compatible patch release. Existing deployments will continue to work: - **DocumentDB deployments**: `STORAGE_BACKEND` defaults to `documentdb`, using SCRAM-SHA-1 - **MongoDB CE deployments**: Can now explicitly set `STORAGE_BACKEND=mongodb-ce` to use SCRAM-SHA-256 --- ## Known Limitations - **AWS DocumentDB**: Still uses SCRAM-SHA-1 authentication. Upgrade to SCRAM-SHA-256 is tracked in [#336](https://github.com/agentic-community/mcp-gateway-registry/issues/336) and depends on AWS adding SCRAM-SHA-256 support to DocumentDB. --- ## Resources ### Documentation - [Environment Configuration](.env.example) - All environment variables documented - [AWS ECS Deployment Guide](terraform/aws-ecs/README.md) - [Database Abstraction Layer](docs/database-abstraction-layer.md) ### Related Issues and PRs - [#334](https://github.com/agentic-community/mcp-gateway-registry/issues/334) - Original issue: Upgrade MongoDB authentication to SCRAM-SHA-256 - [#335](https://github.com/agentic-community/mcp-gateway-registry/pull/335) - Implementation PR - [#336](https://github.com/agentic-community/mcp-gateway-registry/issues/336) - Future work: DocumentDB SCRAM-SHA-256 support --- ## Commits Included ``` 5dc2471 Fix MongoDB authentication compatibility for DocumentDB (#335) 252869c Rewrite roadmap section with milestone-based table format 5761054 Move completed issues #70 and #48 to Completed section ``` **Files Changed:** 16 files changed, 461 insertions(+), 106 deletions(-) --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- **Full Changelog:** [v1.0.9...v1.0.9-patch1](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.9...v1.0.9-patch1) ================================================ FILE: release-notes/v1.0.9.md ================================================ # Release v1.0.9 - Performance & Infrastructure Optimization **January 2026** --- ## Major Features ### Multi-stage Docker Builds & Image Optimization Dramatically reduced Docker image sizes and improved build performance: - **Registry Image**: Reduced from 4.79GB to 1.64GB (66% reduction) - **mcpgw Server**: Reduced from 7.78GB to ~1.5GB (80% reduction) - **Build Context**: Optimized from 1.77GB to <500MB - **Multi-stage Architecture**: 3-stage builds (frontend → backend → runtime) - **CPU-only PyTorch**: Using PyTorch 2.0+ CPU wheels instead of GPU versions - **Selective File Copying**: Only necessary application files in final images [PR #333](https://github.com/agentic-community/mcp-gateway-registry/pull/333) ### MongoDB/DocumentDB Storage Backend Complete migration from file-based storage to production-ready database backends: - **DocumentDB Support**: AWS DocumentDB for production deployments - **MongoDB CE Support**: MongoDB Community Edition for local development - **Repository Pattern**: Abstracted data access layer for flexibility - **Factory Pattern**: Dynamic backend selection via configuration - **Backward Compatibility**: File-based storage deprecated but still supported [PR #328](https://github.com/agentic-community/mcp-gateway-registry/pull/328) ### Test Suite Optimization Comprehensive pytest test suite with dramatic performance improvements: - **Performance**: Reduced test execution time from 150s to 30s (80% improvement) - **Parallel Execution**: 8 parallel workers with pytest-xdist - **Test Coverage**: 701+ tests (unit, integration, E2E) - **GitHub Actions**: Automated testing on all PRs - **Memory Optimization**: Smart test ordering to prevent OOM on EC2 [PR #330](https://github.com/agentic-community/mcp-gateway-registry/pull/330) --- ## What's New ### Infrastructure & Performance - Multi-stage Docker builds for all images - Optimized `.dockerignore` to exclude unnecessary files - CPU-only PyTorch installation to reduce image bloat - Comprehensive test suite with 35% minimum coverage - Enhanced testing documentation ([Testing Guide](docs/testing/README.md)) ### Storage Backend - DocumentDB primary storage backend for production - MongoDB CE support for local development - Repository pattern for clean data access abstraction - Factory-based backend selection - Removed OpenSearch dependencies ### Security & Authentication - Random admin username/password generation for improved security (#325) - Cookie security enhancements (#276) - Domain cookie support for auth-server (#258) - Bitnami Keycloak OCI repository migration (#318) ### Developer Experience - Updated `llms.txt` with critical documentation for AI assistants (#331) - Removed outdated `quick-start.md` documentation - Enhanced database abstraction layer documentation - Podman rootless macOS support (#308) - Improved ECS architecture diagrams ### Frontend Fixes - Fixed frontend authentication issues (#309) - JWT token generation improvements (#307) - Service sidebar filtering fixes (#306) - A2A agents included in statistics panel (#305) - Removed agentsLoading state duplication - Proper server and agent separation in useServerStats ### Deployment - GATEWAY_ADDITIONAL_SERVER_NAMES support for nginx (#320) - Ingress port switching improvements - NAT gateway IP configuration for Keycloak ALB - ECS deployment cleanup and image preservation - Enhanced Kubernetes/Helm deployment documentation --- ## Breaking Changes ### Storage Backend Migration **Action Required**: If you're upgrading from v1.0.8 or earlier, you need to migrate from file-based storage to MongoDB/DocumentDB: 1. **Set storage backend** in your `.env`: ```bash # For production (AWS DocumentDB) STORAGE_BACKEND=documentdb DOCUMENTDB_URI=mongodb://username:password@cluster.amazonaws.com:27017/?tls=true&retryWrites=false # For local development (MongoDB CE) STORAGE_BACKEND=mongodb MONGODB_URI=mongodb://localhost:27017/ ``` 2. **Data migration**: File-based data is not automatically migrated. Re-register servers and agents or use the migration script. --- ## Upgrade Instructions ### For Docker Compose Deployments 1. **Pull the latest changes:** ```bash cd mcp-gateway-registry git pull origin main git checkout v1.0.9 ``` 2. **Update environment configuration:** ```bash # Add storage backend configuration echo "STORAGE_BACKEND=mongodb" >> .env echo "MONGODB_URI=mongodb://localhost:27017/" >> .env # Optional: Remove file-based storage (deprecated) # STORAGE_TYPE=file # Remove this line ``` 3. **Rebuild and restart:** ```bash ./build_and_run.sh ``` ### For AWS ECS Deployment 1. **Update Terraform variables:** ```hcl # In terraform.tfvars storage_backend = "documentdb" ``` 2. **Apply Terraform changes:** ```bash cd terraform/aws-ecs terraform init terraform plan terraform apply ``` 3. **Rebuild and push optimized images:** ```bash export AWS_REGION=us-east-1 make build-push ``` ### Testing the Upgrade Verify all components are working: ```bash # Run E2E tests ./api/test-management-api-e2e.sh --token-file .oauth-tokens/ingress.json --registry-url http://localhost # Check image sizes docker images | grep mcp-gateway-registry # Run pytest suite make test ``` --- ## Performance Improvements ### Docker Build & Deployment - **66-80% smaller images**: Faster deployments and reduced storage costs - **<500MB build context**: Much faster Docker builds - **Layer reuse**: Better Docker layer caching ### Test Execution - **80% faster tests**: From 150s to 30s execution time - **Parallel execution**: 8 workers for faster CI/CD - **Memory efficient**: No more OOM crashes on EC2 ### Storage Backend - **Database-backed storage**: Better scalability and reliability - **Production-ready**: DocumentDB with Multi-AZ support - **Local development**: Fast MongoDB CE for testing --- ## Resources ### New Documentation - [Testing Guide](docs/testing/README.md) - Comprehensive testing documentation - [Writing Tests](docs/testing/WRITING_TESTS.md) - How to write effective tests - [Test Maintenance](docs/testing/MAINTENANCE.md) - Maintaining test suite health - [Database Abstraction Layer](docs/database-abstraction-layer.md) - Storage backend architecture ### Updated Documentation - [AWS ECS Deployment Guide](terraform/aws-ecs/README.md) - [LLMs.txt](docs/llms.txt) - AI assistant reference documentation - [Podman Setup](docs/podman-setup.md) - Podman rootless macOS support ### Migration Guides - Storage Backend Migration (TBD - contact maintainers for assistance) --- ## Support - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) - [GitHub Discussions](https://github.com/agentic-community/mcp-gateway-registry/discussions) - [Documentation](https://github.com/agentic-community/mcp-gateway-registry/tree/main/docs) --- ## Contributors Special thanks to all contributors who made this release possible: - @aarora79 (Amit Arora) - MongoDB/DocumentDB storage backend implementation - @dheerajoruganty - Test suite optimization and performance improvements - @omrishiv - Multi-stage Docker build implementation - Gabriel Rojas - Frontend authentication fixes and improvements - Viviana Luccioli - Security enhancements and cookie improvements - dependabot[bot] - Dependency updates and security patches - All community members who reported issues and provided feedback --- **Full Changelog:** [v1.0.8...v1.0.9](https://github.com/agentic-community/mcp-gateway-registry/compare/v1.0.8...v1.0.9) ================================================ FILE: scripts/README.md ================================================ # MCP Gateway Registry Scripts This directory contains utility scripts for building, testing, and deploying MCP Gateway Registry services. ## DocumentDB Initialization Scripts ### Overview The DocumentDB initialization scripts set up collections and indexes for the MCP Gateway Registry when using AWS DocumentDB Elastic Cluster as the storage backend. ### Quick Start ```bash # Set environment variables export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com export DOCUMENTDB_USERNAME=admin export DOCUMENTDB_PASSWORD=yourpassword # Run initialization ./scripts/init-documentdb.sh # Or with namespace export DOCUMENTDB_NAMESPACE=production ./scripts/init-documentdb.sh ``` ### Scripts #### init-documentdb.sh Bash wrapper script that downloads the CA bundle (if needed) and runs the Python initialization script. **Features:** - Downloads AWS DocumentDB CA bundle automatically if missing - Validates environment configuration - Color-coded output for easy readability - Supports both environment variables and command-line arguments **Usage:** ```bash # Using environment variables (recommended) export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com export DOCUMENTDB_USERNAME=admin export DOCUMENTDB_PASSWORD=yourpassword ./scripts/init-documentdb.sh # Pass through command-line arguments to Python script ./scripts/init-documentdb.sh --recreate --namespace production ``` #### init-documentdb-indexes.py Python script that creates all necessary DocumentDB collections and indexes. **Features:** - Creates vector indexes for embeddings (HNSW, 1536 dimensions, cosine similarity) - Creates standard indexes for servers, agents, scopes, security scans, and federation config - Supports both IAM and username/password authentication - Namespace support for multi-tenancy - Recreate mode to drop and recreate indexes **Usage:** ```bash # Using environment variables uv run python scripts/init-documentdb-indexes.py # Using command-line arguments uv run python scripts/init-documentdb-indexes.py \ --host your-cluster.docdb.amazonaws.com \ --username admin \ --password yourpassword # With IAM authentication uv run python scripts/init-documentdb-indexes.py \ --use-iam \ --host your-cluster.docdb.amazonaws.com # With namespace uv run python scripts/init-documentdb-indexes.py --namespace production # Recreate indexes uv run python scripts/init-documentdb-indexes.py --recreate ``` #### download-documentdb-ca-bundle.sh Downloads the AWS DocumentDB global CA bundle certificate required for TLS connections. **Usage:** ```bash ./scripts/download-documentdb-ca-bundle.sh ``` ### Collections and Indexes Created The initialization script creates the following collections with indexes: 1. **mcp_servers_{namespace}** - Unique index on `_id` (path) - Index on `server_name` - Index on `is_enabled` - Index on `version` - Index on `tags` 2. **mcp_agents_{namespace}** - Unique index on `_id` (path) - Index on `name` - Index on `is_enabled` - Index on `version` - Index on `tags` 3. **mcp_scopes_{namespace}** - Unique index on `_id` (scope name) - Index on `name` 4. **mcp_embeddings_1536_{namespace}** - HNSW vector index on `embedding` (1536 dimensions, cosine similarity) - Unique index on `path` - Index on `name` - Index on `entity_type` 5. **mcp_security_scans_{namespace}** - Unique index on `_id` (scan ID) - Index on `entity_path` - Index on `entity_type` - Index on `scan_status` - Index on `scanned_at` 6. **mcp_federation_config_{namespace}** - Unique index on `_id` (config ID) 7. **audit_events_{namespace}** - Unique index on `request_id` - Compound index on `identity.username` + `timestamp` - Compound index on `action.operation` + `timestamp` - Compound index on `action.resource_type` + `timestamp` - TTL index on `timestamp` (default 7 days, configurable via `AUDIT_LOG_MONGODB_TTL_DAYS`) ### Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `DOCUMENTDB_HOST` | `localhost` | DocumentDB cluster endpoint | | `DOCUMENTDB_PORT` | `27017` | DocumentDB port | | `DOCUMENTDB_DATABASE` | `mcp_registry` | Database name | | `DOCUMENTDB_USERNAME` | - | Username for authentication | | `DOCUMENTDB_PASSWORD` | - | Password for authentication | | `DOCUMENTDB_USE_IAM` | `false` | Use AWS IAM authentication | | `DOCUMENTDB_USE_TLS` | `true` | Enable TLS for connections | | `DOCUMENTDB_TLS_CA_FILE` | `global-bundle.pem` | Path to TLS CA bundle | | `DOCUMENTDB_NAMESPACE` | `default` | Namespace for multi-tenancy | | `AUDIT_LOG_MONGODB_TTL_DAYS` | `7` | Audit log retention in days (TTL index) | ### Prerequisites - Python 3.14+ with motor and boto3 installed - AWS credentials configured (for IAM authentication or DocumentDB access) - Network access to DocumentDB cluster - DocumentDB cluster provisioned via Terraform (see terraform/aws-ecs/documentdb-elastic.tf) ### Authentication Methods #### Username/Password Authentication ```bash export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com export DOCUMENTDB_USERNAME=admin export DOCUMENTDB_PASSWORD=yourpassword export DOCUMENTDB_USE_TLS=true ./scripts/init-documentdb.sh ``` #### IAM Authentication ```bash export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com export DOCUMENTDB_USE_IAM=true export DOCUMENTDB_USE_TLS=true # AWS credentials from environment or IAM role ./scripts/init-documentdb.sh ``` #### Local Development (No Authentication) ```bash export DOCUMENTDB_HOST=localhost export DOCUMENTDB_USE_TLS=false ./scripts/init-documentdb.sh ``` ### Troubleshooting #### "DOCUMENTDB_HOST environment variable is not set" Set the required environment variables before running: ```bash export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com ``` #### "AWS credentials not found for DocumentDB IAM auth" Configure AWS credentials: ```bash aws configure # Or use IAM role attached to EC2/ECS task ``` #### "Failed to download CA bundle" - Check network connectivity - Verify wget or curl is installed - Download manually from: https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem #### "Failed to create vector index" - Ensure DocumentDB cluster version supports vector search - Check that dimensions (1536) match your embeddings model - Verify DocumentDB Elastic Cluster (not instance-based cluster) ### Using with Docker Compose DocumentDB is a managed AWS service and runs outside of Docker. To use DocumentDB with docker-compose services: 1. Initialize DocumentDB: ```bash export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com export DOCUMENTDB_USERNAME=admin export DOCUMENTDB_PASSWORD=yourpassword ./scripts/init-documentdb.sh ``` 2. Update docker-compose environment: ```yaml services: registry: environment: - STORAGE_BACKEND=documentdb - DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com - DOCUMENTDB_USERNAME=admin - DOCUMENTDB_PASSWORD=yourpassword ``` 3. Restart services: ```bash docker-compose up -d ``` ### Further Reading - [AWS DocumentDB Elastic Cluster Documentation](https://docs.aws.amazon.com/documentdb/latest/developerguide/elastic-clusters.html) - [DocumentDB Vector Search](https://docs.aws.amazon.com/documentdb/latest/developerguide/vector-search.html) - [Motor AsyncIO MongoDB Driver](https://motor.readthedocs.io/) - [Terraform Configuration](../terraform/aws-ecs/documentdb-elastic.tf) ## Keycloak Build & Push Script ### Overview The `build-and-push-keycloak.sh` script automates the process of building a Keycloak Docker image and pushing it to AWS ECR (Elastic Container Registry). ### Quick Start ```bash # Build and push with defaults (latest tag to us-west-2) ./scripts/build-and-push-keycloak.sh # Build and push with custom tag ./scripts/build-and-push-keycloak.sh --image-tag v24.0.1 # Build only (don't push) ./scripts/build-and-push-keycloak.sh --no-push ``` ### Using with Make ```bash # Build Keycloak image locally make build-keycloak # Build and push to ECR make build-and-push-keycloak # Deploy to ECS (after push) make deploy-keycloak # Complete workflow: build, push, and deploy make update-keycloak # With custom parameters make build-and-push-keycloak AWS_REGION=us-east-1 IMAGE_TAG=v24.0.1 ``` ### Options - `--aws-region REGION` - AWS region (default: us-west-2) - `--image-tag TAG` - Image tag (default: latest) - `--aws-profile PROFILE` - AWS profile (default: default) - `--dockerfile PATH` - Dockerfile path (default: docker/keycloak/Dockerfile) - `--build-context PATH` - Build context (default: docker/keycloak) - `--no-push` - Build only, don't push to ECR - `--help` - Show help message ### Prerequisites - Docker installed and running - AWS CLI installed and configured - AWS credentials with ECR access - Permission to push to ECR repository `keycloak` ### Features - Color-coded output for easy readability - Step-by-step progress tracking - Error handling with clear error messages - ECR login automation - Image verification after push - Helpful commands for manual deployment ### Workflow Example ```bash # Build and push image ./scripts/build-and-push-keycloak.sh --image-tag v24.0.1 # Deploy to ECS aws ecs update-service \ --cluster keycloak \ --service keycloak \ --force-new-deployment \ --region us-west-2 # Monitor deployment aws ecs describe-services \ --cluster keycloak \ --services keycloak \ --region us-west-2 \ --query 'services[0].[serviceName,status,runningCount,desiredCount]' \ --output table ``` ### Troubleshooting #### "Failed to get AWS account ID" - Check AWS credentials: `aws sts get-caller-identity` - Verify AWS profile: `aws configure list --profile ` #### "Failed to login to ECR" - Verify ECR permissions in IAM - Check if repository exists: `aws ecr describe-repositories --repository-names keycloak` #### "Failed to build Docker image" - Check Docker is running: `docker ps` - Verify Dockerfile exists: `ls -la docker/keycloak/Dockerfile` ### Further Reading - [AWS ECR Documentation](https://docs.aws.amazon.com/ecr/) - [Keycloak Docker Image](https://hub.docker.com/r/keycloak/keycloak) - [ECS Service Updates](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/update-service.html) ================================================ FILE: scripts/backfill_agent_fields.py ================================================ """One-time backfill: normalize supported_protocol, trust_level, and visibility on existing agents and servers.""" import logging from pymongo import MongoClient logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) MONGODB_URI = "mongodb://localhost:27017" DB_NAME = "mcp_registry" AGENTS_COLLECTION = "mcp_agents_default" SERVERS_COLLECTION = "mcp_servers_default" def _backfill_supported_protocol( collection, ) -> None: """Set supported_protocol='other' on agents that don't have the field.""" result = collection.update_many( {"supported_protocol": {"$exists": False}}, {"$set": {"supported_protocol": "other"}}, ) logger.info(f"supported_protocol backfill: {result.modified_count} agents updated") def _backfill_trust_level( collection, ) -> None: """Update trust_level from 'unverified' to 'community' for consistency.""" result = collection.update_many( {"trust_level": "unverified"}, {"$set": {"trust_level": "community"}}, ) logger.info(f"trust_level backfill: {result.modified_count} agents updated") def _backfill_visibility( collection, collection_name: str = "agents", ) -> None: """Normalize visibility from 'internal' to 'private' for consistency. The canonical value is 'private'. Legacy documents may have 'internal' which is now treated as an alias. """ result = collection.update_many( {"visibility": "internal"}, {"$set": {"visibility": "private"}}, ) logger.info( f"visibility backfill ({collection_name}): {result.modified_count} documents updated (internal -> private)" ) def backfill_agent_fields() -> None: """Run all backfill operations on agents and servers.""" client = MongoClient(MONGODB_URI, directConnection=True) db = client[DB_NAME] # Backfill agents collection agents = db[AGENTS_COLLECTION] logger.info(f"Backfilling agents collection: {AGENTS_COLLECTION}") _backfill_supported_protocol(agents) _backfill_trust_level(agents) _backfill_visibility(agents, collection_name="agents") # Backfill servers collection servers = db[SERVERS_COLLECTION] logger.info(f"Backfilling servers collection: {SERVERS_COLLECTION}") _backfill_visibility(servers, collection_name="servers") logger.info("Backfill complete") if __name__ == "__main__": backfill_agent_fields() ================================================ FILE: scripts/build-images.sh ================================================ #!/bin/bash # Build and push Docker images from build-config.yaml to AWS ECR # Usage: ./scripts/build-images.sh [build|push|build-push] [IMAGE=name] [NO_CACHE=true] # Example: ./scripts/build-images.sh build IMAGE=registry # Example: ./scripts/build-images.sh build-push # Example: NO_CACHE=true ./scripts/build-images.sh build IMAGE=registry # Example: NO_CACHE=true make build-push IMAGE=registry set -e # Disable AWS CLI pager to prevent interactive prompts export AWS_PAGER="" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' BOLD='\033[1m' NC='\033[0m' # No Color # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" REPO_ROOT="$(dirname "$SCRIPT_DIR")" # CRITICAL: Check if AWS_REGION is set if [[ -z "${AWS_REGION:-}" ]]; then echo -e "${RED}${BOLD}============================================${NC}" echo -e "${RED}${BOLD}ERROR: AWS_REGION environment variable is not set!${NC}" echo -e "${RED}${BOLD}============================================${NC}" echo "" echo "Please set AWS_REGION before running build/push commands:" echo " export AWS_REGION=us-east-1" echo "" echo "This prevents accidentally pushing to the wrong region." echo "" exit 1 fi # CRITICAL: Check if running in a Python virtual environment if [[ -z "${VIRTUAL_ENV:-}" ]]; then echo -e "${RED}${BOLD}============================================${NC}" echo -e "${RED}${BOLD}ERROR: Not running in a Python virtual environment!${NC}" echo -e "${RED}${BOLD}============================================${NC}" echo "" echo "Please activate a virtual environment before running build commands:" echo " source .venv/bin/activate" echo "" echo "This ensures consistent Python dependencies for the build process." echo "" exit 1 fi # Display region in BIG BOLD LETTERS echo "" echo -e "${GREEN}${BOLD}============================================${NC}" echo -e "${GREEN}${BOLD}AWS REGION: ${AWS_REGION}${NC}" echo -e "${GREEN}${BOLD}============================================${NC}" echo "" # Configuration CONFIG_FILE="${REPO_ROOT}/build-config.yaml" ACTION="${1:-build-push}" TARGET_IMAGE="${IMAGE:-}" # Logging functions log_info() { echo -e "${BLUE}[INFO]${NC} $1" } log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" } log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" } log_error() { echo -e "${RED}[ERROR]${NC} $1" } # Validate configuration file exists if [ ! -f "$CONFIG_FILE" ]; then log_error "Configuration file not found: $CONFIG_FILE" exit 1 fi # Parse AWS account ID and construct ECR registry dynamically based on AWS_REGION AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" if [ -z "$AWS_ACCOUNT_ID" ]; then log_error "Could not determine AWS Account ID" exit 1 fi log_info "AWS Account: $AWS_ACCOUNT_ID" log_info "ECR Registry: $ECR_REGISTRY" log_info "AWS Region: $AWS_REGION" log_info "Build Action: $ACTION" # Determine BUILD_VERSION from git if command -v git &> /dev/null && [ -d "${REPO_ROOT}/.git" ]; then # Get the current git tag GIT_TAG=$(git -C "$REPO_ROOT" describe --tags --exact-match 2>/dev/null || echo "") if [ -n "$GIT_TAG" ]; then # We're on a tagged commit - use just the tag (remove 'v' prefix) BUILD_VERSION="${GIT_TAG#v}" log_info "Build version (release): $BUILD_VERSION" else # Not on a tag - include branch name and commit info GIT_BRANCH=$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") # Sanitize branch name for Docker tag (replace / with -) GIT_BRANCH="${GIT_BRANCH//\//-}" GIT_DESCRIBE=$(git -C "$REPO_ROOT" describe --tags --always 2>/dev/null || echo "dev") # Format: version-branch or describe-branch if [[ "$GIT_DESCRIBE" =~ ^[0-9] ]]; then # Starts with version number from describe BUILD_VERSION="${GIT_DESCRIBE#v}-${GIT_BRANCH}" else # No version tags found, use commit hash BUILD_VERSION="${GIT_DESCRIBE}-${GIT_BRANCH}" fi log_info "Build version (development): $BUILD_VERSION" fi else BUILD_VERSION="1.0.0-dev" log_warning "Git not available, using default version: $BUILD_VERSION" fi # Parse images from YAML and build array declare -A IMAGES declare -A BUILD_ARGS declare -a IMAGE_NAMES # Single pass to parse config and collect image information while IFS='|' read -r name repo_name dockerfile context build_args; do if [ -n "$name" ]; then IMAGES["$name"]="$repo_name|$dockerfile|$context" BUILD_ARGS["$name"]="$build_args" IMAGE_NAMES+=("$name") fi done <<< "$(python3 << PYEOF import yaml import sys try: with open('$CONFIG_FILE') as f: config = yaml.safe_load(f) images = config.get('images', {}) for name, image_config in images.items(): repo_name = image_config.get('repo_name') dockerfile = image_config.get('dockerfile') context = image_config.get('context', '.') build_args = image_config.get('build_args', {}) # Skip external images (they don't have dockerfiles, only external_image) if not repo_name or not dockerfile: continue # Format build_args as key=value pairs separated by spaces build_args_str = ' '.join([f"{k}={v}" for k, v in build_args.items()]) print(f"{name}|{repo_name}|{dockerfile}|{context}|{build_args_str}") except Exception as e: print(f"ERROR: Failed to parse config: {e}", file=sys.stderr) sys.exit(1) PYEOF )" # Function to setup A2A agent build dependencies setup_a2a_agent() { local image_name="$1" local context="$2" local agent_dir="" local tmp_dir="" local deps_source_dir="" # Determine which agent this is and where to place .tmp files if [[ "$image_name" == "flight_booking_agent" ]]; then agent_dir="${REPO_ROOT}/${context}" tmp_dir="${REPO_ROOT}/${context}/.tmp" # Dependencies are at agents/a2a level deps_source_dir="${REPO_ROOT}/agents/a2a" elif [[ "$image_name" == "travel_assistant_agent" ]]; then agent_dir="${REPO_ROOT}/${context}" tmp_dir="${REPO_ROOT}/${context}/.tmp" # Dependencies are at agents/a2a level deps_source_dir="${REPO_ROOT}/agents/a2a" else return 0 # Not an A2A agent fi # Create .tmp directory in context root (where Dockerfile COPY command expects it) log_info "Setting up A2A agent dependencies for $image_name..." mkdir -p "$tmp_dir" || { log_error "Failed to create .tmp directory for $image_name" return 1 } # Copy pyproject.toml and uv.lock from agents/a2a root to context/.tmp/ if [ -f "${deps_source_dir}/pyproject.toml" ] && [ -f "${deps_source_dir}/uv.lock" ]; then cp "${deps_source_dir}/pyproject.toml" "$tmp_dir/" || { log_error "Failed to copy pyproject.toml for $image_name" return 1 } cp "${deps_source_dir}/uv.lock" "$tmp_dir/" || { log_error "Failed to copy uv.lock for $image_name" return 1 } log_success "Copied dependencies to $tmp_dir/" else log_error "Missing pyproject.toml or uv.lock in ${deps_source_dir}" return 1 fi return 0 } # Function to cleanup A2A agent build dependencies cleanup_a2a_agent() { local image_name="$1" local context="$2" local tmp_dir="" # Determine which agent this is if [[ "$image_name" == "flight_booking_agent" ]]; then tmp_dir="${REPO_ROOT}/${context}/.tmp" elif [[ "$image_name" == "travel_assistant_agent" ]]; then tmp_dir="${REPO_ROOT}/${context}/.tmp" else return 0 # Not an A2A agent fi # Remove .tmp directory from context root if [ -d "$tmp_dir" ]; then log_info "Cleaning up A2A agent temporary files for $image_name..." rm -rf "$tmp_dir" || { log_warning "Failed to cleanup .tmp directory for $image_name" } fi return 0 } # Function to build Docker image build_image() { local image_name="$1" local repo_name="$2" local dockerfile="$3" local context="$4" local build_args="${BUILD_ARGS[$image_name]:-}" log_info "Building $image_name..." # Validate dockerfile exists if [ ! -f "$REPO_ROOT/$dockerfile" ]; then log_error "Dockerfile not found: $REPO_ROOT/$dockerfile" return 1 fi # Setup A2A agent dependencies if needed if ! setup_a2a_agent "$image_name" "$context"; then return 1 fi # Construct build args for docker command local build_arg_flags="--build-arg BUILD_VERSION=$BUILD_VERSION" if [ -n "$build_args" ]; then log_info "Build args: $build_args" for arg in $build_args; do build_arg_flags="$build_arg_flags --build-arg $arg" done fi log_info "BUILD_VERSION=$BUILD_VERSION" # Construct cache flags local cache_flags="" if [[ "${NO_CACHE:-}" == "true" ]]; then cache_flags="--no-cache" log_warning "Building without cache (NO_CACHE=true)" fi # Build the Docker image using buildx (faster, better caching, future-proof) # Tag with :latest only (ECS will pull fresh images with imagePullPolicy: always) docker buildx build \ --load \ -f "$REPO_ROOT/$dockerfile" \ -t "$repo_name:latest" \ $cache_flags \ $build_arg_flags \ "$REPO_ROOT/$context" || { log_error "Failed to build $image_name" cleanup_a2a_agent "$image_name" "$context" return 1 } log_success "Built $repo_name:latest" # Cleanup A2A agent dependencies after build cleanup_a2a_agent "$image_name" "$context" return 0 } # Function to push image to ECR push_image() { local image_name="$1" local repo_name="$2" local ecr_uri_latest="${ECR_REGISTRY}/${repo_name}:latest" log_info "Pushing $image_name to ECR..." # Create ECR repository if it doesn't exist log_info "Checking ECR repository: $repo_name" aws ecr describe-repositories \ --repository-names "$repo_name" \ --region "$AWS_REGION" 2>/dev/null || { log_info "Repository doesn't exist, creating: $repo_name" aws ecr create-repository \ --repository-name "$repo_name" \ --region "$AWS_REGION" log_success "Created ECR repository: $repo_name" } # Login to ECR log_info "Authenticating with ECR..." aws ecr get-login-password --region "$AWS_REGION" | \ docker login --username AWS --password-stdin "$ECR_REGISTRY" || { log_error "Failed to authenticate with ECR" return 1 } # Tag image for ECR (:latest only) docker tag "$repo_name:latest" "$ecr_uri_latest" || { log_error "Failed to tag image for ECR" return 1 } # Push to ECR log_info "Pushing $ecr_uri_latest..." docker push "$ecr_uri_latest" || { log_error "Failed to push image to ECR" return 1 } log_success "Pushed $ecr_uri_latest" } # Process images if [ -z "$TARGET_IMAGE" ]; then # Process all images log_info "Processing all ${#IMAGE_NAMES[@]} images..." IMAGES_TO_PROCESS=("${IMAGE_NAMES[@]}") else # Process specific image if [[ " ${IMAGE_NAMES[@]} " =~ " ${TARGET_IMAGE} " ]]; then log_info "Processing specific image: $TARGET_IMAGE" IMAGES_TO_PROCESS=("$TARGET_IMAGE") else log_error "Image not found: $TARGET_IMAGE" log_info "Available images: ${IMAGE_NAMES[*]}" exit 1 fi fi # Execute actions FAILED_IMAGES=() SUCCESSFUL_IMAGES=() for image_name in "${IMAGES_TO_PROCESS[@]}"; do IFS='|' read -r repo_name dockerfile context <<< "${IMAGES[$image_name]}" log_info "==========================================" log_info "Processing: $image_name ($repo_name)" log_info "==========================================" if [[ "$ACTION" == "build" ]] || [[ "$ACTION" == "build-push" ]]; then if ! build_image "$image_name" "$repo_name" "$dockerfile" "$context"; then FAILED_IMAGES+=("$image_name") continue fi fi if [[ "$ACTION" == "push" ]] || [[ "$ACTION" == "build-push" ]]; then if ! push_image "$image_name" "$repo_name"; then FAILED_IMAGES+=("$image_name") continue fi fi SUCCESSFUL_IMAGES+=("$image_name") done # Summary log_info "==========================================" log_info "Build Summary" log_info "==========================================" log_success "Successful: ${#SUCCESSFUL_IMAGES[@]}" if [ ${#SUCCESSFUL_IMAGES[@]} -gt 0 ]; then for img in "${SUCCESSFUL_IMAGES[@]}"; do echo " - $img" done fi if [ ${#FAILED_IMAGES[@]} -gt 0 ]; then log_error "Failed: ${#FAILED_IMAGES[@]}" for img in "${FAILED_IMAGES[@]}"; do echo " - $img" done exit 1 fi log_success "All images processed successfully!" ================================================ FILE: scripts/debug-scopes.py ================================================ #!/usr/bin/env python3 """Debug script to inspect DocumentDB scopes collection.""" import asyncio import json import os from motor.motor_asyncio import AsyncIOMotorClient async def debug_scopes(): """Inspect DocumentDB scopes collection.""" # Get connection details from environment host = os.getenv("DOCUMENTDB_HOST", "localhost") port = int(os.getenv("DOCUMENTDB_PORT", "27017")) username = os.getenv("DOCUMENTDB_USERNAME") password = os.getenv("DOCUMENTDB_PASSWORD") database = os.getenv("DOCUMENTDB_DATABASE", "mcp_registry") namespace = os.getenv("DOCUMENTDB_NAMESPACE", "default") use_tls = os.getenv("DOCUMENTDB_USE_TLS", "true").lower() == "true" ca_file = os.getenv("DOCUMENTDB_TLS_CA_FILE", "/app/certs/global-bundle.pem") print("=" * 80) print("DocumentDB Scopes Debug") print("=" * 80) print(f"Host: {host}:{port}") print(f"Database: {database}") print(f"Namespace: {namespace}") print(f"TLS: {use_tls}") print("=" * 80) print() # Build connection string with appropriate auth mechanism # Choose auth mechanism based on storage backend from environment storage_backend = os.getenv("STORAGE_BACKEND", "documentdb") if storage_backend == "mongodb-ce": auth_mechanism = "SCRAM-SHA-256" else: auth_mechanism = "SCRAM-SHA-1" if username and password: connection_string = f"mongodb://{username}:{password}@{host}:{port}/{database}?authMechanism={auth_mechanism}&authSource=admin" else: connection_string = f"mongodb://{host}:{port}/{database}" # TLS options tls_options = {} if use_tls: tls_options["tls"] = True if ca_file and os.path.exists(ca_file): tls_options["tlsCAFile"] = ca_file print(f"Using CA file: {ca_file}") else: print(f"WARNING: CA file not found: {ca_file}") # Connect to DocumentDB print("Connecting to DocumentDB...") # IMPORTANT: DocumentDB does not support retryable writes client = AsyncIOMotorClient(connection_string, retryWrites=False, **tls_options) db = client[database] try: # Test connection server_info = await client.server_info() print(f"Connected to MongoDB/DocumentDB version: {server_info.get('version')}") print() # Collection name collection_name = f"mcp_scopes_{namespace}" collection = db[collection_name] # Count documents count = await collection.count_documents({}) print(f"Collection: {collection_name}") print(f"Document count: {count}") print() if count == 0: print("WARNING: No scope documents found!") print() print("Listing all collections:") collections = await db.list_collection_names() for coll in sorted(collections): print(f" - {coll}") else: print("Scope documents:") print("-" * 80) # Get all scope documents cursor = collection.find({}) async for doc in cursor: scope_id = doc.get("_id", "unknown") server_access = doc.get("server_access", []) group_mappings = doc.get("group_mappings", []) ui_permissions = doc.get("ui_permissions", {}) print(f"\nScope ID: {scope_id}") print(f" Group Mappings: {group_mappings}") print(f" Server Access Rules: {len(server_access)} rules") if server_access: print(" Server Access:") for rule in server_access: print(f" - {json.dumps(rule, indent=6)}") if ui_permissions: print(f" UI Permissions: {json.dumps(ui_permissions, indent=4)}") print() print("=" * 80) finally: client.close() if __name__ == "__main__": asyncio.run(debug_scopes()) ================================================ FILE: scripts/deploy.sh ================================================ #!/bin/bash # Deploy services to ECS (build, push, force new deployment) # # Usage: # ./scripts/deploy.sh [--service registry|auth|both] [--no-cache] [--skip-monitor] # # Examples: # ./scripts/deploy.sh # Deploy both registry and auth server # ./scripts/deploy.sh --service registry # Deploy registry only # ./scripts/deploy.sh --service auth # Deploy auth server only # ./scripts/deploy.sh --service both # Deploy both (default) # ./scripts/deploy.sh --no-cache # Deploy both without Docker cache # ./scripts/deploy.sh --service auth --no-cache # Deploy auth without cache # ./scripts/deploy.sh --skip-monitor # Deploy without monitoring step # Exit on error set -e # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" REPO_ROOT="$(dirname "$SCRIPT_DIR")" # Configuration AWS_REGION="${AWS_REGION:-us-east-1}" ECS_CLUSTER="mcp-gateway-ecs-cluster" # Service configuration mapping # Format: IMAGE_NAME:ECS_SERVICE_NAME REGISTRY_IMAGE="registry" REGISTRY_ECS_SERVICE="mcp-gateway-v2-registry" AUTH_IMAGE="auth_server" AUTH_ECS_SERVICE="mcp-gateway-v2-auth" # Defaults SERVICE="both" NO_CACHE="" SKIP_MONITOR="false" _print_usage() { echo "Usage: $0 [--service registry|auth|both] [--no-cache] [--skip-monitor]" echo "" echo "Options:" echo " --service Service to deploy: registry, auth, or both (default: both)" echo " --no-cache Build Docker images without cache" echo " --skip-monitor Skip the deployment monitoring step" echo "" echo "Examples:" echo " $0 # Deploy both services" echo " $0 --service registry # Deploy registry only" echo " $0 --service auth # Deploy auth server only" echo " $0 --no-cache --service auth # Deploy auth without cache" } _parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --service) SERVICE="$2" # Accept auth_server as alias for auth if [[ "$SERVICE" == "auth_server" ]]; then SERVICE="auth" fi if [[ "$SERVICE" != "registry" && "$SERVICE" != "auth" && "$SERVICE" != "both" ]]; then echo "Error: --service must be 'registry', 'auth', 'auth_server', or 'both'" _print_usage exit 1 fi shift 2 ;; --no-cache) NO_CACHE="true" shift ;; --skip-monitor) SKIP_MONITOR="true" shift ;; --help|-h) _print_usage exit 0 ;; *) echo "Error: Unknown option: $1" _print_usage exit 1 ;; esac done } _build_and_push() { local image_name="$1" local display_name="$2" echo "Building and pushing ${display_name} image..." echo "----------------------------------------" cd "$REPO_ROOT" if [[ "$NO_CACHE" == "true" ]]; then echo "Building without cache (--no-cache)" NO_CACHE=true make build-push IMAGE="$image_name" else make build-push IMAGE="$image_name" fi echo "${display_name} image built and pushed successfully" echo "" } _force_new_deployment() { local ecs_service="$1" local display_name="$2" echo "Forcing new deployment for ${display_name} (${ecs_service})..." echo "----------------------------------------" aws ecs update-service \ --cluster "$ECS_CLUSTER" \ --service "$ecs_service" \ --force-new-deployment \ --region "$AWS_REGION" \ --output json | jq '{service: .service.serviceName, status: .service.status, desiredCount: .service.desiredCount}' echo "${display_name} deployment triggered" echo "" } _monitor_deployment() { local ecs_services="$1" echo "Monitoring deployment status..." echo "----------------------------------------" echo "Press Ctrl+C to exit monitoring" echo "" sleep 2 watch -n 5 'aws ecs describe-services \ --cluster '"$ECS_CLUSTER"' \ --services '"$ecs_services"' \ --region '"$AWS_REGION"' \ --query "services[*].{Service:serviceName,Status:status,Desired:desiredCount,Running:runningCount,Pending:pendingCount,Deployments:deployments[*].{Status:status,Running:runningCount,Desired:desiredCount,RolloutState:rolloutState}}" \ --output table' } _deploy_services() { local step=1 local total_steps=0 local monitor_services="" # Calculate total steps case "$SERVICE" in registry) total_steps=2 if [[ "$SKIP_MONITOR" == "false" ]]; then total_steps=3 fi ;; auth) total_steps=2 if [[ "$SKIP_MONITOR" == "false" ]]; then total_steps=3 fi ;; both) total_steps=4 if [[ "$SKIP_MONITOR" == "false" ]]; then total_steps=5 fi ;; esac # Build and push if [[ "$SERVICE" == "registry" || "$SERVICE" == "both" ]]; then echo "Step ${step}/${total_steps}: Building Registry" _build_and_push "$REGISTRY_IMAGE" "Registry" step=$((step + 1)) fi if [[ "$SERVICE" == "auth" || "$SERVICE" == "both" ]]; then echo "Step ${step}/${total_steps}: Building Auth Server" _build_and_push "$AUTH_IMAGE" "Auth Server" step=$((step + 1)) fi # Force new deployments if [[ "$SERVICE" == "registry" || "$SERVICE" == "both" ]]; then echo "Step ${step}/${total_steps}: Deploying Registry" _force_new_deployment "$REGISTRY_ECS_SERVICE" "Registry" monitor_services="$REGISTRY_ECS_SERVICE" step=$((step + 1)) fi if [[ "$SERVICE" == "auth" || "$SERVICE" == "both" ]]; then echo "Step ${step}/${total_steps}: Deploying Auth Server" _force_new_deployment "$AUTH_ECS_SERVICE" "Auth Server" if [[ -n "$monitor_services" ]]; then monitor_services="$monitor_services $AUTH_ECS_SERVICE" else monitor_services="$AUTH_ECS_SERVICE" fi step=$((step + 1)) fi # Monitor if [[ "$SKIP_MONITOR" == "false" ]]; then echo "Step ${step}/${total_steps}: Monitoring" _monitor_deployment "$monitor_services" else echo "Skipping deployment monitoring (--skip-monitor)" echo "" echo "To check status manually:" echo " aws ecs describe-services --cluster $ECS_CLUSTER --services $monitor_services --region $AWS_REGION --query 'services[*].{Service:serviceName,Running:runningCount,Desired:desiredCount}' --output table" fi } # Main _parse_args "$@" echo "==========================================" echo "ECS Deployment Script" echo "==========================================" echo "Service: $SERVICE" echo "Region: $AWS_REGION" echo "Cluster: $ECS_CLUSTER" echo "No Cache: ${NO_CACHE:-false}" echo "==========================================" echo "" _deploy_services ================================================ FILE: scripts/docs-dev.sh ================================================ #!/bin/bash # MkDocs Development Helper Script set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # Function to print colored output print_status() { echo -e "${GREEN}[INFO]${NC} $1" } print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" } print_error() { echo -e "${RED}[ERROR]${NC} $1" } # Check if we're in the right directory if [ ! -f "mkdocs.yml" ]; then print_error "mkdocs.yml not found. Please run this script from the repository root." exit 1 fi # Function to install dependencies install_deps() { print_status "Installing MkDocs dependencies with uv..." if command -v uv &> /dev/null; then uv pip install -r requirements-docs.txt elif command -v pip3 &> /dev/null; then print_warning "uv not found, falling back to pip3..." pip3 install -r requirements-docs.txt elif command -v pip &> /dev/null; then print_warning "uv not found, falling back to pip..." pip install -r requirements-docs.txt else print_error "Neither uv nor pip found. Please install uv or Python pip first." print_status "To install uv: curl -LsSf https://astral.sh/uv/install.sh | sh" exit 1 fi print_status "Dependencies installed successfully!" } # Function to serve documentation serve_docs() { print_status "Starting MkDocs development server..." print_status "Documentation will be available at: http://127.0.0.1:8000" print_status "Press Ctrl+C to stop the server" mkdocs serve } # Function to build documentation build_docs() { print_status "Building static documentation..." mkdocs build --clean --strict print_status "Documentation built successfully in ./site/" } # Function to deploy to GitHub Pages deploy_docs() { print_warning "This will deploy to GitHub Pages. Are you sure? (y/N)" read -r response if [[ "$response" =~ ^([yY][eE][sS]|[yY])$ ]]; then print_status "Deploying to GitHub Pages..." mkdocs gh-deploy print_status "Deployed successfully!" else print_status "Deployment cancelled." fi } # Function to check documentation check_docs() { print_status "Checking documentation for issues..." # Check for broken links if command -v mkdocs &> /dev/null; then mkdocs build --strict 2>&1 | grep -i "warning\|error" || print_status "No issues found!" else print_error "MkDocs not installed. Run 'install' first." fi } # Main script logic case "${1:-}" in "install") install_deps ;; "serve") serve_docs ;; "build") build_docs ;; "deploy") deploy_docs ;; "check") check_docs ;; *) echo "MkDocs Development Helper" echo "" echo "Usage: $0 [command]" echo "" echo "Commands:" echo " install Install MkDocs dependencies" echo " serve Start development server with live reload" echo " build Build static documentation" echo " deploy Deploy to GitHub Pages" echo " check Check documentation for issues" echo "" echo "Examples:" echo " $0 install # Install dependencies" echo " $0 serve # Start development server" echo " $0 build # Build static site" echo "" ;; esac ================================================ FILE: scripts/download-documentdb-ca-bundle.sh ================================================ #!/bin/bash # Download AWS DocumentDB global-bundle.pem certificate # This certificate is required for TLS connections to Amazon DocumentDB set -e # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PARENT_DIR="$(dirname "$SCRIPT_DIR")" # Configuration CA_BUNDLE_URL="https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem" CA_BUNDLE_FILE="${DOCUMENTDB_TLS_CA_FILE:-global-bundle.pem}" DOWNLOAD_PATH="${PARENT_DIR}/${CA_BUNDLE_FILE}" # Allow override via environment variable if [ -n "$DOCUMENTDB_CA_BUNDLE_PATH" ]; then DOWNLOAD_PATH="$DOCUMENTDB_CA_BUNDLE_PATH" fi echo "Downloading AWS DocumentDB CA bundle..." echo "Source: ${CA_BUNDLE_URL}" echo "Destination: ${DOWNLOAD_PATH}" # Download the certificate bundle if command -v wget &> /dev/null; then wget -O "$DOWNLOAD_PATH" "$CA_BUNDLE_URL" elif command -v curl &> /dev/null; then curl -o "$DOWNLOAD_PATH" "$CA_BUNDLE_URL" else echo "Error: Neither wget nor curl is available. Please install one of them." exit 1 fi # Verify download if [ -f "$DOWNLOAD_PATH" ]; then FILE_SIZE=$(stat -f%z "$DOWNLOAD_PATH" 2>/dev/null || stat -c%s "$DOWNLOAD_PATH" 2>/dev/null) if [ "$FILE_SIZE" -gt 0 ]; then echo "Successfully downloaded CA bundle (${FILE_SIZE} bytes)" echo "Certificate bundle location: ${DOWNLOAD_PATH}" exit 0 else echo "Error: Downloaded file is empty" rm -f "$DOWNLOAD_PATH" exit 1 fi else echo "Error: Failed to download CA bundle" exit 1 fi ================================================ FILE: scripts/fix_auth_tests.py ================================================ #!/usr/bin/env python3 """ Script to fix auth test patterns in test_server_routes.py Removes manual patching and ensures proper use of auth_override_helper. """ import re def fix_test_file(): file_path = "/home/ubuntu/mcp-gateway-registry-MAIN/tests/unit/api/test_server_routes.py" with open(file_path) as f: content = f.read() # Pattern 1: Remove "with patch" blocks for admin users (single line) # Match: with patch("registry.api.server_routes.enhanced_auth", return_value=admin_user_context): pattern1 = r' with patch\("registry\.api\.server_routes\.(enhanced_auth|nginx_proxied_auth)", return_value=admin_user_context\):\n' content = re.sub(pattern1, "", content) # Pattern 2: Remove multiline patch blocks with user_has_ui_permission_for_service (admin) pattern2 = r' with patch\("registry\.api\.server_routes\.(enhanced_auth|nginx_proxied_auth)", return_value=admin_user_context\), \\\n patch\("registry\.api\.server_routes\.user_has_ui_permission_for_service", return_value=True\):\n' content = re.sub(pattern2, "", content) # Pattern 3: Handle tests with regular_user_context - add auth_override_helper and call it # First, find regular_user_context tests and add auth_override_helper param # Pattern: def test_xxx(self, ..., regular_user_context) # Need to add auth_override_helper after regular_user_context if not present def add_auth_helper_param(match): func_sig = match.group(0) # Check if auth_override_helper already in signature if "auth_override_helper" in func_sig: return func_sig # Add auth_override_helper after regular_user_context return func_sig.replace( "regular_user_context\n", "regular_user_context,\n auth_override_helper\n" ) pattern_func = r" def test_\w+\([^)]+regular_user_context\n \):" content = re.sub(pattern_func, add_auth_helper_param, content, flags=re.MULTILINE) # Pattern 4: For regular_user_context tests, replace patch blocks with auth_override_helper call # Match: with patch(...enhanced_auth...regular_user_context), \ # patch(...user_has_ui_permission...): # # Act # Replace with: # Arrange - override auth to regular user # auth_override_helper(regular_user_context) # # Act pattern4 = r' with patch\("registry\.api\.server_routes\.(enhanced_auth|nginx_proxied_auth)", return_value=regular_user_context\), \\\n patch\("registry\.api\.server_routes\.user_has_ui_permission_for_service", return_value=(True|False)\):\n # Act' def replace_regular_auth(match): permission_val = match.group(3) if permission_val == "True": with_patch = 'with patch("registry.api.server_routes.user_has_ui_permission_for_service", return_value=True):\n' indent = " " else: with_patch = 'with patch("registry.api.server_routes.user_has_ui_permission_for_service", return_value=False):\n' indent = " " return f" # Arrange - override auth to regular user\n auth_override_helper(regular_user_context)\n {with_patch}{indent}# Act" content = re.sub(pattern4, replace_regular_auth, content) # Fix remaining indentation issues # Lines that were indented for "with patch" context should be de-indented lines = content.split("\n") fixed_lines = [] in_test_method = False skip_dedent = False for i, line in enumerate(lines): # Track if we're in a test method if line.strip().startswith("def test_"): in_test_method = True skip_dedent = False elif line.strip().startswith("def ") or ( line.strip().startswith("class ") and not line.strip().startswith("class Test") ): in_test_method = False # Check if this is a comment we added if "# Arrange - override auth" in line or "# Arrange - auth already set" in line: skip_dedent = True elif line.strip().startswith("# Act"): skip_dedent = True elif line.strip() == "" or line.strip().startswith("#"): pass # Keep as is elif skip_dedent and in_test_method and line.startswith(" "): # De-indent by 4 spaces (was indented for with block) line = line[4:] fixed_lines.append(line) content = "\n".join(fixed_lines) with open(file_path, "w") as f: f.write(content) print("Fixed test file") if __name__ == "__main__": fix_test_file() ================================================ FILE: scripts/generate-image-manifest.sh ================================================ #!/bin/bash # Generate image-manifest.json from build-config.yaml for Terraform consumption # This script creates a JSON file with all ECR image URIs for Terraform to reference set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" CONFIG_FILE="${SCRIPT_DIR}/build-config.yaml" OUTPUT_FILE="${SCRIPT_DIR}/image-manifest.json" # Get AWS region from environment or default to us-west-2 AWS_REGION="${AWS_REGION:-us-west-2}" if [ ! -f "$CONFIG_FILE" ]; then echo "Error: $CONFIG_FILE not found" exit 1 fi echo "Generating image manifest from $CONFIG_FILE..." echo "Using AWS Region: $AWS_REGION" # Get AWS account ID AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) if [ -z "$AWS_ACCOUNT_ID" ]; then echo "Error: Could not determine AWS Account ID" exit 1 fi # Construct ECR registry URL dynamically ECR_REGISTRY="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" echo "ECR Registry: $ECR_REGISTRY" python3 << EOF import yaml import json import sys import os with open('$CONFIG_FILE') as f: cfg = yaml.safe_load(f) # Use the dynamically constructed ECR registry from environment ecr_registry = '$ECR_REGISTRY' images = cfg.get('images', {}) if not ecr_registry: print("Error: ecr_registry not available") sys.exit(1) manifest = {} for name, config in images.items(): repo_name = config.get('repo_name') if not repo_name: print(f"Error: Image '{name}' missing repo_name") sys.exit(1) ecr_uri = f'{ecr_registry}/{repo_name}:latest' manifest[name] = ecr_uri # Write manifest with open('$OUTPUT_FILE', 'w') as f: json.dump(manifest, f, indent=2) print(f"Successfully generated {len(manifest)} image URIs in image-manifest.json") print() print("Image URIs (for Terraform):") for name, uri in manifest.items(): print(f" {name:25} = {uri}") EOF echo "" echo "Manifest saved to: $OUTPUT_FILE" echo "" echo "Usage in Terraform:" echo " locals {" echo " image_manifest = jsondecode(file(\"\${path.module}/image-manifest.json\"))" echo " registry_image = local.image_manifest[\"registry\"]" echo " }" ================================================ FILE: scripts/generate-mongodb-keyfile.sh ================================================ #!/bin/bash # Generate MongoDB keyfile for replica set authentication # This is required when running MongoDB with --replSet and authentication enabled set -e SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" KEYFILE_PATH="$SCRIPT_DIR/../.mongodb-keyfile" # Generate a random keyfile if it doesn't exist if [ ! -f "$KEYFILE_PATH" ]; then echo "Generating MongoDB keyfile..." openssl rand -base64 756 > "$KEYFILE_PATH" chmod 400 "$KEYFILE_PATH" echo "Keyfile generated at: $KEYFILE_PATH" else echo "Keyfile already exists at: $KEYFILE_PATH" fi ================================================ FILE: scripts/init-documentdb-indexes.py ================================================ #!/usr/bin/env python3 """ Initialize DocumentDB collections and indexes for MCP Gateway Registry. This script creates all necessary vector indexes and standard indexes for the MCP Gateway Registry DocumentDB backend. Usage: # Using environment variables export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com export DOCUMENTDB_USERNAME=admin export DOCUMENTDB_PASSWORD=yourpassword uv run python scripts/init-documentdb-indexes.py # Using command-line arguments uv run python scripts/init-documentdb-indexes.py --host your-cluster.docdb.amazonaws.com uv run python scripts/init-documentdb-indexes.py --use-iam --host your-cluster.docdb.amazonaws.com # With namespace uv run python scripts/init-documentdb-indexes.py --namespace tenant-a # Recreate indexes uv run python scripts/init-documentdb-indexes.py --recreate Requires: - motor (AsyncIOMotorClient) - boto3 (for IAM authentication) - DocumentDB connection details via environment variables or command-line """ import argparse import asyncio import json import logging import os from pathlib import Path from motor.motor_asyncio import AsyncIOMotorClient # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Collection names COLLECTION_SERVERS = "mcp_servers" COLLECTION_AGENTS = "mcp_agents" COLLECTION_SCOPES = "mcp_scopes" COLLECTION_EMBEDDINGS = "mcp_embeddings_1536" COLLECTION_SECURITY_SCANS = "mcp_security_scans" COLLECTION_FEDERATION_CONFIG = "mcp_federation_config" COLLECTION_AUDIT_EVENTS = "audit_events" async def _get_documentdb_connection_string( host: str, port: int, database: str, username: str | None, password: str | None, use_iam: bool, use_tls: bool, tls_ca_file: str | None, storage_backend: str = "documentdb", ) -> str: """Build DocumentDB connection string with appropriate auth mechanism. Args: storage_backend: Either 'documentdb' (uses SCRAM-SHA-1) or 'mongodb-ce' (uses SCRAM-SHA-256) """ if use_iam: import boto3 session = boto3.Session() credentials = session.get_credentials() if not credentials: raise ValueError("AWS credentials not found for DocumentDB IAM auth") connection_string = ( f"mongodb://{credentials.access_key}:{credentials.secret_key}@" f"{host}:{port}/{database}?" f"tls=true&authSource=$external&authMechanism=MONGODB-AWS" ) if tls_ca_file: connection_string += f"&tlsCAFile={tls_ca_file}" logger.info(f"Using AWS IAM authentication for DocumentDB (host: {host})") else: if username and password: # Choose auth mechanism based on storage backend # - MongoDB CE 8.2+: Use SCRAM-SHA-256 (stronger, modern authentication) # - AWS DocumentDB v5.0: Only supports SCRAM-SHA-1 if storage_backend == "mongodb-ce": auth_mechanism = "SCRAM-SHA-256" else: # AWS DocumentDB (storage_backend="documentdb") auth_mechanism = "SCRAM-SHA-1" connection_string = ( f"mongodb://{username}:{password}@" f"{host}:{port}/{database}?" f"authMechanism={auth_mechanism}&authSource=admin&" f"tls={str(use_tls).lower()}" ) if use_tls and tls_ca_file: connection_string += f"&tlsCAFile={tls_ca_file}" logger.info( f"Using username/password authentication ({auth_mechanism}) for " f"{storage_backend} (host: {host})" ) else: connection_string = f"mongodb://{host}:{port}/{database}" logger.info(f"Using no authentication for DocumentDB (host: {host})") return connection_string async def _create_vector_index( collection, collection_name: str, recreate: bool, ) -> None: """Create vector index for embeddings collection. Note: DocumentDB Elastic does not support vector indexes. This will be skipped for DocumentDB deployments. """ index_name = "embedding_vector_idx" try: await collection.create_index( [("embedding", "vector")], name=index_name, vectorOptions={ "type": "hnsw", "similarity": "cosine", "dimensions": 1536, "m": 16, "efConstruction": 128, }, ) logger.info(f"Created vector index '{index_name}' on {collection_name}") except Exception as e: # Debug logging logger.info("DEBUG: Caught exception in vector index creation") logger.info(f"DEBUG: Exception type: {type(e).__name__}") logger.info(f"DEBUG: Exception str: {str(e)}") logger.info(f"DEBUG: Exception repr: {repr(e)}") # Check if index already exists with different options (error code 85) if ( "'code': 85" in str(e) or "code': 85" in str(e) ) or "already exists with different options" in str(e).lower(): if recreate: logger.info("Vector index exists with different options. Recreating...") # List all indexes to see what's there logger.info(f"Listing all indexes on {collection_name}...") indexes = await collection.list_indexes().to_list(None) for idx in indexes: logger.info( f" Found index: name='{idx.get('name')}', key={idx.get('key', {})}" ) # Drop ALL non-_id indexes to ensure clean slate dropped_count = 0 for idx in indexes: idx_name = idx.get("name") if idx_name and idx_name != "_id_": try: await collection.drop_index(idx_name) logger.info(f"Dropped index '{idx_name}' from {collection_name}") dropped_count += 1 except Exception as drop_err: logger.warning(f"Failed to drop index '{idx_name}': {drop_err}") logger.info(f"Dropped {dropped_count} indexes from {collection_name}") # Now try to create again try: await collection.create_index( [("embedding", "vector")], name=index_name, vectorOptions={ "type": "hnsw", "similarity": "cosine", "dimensions": 1536, "m": 16, "efConstruction": 128, }, ) logger.info( f"Created vector index '{index_name}' on {collection_name} after dropping {dropped_count} old indexes" ) except Exception as create_err: logger.error( f"Failed to create vector index after dropping all indexes: {create_err}", exc_info=True, ) raise else: logger.info( f"Vector index already exists on {collection_name} (recreate=False, skipping)" ) # DocumentDB Elastic doesn't support vector indexes (error code 303) elif "vectorOptions" in str(e) or "not supported" in str(e): logger.warning( f"Vector indexes not supported (DocumentDB Elastic limitation). " f"Skipping vector index creation for {collection_name}. " f"Vector search will use fallback implementation." ) else: logger.error(f"Failed to create vector index on {collection_name}: {e}", exc_info=True) raise async def _create_embeddings_indexes( collection, collection_name: str, recreate: bool, ) -> None: """Create all indexes for embeddings collection.""" await _create_vector_index(collection, collection_name, recreate) indexes = [ ("name", 1), ("path", 1), ("entity_type", 1), ] for field, order in indexes: index_name = f"{field}_idx" unique = field == "path" if recreate: try: await collection.drop_index(index_name) logger.info(f"Dropped existing index '{index_name}' from {collection_name}") except Exception as e: logger.debug(f"No existing index '{index_name}' to drop: {e}") try: await collection.create_index( [(field, order)], name=index_name, unique=unique, ) logger.info( f"Created {'unique ' if unique else ''}index '{index_name}' on {collection_name}" ) except Exception as e: logger.error(f"Failed to create index '{index_name}' on {collection_name}: {e}") async def _create_servers_indexes( collection, collection_name: str, recreate: bool, ) -> None: """Create all indexes for servers collection.""" indexes = [ ("server_name", 1, False), ("is_enabled", 1, False), ("version", 1, False), ("tags", 1, False), ] for field, order, unique in indexes: index_name = f"{field}_idx" if recreate: try: await collection.drop_index(index_name) logger.info(f"Dropped existing index '{index_name}' from {collection_name}") except Exception as e: logger.debug(f"No existing index '{index_name}' to drop: {e}") try: await collection.create_index( [(field, order)], name=index_name, unique=unique, ) logger.info( f"Created {'unique ' if unique else ''}index '{index_name}' on {collection_name}" ) except Exception as e: logger.error(f"Failed to create index '{index_name}' on {collection_name}: {e}") async def _create_agents_indexes( collection, collection_name: str, recreate: bool, ) -> None: """Create all indexes for agents collection.""" indexes = [ ("name", 1, False), ("is_enabled", 1, False), ("version", 1, False), ("tags", 1, False), ] for field, order, unique in indexes: index_name = f"{field}_idx" if recreate: try: await collection.drop_index(index_name) logger.info(f"Dropped existing index '{index_name}' from {collection_name}") except Exception as e: logger.debug(f"No existing index '{index_name}' to drop: {e}") try: await collection.create_index( [(field, order)], name=index_name, unique=unique, ) logger.info( f"Created {'unique ' if unique else ''}index '{index_name}' on {collection_name}" ) except Exception as e: logger.error(f"Failed to create index '{index_name}' on {collection_name}: {e}") async def _create_scopes_indexes( collection, collection_name: str, recreate: bool, ) -> None: """Create all indexes for scopes collection.""" indexes = [ ("name", 1, False), ] for field, order, unique in indexes: index_name = f"{field}_idx" if recreate: try: await collection.drop_index(index_name) logger.info(f"Dropped existing index '{index_name}' from {collection_name}") except Exception as e: logger.debug(f"No existing index '{index_name}' to drop: {e}") try: await collection.create_index( [(field, order)], name=index_name, unique=unique, ) logger.info( f"Created {'unique ' if unique else ''}index '{index_name}' on {collection_name}" ) except Exception as e: logger.error(f"Failed to create index '{index_name}' on {collection_name}: {e}") async def _load_default_scopes( db, namespace: str, entra_group_id: str | None = None, ) -> None: """Load default admin scope from JSON file into scopes collection. Args: db: Database connection namespace: Collection namespace entra_group_id: Optional Entra ID Group Object ID to add to group_mappings. Required when using Microsoft Entra ID as the auth provider. """ collection_name = f"{COLLECTION_SCOPES}_{namespace}" collection = db[collection_name] # Find the registry-admins.json file in the same directory as this script script_dir = Path(__file__).parent admin_scope_file = script_dir / "registry-admins.json" if not admin_scope_file.exists(): logger.warning(f"Default admin scope file not found: {admin_scope_file}") return try: with open(admin_scope_file) as f: admin_scope = json.load(f) logger.info(f"Loading default admin scope from {admin_scope_file}") # Add Entra ID Group Object ID if provided if entra_group_id: if entra_group_id not in admin_scope.get("group_mappings", []): admin_scope["group_mappings"].append(entra_group_id) logger.info(f"Added Entra ID Group Object ID: {entra_group_id}") # Upsert the admin scope document result = await collection.update_one( {"_id": admin_scope["_id"]}, {"$set": admin_scope}, upsert=True ) if result.upserted_id: logger.info(f"Inserted admin scope: {admin_scope['_id']}") elif result.modified_count > 0: logger.info(f"Updated admin scope: {admin_scope['_id']}") else: logger.info(f"Admin scope already up-to-date: {admin_scope['_id']}") logger.info(f"Admin scope group_mappings: {admin_scope.get('group_mappings', [])}") except Exception as e: logger.error(f"Failed to load default admin scope: {e}", exc_info=True) async def _create_security_scans_indexes( collection, collection_name: str, recreate: bool, ) -> None: """Create all indexes for security scans collection.""" indexes = [ ("entity_path", 1, False), ("entity_type", 1, False), ("scan_status", 1, False), ("scanned_at", 1, False), ] for field, order, unique in indexes: index_name = f"{field}_idx" if recreate: try: await collection.drop_index(index_name) logger.info(f"Dropped existing index '{index_name}' from {collection_name}") except Exception as e: logger.debug(f"No existing index '{index_name}' to drop: {e}") try: await collection.create_index( [(field, order)], name=index_name, unique=unique, ) logger.info( f"Created {'unique ' if unique else ''}index '{index_name}' on {collection_name}" ) except Exception as e: logger.error(f"Failed to create index '{index_name}' on {collection_name}: {e}") async def _create_federation_config_indexes( collection, collection_name: str, recreate: bool, ) -> None: """Create all indexes for federation config collection.""" # No additional indexes needed - _id is automatically indexed logger.info(f"No additional indexes to create for {collection_name} (_id is auto-indexed)") async def _create_audit_events_indexes( collection, collection_name: str, recreate: bool, ) -> None: """Create all indexes for audit events collection including TTL index. Indexes support: - Query by username + time range - Query by operation + time range - Query by resource type + time range - Composite unique lookup by (request_id, log_type) - TTL-based automatic expiration (default 7 days) """ # Standard query indexes (compound with timestamp for range queries) indexes = [ (("identity.username", 1), ("timestamp", 1)), (("action.operation", 1), ("timestamp", 1)), (("action.resource_type", 1), ("timestamp", 1)), ] # Single-field index for MCP server name distinct/filter queries single_field_indexes = [ ("mcp_server.name", 1), ] for fields in indexes: index_spec = [(f[0], f[1]) for f in fields] index_name = "_".join(f[0].replace(".", "_") for f in fields) + "_idx" if recreate: try: await collection.drop_index(index_name) logger.info(f"Dropped existing index '{index_name}' from {collection_name}") except Exception as e: logger.debug(f"No existing index '{index_name}' to drop: {e}") try: await collection.create_index( index_spec, name=index_name, ) logger.info(f"Created index '{index_name}' on {collection_name}") except Exception as e: logger.error(f"Failed to create index '{index_name}' on {collection_name}: {e}") # Create single-field indexes for distinct/filter queries for field, order in single_field_indexes: index_name = field.replace(".", "_") + "_idx" if recreate: try: await collection.drop_index(index_name) logger.info(f"Dropped existing index '{index_name}' from {collection_name}") except Exception as e: logger.debug(f"No existing index '{index_name}' to drop: {e}") try: await collection.create_index( [(field, order)], name=index_name, ) logger.info(f"Created index '{index_name}' on {collection_name}") except Exception as e: logger.error(f"Failed to create index '{index_name}' on {collection_name}: {e}") # Composite unique index on (request_id, log_type) # Allows both MCPServerAccessRecord and RegistryApiAccessRecord # to coexist for the same request_id while preventing true duplicates composite_index_name = "request_id_log_type_idx" old_index_name = "request_id_idx" # Always try to drop the old single-field index (migration from previous versions) try: await collection.drop_index(old_index_name) logger.info(f"Dropped old single-field index '{old_index_name}' from {collection_name}") except Exception as e: logger.debug(f"No old index '{old_index_name}' to drop: {e}") if recreate: try: await collection.drop_index(composite_index_name) logger.info(f"Dropped existing index '{composite_index_name}' from {collection_name}") except Exception as e: logger.debug(f"No existing index '{composite_index_name}' to drop: {e}") try: await collection.create_index( [("request_id", 1), ("log_type", 1)], name=composite_index_name, unique=True, ) logger.info(f"Created composite unique index '{composite_index_name}' on {collection_name}") except Exception as e: logger.error(f"Failed to create index '{composite_index_name}' on {collection_name}: {e}") # TTL index for automatic expiration # Default 7 days (604800 seconds), configurable via AUDIT_LOG_MONGODB_TTL_DAYS ttl_index_name = "timestamp_ttl" ttl_days = int(os.getenv("AUDIT_LOG_MONGODB_TTL_DAYS", "7")) ttl_seconds = ttl_days * 24 * 60 * 60 if recreate: try: await collection.drop_index(ttl_index_name) logger.info(f"Dropped existing TTL index '{ttl_index_name}' from {collection_name}") except Exception as e: logger.debug(f"No existing TTL index '{ttl_index_name}' to drop: {e}") try: await collection.create_index( [("timestamp", 1)], name=ttl_index_name, expireAfterSeconds=ttl_seconds, ) logger.info( f"Created TTL index '{ttl_index_name}' on {collection_name} " f"(expireAfterSeconds={ttl_seconds}, {ttl_days} days)" ) except Exception as e: logger.error(f"Failed to create TTL index on {collection_name}: {e}") async def _print_collection_summary( db, namespace: str, ) -> None: """Print summary of all collections and their indexes.""" logger.info("=" * 80) logger.info("DOCUMENTDB COLLECTIONS AND INDEXES SUMMARY") logger.info("=" * 80) collection_names = [ f"{COLLECTION_SERVERS}_{namespace}", f"{COLLECTION_AGENTS}_{namespace}", f"{COLLECTION_SCOPES}_{namespace}", f"{COLLECTION_EMBEDDINGS}_{namespace}", f"{COLLECTION_SECURITY_SCANS}_{namespace}", f"{COLLECTION_FEDERATION_CONFIG}_{namespace}", f"{COLLECTION_AUDIT_EVENTS}_{namespace}", ] for coll_name in collection_names: try: collection = db[coll_name] # Get document count count = await collection.count_documents({}) # Get indexes indexes = await collection.list_indexes().to_list(None) logger.info(f"\nCollection: {coll_name}") logger.info(f" Documents: {count}") logger.info(f" Indexes ({len(indexes)}):") for idx in indexes: idx_name = idx.get("name") if "vectorOptions" in idx: vector_opts = idx["vectorOptions"] logger.info( f" - {idx_name} (VECTOR: {vector_opts.get('type')}, " f"dims={vector_opts.get('dimensions')}, " f"similarity={vector_opts.get('similarity')})" ) else: keys = idx.get("key", {}) unique = " UNIQUE" if idx.get("unique", False) else "" logger.info(f" - {idx_name} on {keys}{unique}") except Exception as e: logger.error(f"Error getting info for {coll_name}: {e}") logger.info("=" * 80) async def _initialize_collections( db, namespace: str, recreate: bool, entra_group_id: str | None = None, ) -> None: """Initialize all collections and indexes. Args: db: Database connection namespace: Collection namespace recreate: Whether to recreate existing indexes entra_group_id: Optional Entra ID Group Object ID for admin scope """ collection_configs = [ (COLLECTION_SERVERS, _create_servers_indexes), (COLLECTION_AGENTS, _create_agents_indexes), (COLLECTION_SCOPES, _create_scopes_indexes), (COLLECTION_EMBEDDINGS, _create_embeddings_indexes), (COLLECTION_SECURITY_SCANS, _create_security_scans_indexes), (COLLECTION_FEDERATION_CONFIG, _create_federation_config_indexes), (COLLECTION_AUDIT_EVENTS, _create_audit_events_indexes), ] for base_name, create_indexes_func in collection_configs: collection_name = f"{base_name}_{namespace}" collection = db[collection_name] logger.info(f"Creating indexes for collection: {collection_name}") # Create collection first (DocumentDB Elastic requires explicit collection creation) try: # Check if collection exists existing_collections = await db.list_collection_names() if collection_name not in existing_collections: logger.info(f"Creating collection: {collection_name}") await db.create_collection(collection_name) logger.info(f"Collection {collection_name} created successfully") else: logger.info(f"Collection {collection_name} already exists") except Exception as e: logger.warning(f"Could not create collection {collection_name}: {e}") try: await create_indexes_func(collection, collection_name, recreate) logger.info(f"Successfully created indexes for {collection_name}") except Exception as e: logger.error(f"Failed to create indexes for {collection_name}: {e}", exc_info=True) # Don't raise - continue with other collections continue # Load default admin scope after scopes collection is initialized logger.info("Loading default admin scope...") await _load_default_scopes(db, namespace, entra_group_id) async def main(): """Main initialization function.""" parser = argparse.ArgumentParser( description="Initialize DocumentDB collections and indexes for MCP Gateway Registry", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Example usage: # Using environment variables export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com uv run python scripts/init-documentdb-indexes.py # Using command-line arguments uv run python scripts/init-documentdb-indexes.py --host your-cluster.docdb.amazonaws.com # With IAM authentication uv run python scripts/init-documentdb-indexes.py --use-iam --host your-cluster.docdb.amazonaws.com # With namespace uv run python scripts/init-documentdb-indexes.py --namespace tenant-a """, ) parser.add_argument( "--host", default=os.getenv("DOCUMENTDB_HOST", "localhost"), help="DocumentDB host (default: from DOCUMENTDB_HOST env var or 'localhost')", ) parser.add_argument( "--port", type=int, default=int(os.getenv("DOCUMENTDB_PORT", "27017")), help="DocumentDB port (default: from DOCUMENTDB_PORT env var or 27017)", ) parser.add_argument( "--database", default=os.getenv("DOCUMENTDB_DATABASE", "mcp_registry"), help="Database name (default: from DOCUMENTDB_DATABASE env var or 'mcp_registry')", ) parser.add_argument( "--username", default=os.getenv("DOCUMENTDB_USERNAME"), help="DocumentDB username (default: from DOCUMENTDB_USERNAME env var)", ) parser.add_argument( "--password", default=os.getenv("DOCUMENTDB_PASSWORD"), help="DocumentDB password (default: from DOCUMENTDB_PASSWORD env var)", ) parser.add_argument( "--use-iam", action="store_true", default=os.getenv("DOCUMENTDB_USE_IAM", "false").lower() == "true", help="Use AWS IAM authentication (default: from DOCUMENTDB_USE_IAM env var or false)", ) parser.add_argument( "--use-tls", action="store_true", default=os.getenv("DOCUMENTDB_USE_TLS", "true").lower() == "true", help="Use TLS for connection (default: from DOCUMENTDB_USE_TLS env var or true)", ) parser.add_argument( "--tls-ca-file", default=os.getenv("DOCUMENTDB_TLS_CA_FILE", "global-bundle.pem"), help="TLS CA file path (default: from DOCUMENTDB_TLS_CA_FILE env var or 'global-bundle.pem')", ) parser.add_argument( "--namespace", default=os.getenv("DOCUMENTDB_NAMESPACE", "default"), help="Namespace for collection names (default: from DOCUMENTDB_NAMESPACE env var or 'default')", ) parser.add_argument( "--storage-backend", default=os.getenv("STORAGE_BACKEND", "documentdb"), choices=["documentdb", "mongodb-ce"], help="Storage backend type: 'documentdb' (uses SCRAM-SHA-1) or 'mongodb-ce' (uses SCRAM-SHA-256) (default: from STORAGE_BACKEND env var or 'documentdb')", ) parser.add_argument( "--recreate", action="store_true", default=True, help="Drop and recreate indexes if they exist (default: True)", ) parser.add_argument( "--no-recreate", dest="recreate", action="store_false", help="Do not recreate existing indexes", ) parser.add_argument( "--entra-group-id", default=os.getenv("ENTRA_ADMIN_GROUP_ID"), help=( "Entra ID Group Object ID for the admin group. Required when using " "Microsoft Entra ID as the auth provider. Get this from: Azure Portal -> " "Groups -> [group name] -> Object Id (default: from ENTRA_ADMIN_GROUP_ID env var)" ), ) args = parser.parse_args() logger.info("Initializing DocumentDB collections and indexes") logger.info(f"Host: {args.host}:{args.port}") logger.info(f"Database: {args.database}") logger.info(f"Namespace: {args.namespace}") logger.info(f"Storage backend: {args.storage_backend}") logger.info(f"Recreate indexes: {args.recreate}") logger.info(f"Use IAM: {args.use_iam}") logger.info(f"Use TLS: {args.use_tls}") logger.info(f"Entra Group ID: {args.entra_group_id or ''}") try: connection_string = await _get_documentdb_connection_string( host=args.host, port=args.port, database=args.database, username=args.username, password=args.password, use_iam=args.use_iam, use_tls=args.use_tls, tls_ca_file=args.tls_ca_file if args.use_tls else None, storage_backend=args.storage_backend, ) # IMPORTANT: DocumentDB does not support retryable writes client = AsyncIOMotorClient(connection_string, retryWrites=False) db = client[args.database] server_info = await client.server_info() logger.info(f"Connected to DocumentDB/MongoDB {server_info.get('version', 'unknown')}") await _initialize_collections( db, args.namespace, args.recreate, args.entra_group_id, ) logger.info(f"DocumentDB initialization complete for namespace '{args.namespace}'") # Print summary of collections and indexes await _print_collection_summary(db, args.namespace) client.close() except Exception as e: logger.error(f"Failed to initialize DocumentDB: {e}", exc_info=True) raise if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: scripts/init-documentdb.sh ================================================ #!/bin/bash # Initialize DocumentDB collections and indexes for MCP Gateway Registry # This script downloads the CA bundle (if needed) and runs the Python initialization script set -e # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PARENT_DIR="$(dirname "$SCRIPT_DIR")" # Configuration CA_BUNDLE_URL="https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem" CA_BUNDLE_FILE="${DOCUMENTDB_TLS_CA_FILE:-global-bundle.pem}" CA_BUNDLE_PATH="${PARENT_DIR}/${CA_BUNDLE_FILE}" # Colors for output GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' echo "DocumentDB Initialization Script" echo "=================================" echo "" # Check if DocumentDB host is set if [ -z "$DOCUMENTDB_HOST" ]; then echo "${RED}Error: DOCUMENTDB_HOST environment variable is not set${NC}" echo "" echo "Please set the required environment variables:" echo " export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com" echo " export DOCUMENTDB_USERNAME=admin" echo " export DOCUMENTDB_PASSWORD=yourpassword" echo "" echo "Or use command-line arguments:" echo " $0 --host your-cluster.docdb.amazonaws.com --username admin --password yourpassword" exit 1 fi # Download CA bundle if it doesn't exist and TLS is enabled USE_TLS="${DOCUMENTDB_USE_TLS:-true}" if [ "$USE_TLS" = "true" ] && [ ! -f "$CA_BUNDLE_PATH" ]; then echo "${YELLOW}TLS is enabled but CA bundle not found${NC}" echo "Downloading AWS DocumentDB CA bundle..." echo "Source: ${CA_BUNDLE_URL}" echo "Destination: ${CA_BUNDLE_PATH}" echo "" if command -v wget &> /dev/null; then wget -O "$CA_BUNDLE_PATH" "$CA_BUNDLE_URL" elif command -v curl &> /dev/null; then curl -o "$CA_BUNDLE_PATH" "$CA_BUNDLE_URL" else echo "${RED}Error: Neither wget nor curl is available. Please install one of them.${NC}" exit 1 fi if [ -f "$CA_BUNDLE_PATH" ]; then FILE_SIZE=$(stat -f%z "$CA_BUNDLE_PATH" 2>/dev/null || stat -c%s "$CA_BUNDLE_PATH" 2>/dev/null) if [ "$FILE_SIZE" -gt 0 ]; then echo "${GREEN}Successfully downloaded CA bundle (${FILE_SIZE} bytes)${NC}" echo "" else echo "${RED}Error: Downloaded file is empty${NC}" rm -f "$CA_BUNDLE_PATH" exit 1 fi else echo "${RED}Error: Failed to download CA bundle${NC}" exit 1 fi elif [ "$USE_TLS" = "true" ]; then echo "${GREEN}CA bundle found at: ${CA_BUNDLE_PATH}${NC}" echo "" fi # Set up environment variables for the Python script export DOCUMENTDB_TLS_CA_FILE="$CA_BUNDLE_PATH" echo "Environment Configuration:" echo " DOCUMENTDB_HOST: ${DOCUMENTDB_HOST}" echo " DOCUMENTDB_PORT: ${DOCUMENTDB_PORT:-27017}" echo " DOCUMENTDB_DATABASE: ${DOCUMENTDB_DATABASE:-mcp_registry}" echo " DOCUMENTDB_NAMESPACE: ${DOCUMENTDB_NAMESPACE:-default}" echo " DOCUMENTDB_USE_TLS: ${USE_TLS}" echo " DOCUMENTDB_USE_IAM: ${DOCUMENTDB_USE_IAM:-false}" if [ -n "$DOCUMENTDB_USERNAME" ]; then echo " DOCUMENTDB_USERNAME: ${DOCUMENTDB_USERNAME}" fi echo "" echo "Step 1: Creating collections and indexes..." echo "" # Run the Python initialization script cd "$PARENT_DIR" if command -v uv &> /dev/null; then PYTHON_CMD="uv run python" elif command -v python3 &> /dev/null; then PYTHON_CMD="python3" else echo "${RED}Error: Neither uv nor python3 is available${NC}" exit 1 fi # Create collections and indexes $PYTHON_CMD scripts/init-documentdb-indexes.py "$@" echo "" echo "${GREEN}Collections and indexes created successfully!${NC}" echo "" # Load scopes if scopes.yml exists # Check both auth_server/scopes.yml (repository location) and config/scopes.yml (custom location) SCOPES_FILE="${PARENT_DIR}/auth_server/scopes.yml" if [ ! -f "$SCOPES_FILE" ]; then SCOPES_FILE="${PARENT_DIR}/config/scopes.yml" fi if [ -f "$SCOPES_FILE" ]; then echo "Step 2: Loading scopes from scopes.yml..." echo "" $PYTHON_CMD scripts/load-scopes.py --scopes-file "$SCOPES_FILE" echo "" echo "${GREEN}Scopes loaded successfully!${NC}" else echo "${YELLOW}Note: scopes.yml not found at ${PARENT_DIR}/auth_server/scopes.yml or ${PARENT_DIR}/config/scopes.yml${NC}" echo "${YELLOW}You can load scopes later using: python scripts/load-scopes.py --scopes-file /path/to/scopes.yml${NC}" fi echo "" echo "${GREEN}DocumentDB initialization complete!${NC}" ================================================ FILE: scripts/init-mongodb-ce.py ================================================ #!/usr/bin/env python3 """ Initialize MongoDB CE for local development. This script: 1. Initializes replica set (rs0) 2. Creates collections and indexes 3. Loads default admin scope from registry-admins.json Usage: python init-mongodb-ce.py """ import asyncio import json import logging import os import sys import time from pathlib import Path from motor.motor_asyncio import AsyncIOMotorClient from pymongo import ASCENDING from pymongo.errors import OperationFailure, ServerSelectionTimeoutError # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Collection names COLLECTION_SERVERS = "mcp_servers" COLLECTION_AGENTS = "mcp_agents" COLLECTION_SCOPES = "mcp_scopes" COLLECTION_EMBEDDINGS = "mcp_embeddings_1536" COLLECTION_SECURITY_SCANS = "mcp_security_scans" COLLECTION_FEDERATION_CONFIG = "mcp_federation_config" COLLECTION_AUDIT_EVENTS = "audit_events" COLLECTION_SKILLS = "agent_skills" def _get_config_from_env() -> dict: """Get MongoDB CE configuration from environment variables.""" return { "host": os.getenv("DOCUMENTDB_HOST", "mongodb"), "port": int(os.getenv("DOCUMENTDB_PORT", "27017")), "database": os.getenv("DOCUMENTDB_DATABASE", "mcp_registry"), "namespace": os.getenv("DOCUMENTDB_NAMESPACE", "default"), "username": os.getenv("DOCUMENTDB_USERNAME", ""), "password": os.getenv("DOCUMENTDB_PASSWORD", ""), "replicaset": os.getenv("DOCUMENTDB_REPLICA_SET", "rs0"), } def _initialize_replica_set( host: str, port: int, username: str, password: str, ) -> None: """Initialize MongoDB replica set using pymongo (synchronous).""" from pymongo import MongoClient logger.info("Initializing MongoDB replica set...") try: # Connect without replica set for initialization # Use auth only if username is provided (MongoDB CE runs without auth by default) if username and password: connection_uri = f"mongodb://{username}:{password}@{host}:{port}/?authMechanism=SCRAM-SHA-256&authSource=admin" else: connection_uri = f"mongodb://{host}:{port}/" logger.info("Connecting without authentication (MongoDB CE no-auth mode)") client = MongoClient( connection_uri, serverSelectionTimeoutMS=5000, directConnection=True, ) # Check if already initialized try: status = client.admin.command("replSetGetStatus") logger.info("Replica set already initialized") client.close() return except OperationFailure as e: if "no replset config has been received" in str(e).lower(): # Not initialized, proceed pass else: raise # Initialize replica set config = {"_id": "rs0", "members": [{"_id": 0, "host": f"{host}:{port}"}]} result = client.admin.command("replSetInitiate", config) logger.info(f"Replica set initialized: {result}") client.close() # Wait for replica set to elect primary logger.info("Waiting for replica set to elect primary...") time.sleep(10) except Exception as e: logger.error(f"Error initializing replica set: {e}") raise async def _create_standard_indexes( collection, collection_name: str, namespace: str, ) -> None: """Create standard indexes for collections.""" full_name = f"{collection_name}_{namespace}" if collection_name == COLLECTION_SERVERS: # Note: path is stored as _id, so no separate path index needed await collection.create_index([("enabled", ASCENDING)]) await collection.create_index([("tags", ASCENDING)]) await collection.create_index([("manifest.serverInfo.name", ASCENDING)]) logger.info(f"Created indexes for {full_name}") elif collection_name == COLLECTION_AGENTS: # Note: path is stored as _id, so no separate path index needed await collection.create_index([("enabled", ASCENDING)]) await collection.create_index([("tags", ASCENDING)]) await collection.create_index([("card.name", ASCENDING)]) logger.info(f"Created indexes for {full_name}") elif collection_name == COLLECTION_SCOPES: # No additional indexes needed - scopes use _id as primary key # group_mappings is an array, not indexed logger.info(f"Created indexes for {full_name}") elif collection_name == COLLECTION_EMBEDDINGS: # Note: path is stored as _id, so no separate path index needed await collection.create_index([("entity_type", ASCENDING)]) logger.info(f"Created indexes for {full_name} (vector search via app code)") elif collection_name == COLLECTION_SECURITY_SCANS: await collection.create_index([("server_path", ASCENDING)]) await collection.create_index([("scan_status", ASCENDING)]) await collection.create_index([("scanned_at", ASCENDING)]) logger.info(f"Created indexes for {full_name}") elif collection_name == COLLECTION_FEDERATION_CONFIG: await collection.create_index([("registry_name", ASCENDING)], unique=True) await collection.create_index([("enabled", ASCENDING)]) logger.info(f"Created indexes for {full_name}") elif collection_name == COLLECTION_AUDIT_EVENTS: # Indexes for audit event queries (Requirements 6.2) # Note: timestamp index is created as TTL index below, so we use compound indexes here await collection.create_index([("identity.username", ASCENDING), ("timestamp", ASCENDING)]) await collection.create_index([("action.operation", ASCENDING), ("timestamp", ASCENDING)]) await collection.create_index( [("action.resource_type", ASCENDING), ("timestamp", ASCENDING)] ) # Index for MCP server name distinct/filter queries await collection.create_index([("mcp_server.name", ASCENDING)]) # Migration: drop old single-field request_id index if it exists # Try both auto-generated name and explicit name variants for old_index_name in ("request_id_1", "request_id_idx"): try: await collection.drop_index(old_index_name) logger.info(f"Dropped old single-field index '{old_index_name}' from {full_name}") except Exception: logger.debug(f"No old index '{old_index_name}' to drop from {full_name}") # Composite unique index on (request_id, log_type) # Allows both MCPServerAccessRecord and RegistryApiAccessRecord # to coexist for the same request_id while preventing true duplicates await collection.create_index( [("request_id", ASCENDING), ("log_type", ASCENDING)], name="request_id_log_type_idx", unique=True, ) # TTL index for automatic expiration (Requirements 6.3) # This also serves as the timestamp index for sorting # Default 7 days (604800 seconds), configurable via AUDIT_LOG_MONGODB_TTL_DAYS ttl_days = int(os.getenv("AUDIT_LOG_MONGODB_TTL_DAYS", "7")) ttl_seconds = ttl_days * 24 * 60 * 60 try: await collection.create_index( [("timestamp", ASCENDING)], expireAfterSeconds=ttl_seconds, name="timestamp_ttl" ) except OperationFailure as e: if e.code == 85: # IndexOptionsConflict logger.info(f"TTL index options changed for {full_name}, recreating index...") await collection.drop_index("timestamp_ttl") await collection.create_index( [("timestamp", ASCENDING)], expireAfterSeconds=ttl_seconds, name="timestamp_ttl" ) else: raise logger.info(f"Created indexes for {full_name} (TTL: {ttl_days} days)") elif collection_name == COLLECTION_SKILLS: # Note: path is stored as _id, so no separate path index needed await collection.create_index([("name", ASCENDING)], unique=True) await collection.create_index([("tags", ASCENDING)]) await collection.create_index([("visibility", ASCENDING)]) await collection.create_index([("is_enabled", ASCENDING)]) await collection.create_index([("registry_name", ASCENDING)]) await collection.create_index([("owner", ASCENDING)]) logger.info(f"Created indexes for {full_name}") async def _load_default_scopes( db, namespace: str, ) -> None: """Load default scopes from JSON files into scopes collection. This loads all scope JSON files from the scripts directory: - registry-admins.json: Bootstrap admin scope with full permissions - mcp-registry-admin.json: MCP registry admin scope (Keycloak group) - mcp-servers-unrestricted-read.json: Read-only access to all servers - mcp-servers-unrestricted-execute.json: Full CRUD access to all servers """ collection_name = f"{COLLECTION_SCOPES}_{namespace}" collection = db[collection_name] # Find scope files in the same directory as this script script_dir = Path(__file__).parent # List of scope files to load (order matters - base scopes first) scope_files = [ "registry-admins.json", "mcp-registry-admin.json", "mcp-servers-unrestricted-read.json", "mcp-servers-unrestricted-execute.json", ] loaded_count = 0 for scope_filename in scope_files: scope_file = script_dir / scope_filename if not scope_file.exists(): logger.warning(f"Scope file not found: {scope_file}") continue try: with open(scope_file) as f: scope_data = json.load(f) logger.info(f"Loading scope from {scope_filename}") # For registry-admins scope, add Entra admin group ID from env if configured if scope_data["_id"] == "registry-admins": entra_admin_group_id = os.getenv("ENTRA_GROUP_ADMIN_ID", "").strip() if entra_admin_group_id: group_mappings = scope_data.get("group_mappings", []) if entra_admin_group_id not in group_mappings: group_mappings.append(entra_admin_group_id) scope_data["group_mappings"] = group_mappings logger.info(f" Added Entra admin group ID: {entra_admin_group_id}") # Upsert the scope document result = await collection.update_one( {"_id": scope_data["_id"]}, {"$set": scope_data}, upsert=True ) if result.upserted_id: logger.info(f"Inserted scope: {scope_data['_id']}") loaded_count += 1 elif result.modified_count > 0: logger.info(f"Updated scope: {scope_data['_id']}") loaded_count += 1 else: logger.info(f"Scope already up-to-date: {scope_data['_id']}") if "group_mappings" in scope_data: logger.info(f" group_mappings: {scope_data.get('group_mappings', [])}") except Exception as e: logger.error(f"Failed to load scope from {scope_filename}: {e}", exc_info=True) logger.info(f"Loaded {loaded_count} scopes into {collection_name}") async def _initialize_mongodb_ce() -> None: """Main initialization function.""" config = _get_config_from_env() logger.info("=" * 60) logger.info("MongoDB CE Initialization for MCP Gateway") logger.info("=" * 60) logger.info(f"Host: {config['host']}:{config['port']}") logger.info(f"Database: {config['database']}") logger.info(f"Namespace: {config['namespace']}") logger.info("") # Wait for MongoDB to be ready logger.info("Waiting for MongoDB to be ready...") time.sleep(10) # Initialize replica set (synchronous) _initialize_replica_set(config["host"], config["port"], config["username"], config["password"]) # Connect with motor for async operations # Use auth only if username is provided (MongoDB CE runs without auth by default) if config["username"] and config["password"]: connection_string = f"mongodb://{config['username']}:{config['password']}@{config['host']}:{config['port']}/{config['database']}?replicaSet={config['replicaset']}&authMechanism=SCRAM-SHA-256&authSource=admin" else: connection_string = f"mongodb://{config['host']}:{config['port']}/{config['database']}?replicaSet={config['replicaset']}" logger.info("Using no-auth connection for async client") try: client = AsyncIOMotorClient( connection_string, serverSelectionTimeoutMS=10000, ) # Verify connection await client.admin.command("ping") logger.info("Connected to MongoDB successfully") db = client[config["database"]] namespace = config["namespace"] # Create collections and indexes logger.info("Creating collections and indexes...") collections = [ COLLECTION_SERVERS, COLLECTION_AGENTS, COLLECTION_SCOPES, COLLECTION_EMBEDDINGS, COLLECTION_SECURITY_SCANS, COLLECTION_FEDERATION_CONFIG, COLLECTION_AUDIT_EVENTS, COLLECTION_SKILLS, ] for coll_name in collections: full_name = f"{coll_name}_{namespace}" # Check if collection already exists existing_collections = await db.list_collection_names() if full_name in existing_collections: logger.info(f"Collection {full_name} already exists, skipping creation") else: logger.info(f"Creating collection: {full_name}") await db.create_collection(full_name) # Create indexes (idempotent - MongoDB handles duplicates) collection = db[full_name] await _create_standard_indexes(collection, coll_name, namespace) # Load default admin scope await _load_default_scopes(db, namespace) logger.info("") logger.info("=" * 60) logger.info("MongoDB CE Initialization Complete!") logger.info("=" * 60) logger.info("Collections created:") for coll_name in collections: if coll_name == COLLECTION_EMBEDDINGS: logger.info(f" - {coll_name}_{namespace} (with vector search)") elif coll_name == COLLECTION_AUDIT_EVENTS: ttl_days = int(os.getenv("AUDIT_LOG_MONGODB_TTL_DAYS", "7")) logger.info(f" - {coll_name}_{namespace} (TTL: {ttl_days} days)") else: logger.info(f" - {coll_name}_{namespace}") logger.info("") logger.info("To use MongoDB CE:") logger.info(" export STORAGE_BACKEND=mongodb-ce") logger.info(" docker-compose up registry") logger.info("") logger.info("Or for AWS DocumentDB:") logger.info(" export STORAGE_BACKEND=documentdb") logger.info(" docker-compose up registry") logger.info("=" * 60) client.close() except ServerSelectionTimeoutError as e: logger.error(f"Failed to connect to MongoDB: {e}") logger.error("Make sure MongoDB is running and accessible") sys.exit(1) except Exception as e: logger.error(f"Error during initialization: {e}") raise def main() -> None: """Entry point.""" asyncio.run(_initialize_mongodb_ce()) if __name__ == "__main__": main() ================================================ FILE: scripts/init-mongodb.sh ================================================ #!/bin/bash # Initialize MongoDB replica set and create vector search indexes # For MongoDB Community Edition local development set -e DOCUMENTDB_HOST="${DOCUMENTDB_HOST:-mongodb}" DOCUMENTDB_PORT="${DOCUMENTDB_PORT:-27017}" DOCUMENTDB_USERNAME="${DOCUMENTDB_USERNAME:-admin}" DOCUMENTDB_PASSWORD="${DOCUMENTDB_PASSWORD:-admin}" DOCUMENTDB_DATABASE="${DOCUMENTDB_DATABASE:-mcp_registry}" DOCUMENTDB_NAMESPACE="${DOCUMENTDB_NAMESPACE:-default}" echo "==========================================" echo "MongoDB Initialization for MCP Gateway" echo "==========================================" echo "Host: $DOCUMENTDB_HOST:$DOCUMENTDB_PORT" echo "Database: $DOCUMENTDB_DATABASE" echo "Namespace: $DOCUMENTDB_NAMESPACE" echo "" echo "Waiting for MongoDB to be ready..." sleep 10 echo "Initializing MongoDB replica set..." # Check if authentication is configured if [ -n "$DOCUMENTDB_USERNAME" ] && [ -n "$DOCUMENTDB_PASSWORD" ] && [ "$DOCUMENTDB_USERNAME" != "admin" ] || [ "$DOCUMENTDB_PASSWORD" != "admin" ]; then MONGO_URL="mongodb://$DOCUMENTDB_USERNAME:$DOCUMENTDB_PASSWORD@$DOCUMENTDB_HOST:$DOCUMENTDB_PORT/admin" else MONGO_URL="mongodb://$DOCUMENTDB_HOST:$DOCUMENTDB_PORT" fi mongosh "$MONGO_URL" < str: """Build DocumentDB connection string with appropriate auth mechanism. Args: storage_backend: Either 'documentdb' (uses SCRAM-SHA-1) or 'mongodb-ce' (uses SCRAM-SHA-256) """ if use_iam: import boto3 session = boto3.Session() credentials = session.get_credentials() if not credentials: raise ValueError("AWS credentials not found for DocumentDB IAM auth") connection_string = ( f"mongodb://{credentials.access_key}:{credentials.secret_key}@" f"{host}:{port}/{database}?" f"tls=true&authSource=$external&authMechanism=MONGODB-AWS" ) if tls_ca_file: connection_string += f"&tlsCAFile={tls_ca_file}" logger.info(f"Using AWS IAM authentication for DocumentDB (host: {host})") else: if username and password: # Choose auth mechanism based on storage backend # - MongoDB CE 8.2+: Use SCRAM-SHA-256 (stronger, modern authentication) # - AWS DocumentDB v5.0: Only supports SCRAM-SHA-1 if storage_backend == "mongodb-ce": auth_mechanism = "SCRAM-SHA-256" else: # AWS DocumentDB (storage_backend="documentdb") auth_mechanism = "SCRAM-SHA-1" connection_string = ( f"mongodb://{username}:{password}@" f"{host}:{port}/{database}?" f"authMechanism={auth_mechanism}&authSource=admin&" f"tls={str(use_tls).lower()}" ) if use_tls and tls_ca_file: connection_string += f"&tlsCAFile={tls_ca_file}" logger.info( f"Using username/password authentication ({auth_mechanism}) for " f"{storage_backend} (host: {host})" ) else: connection_string = f"mongodb://{host}:{port}/{database}?tls={str(use_tls).lower()}" if use_tls and tls_ca_file: connection_string += f"&tlsCAFile={tls_ca_file}" logger.info(f"Using no authentication for DocumentDB (host: {host})") return connection_string async def load_scopes_from_yaml( scopes_file: str, db, namespace: str, clear_existing: bool = False, ) -> None: """Load scopes from YAML file into DocumentDB.""" logger.info(f"Loading scopes from {scopes_file}") # Debug: Check if file exists import os logger.info(f"DEBUG: Current working directory: {os.getcwd()}") logger.info(f"DEBUG: File exists check: {os.path.exists(scopes_file)}") logger.info(f"DEBUG: File is absolute path: {os.path.isabs(scopes_file)}") if os.path.exists("/app/auth_server"): logger.info(f"DEBUG: /app/auth_server exists, contents: {os.listdir('/app/auth_server')}") else: logger.info("DEBUG: /app/auth_server does NOT exist") # Read YAML file with open(scopes_file) as f: scopes_data = yaml.safe_load(f) if not scopes_data: logger.error("Scopes file is empty or invalid") return collection_name = f"mcp_scopes_{namespace}" collection = db[collection_name] # Clear existing scopes if requested if clear_existing: logger.info(f"Clearing existing scopes from {collection_name}") result = await collection.delete_many({}) logger.info(f"Deleted {result.deleted_count} existing scope documents") # Extract group mappings and UI scopes group_mappings = scopes_data.get("group_mappings", {}) ui_scopes = scopes_data.get("UI-Scopes", {}) # Process each scope group scope_groups = [] for key, value in scopes_data.items(): # Skip the top-level keys if key in ["group_mappings", "UI-Scopes"]: continue # This is a scope group scope_name = key server_access = value if isinstance(value, list) else [] # Build the scope document scope_doc = { "_id": scope_name, "group_mappings": [], "server_access": server_access, "ui_permissions": {}, } # Add group mappings for this scope for keycloak_group, scope_names in group_mappings.items(): if scope_name in scope_names: scope_doc["group_mappings"].append(keycloak_group) # Add UI permissions for this scope if scope_name in ui_scopes: scope_doc["ui_permissions"] = ui_scopes[scope_name] scope_groups.append(scope_doc) # Insert scopes into DocumentDB if scope_groups: logger.info(f"Inserting {len(scope_groups)} scope groups into {collection_name}") for scope_doc in scope_groups: try: # Use update_one with upsert to avoid duplicate key errors result = await collection.update_one( {"_id": scope_doc["_id"]}, {"$set": scope_doc}, upsert=True ) if result.upserted_id: logger.info(f"Inserted scope: {scope_doc['_id']}") elif result.modified_count > 0: logger.info(f"Updated scope: {scope_doc['_id']}") else: logger.debug(f"No changes for scope: {scope_doc['_id']}") except Exception as e: logger.error(f"Failed to insert scope {scope_doc['_id']}: {e}") logger.info(f"Successfully loaded {len(scope_groups)} scopes") # Print summary logger.info("=" * 80) logger.info("SCOPES SUMMARY") logger.info("=" * 80) logger.info(f"Total scopes loaded: {len(scope_groups)}") logger.info("\nScope groups:") for scope_doc in scope_groups: logger.info(f" - {scope_doc['_id']}") logger.info(f" Keycloak groups: {scope_doc['group_mappings']}") logger.info(f" Server access rules: {len(scope_doc['server_access'])} rules") logger.info(f" UI permissions: {len(scope_doc['ui_permissions'])} permissions") logger.info("=" * 80) else: logger.warning("No scope groups found in YAML file") async def main(): """Main function.""" parser = argparse.ArgumentParser( description="Load scopes from YAML file into DocumentDB", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Example usage: # Using environment variables export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com python load-scopes.py --scopes-file /app/config/scopes.yml # Clear existing scopes before loading python load-scopes.py --scopes-file /app/config/scopes.yml --clear-existing """, ) parser.add_argument( "--scopes-file", required=True, help="Path to scopes YAML file", ) parser.add_argument( "--host", default=os.getenv("DOCUMENTDB_HOST", "localhost"), help="DocumentDB host (default: from DOCUMENTDB_HOST env var or 'localhost')", ) parser.add_argument( "--port", type=int, default=int(os.getenv("DOCUMENTDB_PORT", "27017")), help="DocumentDB port (default: from DOCUMENTDB_PORT env var or 27017)", ) parser.add_argument( "--database", default=os.getenv("DOCUMENTDB_DATABASE", "mcp_registry"), help="Database name (default: from DOCUMENTDB_DATABASE env var or 'mcp_registry')", ) parser.add_argument( "--username", default=os.getenv("DOCUMENTDB_USERNAME"), help="DocumentDB username (default: from DOCUMENTDB_USERNAME env var)", ) parser.add_argument( "--password", default=os.getenv("DOCUMENTDB_PASSWORD"), help="DocumentDB password (default: from DOCUMENTDB_PASSWORD env var)", ) parser.add_argument( "--use-iam", action="store_true", default=os.getenv("DOCUMENTDB_USE_IAM", "false").lower() == "true", help="Use AWS IAM authentication (default: from DOCUMENTDB_USE_IAM env var or false)", ) parser.add_argument( "--use-tls", action="store_true", default=os.getenv("DOCUMENTDB_USE_TLS", "true").lower() == "true", help="Use TLS for connection (default: from DOCUMENTDB_USE_TLS env var or true)", ) parser.add_argument( "--tls-ca-file", default=os.getenv("DOCUMENTDB_TLS_CA_FILE", "global-bundle.pem"), help="TLS CA file path (default: from DOCUMENTDB_TLS_CA_FILE env var or 'global-bundle.pem')", ) parser.add_argument( "--namespace", default=os.getenv("DOCUMENTDB_NAMESPACE", "default"), help="Namespace for collection names (default: from DOCUMENTDB_NAMESPACE env var or 'default')", ) parser.add_argument( "--clear-existing", action="store_true", help="Clear existing scopes before loading new ones", ) args = parser.parse_args() # Get storage backend from environment variable storage_backend = os.getenv("STORAGE_BACKEND", "documentdb") logger.info("Loading scopes into DocumentDB") logger.info(f"Host: {args.host}:{args.port}") logger.info(f"Database: {args.database}") logger.info(f"Namespace: {args.namespace}") logger.info(f"Storage backend: {storage_backend}") logger.info(f"Scopes file: {args.scopes_file}") logger.info(f"Clear existing: {args.clear_existing}") try: connection_string = await _get_documentdb_connection_string( host=args.host, port=args.port, database=args.database, username=args.username, password=args.password, use_iam=args.use_iam, use_tls=args.use_tls, tls_ca_file=args.tls_ca_file if args.use_tls else None, storage_backend=storage_backend, ) # IMPORTANT: DocumentDB does not support retryable writes client = AsyncIOMotorClient(connection_string, retryWrites=False) db = client[args.database] server_info = await client.server_info() logger.info(f"Connected to DocumentDB/MongoDB {server_info.get('version', 'unknown')}") await load_scopes_from_yaml( scopes_file=args.scopes_file, db=db, namespace=args.namespace, clear_existing=args.clear_existing, ) logger.info("Scopes loading complete") client.close() except Exception as e: logger.error(f"Failed to load scopes: {e}", exc_info=True) raise if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: scripts/manage-documentdb.py ================================================ #!/usr/bin/env python3 """ Manage DocumentDB/MongoDB collections and documents. This script is designed to run inside an ECS task or locally with proper network access. Usage: # List all collections python manage-documentdb.py list # Inspect specific collection python manage-documentdb.py inspect --collection mcp_servers_default # Count documents in collection python manage-documentdb.py count --collection mcp_servers_default # Search documents in collection python manage-documentdb.py search --collection mcp_servers_default --limit 5 # Show sample document from collection python manage-documentdb.py sample --collection mcp_servers_default # Query with filter python manage-documentdb.py query --collection mcp_servers_default --filter '{"enabled": true}' # Drop a collection (with confirmation) python manage-documentdb.py drop --collection mcp_scopes_default --confirm """ import argparse import asyncio import json import logging import os import sys from typing import Any from motor.motor_asyncio import AsyncIOMotorClient logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) async def _get_documentdb_connection_string( host: str, port: int, database: str, username: str | None, password: str | None, use_iam: bool, use_tls: bool, tls_ca_file: str | None, storage_backend: str = "documentdb", ) -> str: """Build DocumentDB connection string with appropriate auth mechanism. Args: storage_backend: Either 'documentdb' (uses SCRAM-SHA-1) or 'mongodb-ce' (uses SCRAM-SHA-256) """ if use_iam: import boto3 session = boto3.Session() credentials = session.get_credentials() if not credentials: raise ValueError("AWS credentials not found for DocumentDB IAM auth") connection_string = ( f"mongodb://{credentials.access_key}:{credentials.secret_key}@" f"{host}:{port}/{database}?" f"tls=true&authSource=$external&authMechanism=MONGODB-AWS" ) if tls_ca_file: connection_string += f"&tlsCAFile={tls_ca_file}" logger.info(f"Using AWS IAM authentication for DocumentDB (host: {host})") else: if username and password: # Choose auth mechanism based on storage backend # - MongoDB CE 8.2+: Use SCRAM-SHA-256 (stronger, modern authentication) # - AWS DocumentDB v5.0: Only supports SCRAM-SHA-1 if storage_backend == "mongodb-ce": auth_mechanism = "SCRAM-SHA-256" else: # AWS DocumentDB (storage_backend="documentdb") auth_mechanism = "SCRAM-SHA-1" connection_string = ( f"mongodb://{username}:{password}@" f"{host}:{port}/{database}?" f"authMechanism={auth_mechanism}&authSource=admin&" f"tls={str(use_tls).lower()}" ) if use_tls and tls_ca_file: connection_string += f"&tlsCAFile={tls_ca_file}" logger.info( f"Using username/password authentication ({auth_mechanism}) for " f"{storage_backend} (host: {host})" ) else: connection_string = f"mongodb://{host}:{port}/{database}?tls={str(use_tls).lower()}" if use_tls and tls_ca_file: connection_string += f"&tlsCAFile={tls_ca_file}" logger.info(f"Using no authentication for DocumentDB (host: {host})") return connection_string async def _get_client( host: str, port: int, database: str, username: str | None, password: str | None, use_iam: bool, use_tls: bool, tls_ca_file: str | None, ) -> AsyncIOMotorClient: """Create DocumentDB async client.""" # Get storage backend from environment variable storage_backend = os.getenv("STORAGE_BACKEND", "documentdb") connection_string = await _get_documentdb_connection_string( host=host, port=port, database=database, username=username, password=password, use_iam=use_iam, use_tls=use_tls, tls_ca_file=tls_ca_file, storage_backend=storage_backend, ) # DocumentDB does not support retryable writes client = AsyncIOMotorClient(connection_string, retryWrites=False) return client async def list_collections( host: str, port: int, database: str, username: str | None, password: str | None, use_iam: bool, use_tls: bool, tls_ca_file: str | None, ) -> int: """List all collections in the DocumentDB database.""" try: client = await _get_client( host, port, database, username, password, use_iam, use_tls, tls_ca_file ) db = client[database] # Verify connection server_info = await client.server_info() logger.info(f"Connected to DocumentDB/MongoDB {server_info.get('version', 'unknown')}") # Get all collection names collection_names = await db.list_collection_names() if not collection_names: logger.info(f"No collections found in database '{database}'") client.close() return 0 # Sort by name collection_names.sort() print("\n" + "=" * 100) print(f"Found {len(collection_names)} collections in database '{database}'") print("=" * 100) # Get document counts for each collection for coll_name in collection_names: collection = db[coll_name] doc_count = await collection.count_documents({}) print(f"\nCollection: {coll_name}") print(f" Documents: {doc_count}") # Get estimated size (if available) try: stats = await db.command("collStats", coll_name) size_bytes = stats.get("size", 0) size_mb = size_bytes / (1024 * 1024) print(f" Size: {size_mb:.2f} MB") except Exception as e: logger.warning(f"Could not retrieve collection stats for {coll_name}: {e}") print("\n" + "=" * 100) client.close() return 0 except Exception as e: logger.error(f"Failed to list collections: {e}", exc_info=True) return 1 async def inspect_collection( host: str, port: int, database: str, collection_name: str, username: str | None, password: str | None, use_iam: bool, use_tls: bool, tls_ca_file: str | None, ) -> int: """Inspect a specific collection (schema and stats).""" try: client = await _get_client( host, port, database, username, password, use_iam, use_tls, tls_ca_file ) db = client[database] collection = db[collection_name] # Check if collection exists collection_names = await db.list_collection_names() if collection_name not in collection_names: logger.error(f"Collection '{collection_name}' does not exist") client.close() return 1 # Get document count doc_count = await collection.count_documents({}) print("\n" + "=" * 100) print(f"Collection: {collection_name}") print("=" * 100) print(f"\nDocument Count: {doc_count}") # Get collection stats try: stats = await db.command("collStats", collection_name) print("\n--- Collection Statistics ---") print(f"Size: {stats.get('size', 0) / (1024 * 1024):.2f} MB") print(f"Storage Size: {stats.get('storageSize', 0) / (1024 * 1024):.2f} MB") print(f"Total Index Size: {stats.get('totalIndexSize', 0) / (1024 * 1024):.2f} MB") print(f"Average Object Size: {stats.get('avgObjSize', 0)} bytes") except Exception as e: logger.warning(f"Could not get collection stats: {e}") # Get indexes try: indexes = await collection.list_indexes().to_list(length=None) print("\n--- Indexes ---") for idx in indexes: print(f"\nIndex: {idx.get('name', 'unknown')}") print(f" Keys: {json.dumps(idx.get('key', {}), indent=4)}") if idx.get("unique"): print(" Unique: True") except Exception as e: logger.warning(f"Could not get indexes: {e}") # Get sample document to infer schema try: sample_doc = await collection.find_one({}) if sample_doc: print("\n--- Sample Document Schema ---") print(json.dumps(_get_schema(sample_doc), indent=2)) except Exception as e: logger.warning(f"Could not get sample document: {e}") print("\n" + "=" * 100) client.close() return 0 except Exception as e: logger.error(f"Failed to inspect collection: {e}", exc_info=True) return 1 async def count_documents( host: str, port: int, database: str, collection_name: str, username: str | None, password: str | None, use_iam: bool, use_tls: bool, tls_ca_file: str | None, ) -> int: """Count documents in a collection.""" try: client = await _get_client( host, port, database, username, password, use_iam, use_tls, tls_ca_file ) db = client[database] collection = db[collection_name] # Get document count doc_count = await collection.count_documents({}) print("\n" + "=" * 100) print(f"Collection: {collection_name}") print(f"Document Count: {doc_count}") print("=" * 100) client.close() return 0 except Exception as e: logger.error(f"Failed to count documents: {e}", exc_info=True) return 1 async def search_documents( host: str, port: int, database: str, collection_name: str, limit: int, username: str | None, password: str | None, use_iam: bool, use_tls: bool, tls_ca_file: str | None, ) -> int: """Search/list documents in a collection.""" try: client = await _get_client( host, port, database, username, password, use_iam, use_tls, tls_ca_file ) db = client[database] collection = db[collection_name] # Get documents cursor = collection.find({}).limit(limit) documents = await cursor.to_list(length=limit) print("\n" + "=" * 100) print(f"Collection: {collection_name}") print(f"Showing {len(documents)} documents (limit: {limit})") print("=" * 100) for i, doc in enumerate(documents, 1): print(f"\n--- Document {i} ---") print(json.dumps(doc, indent=2, default=str)) print("\n" + "=" * 100) client.close() return 0 except Exception as e: logger.error(f"Failed to search documents: {e}", exc_info=True) return 1 async def sample_document( host: str, port: int, database: str, collection_name: str, username: str | None, password: str | None, use_iam: bool, use_tls: bool, tls_ca_file: str | None, ) -> int: """Show a sample document from a collection.""" try: client = await _get_client( host, port, database, username, password, use_iam, use_tls, tls_ca_file ) db = client[database] collection = db[collection_name] # Get one sample document sample_doc = await collection.find_one({}) print("\n" + "=" * 100) print(f"Collection: {collection_name}") print("Sample Document:") print("=" * 100) if sample_doc: print(json.dumps(sample_doc, indent=2, default=str)) else: print("No documents found in collection") print("\n" + "=" * 100) client.close() return 0 except Exception as e: logger.error(f"Failed to get sample document: {e}", exc_info=True) return 1 async def query_documents( host: str, port: int, database: str, collection_name: str, filter_json: str, limit: int, username: str | None, password: str | None, use_iam: bool, use_tls: bool, tls_ca_file: str | None, ) -> int: """Query documents with a filter.""" try: # Parse filter JSON filter_dict = json.loads(filter_json) client = await _get_client( host, port, database, username, password, use_iam, use_tls, tls_ca_file ) db = client[database] collection = db[collection_name] # Get documents matching filter cursor = collection.find(filter_dict).limit(limit) documents = await cursor.to_list(length=limit) print("\n" + "=" * 100) print(f"Collection: {collection_name}") print(f"Filter: {filter_json}") print(f"Found {len(documents)} documents (limit: {limit})") print("=" * 100) for i, doc in enumerate(documents, 1): print(f"\n--- Document {i} ---") print(json.dumps(doc, indent=2, default=str)) print("\n" + "=" * 100) client.close() return 0 except json.JSONDecodeError as e: logger.error(f"Invalid JSON filter: {e}") return 1 except Exception as e: logger.error(f"Failed to query documents: {e}", exc_info=True) return 1 async def drop_collection( host: str, port: int, database: str, collection_name: str, confirm: bool, username: str | None, password: str | None, use_iam: bool, use_tls: bool, tls_ca_file: str | None, ) -> int: """Drop a collection from the database.""" if not confirm: logger.error( "Drop operation requires --confirm flag. " "This will permanently delete all documents in the collection." ) return 1 try: client = await _get_client( host, port, database, username, password, use_iam, use_tls, tls_ca_file ) db = client[database] # Check if collection exists collection_names = await db.list_collection_names() if collection_name not in collection_names: logger.error(f"Collection '{collection_name}' does not exist") client.close() return 1 # Get document count before dropping collection = db[collection_name] doc_count = await collection.count_documents({}) print("\n" + "=" * 100) print(f"Dropping collection: {collection_name}") print(f"Documents to be deleted: {doc_count}") print("=" * 100) # Drop the collection await db.drop_collection(collection_name) logger.info(f"Successfully dropped collection '{collection_name}'") print(f"\nCollection '{collection_name}' has been dropped.") print("=" * 100) client.close() return 0 except Exception as e: logger.error(f"Failed to drop collection: {e}", exc_info=True) return 1 def _get_schema(doc: dict[str, Any], prefix: str = "") -> dict[str, str]: """Infer schema from a document.""" schema = {} for key, value in doc.items(): full_key = f"{prefix}.{key}" if prefix else key if isinstance(value, dict): schema.update(_get_schema(value, full_key)) elif isinstance(value, list): if value and isinstance(value[0], dict): schema[full_key] = "array[object]" else: schema[full_key] = f"array[{type(value[0]).__name__ if value else 'unknown'}]" else: schema[full_key] = type(value).__name__ return schema async def main(): """Main function.""" parser = argparse.ArgumentParser( description="Manage DocumentDB/MongoDB collections", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # List all collections python manage-documentdb.py list # Inspect a collection python manage-documentdb.py inspect --collection mcp_servers_default # Count documents python manage-documentdb.py count --collection mcp_servers_default # Search documents python manage-documentdb.py search --collection mcp_servers_default --limit 5 # Sample document python manage-documentdb.py sample --collection mcp_servers_default # Query with filter python manage-documentdb.py query --collection mcp_servers_default --filter '{"enabled": true}' """, ) subparsers = parser.add_subparsers(dest="command", help="Command to execute") # List command subparsers.add_parser("list", help="List all collections") # Inspect command inspect_parser = subparsers.add_parser("inspect", help="Inspect a collection") inspect_parser.add_argument("--collection", required=True, help="Collection name") # Count command count_parser = subparsers.add_parser("count", help="Count documents in collection") count_parser.add_argument("--collection", required=True, help="Collection name") # Search command search_parser = subparsers.add_parser("search", help="Search documents") search_parser.add_argument("--collection", required=True, help="Collection name") search_parser.add_argument( "--limit", type=int, default=10, help="Number of documents to return" ) # Sample command sample_parser = subparsers.add_parser("sample", help="Show sample document") sample_parser.add_argument("--collection", required=True, help="Collection name") # Query command query_parser = subparsers.add_parser("query", help="Query with filter") query_parser.add_argument("--collection", required=True, help="Collection name") query_parser.add_argument("--filter", required=True, help="MongoDB filter as JSON") query_parser.add_argument("--limit", type=int, default=10, help="Number of documents to return") # Drop command drop_parser = subparsers.add_parser("drop", help="Drop a collection") drop_parser.add_argument("--collection", required=True, help="Collection name to drop") drop_parser.add_argument( "--confirm", action="store_true", help="Confirm the drop operation (required)", ) # Common arguments parser.add_argument( "--host", default=os.getenv("DOCUMENTDB_HOST", "localhost"), help="DocumentDB host", ) parser.add_argument( "--port", type=int, default=int(os.getenv("DOCUMENTDB_PORT", "27017")), help="DocumentDB port", ) parser.add_argument( "--database", default=os.getenv("DOCUMENTDB_DATABASE", "mcp_registry"), help="Database name", ) parser.add_argument( "--username", default=os.getenv("DOCUMENTDB_USERNAME"), help="DocumentDB username", ) parser.add_argument( "--password", default=os.getenv("DOCUMENTDB_PASSWORD"), help="DocumentDB password", ) parser.add_argument( "--use-iam", action="store_true", default=os.getenv("DOCUMENTDB_USE_IAM", "false").lower() == "true", help="Use AWS IAM authentication", ) parser.add_argument( "--use-tls", action="store_true", default=os.getenv("DOCUMENTDB_USE_TLS", "true").lower() == "true", help="Use TLS for connection", ) parser.add_argument( "--tls-ca-file", default=os.getenv("DOCUMENTDB_TLS_CA_FILE", "/app/certs/global-bundle.pem"), help="TLS CA file path", ) args = parser.parse_args() if not args.command: parser.print_help() return 1 logger.info(f"Executing command: {args.command}") logger.info(f"Host: {args.host}:{args.port}") logger.info(f"Database: {args.database}") try: if args.command == "list": exit_code = await list_collections( args.host, args.port, args.database, args.username, args.password, args.use_iam, args.use_tls, args.tls_ca_file, ) elif args.command == "inspect": exit_code = await inspect_collection( args.host, args.port, args.database, args.collection, args.username, args.password, args.use_iam, args.use_tls, args.tls_ca_file, ) elif args.command == "count": exit_code = await count_documents( args.host, args.port, args.database, args.collection, args.username, args.password, args.use_iam, args.use_tls, args.tls_ca_file, ) elif args.command == "search": exit_code = await search_documents( args.host, args.port, args.database, args.collection, args.limit, args.username, args.password, args.use_iam, args.use_tls, args.tls_ca_file, ) elif args.command == "sample": exit_code = await sample_document( args.host, args.port, args.database, args.collection, args.username, args.password, args.use_iam, args.use_tls, args.tls_ca_file, ) elif args.command == "query": exit_code = await query_documents( args.host, args.port, args.database, args.collection, args.filter, args.limit, args.username, args.password, args.use_iam, args.use_tls, args.tls_ca_file, ) elif args.command == "drop": exit_code = await drop_collection( args.host, args.port, args.database, args.collection, args.confirm, args.username, args.password, args.use_iam, args.use_tls, args.tls_ca_file, ) else: logger.error(f"Unknown command: {args.command}") exit_code = 1 return exit_code except Exception as e: logger.error(f"Command failed: {e}", exc_info=True) return 1 if __name__ == "__main__": sys.exit(asyncio.run(main())) ================================================ FILE: scripts/mcp-registry-admin.json ================================================ { "_id": "mcp-registry-admin", "group_mappings": ["mcp-registry-admin", "mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute"], "server_access": [ { "server": "*", "methods": ["all"], "tools": ["all"] }, { "server": "api", "methods": ["tokens", "GET", "POST"] } ], "ui_permissions": { "list_agents": ["all"], "get_agent": ["all"], "publish_agent": ["all"], "modify_agent": ["all"], "delete_agent": ["all"], "list_service": ["all"], "register_service": ["all"], "health_check_service": ["all"], "toggle_service": ["all"], "modify_service": ["all"], "delete_service": ["all"], "list_virtual_server": ["all"], "create_virtual_server": ["all"], "modify_virtual_server": ["all"], "delete_virtual_server": ["all"] } } ================================================ FILE: scripts/mcp-servers-unrestricted-execute.json ================================================ { "_id": "mcp-servers-unrestricted/execute", "group_mappings": [], "server_access": [ { "server": "*", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list", "GET", "POST", "PUT", "DELETE"], "tools": "*" }, { "server": "api", "methods": ["tokens", "GET", "POST"] } ] } ================================================ FILE: scripts/mcp-servers-unrestricted-read.json ================================================ { "_id": "mcp-servers-unrestricted/read", "group_mappings": [], "server_access": [ { "server": "*", "methods": ["initialize", "notifications/initialized", "ping", "tools/list", "tools/call", "resources/list", "resources/templates/list", "GET"], "tools": "*" }, { "server": "api", "methods": ["tokens", "GET"] } ] } ================================================ FILE: scripts/migrate-file-to-mongodb.py ================================================ #!/usr/bin/env python3 """ Migrate file-based storage to MongoDB. This script reads server and agent JSON files from the file-based storage and imports them into MongoDB. Usage: # Run migration from host machine (connects to localhost:27017) python scripts/migrate-file-to-mongodb.py --servers-dir ~/mcp-gateway/servers --agents-dir ~/mcp-gateway/agents # Run with custom host/port python scripts/migrate-file-to-mongodb.py --host localhost --port 27017 # Dry run to see what would be migrated python scripts/migrate-file-to-mongodb.py --dry-run """ import argparse import asyncio import json import logging import os import sys from datetime import UTC, datetime from pathlib import Path from typing import Any from motor.motor_asyncio import AsyncIOMotorClient # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def _get_config_from_env( host_override: str | None = None, port_override: int | None = None, ) -> dict: """Get MongoDB configuration from environment variables or overrides. Args: host_override: Override host (ignores DOCUMENTDB_HOST env var) port_override: Override port (ignores DOCUMENTDB_PORT env var) """ return { "host": host_override or os.getenv("DOCUMENTDB_HOST", "localhost"), "port": port_override or int(os.getenv("DOCUMENTDB_PORT", "27017")), "database": os.getenv("DOCUMENTDB_DATABASE", "mcp_registry"), "namespace": os.getenv("DOCUMENTDB_NAMESPACE", "default"), "username": os.getenv("DOCUMENTDB_USERNAME", ""), "password": os.getenv("DOCUMENTDB_PASSWORD", ""), "replicaset": os.getenv("DOCUMENTDB_REPLICA_SET", "rs0"), } async def _get_mongodb_client( config: dict, direct_connection: bool = True, ) -> AsyncIOMotorClient: """Create MongoDB async client. Args: config: MongoDB connection configuration direct_connection: Use directConnection=true for single-node replica sets """ if config["username"] and config["password"]: connection_string = ( f"mongodb://{config['username']}:{config['password']}@" f"{config['host']}:{config['port']}/{config['database']}?" f"authMechanism=SCRAM-SHA-256&authSource=admin" ) else: connection_string = f"mongodb://{config['host']}:{config['port']}/{config['database']}" logger.info("Using no-auth connection for MongoDB") # Add directConnection for single-node replica set if direct_connection: separator = "&" if "?" in connection_string else "?" connection_string += f"{separator}directConnection=true" logger.info("Using directConnection=true for single-node MongoDB") client = AsyncIOMotorClient( connection_string, serverSelectionTimeoutMS=10000, ) # Verify connection await client.admin.command("ping") logger.info(f"Connected to MongoDB at {config['host']}:{config['port']}") return client def _load_server_json(filepath: Path) -> dict[str, Any] | None: """Load and transform a server JSON file.""" try: with open(filepath) as f: data = json.load(f) # Skip non-server files if "server_name" not in data and "path" not in data: logger.debug(f"Skipping {filepath.name} - not a server config") return None # Ensure path is set if "path" not in data: # Extract path from filename (e.g., currenttime.json -> /currenttime) stem = filepath.stem if stem.endswith("_"): stem = stem[:-1] data["path"] = f"/{stem}" # Normalize path path = data["path"] if not path.startswith("/"): path = f"/{path}" if path.endswith("/"): path = path[:-1] data["path"] = path # Add default fields if missing now = datetime.now(UTC).isoformat() data.setdefault("is_enabled", True) data.setdefault("registered_at", now) data.setdefault("updated_at", now) logger.info(f"Loaded server: {data.get('server_name', 'unknown')} at {data['path']}") return data except json.JSONDecodeError as e: logger.error(f"Invalid JSON in {filepath}: {e}") return None except Exception as e: logger.error(f"Error loading {filepath}: {e}") return None def _load_agent_json(filepath: Path) -> dict[str, Any] | None: """Load and transform an agent JSON file.""" try: with open(filepath) as f: data = json.load(f) # Check for agent card structure if "card" in data: # Agent with card wrapper card = data.get("card", {}) agent_data = { "card": card, "path": data.get("path") or f"/agents/{card.get('name', filepath.stem)}", "is_enabled": data.get("is_enabled", True), "registered_at": data.get("registered_at", datetime.now(UTC).isoformat()), "updated_at": data.get("updated_at", datetime.now(UTC).isoformat()), } elif "name" in data: # Flat agent structure agent_name = data.get("name", filepath.stem) agent_data = { "card": data, "path": f"/agents/{agent_name}", "is_enabled": data.get("is_enabled", True), "registered_at": datetime.now(UTC).isoformat(), "updated_at": datetime.now(UTC).isoformat(), } else: logger.debug(f"Skipping {filepath.name} - not an agent config") return None # Normalize path path = agent_data["path"] if not path.startswith("/"): path = f"/{path}" agent_data["path"] = path logger.info( f"Loaded agent: {agent_data.get('card', {}).get('name', 'unknown')} at {agent_data['path']}" ) return agent_data except json.JSONDecodeError as e: logger.error(f"Invalid JSON in {filepath}: {e}") return None except Exception as e: logger.error(f"Error loading {filepath}: {e}") return None async def _migrate_servers( db, servers_dir: Path, namespace: str, dry_run: bool = False, ) -> int: """Migrate servers from file storage to MongoDB.""" collection_name = f"mcp_servers_{namespace}" collection = db[collection_name] # Find all JSON files (exclude non-server files) exclude_files = {"server_state.json", "service_index_metadata.json"} json_files = [ f for f in servers_dir.glob("*.json") if f.name not in exclude_files and not f.name.endswith(".faiss") ] if not json_files: logger.warning(f"No server JSON files found in {servers_dir}") return 0 logger.info(f"Found {len(json_files)} potential server files") imported = 0 skipped = 0 for filepath in json_files: server_data = _load_server_json(filepath) if not server_data: skipped += 1 continue path = server_data["path"] if dry_run: logger.info( f"[DRY RUN] Would import server: {server_data.get('server_name')} at {path}" ) imported += 1 continue # Check if server already exists existing = await collection.find_one({"_id": path}) if existing: logger.info(f"Server already exists at {path}, updating...") # Update existing document doc = {**server_data} doc.pop("path", None) doc["updated_at"] = datetime.now(UTC).isoformat() await collection.update_one({"_id": path}, {"$set": doc}) else: # Create new document doc = {**server_data} doc["_id"] = doc.pop("path") await collection.insert_one(doc) imported += 1 logger.info(f"Servers: imported={imported}, skipped={skipped}") return imported async def _migrate_agents( db, agents_dir: Path, namespace: str, dry_run: bool = False, ) -> int: """Migrate agents from file storage to MongoDB.""" collection_name = f"mcp_agents_{namespace}" collection = db[collection_name] # Find all JSON files json_files = list(agents_dir.glob("*.json")) if not json_files: logger.warning(f"No agent JSON files found in {agents_dir}") return 0 logger.info(f"Found {len(json_files)} potential agent files") imported = 0 skipped = 0 for filepath in json_files: agent_data = _load_agent_json(filepath) if not agent_data: skipped += 1 continue path = agent_data["path"] if dry_run: logger.info(f"[DRY RUN] Would import agent at {path}") imported += 1 continue # Check if agent already exists existing = await collection.find_one({"_id": path}) if existing: logger.info(f"Agent already exists at {path}, updating...") doc = {**agent_data} doc.pop("path", None) doc["updated_at"] = datetime.now(UTC).isoformat() await collection.update_one({"_id": path}, {"$set": doc}) else: # Create new document doc = {**agent_data} doc["_id"] = doc.pop("path") await collection.insert_one(doc) imported += 1 logger.info(f"Agents: imported={imported}, skipped={skipped}") return imported async def main(): """Main migration function.""" parser = argparse.ArgumentParser( description="Migrate file-based storage to MongoDB", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--servers-dir", type=Path, default=Path.home() / "mcp-gateway" / "servers", help="Directory containing server JSON files", ) parser.add_argument( "--agents-dir", type=Path, default=Path.home() / "mcp-gateway" / "agents", help="Directory containing agent JSON files", ) parser.add_argument( "--dry-run", action="store_true", help="Show what would be migrated without making changes", ) parser.add_argument( "--servers-only", action="store_true", help="Only migrate servers", ) parser.add_argument( "--agents-only", action="store_true", help="Only migrate agents", ) parser.add_argument( "--host", type=str, default="localhost", help="MongoDB host (default: localhost, overrides DOCUMENTDB_HOST env var)", ) parser.add_argument( "--port", type=int, default=27017, help="MongoDB port (default: 27017, overrides DOCUMENTDB_PORT env var)", ) args = parser.parse_args() config = _get_config_from_env( host_override=args.host, port_override=args.port, ) logger.info("=" * 60) logger.info("File to MongoDB Migration") logger.info("=" * 60) logger.info(f"MongoDB: {config['host']}:{config['port']}/{config['database']}") logger.info(f"Namespace: {config['namespace']}") logger.info(f"Servers dir: {args.servers_dir}") logger.info(f"Agents dir: {args.agents_dir}") logger.info(f"Dry run: {args.dry_run}") logger.info("") try: client = await _get_mongodb_client(config) db = client[config["database"]] total_imported = 0 if not args.agents_only: if args.servers_dir.exists(): count = await _migrate_servers( db, args.servers_dir, config["namespace"], args.dry_run ) total_imported += count else: logger.warning(f"Servers directory not found: {args.servers_dir}") if not args.servers_only: if args.agents_dir.exists(): count = await _migrate_agents( db, args.agents_dir, config["namespace"], args.dry_run ) total_imported += count else: logger.warning(f"Agents directory not found: {args.agents_dir}") logger.info("") logger.info("=" * 60) if args.dry_run: logger.info(f"DRY RUN complete. Would import {total_imported} items.") else: logger.info(f"Migration complete. Imported {total_imported} items.") logger.info("=" * 60) client.close() return 0 except Exception as e: logger.error(f"Migration failed: {e}", exc_info=True) return 1 if __name__ == "__main__": sys.exit(asyncio.run(main())) ================================================ FILE: scripts/migrate-servers-add-is-active.py ================================================ #!/usr/bin/env python3 """ Migration script to add is_active field to existing servers. This script ensures all existing servers have the is_active field set to True, which is required for the server version routing feature. Existing servers without this field are treated as active (default behavior). Usage: # Dry run (default) - show what would be updated uv run python scripts/migrate-servers-add-is-active.py # Actually apply changes uv run python scripts/migrate-servers-add-is-active.py --apply # With specific DocumentDB settings uv run python scripts/migrate-servers-add-is-active.py --host your-cluster.docdb.amazonaws.com # Using file-based storage uv run python scripts/migrate-servers-add-is-active.py --storage file --servers-dir /path/to/servers Requires: - motor (AsyncIOMotorClient) for DocumentDB - boto3 (for IAM authentication if using DocumentDB) """ import argparse import asyncio import json import logging import os from pathlib import Path from typing import ( Any, ) # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Constants SERVERS_COLLECTION = "servers" def _parse_args() -> argparse.Namespace: """Parse command-line arguments.""" parser = argparse.ArgumentParser( description="Migrate servers to add is_active field", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Dry run (default) - show what would be updated uv run python scripts/migrate-servers-add-is-active.py # Actually apply changes uv run python scripts/migrate-servers-add-is-active.py --apply # With DocumentDB uv run python scripts/migrate-servers-add-is-active.py --host your-cluster.docdb.amazonaws.com # Using file-based storage uv run python scripts/migrate-servers-add-is-active.py --storage file --servers-dir ./data/servers """, ) parser.add_argument( "--apply", action="store_true", help="Actually apply changes (default is dry run)" ) parser.add_argument( "--storage", type=str, choices=["documentdb", "mongodb-ce", "file"], default=os.getenv("MCP_STORAGE_BACKEND", "documentdb"), help="Storage backend type (default: from MCP_STORAGE_BACKEND env or documentdb)", ) parser.add_argument( "--host", type=str, default=os.getenv("DOCUMENTDB_HOST"), help="DocumentDB/MongoDB host" ) parser.add_argument( "--port", type=int, default=int(os.getenv("DOCUMENTDB_PORT", "27017")), help="DocumentDB/MongoDB port (default: 27017)", ) parser.add_argument( "--database", type=str, default=os.getenv("DOCUMENTDB_DATABASE", "mcp_registry"), help="Database name (default: mcp_registry)", ) parser.add_argument( "--namespace", type=str, default=os.getenv("DOCUMENTDB_NAMESPACE"), help="Namespace prefix for collections", ) parser.add_argument( "--servers-dir", type=str, default=os.getenv("MCP_SERVERS_DIR"), help="Directory for server JSON files (file storage)", ) parser.add_argument( "--use-iam", action="store_true", help="Use IAM authentication for DocumentDB" ) return parser.parse_args() async def _migrate_documentdb(args: argparse.Namespace, dry_run: bool) -> dict[str, Any]: """ Migrate servers in DocumentDB to add is_active field. Args: args: Parsed command-line arguments dry_run: If True, only report what would be done Returns: Migration summary """ try: from motor.motor_asyncio import AsyncIOMotorClient except ImportError: logger.error("motor package required for DocumentDB migration") logger.error("Install with: uv add motor") return {"error": "motor not installed"} # Build connection string host = args.host port = args.port database = args.database if not host: logger.error("DocumentDB host required. Set via --host or DOCUMENTDB_HOST env var") return {"error": "host required"} if args.use_iam: try: import boto3 session = boto3.Session() credentials = session.get_credentials() token = session.client("rds").generate_db_auth_token( DBHostname=host, Port=port, DBUsername="admin", Region=session.region_name ) connection_string = f"mongodb://admin:{token}@{host}:{port}/?authMechanism=MONGODB-AWS&authSource=$external&tls=true&tlsCAFile=global-bundle.pem" except Exception as e: logger.error(f"Failed to get IAM credentials: {e}") return {"error": str(e)} else: username = os.getenv("DOCUMENTDB_USERNAME") password = os.getenv("DOCUMENTDB_PASSWORD") if username and password: connection_string = f"mongodb://{username}:{password}@{host}:{port}/" else: connection_string = f"mongodb://{host}:{port}/" # Handle MongoDB CE with directConnection if args.storage == "mongodb-ce": connection_string += "?directConnection=true" logger.info(f"Connecting to {args.storage} at {host}:{port}") client = AsyncIOMotorClient(connection_string) db = client[database] # Get collection name with namespace collection_name = SERVERS_COLLECTION if args.namespace: collection_name = f"{args.namespace}_{SERVERS_COLLECTION}" collection = db[collection_name] # Find servers without is_active field query = {"is_active": {"$exists": False}} servers_to_update: list[dict[str, Any]] = [] async for server in collection.find(query): servers_to_update.append( {"_id": server["_id"], "server_name": server.get("server_name", "unknown")} ) logger.info(f"Found {len(servers_to_update)} servers without is_active field") if dry_run: logger.info("DRY RUN - No changes will be made") for server in servers_to_update: logger.info(f" Would update: {server['_id']} ({server['server_name']})") else: if servers_to_update: result = await collection.update_many(query, {"$set": {"is_active": True}}) logger.info(f"Updated {result.modified_count} servers with is_active=True") else: logger.info("No servers need updating") client.close() return { "storage": args.storage, "servers_found": len(servers_to_update), "servers_updated": 0 if dry_run else len(servers_to_update), "dry_run": dry_run, } async def _migrate_file_storage(args: argparse.Namespace, dry_run: bool) -> dict[str, Any]: """ Migrate servers in file storage to add is_active field. Args: args: Parsed command-line arguments dry_run: If True, only report what would be done Returns: Migration summary """ servers_dir = args.servers_dir if not servers_dir: servers_dir = os.getenv("MCP_SERVERS_DIR", "./data/servers") servers_path = Path(servers_dir) if not servers_path.exists(): logger.error(f"Servers directory not found: {servers_path}") return {"error": f"directory not found: {servers_path}"} logger.info(f"Scanning servers directory: {servers_path}") servers_to_update: list[dict[str, Any]] = [] updated_count = 0 for json_file in servers_path.glob("*.json"): if json_file.name == "_state.json": continue try: with open(json_file) as f: server_data = json.load(f) # Check if is_active field is missing if "is_active" not in server_data: servers_to_update.append( { "file": str(json_file), "server_name": server_data.get("server_name", "unknown"), "path": server_data.get("path", "unknown"), } ) if not dry_run: server_data["is_active"] = True with open(json_file, "w") as f: json.dump(server_data, f, indent=2) updated_count += 1 except json.JSONDecodeError as e: logger.warning(f"Skipping invalid JSON file {json_file}: {e}") except Exception as e: logger.error(f"Error processing {json_file}: {e}") logger.info(f"Found {len(servers_to_update)} servers without is_active field") if dry_run: logger.info("DRY RUN - No changes will be made") for server in servers_to_update: logger.info(f" Would update: {server['file']} ({server['server_name']})") else: logger.info(f"Updated {updated_count} server files with is_active=True") return { "storage": "file", "servers_found": len(servers_to_update), "servers_updated": updated_count, "dry_run": dry_run, } async def main() -> None: """Main entry point for the migration script.""" args = _parse_args() dry_run = not args.apply logger.info("=" * 60) logger.info("Server Migration: Add is_active Field") logger.info("=" * 60) logger.info(f"Storage backend: {args.storage}") logger.info(f"Mode: {'DRY RUN' if dry_run else 'APPLY CHANGES'}") logger.info("=" * 60) if args.storage in ["documentdb", "mongodb-ce"]: result = await _migrate_documentdb(args, dry_run) elif args.storage == "file": result = await _migrate_file_storage(args, dry_run) else: logger.error(f"Unknown storage backend: {args.storage}") result = {"error": f"unknown storage: {args.storage}"} logger.info("=" * 60) logger.info("Migration Summary:") logger.info(f" Storage: {result.get('storage', 'unknown')}") logger.info(f" Servers found: {result.get('servers_found', 0)}") logger.info(f" Servers updated: {result.get('servers_updated', 0)}") if result.get("dry_run"): logger.info(" Note: This was a dry run. Use --apply to make changes.") logger.info("=" * 60) if __name__ == "__main__": asyncio.run(main()) ================================================ FILE: scripts/mongodb-entrypoint.sh ================================================ #!/bin/bash # MongoDB entrypoint that ensures keyfile has correct permissions # MongoDB requires keyfile to be owned by mongodb user with 400 permissions set -e # Copy keyfile to a location where we can change ownership if [ -f /data/mongodb-keyfile ]; then cp /data/mongodb-keyfile /tmp/mongodb-keyfile chown mongodb:mongodb /tmp/mongodb-keyfile chmod 400 /tmp/mongodb-keyfile else echo "ERROR: Keyfile not found at /data/mongodb-keyfile" exit 1 fi # Run the standard MongoDB docker-entrypoint script with keyfile # This ensures MONGO_INITDB_ROOT_USERNAME/PASSWORD are processed correctly exec docker-entrypoint.sh mongod --replSet rs0 --bind_ip_all --keyFile /tmp/mongodb-keyfile "$@" ================================================ FILE: scripts/opensearch-schemas/hybrid-search-pipeline.json ================================================ { "description": "Pipeline for hybrid search combining BM25 and k-NN scores", "phase_results_processors": [ { "normalization-processor": { "normalization": { "technique": "min_max" }, "combination": { "technique": "arithmetic_mean", "parameters": { "weights": [0.4, 0.6] } } } } ] } ================================================ FILE: scripts/opensearch-schemas/mcp-agents.json ================================================ { "settings": { "number_of_shards": 1, "number_of_replicas": 1 }, "mappings": { "properties": { "protocol_version": {"type": "keyword"}, "name": { "type": "text", "fields": {"keyword": {"type": "keyword"}} }, "description": {"type": "text"}, "path": {"type": "keyword"}, "url": {"type": "keyword"}, "version": {"type": "keyword"}, "skills": { "type": "nested", "properties": { "id": {"type": "keyword"}, "name": {"type": "text"}, "description": {"type": "text"}, "tags": {"type": "keyword"} } }, "tags": {"type": "keyword"}, "is_enabled": {"type": "boolean"}, "visibility": {"type": "keyword"}, "trust_level": {"type": "keyword"}, "registered_at": {"type": "date"}, "updated_at": {"type": "date"} } } } ================================================ FILE: scripts/opensearch-schemas/mcp-embeddings-serverless.json ================================================ { "settings": { "index": { "knn": true, "knn.algo_param.ef_search": 100 } }, "mappings": { "properties": { "entity_type": {"type": "keyword"}, "path": {"type": "keyword"}, "name": { "type": "text", "fields": {"keyword": {"type": "keyword"}} }, "description": {"type": "text"}, "tags": {"type": "keyword"}, "is_enabled": {"type": "boolean"}, "text_for_embedding": {"type": "text"}, "embedding": { "type": "knn_vector", "dimension": 1536, "method": { "name": "hnsw", "space_type": "cosinesimil", "parameters": { "ef_construction": 128, "m": 16 } } }, "embedding_metadata": { "type": "object", "properties": { "provider": {"type": "keyword"}, "model": {"type": "keyword"}, "model_family": {"type": "keyword"}, "dimensions": {"type": "integer"}, "version": {"type": "keyword"}, "created_at": {"type": "date"}, "api_version": {"type": "keyword"}, "cost_per_1k_tokens": {"type": "float"}, "indexing_strategy": {"type": "keyword"} } }, "tools": { "type": "nested", "properties": { "name": {"type": "keyword"}, "description": {"type": "text"} } }, "skills": { "type": "nested", "properties": { "id": {"type": "keyword"}, "name": {"type": "text"}, "description": {"type": "text"} } }, "metadata": {"type": "object", "enabled": false}, "indexed_at": {"type": "date"} } } } ================================================ FILE: scripts/opensearch-schemas/mcp-embeddings.json ================================================ { "settings": { "index": { "knn": true, "knn.algo_param.ef_search": 100 }, "number_of_shards": 1, "number_of_replicas": 1 }, "mappings": { "properties": { "entity_type": {"type": "keyword"}, "path": {"type": "keyword"}, "name": { "type": "text", "fields": {"keyword": {"type": "keyword"}} }, "description": {"type": "text"}, "tags": {"type": "keyword"}, "is_enabled": {"type": "boolean"}, "text_for_embedding": {"type": "text"}, "embedding": { "type": "knn_vector", "dimension": 384, "method": { "name": "hnsw", "space_type": "cosinesimil", "engine": "lucene", "parameters": { "ef_construction": 128, "m": 16 } } }, "tools": { "type": "nested", "properties": { "name": {"type": "keyword"}, "description": {"type": "text"} } }, "skills": { "type": "nested", "properties": { "id": {"type": "keyword"}, "name": {"type": "text"}, "description": {"type": "text"} } }, "metadata": {"type": "object", "enabled": false}, "indexed_at": {"type": "date"} } } } ================================================ FILE: scripts/opensearch-schemas/mcp-scopes.json ================================================ { "settings": { "number_of_shards": 1, "number_of_replicas": 1 }, "mappings": { "properties": { "scope_type": {"type": "keyword"}, "scope_name": {"type": "keyword"}, "group_name": {"type": "keyword"}, "ui_permissions": {"type": "object", "enabled": false}, "server_access": { "type": "nested", "properties": { "server": {"type": "keyword"}, "methods": {"type": "keyword"}, "tools": {"type": "keyword"} } }, "group_mappings": {"type": "keyword"}, "updated_at": {"type": "date"} } } } ================================================ FILE: scripts/opensearch-schemas/mcp-security-scans.json ================================================ { "settings": { "number_of_shards": 1, "number_of_replicas": 1 }, "mappings": { "properties": { "server_path": { "type": "keyword" }, "scan_timestamp": { "type": "date" }, "scan_status": { "type": "keyword" }, "vulnerabilities": { "type": "nested", "properties": { "severity": { "type": "keyword" }, "title": { "type": "text" }, "description": { "type": "text" }, "cve_id": { "type": "keyword" }, "package_name": { "type": "keyword" }, "package_version": { "type": "keyword" }, "fixed_version": { "type": "keyword" } } }, "risk_score": { "type": "float" }, "scan_metadata": { "type": "object", "enabled": false }, "total_vulnerabilities": { "type": "integer" }, "critical_count": { "type": "integer" }, "high_count": { "type": "integer" }, "medium_count": { "type": "integer" }, "low_count": { "type": "integer" } } } } ================================================ FILE: scripts/opensearch-schemas/mcp-servers.json ================================================ { "settings": { "number_of_shards": 1, "number_of_replicas": 1 }, "mappings": { "properties": { "server_name": { "type": "text", "fields": {"keyword": {"type": "keyword"}} }, "description": {"type": "text"}, "path": {"type": "keyword"}, "proxy_pass_url": {"type": "keyword"}, "supported_transports": {"type": "keyword"}, "auth_type": {"type": "keyword"}, "tags": {"type": "keyword"}, "num_tools": {"type": "integer"}, "license": {"type": "keyword"}, "tool_list": { "type": "nested", "properties": { "name": {"type": "keyword"}, "description": {"type": "text"}, "parsed_description": {"type": "object", "enabled": false}, "schema": {"type": "object", "enabled": false} } }, "is_enabled": {"type": "boolean"}, "registered_at": {"type": "date"}, "updated_at": {"type": "date"} } } } ================================================ FILE: scripts/publish_containers.sh ================================================ #!/bin/bash # Build and publish container images to Docker Hub and GitHub Container Registry # Based on issue #122: Publish Pre-built Container Images for Fast Deployment set -e # Color codes for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" # Load environment variables from .env if it exists if [ -f "$PROJECT_ROOT/.env" ]; then source "$PROJECT_ROOT/.env" fi # Configuration DOCKERHUB_ORG="${DOCKERHUB_ORG:-}" GITHUB_ORG="${GITHUB_ORG:-}" GITHUB_REGISTRY="ghcr.io" # Version management VERSION="${VERSION:-latest}" BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") COMMIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') # Platforms to build for PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}" # Components to build declare -a COMPONENTS=( "registry:.:./docker/Dockerfile.registry" "auth-server:.:./docker/Dockerfile.auth" "currenttime-server:.:./docker/Dockerfile.mcp-server-light" "realserverfaketools-server:.:./docker/Dockerfile.mcp-server-light" "fininfo-server:.:./docker/Dockerfile.mcp-server-light" "mcpgw-server:.:./docker/Dockerfile.mcp-server" "metrics-service:metrics-service:./metrics-service/Dockerfile" ) # External images to mirror (pull from source and push to our registries) declare -a EXTERNAL_IMAGES=( "postgres:postgres:16-alpine" "prometheus:prom/prometheus:latest" "grafana:grafana/grafana:latest" "keycloak:quay.io/keycloak/keycloak:25.0" "alpine:alpine:latest" "mongo:mongo:8.2" ) # Map component names to actual server directory paths declare -A SERVER_PATH_MAP=( ["currenttime-server"]="servers/currenttime" ["realserverfaketools-server"]="servers/realserverfaketools" ["fininfo-server"]="servers/fininfo" ["mcpgw-server"]="servers/mcpgw" ) # Function to print colored output print_color() { local color=$1 shift echo -e "${color}$@${NC}" } # Function to print section headers print_header() { echo "" print_color "$BLUE" "==========================================" print_color "$BLUE" "$1" print_color "$BLUE" "==========================================" echo "" } # Function to check if Docker is available check_docker() { if ! docker --version &> /dev/null; then print_color "$RED" "❌ Docker is not available. Please install Docker." exit 1 fi } # Function to setup Docker for building (no buildx needed) setup_docker() { print_color "$GREEN" "✅ Using standard Docker build (no buildx required)" print_color "$YELLOW" "⚠️ Note: Building for current platform only (not multi-platform)" } # Function to login to Docker Hub login_dockerhub() { if [ -z "$DOCKERHUB_USERNAME" ] || [ -z "$DOCKERHUB_TOKEN" ]; then print_color "$YELLOW" "⚠️ Docker Hub credentials not found in environment variables." print_color "$YELLOW" " Please set DOCKERHUB_USERNAME and DOCKERHUB_TOKEN" print_color "$YELLOW" " Attempting to use existing Docker login..." # Check if already logged in if ! docker pull "$DOCKERHUB_ORG/registry:latest" &> /dev/null; then print_color "$RED" "❌ Not logged in to Docker Hub. Please login first:" print_color "$YELLOW" " docker login" return 1 fi else print_color "$GREEN" "✅ Logging in to Docker Hub..." echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin fi } # Function to login to GitHub Container Registry login_ghcr() { if [ -z "$GITHUB_TOKEN" ]; then print_color "$YELLOW" "⚠️ GITHUB_TOKEN not found in environment variables." print_color "$YELLOW" " Skipping GitHub Container Registry push." return 1 else print_color "$GREEN" "✅ Logging in to GitHub Container Registry..." echo "$GITHUB_TOKEN" | docker login "$GITHUB_REGISTRY" -u "$GITHUB_USERNAME" --password-stdin fi } # Function to generate tags for an image generate_tags() { local base_name=$1 local registry=$2 local tags="" # Always include latest tag tags="$tags --tag $registry/$base_name:latest" # Add version tag if not "latest" if [ "$VERSION" != "latest" ]; then tags="$tags --tag $registry/$base_name:$VERSION" fi # Add branch tag if not main/master if [ "$BRANCH_NAME" != "main" ] && [ "$BRANCH_NAME" != "master" ] && [ "$BRANCH_NAME" != "unknown" ]; then # Sanitize branch name for Docker tag sanitized_branch=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9._-]/-/g') tags="$tags --tag $registry/$base_name:$sanitized_branch" fi # Add commit SHA tag if [ "$COMMIT_SHA" != "unknown" ]; then tags="$tags --tag $registry/$base_name:sha-$COMMIT_SHA" fi echo "$tags" } # Function to build and push a single component build_and_push_component() { local component_info=$1 local push_dockerhub=$2 local push_ghcr=$3 IFS=':' read -r name context dockerfile <<< "$component_info" print_color "$BLUE" "📦 Building $name..." print_color "$YELLOW" " Context: $context" print_color "$YELLOW" " Dockerfile: $dockerfile" # Check if Dockerfile exists if [ ! -f "$PROJECT_ROOT/$dockerfile" ]; then print_color "$RED" "❌ Dockerfile not found: $PROJECT_ROOT/$dockerfile" return 1 fi # Generate all tags local all_tags="" if [ "$push_dockerhub" = true ]; then # Use organization if set, otherwise use username for personal account if [ -n "$DOCKERHUB_ORG" ]; then dockerhub_base="$DOCKERHUB_ORG/$name" else dockerhub_base="$DOCKERHUB_USERNAME/$name" fi dockerhub_tags=$(generate_tags "$dockerhub_base" "docker.io") all_tags="$all_tags $dockerhub_tags" fi if [ "$push_ghcr" = true ]; then # Use organization if set, otherwise use username for personal account if [ -n "$GITHUB_ORG" ]; then ghcr_base="$GITHUB_ORG/mcp-$name" else ghcr_base="$GITHUB_USERNAME/mcp-$name" fi ghcr_tags=$(generate_tags "$ghcr_base" "$GITHUB_REGISTRY") all_tags="$all_tags $ghcr_tags" fi # Build and push with buildx print_color "$GREEN" "✅ Building for platforms: $PLATFORMS" local push_flag="" if [ "$push_dockerhub" = true ] || [ "$push_ghcr" = true ]; then push_flag="--push" fi cd "$PROJECT_ROOT" # Build the image first print_color "$GREEN" "✅ Building image..." # Add SERVER_DIR build arg for MCP servers (when building from repo root) local build_args="" if [[ "$dockerfile" == *"Dockerfile.mcp-server"* ]]; then # Use the mapped server path if available, otherwise fallback to the component name local server_path="${SERVER_PATH_MAP[$name]:-servers/$name}" build_args="--build-arg SERVER_DIR=$server_path" print_color "$YELLOW" " Adding build arg: SERVER_DIR=$server_path" fi docker build \ --file "$dockerfile" \ $build_args \ --label "org.opencontainers.image.created=$BUILD_DATE" \ --label "org.opencontainers.image.source=https://github.com/agentic-community/mcp-gateway-registry" \ --label "org.opencontainers.image.version=$VERSION" \ --label "org.opencontainers.image.revision=$COMMIT_SHA" \ --label "org.opencontainers.image.title=MCP Gateway $name" \ --label "org.opencontainers.image.description=MCP Gateway Registry - $name component" \ --label "org.opencontainers.image.vendor=Agentic Community" \ --tag "local/$name:$VERSION" \ "$context" if [ $? -ne 0 ]; then print_color "$RED" "❌ Failed to build $name" return 1 fi # Tag and push images if needed if [ "$push_dockerhub" = true ] || [ "$push_ghcr" = true ]; then print_color "$GREEN" "✅ Tagging and pushing images..." # Parse all tags and push them # Convert the tag string to an array eval "tag_array=($all_tags)" i=0 while [ $i -lt ${#tag_array[@]} ]; do if [ "${tag_array[$i]}" = "--tag" ]; then # Next element is the tag value i=$((i + 1)) if [ $i -lt ${#tag_array[@]} ]; then tag_value="${tag_array[$i]}" print_color "$YELLOW" " Tagging: $tag_value" docker tag "local/$name:$VERSION" "$tag_value" if [ "$push_dockerhub" = true ] || [ "$push_ghcr" = true ]; then print_color "$YELLOW" " Pushing: $tag_value" docker push "$tag_value" fi fi fi i=$((i + 1)) done fi if [ $? -eq 0 ]; then print_color "$GREEN" "✅ Successfully built and pushed $name" else print_color "$RED" "❌ Failed to build and push $name" return 1 fi } # Function to mirror external images mirror_external_image() { local image_info=$1 local push_dockerhub=$2 local push_ghcr=$3 IFS=':' read -r name source_image <<< "$image_info" print_color "$BLUE" "🔄 Mirroring $name from $source_image..." # Pull the source image print_color "$YELLOW" " Pulling: $source_image" if ! docker pull "$source_image"; then print_color "$RED" "❌ Failed to pull $source_image" return 1 fi # Tag and push to registries if [ "$push_dockerhub" = true ]; then if [ -n "$DOCKERHUB_ORG" ]; then dockerhub_target="$DOCKERHUB_ORG/$name:latest" else dockerhub_target="$DOCKERHUB_USERNAME/$name:latest" fi print_color "$YELLOW" " Tagging: $dockerhub_target" docker tag "$source_image" "$dockerhub_target" print_color "$YELLOW" " Pushing: $dockerhub_target" if ! docker push "$dockerhub_target"; then print_color "$RED" "❌ Failed to push to Docker Hub" return 1 fi # Also tag with version if not latest if [ "$VERSION" != "latest" ]; then if [ -n "$DOCKERHUB_ORG" ]; then dockerhub_version_target="$DOCKERHUB_ORG/$name:$VERSION" else dockerhub_version_target="$DOCKERHUB_USERNAME/$name:$VERSION" fi docker tag "$source_image" "$dockerhub_version_target" docker push "$dockerhub_version_target" fi fi if [ "$push_ghcr" = true ]; then if [ -n "$GITHUB_ORG" ]; then ghcr_target="$GITHUB_REGISTRY/$GITHUB_ORG/mcp-$name:latest" else ghcr_target="$GITHUB_REGISTRY/$GITHUB_USERNAME/mcp-$name:latest" fi print_color "$YELLOW" " Tagging: $ghcr_target" docker tag "$source_image" "$ghcr_target" print_color "$YELLOW" " Pushing: $ghcr_target" if ! docker push "$ghcr_target"; then print_color "$RED" "❌ Failed to push to GHCR" return 1 fi # Also tag with version if not latest if [ "$VERSION" != "latest" ]; then if [ -n "$GITHUB_ORG" ]; then ghcr_version_target="$GITHUB_REGISTRY/$GITHUB_ORG/mcp-$name:$VERSION" else ghcr_version_target="$GITHUB_REGISTRY/$GITHUB_USERNAME/mcp-$name:$VERSION" fi docker tag "$source_image" "$ghcr_version_target" docker push "$ghcr_version_target" fi fi print_color "$GREEN" "✅ Successfully mirrored $name" return 0 } # Function to display usage usage() { cat << EOF Usage: $0 [OPTIONS] Build and publish MCP Gateway Registry container images to Docker Hub and GitHub Container Registry. OPTIONS: -d, --dockerhub Push to Docker Hub (requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN) -g, --ghcr Push to GitHub Container Registry (requires GITHUB_TOKEN) -v, --version Version tag (default: latest) -p, --platforms Platforms to build for (note: only current platform supported without buildx) -c, --component Build specific component only (registry, auth-server, nginx-proxy, currenttime-server, realserverfaketools, metrics-service) -s, --skip-mirror Skip mirroring external images (by default, external images ARE mirrored) -l, --local Build locally without pushing (for testing) -h, --help Display this help message ENVIRONMENT VARIABLES: DOCKERHUB_USERNAME Docker Hub username DOCKERHUB_TOKEN Docker Hub access token GITHUB_USERNAME GitHub username (defaults to current git user) GITHUB_TOKEN GitHub personal access token with write:packages permission DOCKERHUB_ORG Docker Hub organization (default: mcpgateway) GITHUB_ORG GitHub organization (default: agentic-community) VERSION Version tag (default: latest) PLATFORMS Build platforms (note: only current platform supported without buildx) EXAMPLES: # Build and push everything to both registries (includes external images by default) $0 --dockerhub --ghcr --version v1.0.0 # Build and push to Docker Hub only (includes external images) $0 --dockerhub # Build specific component only (skips external images) $0 --dockerhub --component registry # Build and push WITHOUT mirroring external images $0 --dockerhub --skip-mirror # Build locally for testing (no push) $0 --local # Build with custom platforms $0 --dockerhub --platforms linux/amd64 EOF } # Parse command line arguments PUSH_DOCKERHUB=false PUSH_GHCR=false BUILD_LOCAL=false MIRROR_EXTERNAL=true # Default to TRUE - mirror by default SPECIFIC_COMPONENT="" while [[ $# -gt 0 ]]; do case $1 in -d|--dockerhub) PUSH_DOCKERHUB=true shift ;; -g|--ghcr) PUSH_GHCR=true shift ;; -v|--version) VERSION="$2" shift 2 ;; -p|--platforms) PLATFORMS="$2" shift 2 ;; -c|--component) SPECIFIC_COMPONENT="$2" shift 2 ;; -s|--skip-mirror) MIRROR_EXTERNAL=false shift ;; -l|--local) BUILD_LOCAL=true shift ;; -h|--help) usage exit 0 ;; *) print_color "$RED" "Unknown option: $1" usage exit 1 ;; esac done # Main execution print_header "MCP Gateway Registry Container Publisher" print_color "$BLUE" "Configuration:" print_color "$YELLOW" " Version: $VERSION" print_color "$YELLOW" " Branch: $BRANCH_NAME" print_color "$YELLOW" " Commit: $COMMIT_SHA" print_color "$YELLOW" " Platforms: $PLATFORMS" print_color "$YELLOW" " Docker Hub Org: $DOCKERHUB_ORG" print_color "$YELLOW" " GitHub Org: $GITHUB_ORG" echo "" # Check if any action is specified if [ "$PUSH_DOCKERHUB" = false ] && [ "$PUSH_GHCR" = false ] && [ "$BUILD_LOCAL" = false ]; then print_color "$RED" "❌ No action specified. Use --dockerhub, --ghcr, or --local" usage exit 1 fi # Setup Docker print_header "Setting up Docker" check_docker setup_docker # Login to registries if needed if [ "$PUSH_DOCKERHUB" = true ]; then print_header "Docker Hub Authentication" if ! login_dockerhub; then print_color "$RED" "❌ Failed to login to Docker Hub" exit 1 fi fi if [ "$PUSH_GHCR" = true ]; then print_header "GitHub Container Registry Authentication" # Get GitHub username if not set if [ -z "$GITHUB_USERNAME" ]; then GITHUB_USERNAME=$(git config --get user.name 2>/dev/null || echo "") if [ -z "$GITHUB_USERNAME" ]; then print_color "$RED" "❌ GITHUB_USERNAME not set and couldn't determine from git config" exit 1 fi fi if ! login_ghcr; then print_color "$YELLOW" "⚠️ Skipping GitHub Container Registry" PUSH_GHCR=false fi fi # Build and push components print_header "Building and Publishing Container Images" # Track success/failure declare -a failed_components=() declare -a successful_components=() # Build components for component_info in "${COMPONENTS[@]}"; do component_name=$(echo "$component_info" | cut -d':' -f1) # Skip if specific component is requested and this isn't it if [ -n "$SPECIFIC_COMPONENT" ] && [ "$component_name" != "$SPECIFIC_COMPONENT" ]; then continue fi print_color "$BLUE" "Building $component_name..." if build_and_push_component "$component_info" "$PUSH_DOCKERHUB" "$PUSH_GHCR"; then successful_components+=("$component_name") else failed_components+=("$component_name") fi echo "" done # Mirror external images if requested (skip if building specific component) if [ "$MIRROR_EXTERNAL" = true ] && [ -z "$SPECIFIC_COMPONENT" ]; then print_header "Mirroring External Container Images" for image_info in "${EXTERNAL_IMAGES[@]}"; do image_name=$(echo "$image_info" | cut -d':' -f1) print_color "$BLUE" "Mirroring $image_name..." if mirror_external_image "$image_info" "$PUSH_DOCKERHUB" "$PUSH_GHCR"; then successful_components+=("$image_name (mirrored)") else failed_components+=("$image_name (mirrored)") fi echo "" done fi # Summary print_header "Build Summary" if [ ${#successful_components[@]} -gt 0 ]; then print_color "$GREEN" "✅ Successfully built and pushed:" for component in "${successful_components[@]}"; do print_color "$GREEN" " - $component" done fi if [ ${#failed_components[@]} -gt 0 ]; then print_color "$RED" "❌ Failed to build:" for component in "${failed_components[@]}"; do print_color "$RED" " - $component" done exit 1 fi print_color "$GREEN" "" print_color "$GREEN" "🎉 All components built and pushed successfully!" if [ "$PUSH_DOCKERHUB" = true ]; then print_color "$BLUE" "" print_color "$BLUE" "Docker Hub images:" for component_info in "${COMPONENTS[@]}"; do component_name=$(echo "$component_info" | cut -d':' -f1) if [ -n "$SPECIFIC_COMPONENT" ] && [ "$component_name" != "$SPECIFIC_COMPONENT" ]; then continue fi if [ -n "$DOCKERHUB_ORG" ]; then print_color "$YELLOW" " docker pull $DOCKERHUB_ORG/$component_name:$VERSION" else print_color "$YELLOW" " docker pull $DOCKERHUB_USERNAME/$component_name:$VERSION" fi done # Show mirrored external images if [ "$MIRROR_EXTERNAL" = true ]; then print_color "$BLUE" "" print_color "$BLUE" "Mirrored External Images:" for image_info in "${EXTERNAL_IMAGES[@]}"; do image_name=$(echo "$image_info" | cut -d':' -f1) if [ -n "$DOCKERHUB_ORG" ]; then print_color "$YELLOW" " docker pull $DOCKERHUB_ORG/$image_name:latest" else print_color "$YELLOW" " docker pull $DOCKERHUB_USERNAME/$image_name:latest" fi done fi fi if [ "$PUSH_GHCR" = true ]; then print_color "$BLUE" "" print_color "$BLUE" "GitHub Container Registry images:" for component_info in "${COMPONENTS[@]}"; do component_name=$(echo "$component_info" | cut -d':' -f1) if [ -n "$SPECIFIC_COMPONENT" ] && [ "$component_name" != "$SPECIFIC_COMPONENT" ]; then continue fi if [ -n "$GITHUB_ORG" ]; then print_color "$YELLOW" " docker pull $GITHUB_REGISTRY/$GITHUB_ORG/mcp-$component_name:$VERSION" else print_color "$YELLOW" " docker pull $GITHUB_REGISTRY/$GITHUB_USERNAME/mcp-$component_name:$VERSION" fi done # Show mirrored external images if [ "$MIRROR_EXTERNAL" = true ]; then print_color "$BLUE" "" print_color "$BLUE" "Mirrored External Images:" for image_info in "${EXTERNAL_IMAGES[@]}"; do image_name=$(echo "$image_info" | cut -d':' -f1) if [ -n "$GITHUB_ORG" ]; then print_color "$YELLOW" " docker pull $GITHUB_REGISTRY/$GITHUB_ORG/mcp-$image_name:latest" else print_color "$YELLOW" " docker pull $GITHUB_REGISTRY/$GITHUB_USERNAME/mcp-$image_name:latest" fi done fi fi ================================================ FILE: scripts/refresh_m2m_token.sh ================================================ #!/bin/bash # Script to refresh any M2M (machine-to-machine) token # Usage: ./scripts/refresh_m2m_token.sh # Example: ./scripts/refresh_m2m_token.sh bot-008 set -e # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" OAUTH_DIR="$PROJECT_ROOT/.oauth-tokens" # Check if client name provided if [ -z "$1" ]; then echo "Error: Client name required" echo "" echo "Usage: $0 " echo "" echo "Example: $0 bot-008" echo "" echo "Available clients:" find "$OAUTH_DIR" -name "*.json" -type f ! -name "*-token.json" ! -name "*-m2m-token.json" -exec basename {} .json \; | sort exit 1 fi CLIENT_NAME="$1" CLIENT_FILE="$OAUTH_DIR/${CLIENT_NAME}.json" TOKEN_FILE="$OAUTH_DIR/${CLIENT_NAME}-token.json" # Check if client file exists if [ ! -f "$CLIENT_FILE" ]; then echo "Error: Client file not found: $CLIENT_FILE" echo "" echo "Available clients:" find "$OAUTH_DIR" -name "*.json" -type f ! -name "*-token.json" ! -name "*-m2m-token.json" -exec basename {} .json \; | sort exit 1 fi # Extract client credentials CLIENT_ID=$(jq -r '.client_id' "$CLIENT_FILE") CLIENT_SECRET=$(jq -r '.client_secret' "$CLIENT_FILE") if [ -z "$CLIENT_ID" ] || [ "$CLIENT_ID" = "null" ]; then echo "Error: Invalid client_id in $CLIENT_FILE" exit 1 fi if [ -z "$CLIENT_SECRET" ] || [ "$CLIENT_SECRET" = "null" ]; then echo "Error: Invalid client_secret in $CLIENT_FILE" exit 1 fi # Keycloak configuration KEYCLOAK_URL="${KEYCLOAK_URL:-http://localhost:8080}" REALM="${REALM:-mcp-gateway}" TOKEN_ENDPOINT="${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token" echo "Refreshing token for client: $CLIENT_NAME" echo "Keycloak URL: $KEYCLOAK_URL" echo "Realm: $REALM" echo "" # Request new token from Keycloak RESPONSE=$(curl -s -X POST "$TOKEN_ENDPOINT" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id=$CLIENT_ID" \ -d "client_secret=$CLIENT_SECRET") # Check if request was successful if echo "$RESPONSE" | jq -e '.access_token' > /dev/null 2>&1; then ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r '.access_token') REFRESH_TOKEN=$(echo "$RESPONSE" | jq -r '.refresh_token // empty') EXPIRES_IN=$(echo "$RESPONSE" | jq -r '.expires_in') TOKEN_TYPE=$(echo "$RESPONSE" | jq -r '.token_type') SCOPE=$(echo "$RESPONSE" | jq -r '.scope // empty') # Calculate expiration timestamp CURRENT_TIME=$(date +%s) EXPIRES_AT=$((CURRENT_TIME + EXPIRES_IN)) # Build token JSON TOKEN_JSON=$(jq -n \ --arg access_token "$ACCESS_TOKEN" \ --arg token_type "$TOKEN_TYPE" \ --arg expires_in "$EXPIRES_IN" \ --arg expires_at "$EXPIRES_AT" \ --arg scope "$SCOPE" \ --arg refresh_token "$REFRESH_TOKEN" \ '{ access_token: $access_token, token_type: $token_type, expires_in: ($expires_in | tonumber), expires_at: ($expires_at | tonumber), scope: $scope } + (if $refresh_token != "" then {refresh_token: $refresh_token} else {} end)') # Save to file echo "$TOKEN_JSON" > "$TOKEN_FILE" chmod 600 "$TOKEN_FILE" echo "✓ Token refreshed successfully!" echo "" echo "Token file: $TOKEN_FILE" echo "Expires in: $EXPIRES_IN seconds ($(($EXPIRES_IN / 60)) minutes)" echo "Expires at: $(date -d @$EXPIRES_AT)" echo "" echo "To use this token:" echo " export TOKEN=\$(jq -r '.access_token' $TOKEN_FILE)" echo " curl -H \"Authorization: Bearer \$TOKEN\" http://localhost/v0/servers" echo "" # Also print first 50 chars for verification echo "Token preview: ${ACCESS_TOKEN:0:50}..." else echo "✗ Failed to refresh token" echo "" echo "Error response:" echo "$RESPONSE" | jq '.' exit 1 fi ================================================ FILE: scripts/registry-admins.json ================================================ { "_id": "registry-admins", "group_mappings": [ "registry-admins" ], "server_access": [ { "server": "*", "methods": ["all"], "tools": ["all"] }, { "agents": { "actions": [ {"action": "list_agents", "resources": ["all"]}, {"action": "get_agent", "resources": ["all"]}, {"action": "publish_agent", "resources": ["all"]}, {"action": "modify_agent", "resources": ["all"]}, {"action": "delete_agent", "resources": ["all"]} ] } } ], "ui_permissions": { "list_agents": ["all"], "get_agent": ["all"], "publish_agent": ["all"], "modify_agent": ["all"], "delete_agent": ["all"], "list_service": ["all"], "register_service": ["all"], "health_check_service": ["all"], "toggle_service": ["all"], "modify_service": ["all"], "delete_service": ["all"], "list_virtual_server": ["all"], "create_virtual_server": ["all"], "modify_virtual_server": ["all"], "delete_virtual_server": ["all"] } } ================================================ FILE: scripts/run-oauth-setup.sh ================================================ #!/bin/bash echo "Running Atlassian OAuth Setup..." echo "This will start a temporary container on port 8080 for OAuth configuration." echo "" # Ensure the directory has proper permissions sudo chown -R ubuntu:ubuntu ~/.mcp-atlassian/ sudo chmod 755 ~/.mcp-atlassian/ echo "Starting OAuth setup container..." echo "Visit http://localhost:8080 in your browser to complete the OAuth setup." echo "" ./setup-atlassian-env.sh echo "" echo "OAuth setup completed. Checking for created files..." ls -la ~/.mcp-atlassian/ echo "" # Update .env files with Atlassian OAuth tokens echo "Updating .env files with Atlassian OAuth tokens..." python3 <<'EOF' import json import os import glob # Find the OAuth JSON file oauth_files = glob.glob('/home/ubuntu/.mcp-atlassian/oauth-*.json') if not oauth_files: print("❌ No OAuth JSON file found in ~/.mcp-atlassian/") exit(1) oauth_file = oauth_files[0] print(f"📖 Reading OAuth token from: {oauth_file}") try: # Read the OAuth data with open(oauth_file, 'r') as f: oauth_data = json.load(f) access_token = oauth_data.get('access_token', '') cloud_id = oauth_data.get('cloud_id', '') if not access_token: print("❌ No access_token found in OAuth file") exit(1) print(f"✅ Found access_token (first 50 chars): {access_token[:50]}...") print(f"✅ Found cloud_id: {cloud_id}") # Update function to add/update tokens in .env files def update_env_file(file_path, updates): """Update or add environment variables in a .env file""" lines = [] updated_vars = set() # Read existing file if it exists if os.path.exists(file_path): with open(file_path, 'r') as f: for line in f: # Check if this line sets one of our variables var_updated = False for var_name in updates: if line.startswith(f'{var_name}='): lines.append(f'{var_name}={updates[var_name]}\n') updated_vars.add(var_name) var_updated = True break if not var_updated: lines.append(line) # Add any variables that weren't already in the file for var_name, var_value in updates.items(): if var_name not in updated_vars: # Ensure there's a newline before adding new vars if lines and not lines[-1].endswith('\n'): lines[-1] += '\n' lines.append(f'{var_name}={var_value}\n') # Write the updated file with open(file_path, 'w') as f: f.writelines(lines) print(f"✅ Updated {file_path}") # Prepare the updates env_updates = { 'ATLASSIAN_AUTH_TOKEN': access_token, 'ATLASSIAN_CLOUD_ID': cloud_id } # Update both .env.agent and .env.user files agent_env = '/home/ubuntu/repos/mcp-gateway-registry/agents/.env.agent' user_env = '/home/ubuntu/repos/mcp-gateway-registry/agents/.env.user' update_env_file(agent_env, env_updates) update_env_file(user_env, env_updates) print("\n✅ Successfully updated both .env files with Atlassian OAuth tokens!") print(" - ATLASSIAN_AUTH_TOKEN: Set") print(f" - ATLASSIAN_CLOUD_ID: {cloud_id}") except Exception as e: print(f"❌ Error processing OAuth file: {e}") exit(1) EOF echo "" echo "If you see oauth-*.json files above, the setup was successful." ================================================ FILE: scripts/scan-images-trivy.sh ================================================ #!/bin/bash # Scan Docker images for vulnerabilities using Trivy # Requires Trivy to be installed: https://aquasecurity.github.io/trivy/ set -e echo "Scanning Docker images with Trivy..." echo "====================================" # Check if Trivy is installed if ! command -v trivy &> /dev/null; then echo "❌ ERROR: Trivy is not installed" echo "Install Trivy: https://aquasecurity.github.io/trivy/latest/getting-started/installation/" exit 1 fi # List of images to scan IMAGES=( "mcp-gateway-registry-registry:latest" "mcp-gateway-registry-auth-server:latest" "mcp-gateway-registry-metrics-service:latest" "mcp-gateway-registry-metrics-db:latest" ) # Severity levels to report (CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN) SEVERITY="CRITICAL,HIGH" # Exit code tracking EXIT_CODE=0 echo "Trivy version: $(trivy --version)" echo "Scanning for: $SEVERITY" echo "" for image in "${IMAGES[@]}"; do echo "==================================================" echo "Scanning: $image" echo "==================================================" # Check if image exists locally if ! docker image inspect "$image" &> /dev/null; then echo "⚠ WARNING: Image $image not found locally, skipping..." echo "" continue fi # Scan the image echo "Running Trivy scan..." if trivy image \ --severity "$SEVERITY" \ --no-progress \ --timeout 5m \ "$image"; then echo "✅ $image: No vulnerabilities found at $SEVERITY level" else echo "❌ $image: Vulnerabilities found" EXIT_CODE=1 fi echo "" done echo "====================================" if [ $EXIT_CODE -eq 0 ]; then echo "✅ All scans completed successfully" else echo "❌ Some images have vulnerabilities" fi exit $EXIT_CODE ================================================ FILE: scripts/setup-atlassian-env.sh ================================================ #!/bin/bash # Atlassian OAuth Environment Variables Setup Script # This script sets up the required environment variables for the Atlassian MCP server echo "Setting up Atlassian OAuth environment variables..." # Check if required environment variables are set if [ -z "$ATLASSIAN_OAUTH_CLIENT_ID" ] || [ -z "$ATLASSIAN_OAUTH_CLIENT_SECRET" ]; then echo "" echo "ERROR: Required environment variables are not set!" echo "" echo "Please set the following environment variables before running this script:" echo " ATLASSIAN_OAUTH_CLIENT_ID - Your Atlassian OAuth client ID" echo " ATLASSIAN_OAUTH_CLIENT_SECRET - Your Atlassian OAuth client secret" echo "" echo "You can set them by running:" echo " export ATLASSIAN_OAUTH_CLIENT_ID=\"your_client_id_here\"" echo " export ATLASSIAN_OAUTH_CLIENT_SECRET=\"your_client_secret_here\"" echo "" echo "Or create a .env file and source it before running this script." echo "" exit 1 fi # Validate that the environment variables are not empty if [ -z "${ATLASSIAN_OAUTH_CLIENT_ID// }" ] || [ -z "${ATLASSIAN_OAUTH_CLIENT_SECRET// }" ]; then echo "" echo "ERROR: Environment variables cannot be empty!" echo "" exit 1 fi export ATLASSIAN_OAUTH_REDIRECT_URI="http://localhost:8080/callback" export ATLASSIAN_OAUTH_SCOPE="offline_access write:confluence-content read:confluence-space.summary write:confluence-space write:confluence-file read:confluence-props write:confluence-props manage:confluence-configuration read:confluence-content.all read:confluence-content.summary search:confluence read:confluence-content.permission read:confluence-user read:confluence-groups write:confluence-groups readonly:content.attachment:confluence read:jira-work manage:jira-project manage:jira-configuration read:jira-user write:jira-work manage:jira-webhook manage:jira-data-provider read:servicedesk-request manage:servicedesk-customer write:servicedesk-request read:servicemanagement-insight-objects read:me read:account report:personal-data write:component:compass read:scorecard:compass write:scorecard:compass read:component:compass read:event:compass write:event:compass read:metric:compass write:metric:compass read:backup:brie write:backup:brie read:restore:brie write:restore:brie read:account:brie write:storage:brie" echo "Environment variables validated successfully!" echo "" echo "Using configured variables:" echo " ATLASSIAN_OAUTH_CLIENT_ID: $ATLASSIAN_OAUTH_CLIENT_ID" echo " ATLASSIAN_OAUTH_CLIENT_SECRET: ${ATLASSIAN_OAUTH_CLIENT_SECRET:0:20}... (truncated for security)" echo " ATLASSIAN_OAUTH_REDIRECT_URI: $ATLASSIAN_OAUTH_REDIRECT_URI" echo " ATLASSIAN_OAUTH_SCOPE: ${ATLASSIAN_OAUTH_SCOPE:0:50}... (truncated for display)" echo "" echo "Now running the OAuth setup container..." echo "" # Run the OAuth setup container docker run --rm -i \ -p 8080:8080 \ -v "${HOME}/.mcp-atlassian:/home/app/.mcp-atlassian" \ -e "ATLASSIAN_OAUTH_CLIENT_ID=${ATLASSIAN_OAUTH_CLIENT_ID}" \ -e "ATLASSIAN_OAUTH_CLIENT_SECRET=${ATLASSIAN_OAUTH_CLIENT_SECRET}" \ -e "ATLASSIAN_OAUTH_REDIRECT_URI=${ATLASSIAN_OAUTH_REDIRECT_URI}" \ -e "ATLASSIAN_OAUTH_SCOPE=${ATLASSIAN_OAUTH_SCOPE}" \ ghcr.io/sooperset/mcp-atlassian:latest --oauth-setup -v echo "" echo "OAuth setup completed!" echo "You can now use the configured credentials with the Atlassian MCP server." ================================================ FILE: scripts/test-mcpgw-tools-README.md ================================================ # mcpgw MCP Server Test Script ## Overview This script comprehensively tests the mcpgw MCP server by exercising all 5 tools through the FastMCP streamable-http protocol. **It also demonstrates WHY the `Mcp-Session-Id` header forwarding in nginx is absolutely necessary.** ## What It Tests The script performs the following operations in order: 1. **Initialize MCP Session** - Establishes a session and captures the `Mcp-Session-Id` 2. **Send Initialized Notification** - Completes the MCP handshake 3. **List Available Tools** - Discovers all 5 tools provided by mcpgw 4. **Test Each Tool**: - `list_services` - Lists all MCP servers in registry - `list_agents` - Lists all agents in registry - `list_skills` - Lists all skills in registry - `intelligent_tool_finder` - Semantic search for tools - `healthcheck` - Gets registry health status 5. **Verify Session Persistence** - Calls a tool again using the SAME session ID to prove session continuity ## Why Mcp-Session-Id Header Is Required ### The Problem Without Header Forwarding FastMCP's streamable-http transport uses **stateful sessions**: ``` Client Nginx mcpgw Server | | | |-- POST /mcp ----------->|-- forward -------------->| | (initialize) | | |<------------------------|<-- Mcp-Session-Id: abc --| | | | |-- POST /mcp ----------->|-- forward (MISSING ID!)->| | tools/list | | |<-- 404 Session Not Found|<-------------------------| ``` **Without nginx forwarding `Mcp-Session-Id`**, the mcpgw server receives requests without session context and returns `404 Session not found` errors. ### The Fix Added to [nginx_service.py:1110](../registry/core/nginx_service.py#L1110): ```nginx proxy_set_header Mcp-Session-Id $http_mcp_session_id; ``` This ensures: ``` Client Nginx mcpgw Server | | | |-- POST /mcp ----------->|-- forward -------------->| | (initialize) | | |<------------------------|<-- Mcp-Session-Id: abc --| | | | |-- POST /mcp ----------->|-- forward + Session ✓ -->| | Mcp-Session-Id: abc | Mcp-Session-Id: abc | |<-- tools list -----------|<-------------------------| ``` ## Usage ### Prerequisites 1. **Token file**: Create `.token` file in project root with your bearer token ```bash # Extract token from roo's config (already done) cat /home/ubuntu/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/settings/mcp_settings.json | \ jq -r '.mcpServers["mcp-gateway-tools"].headers.Authorization' | \ cut -d' ' -f2 > .token ``` 2. **Dependencies**: Requires `jq` and `curl` ```bash sudo apt-get install -y jq curl ``` ### Run the Test ```bash # From project root ./scripts/test-mcpgw-tools.sh # Or with custom URLs MCPGW_URL=https://mcpgateway.ddns.net/mcpgw/mcp \ TOKEN_FILE=.token \ ./scripts/test-mcpgw-tools.sh ``` ### Expected Output ``` === MCP Gateway Tools Test Script === MCPGW URL: https://mcpgateway.ddns.net/mcpgw/mcp Registry URL: https://mcpgateway.ddns.net ✓ Token loaded from .token === Step 1: Initialize MCP Session === → Request: initialize (id=init-1) { "jsonrpc": "2.0", "method": "initialize", "params": {...}, "id": "init-1" } Session created: abc123def456... ← Response: { "jsonrpc": "2.0", "id": "init-1", "result": {...} } ✓ Session initialized successfully === Step 3: List Available Tools === ✓ Found 5 tools: list_services, list_agents, list_skills, intelligent_tool_finder, healthcheck === Step 4: Test All Tools === --- Testing: list_services --- ✓ list_services: Found 12 services --- Testing: list_agents --- ✓ list_agents: Found 3 agents --- Testing: list_skills --- ✓ list_skills: Found 8 skills --- Testing: intelligent_tool_finder --- ✓ intelligent_tool_finder: Found 3 results --- Testing: healthcheck --- ✓ healthcheck: Status=success === Step 5: Verify Session Persistence === Calling list_services again with the SAME session ID... This proves that Mcp-Session-Id must be forwarded by nginx! ✓ Session persistence verified: Found 12 services === Test Summary === ✓ Session ID: abc123def456... ✓ All 5 tools tested successfully ✓ Session persistence verified Key Insight: Without the Mcp-Session-Id header being forwarded by nginx, the FastMCP streamable-http transport cannot maintain sessions. Each request would create a NEW session, causing 404 errors when clients try to reuse session IDs. This proves the nginx configuration change is NECESSARY! ``` ## Troubleshooting ### Error: Session Not Found (404) If you see `404 Session not found` errors, it means: 1. The nginx configuration is NOT forwarding `Mcp-Session-Id` header 2. Run `docker exec mcp-gateway-registry-registry-1 grep -i "mcp-session" /etc/nginx/conf.d/nginx_rev_proxy.conf` to verify 3. Expected: `proxy_set_header Mcp-Session-Id $http_mcp_session_id;` ### Error: 401 Unauthorized If you see `401 Unauthorized` errors: 1. Check your `.token` file contains a valid bearer token 2. Verify token hasn't expired 3. Test token directly: `curl -H "Authorization: Bearer $(cat .token)" https://mcpgateway.ddns.net/api/servers` ### Connection Refused If connection fails: 1. Verify mcpgw container is running: `docker ps | grep mcpgw` 2. Check container logs: `docker logs mcp-gateway-registry-mcpgw-server-1` 3. Verify nginx is forwarding correctly: `docker logs mcp-gateway-registry-registry-1 | grep mcpgw` ## mcpgw Server Architecture ### Tools Overview | Tool | Registry API | Description | |------|-------------|-------------| | `list_services` | `GET /api/servers` | Lists all registered MCP servers | | `list_agents` | `GET /api/agents` | Lists all registered agents | | `list_skills` | `GET /api/skills` | Lists all registered skills | | `intelligent_tool_finder` | `POST /api/search/semantic` | Semantic search for tools | | `healthcheck` | `GET /api/servers/health` | Registry health statistics | ### Token Flow ``` 1. User stores token in .token file 2. Script reads token: cat .token 3. Script sends: Authorization: Bearer 4. Nginx forwards to mcpgw: Authorization: Bearer 5. mcpgw extracts from Context: _extract_bearer_token(ctx) 6. mcpgw forwards to registry APIs: Authorization: Bearer 7. Registry validates and processes request ``` ### Session Management Flow ``` 1. Client: POST /mcp (initialize) → mcpgw: Creates session, returns Mcp-Session-Id 2. Client: POST /mcp (tools/list) + Mcp-Session-Id → nginx: MUST forward Mcp-Session-Id header → mcpgw: Looks up session, processes request 3. Client: POST /mcp (tools/call) + Mcp-Session-Id → nginx: MUST forward Mcp-Session-Id header → mcpgw: Reuses same session, maintains context ``` ## Related Files - [mcpgw server.py](../servers/mcpgw/server.py) - MCP server implementation - [nginx_service.py](../registry/core/nginx_service.py#L1110) - Nginx config with Mcp-Session-Id forwarding - [Issue #583](https://github.com/agentic-community/mcp-gateway-registry/issues/583) - mcpgw rewrite - [PR #584](https://github.com/agentic-community/mcp-gateway-registry/pull/584) - Implementation PR ## Proof of Necessity This script **empirically proves** that the `Mcp-Session-Id` header forwarding is not optional: 1. **Step 1 (Initialize)**: Creates session, receives `Mcp-Session-Id` in response 2. **Step 3 (List Tools)**: Sends `Mcp-Session-Id` in request - nginx MUST forward it 3. **Step 4 (Tool Calls)**: Each tool call reuses the same session ID 4. **Step 5 (Persistence)**: Calls same tool again - proves session is maintained **Without nginx forwarding this header**, FastMCP's session manager in mcpgw would be unable to match incoming requests to existing sessions, resulting in `404 Session not found` errors. The architectural change from the old mcpgw (which managed its own sessions internally) to the new mcpgw (stateless HTTP client where FastMCP manages sessions) made this header forwarding **absolutely necessary**. ================================================ FILE: scripts/test-mcpgw-tools.sh ================================================ #!/bin/bash # Test script for mcpgw MCP server - exercises all 5 tools via FastMCP streamable-http protocol # This demonstrates WHY the Mcp-Session-Id header is required for session management set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Configuration MCPGW_URL="${MCPGW_URL:-https://mcpgateway.ddns.net/airegistry-tools/mcp}" TOKEN_FILE="${TOKEN_FILE:-.token}" echo -e "${BLUE}=== MCP Gateway Tools Test Script ===${NC}" echo "MCPGW URL: $MCPGW_URL" echo # Read token from .token file if [[ ! -f "$TOKEN_FILE" ]]; then echo -e "${RED}ERROR: Token file not found: $TOKEN_FILE${NC}" echo "Create a .token file with your bearer token (without 'Bearer ' prefix)" exit 1 fi # Try to parse as JSON first (if it's a token response object) TOKEN=$(cat "$TOKEN_FILE" | jq -r '.tokens.access_token // empty' 2>/dev/null) # If not JSON or no access_token field, treat entire file as raw token if [[ -z "$TOKEN" ]]; then TOKEN=$(cat "$TOKEN_FILE" | tr -d '\n\r') fi if [[ -z "$TOKEN" ]]; then echo -e "${RED}ERROR: Token file is empty or invalid${NC}" exit 1 fi echo -e "${GREEN}✓ Token loaded from $TOKEN_FILE${NC}" echo # Temp file for capturing response headers HEADER_FILE=$(mktemp) trap "rm -f $HEADER_FILE" EXIT # Helper to extract response and update SESSION_ID from make_request output extract_response() { local output="$1" # Extract session ID from last line local new_session=$(echo "$output" | tail -1 | grep "^SESSION_ID=" | cut -d= -f2) if [[ -n "$new_session" ]]; then SESSION_ID="$new_session" fi # Return everything except last line (the SESSION_ID= line) echo "$output" | head -n -1 } # Function to make JSON-RPC request make_request() { local method=$1 local params=$2 local request_id=$3 local payload=$(jq -n \ --arg method "$method" \ --argjson params "$params" \ --arg id "$request_id" \ '{jsonrpc: "2.0", method: $method, params: $params, id: $id}') echo -e "${BLUE}→ Request: $method (id=$request_id)${NC}" >&2 echo "$payload" | jq -C '.' >&2 # Make request with session ID if available local curl_args=(-s -D "$HEADER_FILE" -X POST "$MCPGW_URL" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer $TOKEN" \ -d "$payload") if [[ -n "$SESSION_ID" ]]; then curl_args+=(-H "Mcp-Session-Id: $SESSION_ID") echo -e "${YELLOW} Using session: $SESSION_ID${NC}" >&2 fi local response=$(curl "${curl_args[@]}") local http_status=$(grep "^HTTP" "$HEADER_FILE" | tail -1 | awk '{print $2}') # Extract session ID from response headers if present (case-insensitive) if [[ -z "$SESSION_ID" ]]; then SESSION_ID=$(grep -i "^mcp-session-id:" "$HEADER_FILE" | head -1 | cut -d' ' -f2 | tr -d '\r\n' || true) if [[ -n "$SESSION_ID" ]]; then echo -e "${GREEN} Session created: $SESSION_ID${NC}" >&2 fi fi echo -e "${BLUE}← Response (HTTP $http_status):${NC}" >&2 # Check if response is SSE format (starts with "event:" or "data:") if echo "$response" | grep -q "^event:\|^data:"; then # Extract JSON from SSE data: line local json_data=$(echo "$response" | grep "^data:" | sed 's/^data: //') if [[ -n "$json_data" ]]; then echo "$json_data" | jq -C '.' >&2 response="$json_data" else echo "$response" >&2 fi else # Try to parse as JSON, if fails show raw response if echo "$response" | jq -C '.' >&2 2>/dev/null; then : # Successfully parsed and displayed else echo -e "${YELLOW}Raw response (not JSON):${NC}" >&2 echo "$response" >&2 fi fi echo >&2 # Check HTTP status if [[ "$http_status" != "200" && "$http_status" != "202" ]]; then echo -e "${RED}✗ HTTP error: $http_status${NC}" >&2 return 1 fi # Check for JSON-RPC errors if echo "$response" | jq -e '.error' > /dev/null 2>&1; then echo -e "${RED}✗ JSON-RPC error in response${NC}" >&2 return 1 fi # Return response JSON and session ID on separate lines echo "$response" echo "SESSION_ID=$SESSION_ID" } # 1. Initialize MCP session echo -e "${GREEN}=== Step 1: Initialize MCP Session ===${NC}" INIT_PARAMS=$(jq -n '{ protocolVersion: "2024-11-05", capabilities: { tools: {} }, clientInfo: { name: "mcpgw-test-script", version: "1.0.0" } }') INIT_OUTPUT=$(make_request "initialize" "$INIT_PARAMS" "init-1") # Extract session ID from last line of output SESSION_ID=$(echo "$INIT_OUTPUT" | tail -1 | grep "^SESSION_ID=" | cut -d= -f2) INIT_RESPONSE=$(echo "$INIT_OUTPUT" | head -n -1) if [[ -z "$SESSION_ID" ]]; then echo -e "${RED}ERROR: Failed to get session ID from initialize response${NC}" echo "This proves that Mcp-Session-Id header forwarding is REQUIRED!" exit 1 fi echo -e "${GREEN}✓ Session initialized successfully${NC}" echo # 2. Send initialized notification echo -e "${GREEN}=== Step 2: Send Initialized Notification ===${NC}" INITIALIZED_PAYLOAD=$(jq -n '{ jsonrpc: "2.0", method: "notifications/initialized" }') curl -s -X POST "$MCPGW_URL" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -H "Authorization: Bearer $TOKEN" \ -H "Mcp-Session-Id: $SESSION_ID" \ -d "$INITIALIZED_PAYLOAD" > /dev/null echo -e "${GREEN}✓ Initialization complete${NC}" echo # 3. List available tools echo -e "${GREEN}=== Step 3: List Available Tools ===${NC}" TOOLS_OUTPUT=$(make_request "tools/list" "{}" "tools-list-1") TOOLS_RESPONSE=$(extract_response "$TOOLS_OUTPUT") TOOL_NAMES=$(echo "$TOOLS_RESPONSE" | jq -r '.result.tools[].name' | tr '\n' ', ' | sed 's/,$//') TOOL_COUNT=$(echo "$TOOLS_RESPONSE" | jq '.result.tools | length') echo -e "${GREEN}✓ Found $TOOL_COUNT tools: $TOOL_NAMES${NC}" echo # 4. Test each tool echo -e "${GREEN}=== Step 4: Test All Tools ===${NC}" # Tool 1: list_services echo -e "${YELLOW}--- Testing: list_services ---${NC}" LIST_SERVICES_PARAMS=$(jq -n '{ name: "list_services", arguments: {} }') LIST_SERVICES_OUTPUT=$(make_request "tools/call" "$LIST_SERVICES_PARAMS" "call-1") LIST_SERVICES_RESPONSE=$(extract_response "$LIST_SERVICES_OUTPUT") SERVICE_COUNT=$(echo "$LIST_SERVICES_RESPONSE" | jq -r '.result.content[0].text' | jq '.total_count') echo -e "${GREEN}✓ list_services: Found $SERVICE_COUNT services${NC}" echo # Tool 2: list_agents echo -e "${YELLOW}--- Testing: list_agents ---${NC}" LIST_AGENTS_PARAMS=$(jq -n '{ name: "list_agents", arguments: {} }') LIST_AGENTS_OUTPUT=$(make_request "tools/call" "$LIST_AGENTS_PARAMS" "call-2") LIST_AGENTS_RESPONSE=$(extract_response "$LIST_AGENTS_OUTPUT") AGENT_COUNT=$(echo "$LIST_AGENTS_RESPONSE" | jq -r '.result.content[0].text' | jq '.total_count') echo -e "${GREEN}✓ list_agents: Found $AGENT_COUNT agents${NC}" echo # Tool 3: list_skills echo -e "${YELLOW}--- Testing: list_skills ---${NC}" LIST_SKILLS_PARAMS=$(jq -n '{ name: "list_skills", arguments: {} }') LIST_SKILLS_OUTPUT=$(make_request "tools/call" "$LIST_SKILLS_PARAMS" "call-3") LIST_SKILLS_RESPONSE=$(extract_response "$LIST_SKILLS_OUTPUT") SKILL_COUNT=$(echo "$LIST_SKILLS_RESPONSE" | jq -r '.result.content[0].text' | jq '.total_count') echo -e "${GREEN}✓ list_skills: Found $SKILL_COUNT skills${NC}" echo # Tool 4: intelligent_tool_finder echo -e "${YELLOW}--- Testing: intelligent_tool_finder ---${NC}" SEARCH_PARAMS=$(jq -n '{ name: "intelligent_tool_finder", arguments: { query: "find weather information", top_n: 3 } }') SEARCH_OUTPUT=$(make_request "tools/call" "$SEARCH_PARAMS" "call-4") SEARCH_RESPONSE=$(extract_response "$SEARCH_OUTPUT") RESULT_COUNT=$(echo "$SEARCH_RESPONSE" | jq -r '.result.content[0].text' | jq '.total_results') echo -e "${GREEN}✓ intelligent_tool_finder: Found $RESULT_COUNT results${NC}" echo # Tool 5: healthcheck echo -e "${YELLOW}--- Testing: healthcheck ---${NC}" HEALTH_PARAMS=$(jq -n '{ name: "healthcheck", arguments: {} }') HEALTH_OUTPUT=$(make_request "tools/call" "$HEALTH_PARAMS" "call-5") HEALTH_RESPONSE=$(extract_response "$HEALTH_OUTPUT") HEALTH_STATUS=$(echo "$HEALTH_RESPONSE" | jq -r '.result.content[0].text' | jq -r '.status') echo -e "${GREEN}✓ healthcheck: Status=$HEALTH_STATUS${NC}" echo # 5. Test session persistence - call same tool again with same session echo -e "${GREEN}=== Step 5: Verify Session Persistence ===${NC}" echo "Calling list_services again with the SAME session ID..." echo "This proves that Mcp-Session-Id must be forwarded by nginx!" echo LIST_SERVICES_OUTPUT_2=$(make_request "tools/call" "$LIST_SERVICES_PARAMS" "call-6") LIST_SERVICES_RESPONSE_2=$(extract_response "$LIST_SERVICES_OUTPUT_2") SERVICE_COUNT_2=$(echo "$LIST_SERVICES_RESPONSE_2" | jq -r '.result.content[0].text' | jq '.total_count') echo -e "${GREEN}✓ Session persistence verified: Found $SERVICE_COUNT_2 services${NC}" echo # Summary echo -e "${GREEN}=== Test Summary ===${NC}" echo -e "${GREEN}✓ Session ID: $SESSION_ID${NC}" echo -e "${GREEN}✓ All 5 tools tested successfully${NC}" echo -e "${GREEN}✓ Session persistence verified${NC}" ================================================ FILE: scripts/test-peer-federation-docker.sh ================================================ #!/bin/bash # # Peer Federation Docker Test Script # # This script: # 1. Builds and starts two registry instances via Docker Compose # 2. Registers test servers on Registry A # 3. Configures Registry B to peer with Registry A # 4. Triggers sync and verifies data replication # 5. Cleans up on exit # # Usage: # ./scripts/test-peer-federation-docker.sh # ./scripts/test-peer-federation-docker.sh --no-cleanup # Keep containers running # ./scripts/test-peer-federation-docker.sh --rebuild # Force rebuild images # set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # Configuration SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" COMPOSE_FILE="$PROJECT_DIR/docker-compose.federation-test.yml" REGISTRY_A_URL="http://localhost:7860" REGISTRY_B_URL="http://localhost:7861" AUTH_A_URL="http://localhost:8888" AUTH_B_URL="http://localhost:8889" SECRET_KEY_A="${SECRET_KEY:-federation-test-secret-key-a}" SECRET_KEY_B="${SECRET_KEY:-federation-test-secret-key-b}" # Parse arguments CLEANUP=true REBUILD="" for arg in "$@"; do case $arg in --no-cleanup) CLEANUP=false ;; --rebuild) REBUILD="--build --no-cache" ;; esac done # Cleanup function cleanup() { if [ "$CLEANUP" = true ]; then echo -e "\n${YELLOW}Cleaning up...${NC}" cd "$PROJECT_DIR" docker compose -f "$COMPOSE_FILE" down -v 2>/dev/null || true echo -e "${GREEN}Cleanup complete${NC}" else echo -e "\n${YELLOW}Containers left running (--no-cleanup specified)${NC}" echo "To stop: docker compose -f $COMPOSE_FILE down -v" fi } trap cleanup EXIT INT TERM # Print helpers print_section() { echo -e "\n${BLUE}========================================${NC}" echo -e "${BLUE}$1${NC}" echo -e "${BLUE}========================================${NC}\n" } print_success() { echo -e "${GREEN}[OK]${NC} $1" } print_error() { echo -e "${RED}[ERROR]${NC} $1" } print_info() { echo -e "${YELLOW}[INFO]${NC} $1" } # Wait for service to be healthy wait_for_service() { local url=$1 local name=$2 local max_attempts=${3:-60} local attempt=1 echo -n "Waiting for $name to be ready" while [ $attempt -le $max_attempts ]; do if curl -s -f "$url/health" > /dev/null 2>&1; then echo -e " ${GREEN}Ready${NC}" return 0 fi echo -n "." sleep 2 attempt=$((attempt + 1)) done echo -e " ${RED}Failed${NC}" return 1 } # Generate a session cookie using the SECRET_KEY (same signing as the registry) generate_session_cookie() { local secret_key=$1 local cookie_file=$2 local cookie_name="mcp_gateway_session" print_info "Generating session cookie using SECRET_KEY..." local cookie_value cookie_value=$(python3 -c " from itsdangerous import URLSafeTimedSerializer signer = URLSafeTimedSerializer('${secret_key}') data = {'username': 'admin', 'auth_method': 'oauth2', 'provider': 'test', 'groups': ['mcp-registry-admin']} print(signer.dumps(data)) " 2>/dev/null) if [ -z "$cookie_value" ]; then print_error "Failed to generate session cookie (is itsdangerous installed?)" return 1 fi # Write cookie in Netscape cookie format for curl -b echo "# Netscape HTTP Cookie File" > "$cookie_file" echo "localhost FALSE / FALSE 0 ${cookie_name} ${cookie_value}" >> "$cookie_file" print_success "Session cookie generated" return 0 } # Main test flow main() { cd "$PROJECT_DIR" print_section "Peer Federation Docker Test" echo "Registry A: $REGISTRY_A_URL" echo "Registry B: $REGISTRY_B_URL" # Start services print_section "Starting Docker Services" print_info "Building and starting containers (this may take a few minutes)..." if [ -n "$REBUILD" ]; then docker compose -f "$COMPOSE_FILE" build --no-cache fi docker compose -f "$COMPOSE_FILE" up -d ${REBUILD:+--build} # Wait for services print_section "Waiting for Services" wait_for_service "$AUTH_A_URL" "Auth Server A" 90 || { print_error "Auth A failed to start"; docker compose -f "$COMPOSE_FILE" logs auth-server-a; exit 1; } wait_for_service "$AUTH_B_URL" "Auth Server B" 90 || { print_error "Auth B failed to start"; docker compose -f "$COMPOSE_FILE" logs auth-server-b; exit 1; } wait_for_service "$REGISTRY_A_URL" "Registry A" 90 || { print_error "Registry A failed to start"; docker compose -f "$COMPOSE_FILE" logs registry-a; exit 1; } wait_for_service "$REGISTRY_B_URL" "Registry B" 90 || { print_error "Registry B failed to start"; docker compose -f "$COMPOSE_FILE" logs registry-b; exit 1; } # Create cookie files COOKIE_A=$(mktemp) COOKIE_B=$(mktemp) trap "rm -f $COOKIE_A $COOKIE_B; cleanup" EXIT INT TERM # Generate session cookies for both registries using their SECRET_KEYs print_section "Authenticating" generate_session_cookie "$SECRET_KEY_A" "$COOKIE_A" || exit 1 generate_session_cookie "$SECRET_KEY_B" "$COOKIE_B" || exit 1 # Register test servers on Registry A print_section "Registering Test Servers on Registry A" # Server 1 print_info "Registering 'Test Server 1'..." REGISTER_RESULT=$(curl -s -b "$COOKIE_A" -X POST "$REGISTRY_A_URL/api/servers/register" \ -F "name=Test Server 1" \ -F "description=First test server for federation" \ -F "path=/test-server-1" \ -F "proxy_pass_url=http://localhost:9001" \ -F "tags=production,federation-test") if echo "$REGISTER_RESULT" | grep -q "registered successfully\|already exists"; then print_success "Server 1 registered" else print_error "Failed to register Server 1: $REGISTER_RESULT" fi # Server 2 print_info "Registering 'Test Server 2'..." REGISTER_RESULT=$(curl -s -b "$COOKIE_A" -X POST "$REGISTRY_A_URL/api/servers/register" \ -F "name=Test Server 2" \ -F "description=Second test server for federation" \ -F "path=/test-server-2" \ -F "proxy_pass_url=http://localhost:9002" \ -F "tags=development,federation-test") if echo "$REGISTER_RESULT" | grep -q "registered successfully\|already exists"; then print_success "Server 2 registered" else print_error "Failed to register Server 2: $REGISTER_RESULT" fi # Enable the servers print_info "Enabling test servers..." curl -s -b "$COOKIE_A" -X POST "$REGISTRY_A_URL/api/servers/toggle" \ -F "path=/test-server-1" -F "new_state=true" > /dev/null curl -s -b "$COOKIE_A" -X POST "$REGISTRY_A_URL/api/servers/toggle" \ -F "path=/test-server-2" -F "new_state=true" > /dev/null print_success "Servers enabled" # Verify servers on Registry A print_section "Verifying Servers on Registry A" SERVERS_A=$(curl -s -b "$COOKIE_A" "$REGISTRY_A_URL/api/servers") echo "$SERVERS_A" | python3 -m json.tool 2>/dev/null || echo "$SERVERS_A" # Check federation export endpoint print_section "Testing Federation Export (Registry A)" FED_EXPORT=$(curl -s -b "$COOKIE_A" "$REGISTRY_A_URL/api/federation/servers") echo "$FED_EXPORT" | python3 -m json.tool 2>/dev/null || echo "$FED_EXPORT" EXPORT_COUNT=$(echo "$FED_EXPORT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('total_count', 0))" 2>/dev/null || echo "0") if [ "$EXPORT_COUNT" -gt 0 ]; then print_success "Federation export has $EXPORT_COUNT servers" else print_info "Federation export shows 0 servers (servers may need to be public)" fi # Configure peer on Registry B print_section "Configuring Peer on Registry B" print_info "Adding Registry A as peer..." # Note: Using internal Docker network hostname PEER_RESULT=$(curl -s -b "$COOKIE_B" -X POST "$REGISTRY_B_URL/api/peers" \ -H "Content-Type: application/json" \ -d '{ "peer_id": "registry-a", "name": "Registry A (Primary)", "endpoint": "http://registry-a:7860", "enabled": true, "sync_mode": "all", "sync_interval_minutes": 5 }') if echo "$PEER_RESULT" | grep -q "registry-a\|already exists"; then print_success "Peer configured" echo "$PEER_RESULT" | python3 -m json.tool 2>/dev/null || echo "$PEER_RESULT" else print_error "Failed to configure peer: $PEER_RESULT" fi # List peers on Registry B print_section "Peers on Registry B" curl -s -b "$COOKIE_B" "$REGISTRY_B_URL/api/peers" | python3 -m json.tool 2>/dev/null || true # Trigger sync print_section "Triggering Sync" print_info "Syncing from Registry A to Registry B..." SYNC_RESULT=$(curl -s -b "$COOKIE_B" -X POST "$REGISTRY_B_URL/api/peers/registry-a/sync") echo "$SYNC_RESULT" | python3 -m json.tool 2>/dev/null || echo "$SYNC_RESULT" SYNC_SUCCESS=$(echo "$SYNC_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('success', False))" 2>/dev/null || echo "false") SERVERS_SYNCED=$(echo "$SYNC_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('servers_synced', 0))" 2>/dev/null || echo "0") if [ "$SYNC_SUCCESS" = "True" ] || [ "$SYNC_SUCCESS" = "true" ]; then print_success "Sync completed: $SERVERS_SYNCED servers synced" else print_error "Sync failed" ERROR_MSG=$(echo "$SYNC_RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('error_message', 'unknown'))" 2>/dev/null || echo "unknown") print_info "Error: $ERROR_MSG" fi # Check peer status print_section "Peer Sync Status" curl -s -b "$COOKIE_B" "$REGISTRY_B_URL/api/peers/registry-a/status" | python3 -m json.tool 2>/dev/null || true # Verify servers on Registry B print_section "Servers on Registry B (After Sync)" SERVERS_B=$(curl -s -b "$COOKIE_B" "$REGISTRY_B_URL/api/servers") echo "$SERVERS_B" | python3 -m json.tool 2>/dev/null || echo "$SERVERS_B" # Check for federated servers if echo "$SERVERS_B" | grep -q "registry-a"; then print_success "Federation test PASSED - servers synced from Registry A to Registry B" else print_info "No federated servers found on Registry B" print_info "This may be expected if servers on Registry A are not publicly visible" fi # Summary print_section "Test Summary" echo "Registry A: $REGISTRY_A_URL (UI: http://localhost:80)" echo "Registry B: $REGISTRY_B_URL (UI: http://localhost:81)" echo "" echo "Authentication: via SECRET_KEY signed session cookies" echo "" echo "To manually test:" echo " 1. Open Registry A UI and register/enable servers" echo " 2. Open Registry B UI and check for synced servers" echo " 3. Or use the API endpoints shown above" echo "" if [ "$CLEANUP" = false ]; then echo -e "${YELLOW}Containers are still running.${NC}" echo "To stop: docker compose -f docker-compose.federation-test.yml down -v" echo "" echo -e "${YELLOW}Press Ctrl+C when done testing.${NC}" # Keep script running so user can test manually while true; do sleep 60 done fi } main "$@" ================================================ FILE: scripts/test-peer-federation.sh ================================================ #!/bin/bash # # Peer Federation Test Script # # Sets up 2 registry instances and tests federation sync between them. # # Usage: # ./scripts/test-peer-federation.sh # # This script will: # 1. Start Registry A on port 7860 # 2. Start Registry B on port 7861 # 3. Register test servers/agents on Registry A # 4. Configure Registry B to peer with Registry A # 5. Trigger sync and verify data was replicated # 6. Clean up when done (Ctrl+C) set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Configuration REGISTRY_A_PORT=7860 REGISTRY_B_PORT=7861 REGISTRY_A_DATA="/tmp/registry-a-data-$$" REGISTRY_B_DATA="/tmp/registry-b-data-$$" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # Auth headers for testing (simulates nginx-proxied authentication) AUTH_HEADERS='-H "X-Username: test-admin" -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" -H "X-Auth-Method: keycloak"' # PIDs for cleanup REGISTRY_A_PID="" REGISTRY_B_PID="" # Cleanup function cleanup() { echo -e "\n${YELLOW}Cleaning up...${NC}" if [ -n "$REGISTRY_A_PID" ]; then echo "Stopping Registry A (PID: $REGISTRY_A_PID)" kill $REGISTRY_A_PID 2>/dev/null || true fi if [ -n "$REGISTRY_B_PID" ]; then echo "Stopping Registry B (PID: $REGISTRY_B_PID)" kill $REGISTRY_B_PID 2>/dev/null || true fi # Clean up data directories rm -rf "$REGISTRY_A_DATA" "$REGISTRY_B_DATA" 2>/dev/null || true echo -e "${GREEN}Cleanup complete${NC}" exit 0 } # Set up trap for cleanup trap cleanup EXIT INT TERM # Wait for a service to be ready wait_for_service() { local port=$1 local name=$2 local max_attempts=30 local attempt=1 echo -n "Waiting for $name (port $port) to be ready" while [ $attempt -le $max_attempts ]; do if curl -s "http://localhost:$port/health" > /dev/null 2>&1; then echo -e " ${GREEN}Ready${NC}" return 0 fi echo -n "." sleep 1 attempt=$((attempt + 1)) done echo -e " ${RED}Failed${NC}" return 1 } # Print section header print_section() { echo -e "\n${BLUE}========================================${NC}" echo -e "${BLUE}$1${NC}" echo -e "${BLUE}========================================${NC}\n" } # Print success message print_success() { echo -e "${GREEN}[OK]${NC} $1" } # Print error message print_error() { echo -e "${RED}[ERROR]${NC} $1" } # Print info message print_info() { echo -e "${YELLOW}[INFO]${NC} $1" } # Main script main() { cd "$PROJECT_DIR" print_section "Peer Federation Test" echo "Registry A: http://localhost:$REGISTRY_A_PORT (data: $REGISTRY_A_DATA)" echo "Registry B: http://localhost:$REGISTRY_B_PORT (data: $REGISTRY_B_DATA)" # Create data directories mkdir -p "$REGISTRY_A_DATA" "$REGISTRY_B_DATA" # Start Registry A print_section "Starting Registry A" STORAGE_BACKEND=file \ SERVERS_DIR_OVERRIDE="$REGISTRY_A_DATA" \ uv run uvicorn registry.main:app --host 127.0.0.1 --port $REGISTRY_A_PORT \ > /tmp/registry-a.log 2>&1 & REGISTRY_A_PID=$! print_info "Registry A started with PID $REGISTRY_A_PID" # Start Registry B print_section "Starting Registry B" STORAGE_BACKEND=file \ SERVERS_DIR_OVERRIDE="$REGISTRY_B_DATA" \ uv run uvicorn registry.main:app --host 127.0.0.1 --port $REGISTRY_B_PORT \ > /tmp/registry-b.log 2>&1 & REGISTRY_B_PID=$! print_info "Registry B started with PID $REGISTRY_B_PID" # Wait for both services wait_for_service $REGISTRY_A_PORT "Registry A" || { print_error "Registry A failed to start"; cat /tmp/registry-a.log; exit 1; } wait_for_service $REGISTRY_B_PORT "Registry B" || { print_error "Registry B failed to start"; cat /tmp/registry-b.log; exit 1; } # Register test server on Registry A (uses Form data, not JSON) print_section "Registering Test Server on Registry A" REGISTER_RESPONSE=$(curl -s -X POST "http://localhost:$REGISTRY_A_PORT/api/servers/register" \ -H "X-Username: test-admin" \ -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" \ -H "X-Auth-Method: keycloak" \ -F "name=Test Server from Registry A" \ -F "description=A server for testing federation sync" \ -F "path=/test-server" \ -F "proxy_pass_url=http://localhost:8000" \ -F "tags=production,test") if echo "$REGISTER_RESPONSE" | grep -q "test-server"; then print_success "Server registered on Registry A" echo "$REGISTER_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$REGISTER_RESPONSE" else print_error "Failed to register server" echo "$REGISTER_RESPONSE" fi # Register another test server print_info "Registering second test server..." curl -s -X POST "http://localhost:$REGISTRY_A_PORT/api/servers/register" \ -H "X-Username: test-admin" \ -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" \ -H "X-Auth-Method: keycloak" \ -F "name=Another Test Server" \ -F "description=Second server for testing" \ -F "path=/another-server" \ -F "proxy_pass_url=http://localhost:8001" \ -F "tags=development" > /dev/null print_success "Second server registered" # Enable the servers (they're disabled by default) print_info "Enabling test servers..." curl -s -X POST "http://localhost:$REGISTRY_A_PORT/api/servers/toggle" \ -H "X-Username: test-admin" \ -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" \ -H "X-Auth-Method: keycloak" \ -F "path=/test-server" \ -F "new_state=true" > /dev/null curl -s -X POST "http://localhost:$REGISTRY_A_PORT/api/servers/toggle" \ -H "X-Username: test-admin" \ -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" \ -H "X-Auth-Method: keycloak" \ -F "path=/another-server" \ -F "new_state=true" > /dev/null print_success "Servers enabled" # List servers on Registry A print_section "Servers on Registry A" curl -s "http://localhost:$REGISTRY_A_PORT/api/servers" \ -H "X-Username: test-admin" \ -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" \ -H "X-Auth-Method: keycloak" | python3 -m json.tool 2>/dev/null || true # Configure Registry B to peer with Registry A print_section "Configuring Peer on Registry B" PEER_RESPONSE=$(curl -s -X POST "http://localhost:$REGISTRY_B_PORT/api/peers" \ -H "Content-Type: application/json" \ -H "X-Username: test-admin" \ -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" \ -H "X-Auth-Method: keycloak" \ -d "{ \"peer_id\": \"registry-a\", \"name\": \"Registry A\", \"endpoint\": \"http://localhost:$REGISTRY_A_PORT\", \"enabled\": true, \"sync_mode\": \"all\", \"sync_interval_minutes\": 5 }") if echo "$PEER_RESPONSE" | grep -q "registry-a"; then print_success "Peer configured on Registry B" echo "$PEER_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$PEER_RESPONSE" else print_error "Failed to configure peer" echo "$PEER_RESPONSE" fi # Trigger sync print_section "Triggering Sync from Registry A to Registry B" SYNC_RESPONSE=$(curl -s -X POST "http://localhost:$REGISTRY_B_PORT/api/peers/registry-a/sync" \ -H "X-Username: test-admin" \ -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" \ -H "X-Auth-Method: keycloak") if echo "$SYNC_RESPONSE" | grep -q '"success"'; then print_success "Sync completed" echo "$SYNC_RESPONSE" | python3 -m json.tool 2>/dev/null || echo "$SYNC_RESPONSE" else print_error "Sync failed" echo "$SYNC_RESPONSE" fi # Verify servers were synced to Registry B print_section "Servers on Registry B (After Sync)" SERVERS_B=$(curl -s "http://localhost:$REGISTRY_B_PORT/api/servers" \ -H "X-Username: test-admin" \ -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" \ -H "X-Auth-Method: keycloak") echo "$SERVERS_B" | python3 -m json.tool 2>/dev/null || echo "$SERVERS_B" # Check for federated servers if echo "$SERVERS_B" | grep -q "registry-a/test-server"; then print_success "Federation test PASSED - servers synced correctly" else print_error "Federation test FAILED - servers not found on Registry B" fi # Show peer status print_section "Peer Sync Status" curl -s "http://localhost:$REGISTRY_B_PORT/api/peers/registry-a/status" \ -H "X-Username: test-admin" \ -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" \ -H "X-Auth-Method: keycloak" | python3 -m json.tool 2>/dev/null || true # Test federation export endpoint print_section "Testing Federation Export Endpoint (Registry A)" print_info "GET /api/federation/servers" curl -s "http://localhost:$REGISTRY_A_PORT/api/federation/servers" \ -H "X-Username: test-admin" \ -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" \ -H "X-Auth-Method: keycloak" | python3 -m json.tool 2>/dev/null || true # Test whitelist mode print_section "Testing Whitelist Mode" print_info "Adding peer with whitelist mode..." curl -s -X POST "http://localhost:$REGISTRY_B_PORT/api/peers" \ -H "Content-Type: application/json" \ -H "X-Username: test-admin" \ -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" \ -H "X-Auth-Method: keycloak" \ -d "{ \"peer_id\": \"registry-a-whitelist\", \"name\": \"Registry A (Whitelist)\", \"endpoint\": \"http://localhost:$REGISTRY_A_PORT\", \"enabled\": true, \"sync_mode\": \"whitelist\", \"whitelist_servers\": [\"/test-server\"], \"sync_interval_minutes\": 5 }" | python3 -m json.tool 2>/dev/null || true print_info "Syncing with whitelist mode..." WHITELIST_SYNC=$(curl -s -X POST "http://localhost:$REGISTRY_B_PORT/api/peers/registry-a-whitelist/sync" \ -H "X-Username: test-admin" \ -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" \ -H "X-Auth-Method: keycloak") echo "$WHITELIST_SYNC" | python3 -m json.tool 2>/dev/null || echo "$WHITELIST_SYNC" if echo "$WHITELIST_SYNC" | grep -q '"servers_synced": 1'; then print_success "Whitelist mode test PASSED - only whitelisted server synced" else print_info "Whitelist sync result (check servers_synced count)" fi # Summary print_section "Test Summary" echo "Registry A: http://localhost:$REGISTRY_A_PORT" echo "Registry B: http://localhost:$REGISTRY_B_PORT" echo "" echo "Auth headers (add to all requests):" echo ' -H "X-Username: test-admin" -H "X-Scopes: mcp-servers-unrestricted/read mcp-servers-unrestricted/execute federation-service" -H "X-Auth-Method: keycloak"' echo "" echo "Useful commands (with auth):" echo " List peers: curl http://localhost:$REGISTRY_B_PORT/api/peers -H 'X-Username: test-admin' ..." echo " List servers: curl http://localhost:$REGISTRY_B_PORT/api/servers -H 'X-Username: test-admin' ..." echo " Trigger sync: curl -X POST http://localhost:$REGISTRY_B_PORT/api/peers/registry-a/sync -H 'X-Username: test-admin' ..." echo " Fed export: curl http://localhost:$REGISTRY_A_PORT/api/federation/servers" echo "" echo -e "${YELLOW}Press Ctrl+C to stop both registries and clean up${NC}" # Keep running until interrupted while true; do sleep 1 done } main "$@" ================================================ FILE: scripts/test.py ================================================ #!/usr/bin/env python3 """ Test runner script for MCP Registry. This script provides a unified interface for running tests with various configurations, checking dependencies, and generating reports. """ import argparse import logging import subprocess # nosec B404 import sys import time from pathlib import Path # Configure logging with basicConfig logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # ANSI color codes for terminal output class Colors: """ANSI color codes for terminal output.""" GREEN = "\033[92m" YELLOW = "\033[93m" RED = "\033[91m" BLUE = "\033[94m" CYAN = "\033[96m" RESET = "\033[0m" BOLD = "\033[1m" # Required test dependencies # Note: These are the actual Python import names, not package names REQUIRED_DEPENDENCIES = [ "pytest", "pytest_asyncio", "pytest_cov", "pytest_mock", "xdist", # pytest-xdist package "pytest_html", "pytest_jsonreport", # pytest-json-report package "factory", # factory-boy package "faker", "freezegun", "itsdangerous", ] def _print_colored(message: str, color: str = Colors.RESET) -> None: """Print a colored message to stdout. Args: message: The message to print color: ANSI color code """ print(f"{color}{message}{Colors.RESET}") def _print_header(message: str) -> None: """Print a section header. Args: message: The header message """ _print_colored(f"\n{'=' * 70}", Colors.CYAN) _print_colored(f"{message}", Colors.CYAN + Colors.BOLD) _print_colored(f"{'=' * 70}\n", Colors.CYAN) def _check_dependency(module_name: str) -> bool: """Check if a Python module is installed. Args: module_name: Name of the module to check Returns: True if module is installed, False otherwise """ try: __import__(module_name) return True except ImportError: return False def _check_dependencies() -> bool: """Check if all required test dependencies are installed. Returns: True if all dependencies are installed, False otherwise """ _print_header("Checking Test Dependencies") missing_deps = [] for dep in REQUIRED_DEPENDENCIES: if _check_dependency(dep): _print_colored(f" ✓ {dep}", Colors.GREEN) else: _print_colored(f" ✗ {dep} (MISSING)", Colors.RED) missing_deps.append(dep) if missing_deps: _print_colored("\n❌ Missing Dependencies!", Colors.RED + Colors.BOLD) _print_colored("\nTo install missing dependencies, run:", Colors.YELLOW) _print_colored(" uv sync --extra dev\n", Colors.CYAN) return False _print_colored("\n✅ All dependencies installed!", Colors.GREEN + Colors.BOLD) return True def _run_pytest(args: list[str], description: str, workers: str | None = None) -> int: """Run pytest with the specified arguments. Args: args: List of pytest arguments description: Description of what is being tested workers: Number of parallel workers or 'auto' (None = serial) Returns: Exit code from pytest """ _print_header(description) # Ensure reports directory exists reports_dir = Path("tests/reports") reports_dir.mkdir(parents=True, exist_ok=True) # Add worker configuration if specified if workers is not None: if "-n" not in args: args = args + ["-n", str(workers)] if workers != "auto" and int(workers) > 2: _print_colored( f"WARNING: Running with {workers} workers may cause OOM on EC2", Colors.YELLOW ) # Build the command cmd = ["pytest"] + args logger.info(f"Running: {' '.join(cmd)}") # Run pytest start_time = time.time() result = subprocess.run(cmd, cwd=Path.cwd()) # nosec B603 - pytest with args from argparse, development tool elapsed_time = time.time() - start_time # Display elapsed time minutes = int(elapsed_time // 60) seconds = elapsed_time % 60 if minutes > 0: logger.info(f"Completed in {minutes} minutes and {seconds:.1f} seconds") else: logger.info(f"Completed in {seconds:.1f} seconds") if result.returncode == 0: _print_colored(f"\n✅ {description} - PASSED", Colors.GREEN + Colors.BOLD) else: _print_colored(f"\n❌ {description} - FAILED", Colors.RED + Colors.BOLD) return result.returncode def _run_check() -> int: """Check if test dependencies are installed. Returns: Exit code (0 if all dependencies present, 1 otherwise) """ if _check_dependencies(): return 0 return 1 def _run_unit(workers: str | None = None) -> int: """Run unit tests only. Args: workers: Number of parallel workers or 'auto' Returns: Exit code from pytest """ args = ["-m", "unit", "-v"] return _run_pytest(args, "Running Unit Tests", workers) def _run_integration(workers: str | None = None) -> int: """Run integration tests only. Args: workers: Number of parallel workers or 'auto' Returns: Exit code from pytest """ # Override coverage threshold for integration tests (they don't hit all code paths) args = ["-m", "integration", "-v", "--cov-fail-under=0"] return _run_pytest(args, "Running Integration Tests", workers) def _run_e2e(workers: str | None = None) -> int: """Run end-to-end tests only. Args: workers: Number of parallel workers or 'auto' Returns: Exit code from pytest """ args = ["-m", "e2e", "-v"] return _run_pytest(args, "Running End-to-End Tests", workers) def _run_fast(workers: str | None = None) -> int: """Run fast tests (exclude slow tests). Args: workers: Number of parallel workers or 'auto' Returns: Exit code from pytest """ # Use 2 workers by default for fast tests if not specified if workers is None: workers = "2" args = ["-m", "not slow", "-v"] return _run_pytest(args, "Running Fast Tests (Excluding Slow)", workers) def _run_full(workers: str | None = None) -> int: """Run full test suite serially (memory-safe for EC2). Args: workers: Number of parallel workers or 'auto' Returns: Exit code from pytest """ # Run serially by default to avoid OOM crashes on EC2 args = ["-v"] return _run_pytest(args, "Running Full Test Suite", workers) def _run_coverage(workers: str | None = None) -> int: """Generate coverage reports. Args: workers: Number of parallel workers or 'auto' Returns: Exit code from pytest """ args = [ "-v", "--cov=registry", "--cov-report=term-missing", "--cov-report=html:htmlcov", "--cov-report=xml:coverage.xml", ] return _run_pytest(args, "Running Tests with Coverage", workers) def _run_domain(domain: str, workers: str | None = None) -> int: """Run domain-specific tests. Args: domain: Domain name (auth, servers, search, health, core) workers: Number of parallel workers or 'auto' Returns: Exit code from pytest """ args = ["-m", domain, "-v"] description = f"Running {domain.capitalize()} Domain Tests" return _run_pytest(args, description, workers) def main() -> int: """Main entry point for the test runner. Returns: Exit code from the selected test command """ parser = argparse.ArgumentParser( description="Test runner for MCP Registry", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Check dependencies python scripts/test.py check # Run unit tests python scripts/test.py unit # Run integration tests python scripts/test.py integration # Run full test suite python scripts/test.py full # Run fast tests (exclude slow) python scripts/test.py fast # Generate coverage reports python scripts/test.py coverage # Run domain-specific tests python scripts/test.py auth python scripts/test.py servers python scripts/test.py search python scripts/test.py health python scripts/test.py core """, ) parser.add_argument( "command", choices=[ "check", "unit", "integration", "e2e", "fast", "full", "coverage", "auth", "servers", "search", "health", "core", ], help="Test command to run", ) parser.add_argument( "--debug", action="store_true", help="Enable debug logging", ) parser.add_argument( "-n", "--workers", type=str, default=None, help="Number of parallel workers or 'auto' (default: serial). Use with caution on EC2.", ) args = parser.parse_args() # Set debug logging if requested if args.debug: logging.getLogger().setLevel(logging.DEBUG) logger.debug("Debug logging enabled") # Route to appropriate function workers = args.workers command_map = { "check": _run_check, "unit": lambda: _run_unit(workers), "integration": lambda: _run_integration(workers), "e2e": lambda: _run_e2e(workers), "fast": lambda: _run_fast(workers), "full": lambda: _run_full(workers), "coverage": lambda: _run_coverage(workers), "auth": lambda: _run_domain("auth", workers), "servers": lambda: _run_domain("servers", workers), "search": lambda: _run_domain("search", workers), "health": lambda: _run_domain("health", workers), "core": lambda: _run_domain("core", workers), } return command_map[args.command]() if __name__ == "__main__": sys.exit(main()) ================================================ FILE: scripts/validate-dockerfiles.sh ================================================ #!/bin/bash # Validate Dockerfiles for security best practices # Checks for non-root USER directive in all project Dockerfiles set -e echo "Validating Dockerfiles for security best practices..." echo "==================================================" # List of Dockerfiles to check DOCKERFILES=( "Dockerfile" "docker/Dockerfile.auth" "docker/Dockerfile.registry" "docker/Dockerfile.registry-cpu" "docker/Dockerfile.mcp-server" "docker/Dockerfile.mcp-server-cpu" "docker/Dockerfile.mcp-server-light" "docker/Dockerfile.scopes-init" "docker/Dockerfile.metrics-db" "docker/keycloak/Dockerfile" "metrics-service/Dockerfile" "terraform/aws-ecs/grafana/Dockerfile" ) ERRORS=0 WARNINGS=0 for dockerfile in "${DOCKERFILES[@]}"; do if [ ! -f "$dockerfile" ]; then echo "❌ ERROR: $dockerfile not found" ERRORS=$((ERRORS + 1)) continue fi echo "" echo "Checking: $dockerfile" echo "---" # Check for USER directive if grep -q "^USER " "$dockerfile"; then USER_LINE=$(grep "^USER " "$dockerfile" | tail -1) echo "✓ Has USER directive: $USER_LINE" else echo "❌ ERROR: Missing USER directive" ERRORS=$((ERRORS + 1)) fi # Check for HEALTHCHECK directive if grep -q "^HEALTHCHECK " "$dockerfile"; then echo "✓ Has HEALTHCHECK directive" else echo "⚠ WARNING: Missing HEALTHCHECK directive" WARNINGS=$((WARNINGS + 1)) fi # Check for PIP_NO_CACHE_DIR (Python images only) if grep -q "FROM.*python" "$dockerfile" 2>/dev/null; then if grep -q "PIP_NO_CACHE_DIR" "$dockerfile"; then echo "✓ Has PIP_NO_CACHE_DIR set" else echo "⚠ WARNING: Python image but missing PIP_NO_CACHE_DIR" WARNINGS=$((WARNINGS + 1)) fi fi # Check for sudo package (should be removed) if grep -q "sudo" "$dockerfile"; then echo "❌ ERROR: Contains 'sudo' package (security risk)" ERRORS=$((ERRORS + 1)) else echo "✓ No sudo package found" fi # Check for low-numbered ports in EXPOSE (< 1024 requires root) if grep -E "^EXPOSE.*(^| )(80|443|22|21)( |$)" "$dockerfile"; then echo "⚠ WARNING: Exposes privileged port (< 1024), requires root or port mapping" WARNINGS=$((WARNINGS + 1)) fi done echo "" echo "==================================================" echo "Validation Summary:" echo " Total Dockerfiles: ${#DOCKERFILES[@]}" echo " Errors: $ERRORS" echo " Warnings: $WARNINGS" if [ $ERRORS -gt 0 ]; then echo "" echo "❌ VALIDATION FAILED" exit 1 else echo "" echo "✅ VALIDATION PASSED" if [ $WARNINGS -gt 0 ]; then echo " (with $WARNINGS warnings)" fi exit 0 fi ================================================ FILE: servers/currenttime/.dockerignore ================================================ # Python cache __pycache__/ *.py[cod] *$py.class *.so # Virtual environments .venv/ venv/ env/ # IDE .vscode/ .idea/ *.swp *.swo # OS .DS_Store Thumbs.db # Documentation *.md README* # Tests *_test.py test_*.py tests/ # Git .git/ .gitignore # Logs *.log # Temporary files *.tmp tmp/ temp/ ================================================ FILE: servers/currenttime/pyproject.toml ================================================ [project] name = "current-time-mcp" version = "0.1.0" description = "MCP server to get current time from the timeapi.io API" readme = "README.md" requires-python = ">=3.14" dependencies = [ "fastmcp>=2.0.0", "pydantic>=2.11.3", "pytz>=2025.2", "pyjwt>=2.12.0", ] [tool.uv] # Local-only project - never resolve from PyPI package = false ================================================ FILE: servers/currenttime/server.py ================================================ """ This server provides an interface to get the current time in a specified timezone using the timeapi.io API. """ import argparse import logging import os from datetime import datetime from typing import Annotated import pytz from fastmcp import FastMCP from pydantic import Field # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def parse_arguments(): """Parse command line arguments with defaults matching environment variables.""" parser = argparse.ArgumentParser(description="Current Time MCP Server") parser.add_argument( "--port", type=str, default=os.environ.get("MCP_SERVER_LISTEN_PORT", "8000"), help="Port for the MCP server to listen on (default: 8000)", ) parser.add_argument( "--transport", type=str, default=os.environ.get("MCP_TRANSPORT", "streamable-http"), choices=["sse", "streamable-http"], help="Transport type for the MCP server (default: streamable-http)", ) return parser.parse_args() # Parse arguments at module level to make them available args = parse_arguments() # Log parsed arguments for debugging logger.info(f"Parsed arguments - port: {args.port}, transport: {args.transport}") logger.info( f"Environment variables - MCP_TRANSPORT: {os.environ.get('MCP_TRANSPORT', 'NOT SET')}, MCP_SERVER_LISTEN_PORT: {os.environ.get('MCP_SERVER_LISTEN_PORT', 'NOT SET')}" ) # Initialize FastMCP server mcp = FastMCP("CurrentTimeAPI") @mcp.prompt() def system_prompt_for_agent(location: str) -> str: """ Generates a system prompt for an AI Agent that wants to use the current_time MCP server. This function creates a specialized prompt for an AI agent that wants to determine the current time in a specific timezone. The prompt instructs an model to provide the name of a timezone closest to the current location provided by the user so that the timezone name (such as America/New_York, Africa/Cairo etc.) can be passed as an input to the tools provided by the current_time MCP server. Args: location (str): The location of the user, which will be used to determine the timezone. Returns: str: A formatted system prompt for the AI Agent. """ system_prompt = f""" You are an expert AI agent that wants to use the current_time MCP server. You will be provided with the user's location as input. You will need to determine the name of the timezone closest to the current location provided by the user so that the timezone name (such as America/New_York, Africa/Cairo etc.) can be passed as an input to the tools provided by the current_time MCP server. The user's location is: {location} """ return system_prompt def get_current_time_in_timezone(timezone_name): """ Retrieves the current time in a specified timezone. Args: timezone_name: A string representing the timezone name (e.g., 'America/New_York', 'Europe/London'). Returns: A datetime object representing the current time in the specified timezone, or None if the timezone is invalid. """ try: timezone = pytz.timezone(timezone_name) current_time = datetime.now(timezone) return current_time except pytz.exceptions.UnknownTimeZoneError: return None @mcp.tool() def current_time_by_timezone( tz_name: Annotated[ str, Field( default="America/New_York", description="Name of the timezone for which to find out the current time", ), ] = "America/New_York", ) -> str: """ Get the current time for a specified timezone using the timeapi.io API. Args: tz_name: Name of the timezone for which to find out the current time (default: America/New_York) Returns: str: string representation of the current time in the %Y-%m-%d %H:%M:%S %Z%z format for the specified timezone. Raises: Exception: If the API request fails """ try: timezone = pytz.timezone(tz_name) current_time = datetime.now(timezone) return current_time.strftime("%Y-%m-%d %H:%M:%S %Z%z") except Exception as e: return f"Error: {str(e)}" @mcp.resource("config://app") def get_config() -> str: """Static configuration data""" return "App configuration here" def main(): # Use configurable host with secure default (127.0.0.1) # Set HOST=0.0.0.0 in environment for Docker deployments host = os.environ.get("HOST", "127.0.0.1") # Log startup information logger.info(f"Starting CurrentTime server on {host}:{args.port}") logger.info(f"Server will be available at: http://{host}:{args.port}/mcp") # Run the server mcp.run(transport=args.transport, host=host, port=int(args.port)) if __name__ == "__main__": main() ================================================ FILE: servers/example-server/pyproject.toml ================================================ [project] name = "example-mcp-server" version = "0.1.0" description = "Example MCP server demonstrating basic functionality" readme = "README.md" requires-python = ">=3.14" dependencies = [ "mcp>=1.9.3", "pydantic>=2.11.3", "aiohttp>=3.8.0", ] [tool.uv] # Local-only project - never resolve from PyPI package = false ================================================ FILE: servers/example-server/server.py ================================================ """ Example MCP Server demonstrating basic functionality. This server provides simple tools for demonstration purposes. """ import argparse import logging import os from typing import Annotated, Any from mcp.server.fastmcp import FastMCP from pydantic import Field # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) def _parse_arguments(): """Parse command line arguments with defaults matching environment variables.""" parser = argparse.ArgumentParser(description="Example MCP Server") parser.add_argument( "--port", type=str, default=os.environ.get("MCP_SERVER_LISTEN_PORT", "9000"), help="Port for the MCP server to listen on (default: 9000)", ) parser.add_argument( "--transport", type=str, default=os.environ.get("MCP_TRANSPORT", "streamable-http"), choices=["sse", "streamable-http"], help="Transport type for the MCP server (default: streamable-http)", ) return parser.parse_args() # Parse arguments at module level to make them available args = _parse_arguments() # Log parsed arguments for debugging logger.info(f"Parsed arguments - port: {args.port}, transport: {args.transport}") logger.info( f"Environment variables - MCP_TRANSPORT: {os.environ.get('MCP_TRANSPORT', 'NOT SET')}, MCP_SERVER_LISTEN_PORT: {os.environ.get('MCP_SERVER_LISTEN_PORT', 'NOT SET')}" ) # Initialize FastMCP server # Example server - binds to 0.0.0.0 for demonstration purposes only. # In production, bind to 127.0.0.1 or specific IP with proper firewall rules. mcp = FastMCP("ExampleMCPServer", host="0.0.0.0", port=int(args.port)) # nosec B104 - example/demo server mcp.settings.mount_path = "/example-server" @mcp.prompt() def system_prompt_for_agent(task: str) -> str: """ Generates a system prompt for an AI Agent that wants to use the example MCP server. This function creates a specialized prompt for an AI agent that wants to demonstrate basic MCP functionality using the example tools provided by this server. Args: task (str): The task or operation the agent wants to perform. Returns: str: A formatted system prompt for the AI Agent. """ system_prompt = f""" You are an expert AI agent that wants to use the Example MCP server. You will be provided with a task to perform. You can use the available tools to demonstrate basic MCP functionality. The task you need to perform is: {task} Available tools: - example_tool: Process a message and return a formatted response - echo_tool: Echo back the input with additional metadata - status_tool: Get the current status of the example server """ return system_prompt def _process_message(message: str) -> dict[str, Any]: """ Internal function to process a message. Args: message: The message to process Returns: Dict containing processed message information """ processed = { "original_message": message, "processed_message": message.upper(), "message_length": len(message), "word_count": len(message.split()), "timestamp": "2025-09-26T23:00:00Z", } return processed @mcp.tool() def example_tool( message: Annotated[str, Field(description="Example message to process")], ) -> dict[str, Any]: """ An example tool that demonstrates MCP functionality. This tool takes a message as input, processes it, and returns a structured response containing various information about the message. Args: message: Example message to process Returns: Dict[str, Any]: Result of the example operation containing processed message info Raises: Exception: If the operation fails """ try: logger.info(f"Processing message: {message}") result = _process_message(message) logger.info("Successfully processed message") return result except Exception as e: logger.error(f"Error processing message: {str(e)}") raise Exception(f"Failed to process message: {str(e)}") @mcp.tool() def echo_tool( input_text: Annotated[str, Field(description="Text to echo back")], include_metadata: Annotated[ bool, Field(default=True, description="Whether to include metadata in the response") ] = True, ) -> dict[str, Any]: """ A simple echo tool that returns the input with optional metadata. Args: input_text: Text to echo back include_metadata: Whether to include metadata in the response Returns: Dict[str, Any]: Echo response with optional metadata Raises: Exception: If the operation fails """ try: logger.info(f"Echoing text: {input_text}") response = {"echo": input_text, "success": True} if include_metadata: response.update( { "metadata": { "character_count": len(input_text), "server": "Example MCP Server", "version": "0.1.0", } } ) return response except Exception as e: logger.error(f"Error in echo tool: {str(e)}") raise Exception(f"Echo operation failed: {str(e)}") @mcp.tool() def status_tool() -> dict[str, Any]: """ Get the current status of the example server. Returns: Dict[str, Any]: Server status information Raises: Exception: If unable to get status """ try: logger.info("Getting server status") status = { "server_name": "Example MCP Server", "version": "0.1.0", "status": "running", "port": args.port, "transport": args.transport, "available_tools": ["example_tool", "echo_tool", "status_tool"], "health": "healthy", } return status except Exception as e: logger.error(f"Error getting status: {str(e)}") raise Exception(f"Failed to get server status: {str(e)}") @mcp.resource("config://app") def get_config() -> str: """Static configuration data for the example server""" return """ Example MCP Server Configuration: - Server Name: Example MCP Server - Version: 0.1.0 - Available Tools: example_tool, echo_tool, status_tool - Transport: streamable-http - Description: Demonstrates basic MCP functionality """ def main(): # Log transport and endpoint information endpoint = "/mcp" if args.transport == "streamable-http" else "/sse" logger.info(f"Starting Example MCP server on port {args.port} with transport {args.transport}") logger.info(f"Server will be available at: http://localhost:{args.port}{endpoint}") # Run the server with the specified transport from command line args mcp.run(transport=args.transport) if __name__ == "__main__": main() ================================================ FILE: servers/fininfo/.dockerignore ================================================ # Python cache __pycache__/ *.py[cod] *$py.class *.so # Virtual environments .venv/ venv/ env/ # IDE .vscode/ .idea/ *.swp *.swo # OS .DS_Store Thumbs.db # Documentation *.md README* # Tests *_test.py test_*.py tests/ # Git .git/ .gitignore # Logs *.log # Temporary files *.tmp tmp/ temp/ ================================================ FILE: servers/fininfo/.keys.yml.template ================================================ # Financial Info MCP Server - Client API Keys Configuration # # This file maps client IDs to their respective Polygon API keys. # Format: client_id: api_key # # Example: # client1: your_polygon_api_key_1 # client2: your_polygon_api_key_2 # default: fallback_api_key # # Note: In production, this file should be: # - Stored securely with appropriate file permissions (600) # - Backed up and version controlled separately from code # - Potentially encrypted using the SECRET_KEY # - Monitored for unauthorized access # Default fallback key (uses the existing POLYGON_API_KEY from environment) default: default_polygon_key_here # Example client configurations (uncomment and modify as needed) # client_demo: oN7dCYnQLIGMN1uCrHFpjX4YluM0EKTp # client_prod: your_production_polygon_api_key_here # client_test: your_test_polygon_api_key_here ================================================ FILE: servers/fininfo/README.md ================================================ # Fininfo MCP Server This MCP server provides financial information using the Polygon.io API with FastMCP 2.0. ## Features - **Stock aggregate data**: Get historical stock data from Polygon.io - **HTTP header debugging**: View HTTP headers sent to the server - **FastMCP 2.0**: Built with the latest FastMCP framework ## Quick Start ### 1. Install Dependencies ```bash # Install Python dependencies with uv uv sync ``` ### 2. Set Environment Variables ```bash # Set your Polygon.io API key export POLYGON_API_KEY="your_polygon_api_key_here" ``` ### 3. Run the Server ```bash # Using uv uv run python server.py --port 8000 --transport sse # Or activate the virtual environment first source .venv/bin/activate python server.py --port 8000 --transport sse ``` ## Usage ### Using the Python Client ```bash # Test the server uv run python client.py # Connect to remote server uv run python client.py --host your-server.com --port 8000 ``` ### Available Tools - `get_stock_aggregates`: Get stock aggregate data from Polygon.io - `print_stock_data`: Get formatted stock data as a string - `get_http_headers`: Debug tool to view HTTP headers ## Environment Variables - `POLYGON_API_KEY`: Your Polygon.io API key (required) - `MCP_SERVER_LISTEN_PORT`: Server port (default: 8000) - `MCP_TRANSPORT`: Transport type (default: sse) ## Example API Call ```python # Get Apple stock data for the last week params = { "stock_ticker": "AAPL", "multiplier": 1, "timespan": "day", "from_date": "2023-01-01", "to_date": "2023-01-31", "adjusted": True, "sort": "desc", "limit": 10 } ``` ## Development The server includes comprehensive HTTP header debugging to help with development and troubleshooting. The `get_http_headers` tool shows all incoming headers with sensitive information masked for security. ================================================ FILE: servers/fininfo/README_SECRETS.md ================================================ # Financial Info MCP Server - Secrets Manager This document describes the local secrets manager implementation for the Financial Info MCP Server. ## Overview The secrets manager allows different clients to use their own Polygon API keys by including an `x-client-id` header in their HTTP requests. This enables: - Multi-tenant API key management - Client-specific rate limiting and billing - Secure key storage and rotation - Fallback mechanisms for backward compatibility ## Setup ### 1. Docker Configuration The `docker-compose.yml` has been updated to map the secrets file: ```yaml volumes: - ${HOME}/mcp-gateway/secrets/.keys.yml:/app/fininfo/.keys.yml ``` ### 2. Create Secrets File Create the secrets file on your host system: ```bash mkdir -p ${HOME}/mcp-gateway/secrets touch ${HOME}/mcp-gateway/secrets/.keys.yml chmod 600 ${HOME}/mcp-gateway/secrets/.keys.yml ``` ### 3. Configure Client API Keys Edit the secrets file with your client configurations: ```yaml # Default fallback key default: your_default_polygon_api_key # Client-specific keys client_demo: demo_polygon_api_key client_prod: production_polygon_api_key client_test: test_polygon_api_key ``` ## Usage ### Client Requests Clients should include the `x-client-id` header in their HTTP requests: ```bash curl -X POST "http://localhost:8001/sse" \ -H "Content-Type: application/json" \ -H "x-client-id: client_demo" \ -d '{ "method": "tools/call", "params": { "name": "get_stock_aggregates", "arguments": { "stock_ticker": "AAPL", "multiplier": 1, "timespan": "day", "from_date": "2023-01-01", "to_date": "2023-01-31" } } }' ``` ### Fallback Behavior If no `x-client-id` header is provided or the client ID is not found: 1. Uses the `POLYGON_API_KEY` environment variable (backward compatibility) 2. Falls back to the `default` key from the secrets file 3. Throws an error if no API key is available ## Security Features ### Current Implementation - YAML file-based storage with secure file permissions - Client ID validation and logging - API key masking in logs - Graceful fallback mechanisms ### Future Enhancements The secrets manager is designed to be extensible: ```python # Encryption support using SECRET_KEY def _decrypt_file_content(self, encrypted_content: bytes) -> str: encryption_key = self._get_encryption_key() fernet = Fernet(encryption_key) return fernet.decrypt(encrypted_content).decode('utf-8') # External secrets manager integration def _fetch_from_vault(self, client_id: str) -> str: # Connect to HashiCorp Vault, AWS Secrets Manager, etc. pass ``` ## API Key Management ### Reloading Secrets The secrets manager supports runtime reloading: ```python # Programmatically reload secrets secrets_manager.reload_secrets() ``` ### Monitoring The server logs all API key access attempts with redacted keys for security: ``` INFO: 🔑 Client ID found in header: client_demo INFO: API key found for client_id: client_demo (key: oN7d...EKTp) INFO: ✅ Using client-specific API key for client: client_demo ``` API keys are automatically redacted in logs showing only the first 4 and last 4 characters. ## File Encryption (Supported) The secrets manager now supports encrypted secrets files using the existing `SECRET_KEY`. ### Encrypting a Secrets File Use the built-in encryption method: ```python # Encrypt the current secrets file success = secrets_manager.encrypt_secrets_file() # Encrypt a specific file success = secrets_manager.encrypt_secrets_file('plain.yml', 'encrypted.yml') ``` Or manually encrypt using the SECRET_KEY: ```python from cryptography.fernet import Fernet import base64 import hashlib # Generate encryption key from SECRET_KEY secret_key = os.environ.get("SECRET_KEY") key_bytes = hashlib.sha256(secret_key.encode()).digest() encryption_key = base64.urlsafe_b64encode(key_bytes) # Encrypt secrets file fernet = Fernet(encryption_key) with open('.keys.yml', 'r') as f: plain_content = f.read() encrypted_data = fernet.encrypt(plain_content.encode('utf-8')) encoded_data = base64.b64encode(encrypted_data).decode('utf-8') with open('.keys.yml.encrypted', 'w') as f: f.write(encoded_data) ``` ### Using Encrypted Files The secrets manager automatically detects and decrypts encrypted files: 1. **Filename-based Detection**: Files ending with `.encrypted` are recognized as encrypted 2. **Transparent Decryption**: Encrypted files are automatically decrypted using the SECRET_KEY 3. **Error Handling**: Clear error messages if decryption fails ``` INFO: Encrypted secrets file detected (filename ends with .encrypted), attempting to decrypt... INFO: Successfully decrypted secrets file ``` Example usage: - Plain text: `.keys.yml` → loaded directly - Encrypted: `.keys.yml.encrypted` → automatically decrypted ### Encryption Format Encrypted files are stored as base64-encoded Fernet tokens: - **Detection**: Files with `.encrypted` extension are treated as encrypted - **Encoding**: Base64 encoded for text file storage - **Key Derivation**: SHA256 hash of SECRET_KEY for consistent key generation - **Content**: Fernet-encrypted YAML data encoded as base64 text ### Encryption Utility Script A utility script [`encrypt_secrets.py`](servers/fininfo/encrypt_secrets.py:1-78) is provided for easy encryption/decryption: ```bash # Encrypt the default secrets file python encrypt_secrets.py # Encrypt a specific file python encrypt_secrets.py plain.yml encrypted.yml # Test decryption of an encrypted file python encrypt_secrets.py --test encrypted.yml # Decrypt an encrypted file back to plain text python encrypt_secrets.py --decrypt encrypted.yml decrypted.yml ``` The script requires the `SECRET_KEY` environment variable to be set. ## Troubleshooting ### Common Issues 1. **File not found**: Ensure the secrets file exists at the mapped path 2. **Permission denied**: Check file permissions (should be 600) 3. **YAML parsing error**: Validate YAML syntax 4. **No API key found**: Check client ID spelling and file contents ### Debug Logging Enable debug logging to see detailed information: ```python logging.basicConfig(level=logging.DEBUG) ``` ### Health Check Check secrets manager status: ```python stats = secrets_manager.get_stats() print(f"Loaded {stats['client_count']} clients") print(f"File exists: {stats['file_exists']}") ``` ## Production Considerations 1. **Backup**: Regularly backup the secrets file 2. **Rotation**: Implement API key rotation procedures 3. **Monitoring**: Monitor API usage per client 4. **Encryption**: Consider encrypting the secrets file 5. **Access Control**: Restrict file system access 6. **Auditing**: Log all key access attempts ## Integration Examples ### AWS Secrets Manager ```python import boto3 class AWSSecretsManager(SecretsManager): def __init__(self): self.client = boto3.client('secretsmanager') def get_api_key(self, client_id: str) -> str: response = self.client.get_secret_value( SecretId=f'fininfo/clients/{client_id}/api-key' ) return response['SecretString'] ``` ### HashiCorp Vault ```python import hvac class VaultSecretsManager(SecretsManager): def __init__(self): self.client = hvac.Client(url='https://vault.example.com') def get_api_key(self, client_id: str) -> str: response = self.client.secrets.kv.v2.read_secret_version( path=f'fininfo/clients/{client_id}' ) return response['data']['data']['api_key'] ================================================ FILE: servers/fininfo/client.py ================================================ """ This file provides a simple MCP client using just the mcp Python package. It shows how to access the different MCP server capabilities (prompts, tools etc.) via the message types supported by the protocol. See: https://modelcontextprotocol.io/docs/concepts/architecture. Usage: python client.py [--host HOSTNAME] [--port PORT] Example: python client.py --host localhost --port 8000 """ import argparse import logging from mcp import ClientSession from mcp.client.sse import sse_client # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s.%(msecs)03d - PID:%(process)d - %(filename)s:%(lineno)d - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) logger = logging.getLogger(__name__) async def run(server_url, args): logger.info(f"Connecting to MCP server at: {server_url}") logger.info(f"Using client ID: {args.client_id}") # Set up headers including x-client-id headers = {"x-client-id": args.client_id} async with sse_client(server_url, headers=headers) as (read, write): async with ClientSession(read, write, sampling_callback=None) as session: # Initialize the connection await session.initialize() # List available prompts prompts = await session.list_prompts() logger.info("=" * 50) logger.info("Available prompts:") logger.info("=" * 50) logger.info(f"{prompts}") logger.info("=" * 50) # List available resources resources = await session.list_resources() logger.info("=" * 50) logger.info("Available resources:") logger.info("=" * 50) logger.info(f"{resources}") logger.info("=" * 50) # List available tools tools = await session.list_tools() logger.info("=" * 50) logger.info("Available tools:") logger.info("=" * 50) logger.info(f"{tools}") logger.info("=" * 50) # Call the print_stock_data tool from datetime import date, timedelta params = dict( stock_ticker="AAPL", multiplier=1, timespan="day", from_date=str(date.today() - timedelta(days=7)), to_date=str(date.today()), adjusted=True, sort="desc", limit=10, ) # Get daily data for Apple stock logger.info(f"\nCalling print_stock_data tool with params={params}") result = await session.call_tool("print_stock_data", arguments=params) # Display the results logger.info("=" * 50) logger.info("Results:") logger.info("=" * 50) for r in result.content: logger.info(r.text) logger.info("=" * 50) if __name__ == "__main__": # Set up command-line argument parsing parser = argparse.ArgumentParser(description="MCP Client for Bedrock Usage Statistics") parser.add_argument("--host", type=str, default="localhost", help="Hostname of the MCP server") parser.add_argument("--port", type=int, default=8000, help="Port of the MCP server") parser.add_argument( "--server-name", type=str, default=None, help='Name of the MCP server to connect to (e.g., "fininfo")', ) parser.add_argument( "--client-id", type=str, default="test-client", help='Client ID to send in x-client-id header (default: "test-client")', ) # Parse the arguments args = parser.parse_args() # Build the server secure = "" # Automatically turn to https if port is 443 if args.port == 443: secure = "s" if args.server_name is not None: server_url = f"http{secure}://{args.host}:{args.port}/{args.server_name}/sse" else: server_url = f"http{secure}://{args.host}:{args.port}/sse" # Run the async main function import asyncio asyncio.run(run(server_url, args)) ================================================ FILE: servers/fininfo/encrypt_secrets.py ================================================ #!/usr/bin/env python3 """ Utility script to encrypt secrets files for the Financial Info MCP Server. Usage: python encrypt_secrets.py [input_file] [output_file] Examples: # Encrypt the default secrets file python encrypt_secrets.py # Encrypt a specific file python encrypt_secrets.py plain.yml encrypted.yml # Test decryption python encrypt_secrets.py --test encrypted.yml """ import argparse import os import sys from secrets_manager import SecretsManager def main(): parser = argparse.ArgumentParser(description="Encrypt/decrypt secrets files") parser.add_argument( "input_file", nargs="?", default=".keys.yml", help="Input file path (default: .keys.yml)" ) parser.add_argument( "output_file", nargs="?", help="Output file path (default: input_file.encrypted)" ) parser.add_argument("--test", action="store_true", help="Test decryption of an encrypted file") parser.add_argument("--decrypt", action="store_true", help="Decrypt an encrypted file") args = parser.parse_args() # Check if SECRET_KEY is available if not os.environ.get("SECRET_KEY"): print("ERROR: SECRET_KEY environment variable is required for encryption/decryption") print("Please set SECRET_KEY in your environment or .env file") sys.exit(1) if args.test: print(f"Testing decryption of: {args.input_file}") try: # Try to load the encrypted file secrets_manager = SecretsManager(args.input_file) client_ids = secrets_manager.get_all_client_ids() print(f"Successfully decrypted and loaded {len(client_ids)} client configurations") print(f"Number of client IDs loaded: {len(client_ids)}") except Exception as e: print(f"❌ Failed to decrypt file: {e}") sys.exit(1) elif args.decrypt: print(f"Decrypting: {args.input_file}") output_file = args.output_file or args.input_file.replace(".encrypted", ".decrypted") try: # Load encrypted file and save as plain text secrets_manager = SecretsManager(args.input_file) # Save as plain YAML import yaml with open(output_file, "w") as f: yaml.dump(secrets_manager.secrets, f, default_flow_style=False) print(f"✅ Successfully decrypted to: {output_file}") except Exception as e: print(f"❌ Failed to decrypt file: {e}") sys.exit(1) else: # Encrypt mode print(f"Encrypting: {args.input_file}") if not os.path.exists(args.input_file): print(f"ERROR: Input file does not exist: {args.input_file}") sys.exit(1) # Initialize secrets manager and encrypt secrets_manager = SecretsManager() success = secrets_manager.encrypt_secrets_file( input_file=args.input_file, output_file=args.output_file ) if success: output_file = args.output_file or (args.input_file + ".encrypted") print(f"✅ Successfully encrypted to: {output_file}") print("\nTo use the encrypted file:") print("1. Replace your plain text secrets file with the encrypted version") print("2. The secrets manager will automatically detect and decrypt it") print("3. Ensure SECRET_KEY environment variable is available") else: print("❌ Encryption failed") sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: servers/fininfo/pyproject.toml ================================================ [project] name = "fininfo-mcp-server" version = "0.1.0" description = "MCP server to provide financial information using Polygon.io API" readme = "README.md" requires-python = ">=3.14" dependencies = [ "fastmcp>=2.0.0", "pydantic>=2.11.3", "requests>=2.32.3", "python-dotenv>=1.0.0", "PyYAML>=6.0.0", "cryptography>=41.0.0", ] [tool.uv] # Local-only project - never resolve from PyPI package = false ================================================ FILE: servers/fininfo/secrets_manager.py ================================================ """ Secrets Manager for Financial Info MCP Server This is a wrapper class to illustrate how you can plugin any secrets manager of choice. For simplicity, we are reading from a YAML file, but this could be extended to: - Read from encrypted files that can be decrypted with a secret key - Connect to AWS Secrets Manager, HashiCorp Vault, Azure Key Vault, etc. - Use the SECRET_KEY from environment variables for encryption/decryption - Implement key rotation and caching mechanisms The current implementation provides a foundation that can be easily extended for production use cases while maintaining a simple interface. """ import base64 import hashlib import logging import os from pathlib import Path import yaml from cryptography.fernet import Fernet logger = logging.getLogger(__name__) class SecretsManager: """ Generic secrets manager that provides a simple interface for loading and retrieving API keys. This implementation reads from a YAML file but can be extended to support: - Encrypted file storage using the SECRET_KEY from environment - External secrets management services (AWS Secrets Manager, Vault, etc.) - Database storage with encryption at rest - Key rotation and automatic reloading """ def __init__(self, secrets_file_path: str = "/app/fininfo/.keys.yml"): """ Initialize the secrets manager. Args: secrets_file_path: Base path to the secrets file (default: /app/fininfo/.keys.yml) Will first try .encrypted version, then fall back to plain text """ self.base_secrets_file_path = Path(secrets_file_path) self.secrets: dict[str, str] = {} self.secret_key = os.environ.get("SECRET_KEY") # Load secrets on initialization self.load_secrets() def _get_encryption_key(self) -> bytes | None: """ Generate a Fernet encryption key from the SECRET_KEY environment variable. This demonstrates how the existing SECRET_KEY could be used for encryption. In production, you might want to use a dedicated encryption key. Returns: bytes: Fernet-compatible encryption key, or None if SECRET_KEY not available """ if not self.secret_key: return None # Create a consistent 32-byte key from the SECRET_KEY key_bytes = hashlib.sha256(self.secret_key.encode()).digest() return base64.urlsafe_b64encode(key_bytes) def _decrypt_file_content(self, encrypted_content: bytes) -> str: """ Decrypt file content using the SECRET_KEY. This is an example of how encrypted secrets could be handled. Currently not used but shows the extensibility. Args: encrypted_content: Encrypted file content Returns: str: Decrypted content Raises: ValueError: If decryption fails or SECRET_KEY not available """ encryption_key = self._get_encryption_key() if not encryption_key: raise ValueError("SECRET_KEY not available for decryption") fernet = Fernet(encryption_key) try: decrypted_bytes = fernet.decrypt(encrypted_content) return decrypted_bytes.decode("utf-8") except Exception as e: raise ValueError(f"Failed to decrypt secrets file: {e}") def load_secrets(self) -> None: """ Load secrets from the configured file with fallback logic. First tries to load from .encrypted file, then falls back to plain text file. The file format supports: - Simple key-value pairs: client_id: api_key - Multiple client IDs with their respective API keys Example YAML format: client1: api_key_1 client2: api_key_2 default: fallback_api_key Fallback logic: 1. Try base_path + '.encrypted' (encrypted file) 2. If not found, try base_path (plain text file) 3. If neither found, create empty secrets dictionary """ # Try encrypted file first encrypted_file_path = Path(str(self.base_secrets_file_path) + ".encrypted") plain_file_path = self.base_secrets_file_path secrets_file_path = None is_encrypted = False if encrypted_file_path.exists(): secrets_file_path = encrypted_file_path is_encrypted = True logger.info(f"Found encrypted secrets file: {encrypted_file_path}") elif plain_file_path.exists(): secrets_file_path = plain_file_path is_encrypted = False logger.info(f"Found plain text secrets file: {plain_file_path}") else: logger.warning("No secrets file found. Tried:") logger.warning(f" - Encrypted: {encrypted_file_path}") logger.warning(f" - Plain text: {plain_file_path}") logger.info( "Creating empty secrets dictionary. Add secrets to enable client-specific API keys." ) self.secrets = {} return try: if is_encrypted: logger.info("Loading encrypted secrets file, attempting to decrypt...") try: with open(secrets_file_path) as file: encrypted_content_b64 = file.read().strip() # Decode the base64 content and decrypt import base64 encrypted_content = base64.b64decode(encrypted_content_b64) content = self._decrypt_file_content(encrypted_content) logger.info("Successfully decrypted secrets file") except Exception as e: logger.error(f"Failed to decrypt secrets file: {e}") raise ValueError(f"Cannot decrypt secrets file: {e}") else: # Plain text file logger.info("Loading plain text secrets file...") with open(secrets_file_path) as file: content = file.read() self.secrets = yaml.safe_load(content) or {} logger.info(f"Loaded {len(self.secrets)} client secrets from {secrets_file_path}") logger.debug(f"Available client IDs: {list(self.secrets.keys())}") except yaml.YAMLError as e: logger.error(f"Error parsing YAML secrets file: {e}") self.secrets = {} except Exception as e: logger.error(f"Error loading secrets file: {e}") self.secrets = {} def reload_secrets(self) -> None: """ Reload secrets from the file. This allows for runtime updates without restarting the server. In production, you might want to add file watching or periodic reloading. """ logger.info("Reloading secrets from file...") old_count = len(self.secrets) self.load_secrets() new_count = len(self.secrets) if new_count != old_count: logger.info(f"Secrets reloaded: {old_count} -> {new_count} client configurations") else: logger.info("Secrets reloaded successfully") def get_api_key(self, client_id: str) -> str | None: """ Retrieve the API key for a specific client ID. Args: client_id: The client identifier Returns: str: The API key for the client, or None if not found Note: This method could be extended to: - Log access attempts for auditing - Implement rate limiting per client - Cache frequently accessed keys - Validate key expiration dates """ if not client_id: logger.warning("Empty client_id provided to get_api_key") return None api_key = self.secrets.get(client_id) if api_key: logger.info(f"API key found for client_id: {client_id}") logger.debug(f"API key length for {client_id}: {len(api_key)} characters") else: logger.warning(f"No API key found for client_id: {client_id}") logger.debug(f"Number of available client IDs: {len(self.secrets)}") return api_key def has_client(self, client_id: str) -> bool: """ Check if a client ID exists in the secrets. Args: client_id: The client identifier Returns: bool: True if client exists, False otherwise """ return client_id in self.secrets def get_all_client_ids(self) -> list: """ Get a list of all configured client IDs. Returns: list: List of client IDs Note: This method is useful for debugging and administrative purposes. In production, you might want to restrict access to this information. """ return list(self.secrets.keys()) def encrypt_secrets_file(self, input_file: str = None, output_file: str = None) -> bool: """ Encrypt a secrets file using the SECRET_KEY. Args: input_file: Path to the plain text secrets file (default: current secrets file) output_file: Path to save encrypted file (default: input_file + '.encrypted') Returns: bool: True if encryption successful, False otherwise Example: # Encrypt the current secrets file secrets_manager.encrypt_secrets_file() # Encrypt a specific file secrets_manager.encrypt_secrets_file('plain.yml', 'encrypted.yml') """ if not input_file: input_file = str(self.base_secrets_file_path) if not output_file: output_file = input_file + ".encrypted" try: encryption_key = self._get_encryption_key() if not encryption_key: logger.error("Cannot encrypt: SECRET_KEY not available") return False # Read the plain text file with open(input_file) as f: plain_content = f.read() # Encrypt the content fernet = Fernet(encryption_key) encrypted_data = fernet.encrypt(plain_content.encode("utf-8")) # Encode to base64 for storage import base64 encoded_data = base64.b64encode(encrypted_data).decode("utf-8") # Write encrypted file with open(output_file, "w") as f: f.write(encoded_data) logger.info(f"Successfully encrypted {input_file} to {output_file}") return True except Exception as e: logger.error(f"Failed to encrypt secrets file: {e}") return False def get_stats(self) -> dict[str, any]: """ Get statistics about the secrets manager. Returns: dict: Statistics including client count, file path, etc. """ encrypted_file_path = Path(str(self.base_secrets_file_path) + ".encrypted") plain_file_path = self.base_secrets_file_path return { "base_secrets_file": str(self.base_secrets_file_path), "encrypted_file_path": str(encrypted_file_path), "plain_file_path": str(plain_file_path), "encrypted_file_exists": encrypted_file_path.exists(), "plain_file_exists": plain_file_path.exists(), "active_file": str(encrypted_file_path) if encrypted_file_path.exists() else str(plain_file_path), "using_encrypted": encrypted_file_path.exists(), "client_count": len(self.secrets), "client_ids": list(self.secrets.keys()), "encryption_available": self.secret_key is not None, "secret_key_length": len(self.secret_key) if self.secret_key else 0, } ================================================ FILE: servers/fininfo/server.py ================================================ """ This server provides stock market data using the Polygon.io API. Now supports client-specific API keys via x-client-id header and secrets manager. """ import argparse import asyncio import logging import os import time from typing import Annotated, Any, ClassVar import requests from dotenv import load_dotenv from fastmcp import Context, FastMCP # Updated import for FastMCP 2.0 from fastmcp.server.dependencies import get_http_request # New dependency function for HTTP access from pydantic import BaseModel, Field from secrets_manager import SecretsManager # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) load_dotenv() # Load environment variables from .env file # Initialize secrets manager for client-specific API keys secrets_manager = SecretsManager("/app/fininfo/.keys.yml") # Fallback API key from environment (for backward compatibility) FALLBACK_API_KEY = os.environ.get("POLYGON_API_KEY") if FALLBACK_API_KEY is None: logger.warning( "POLYGON_API_KEY environment variable is not set. Relying on secrets manager only." ) class Constants(BaseModel): # Using ClassVar to define class-level constants DESCRIPTION: ClassVar[str] = "Fininfo MCP Server" MAX_RETRIES: ClassVar[int] = 3 RETRY_DELAY: ClassVar[float] = 1 DEFAULT_TIMEOUT: ClassVar[float] = 1 DEFAULT_MCP_TRANSPORT: ClassVar[str] = "sse" DEFAULT_MCP_SEVER_LISTEN_PORT: ClassVar[str] = "8000" # Disable instance creation - optional but recommended for constants class Config: frozen = True # Make instances immutable def parse_arguments(): """Parse command line arguments with defaults matching environment variables.""" parser = argparse.ArgumentParser(description=Constants.DESCRIPTION) parser.add_argument( "--port", type=str, default=os.environ.get("MCP_SERVER_LISTEN_PORT", Constants.DEFAULT_MCP_SEVER_LISTEN_PORT), help=f"Port for the MCP server to listen on (default: {Constants.DEFAULT_MCP_SEVER_LISTEN_PORT})", ) parser.add_argument( "--transport", type=str, default=os.environ.get("MCP_TRANSPORT", Constants.DEFAULT_MCP_TRANSPORT), help=f"Transport type for the MCP server (default: {Constants.DEFAULT_MCP_TRANSPORT})", ) return parser.parse_args() # Parse arguments at module level to make them available args = parse_arguments() # Initialize FastMCP 2.0 server mcp = FastMCP("fininfo") # Note: FastMCP 2.0 handles host/port differently - set in run() method def get_api_key_for_request() -> str: """ Extract client ID from x-client-id header and retrieve the corresponding API key. Returns: str: API key for the client, or fallback key if client ID not found """ try: # Get HTTP request to extract headers http_request = get_http_request() if http_request: # Extract x-client-id header client_id = http_request.headers.get("x-client-id") if client_id: logger.info(f"🔑 Client ID found in header: {client_id}") # Get API key for this client api_key = secrets_manager.get_api_key(client_id) if api_key: logger.info(f"✅ Using client-specific API key for client: {client_id}") return api_key else: logger.warning(f"❌ No API key found for client: {client_id}, using fallback") else: logger.info("ℹ️ No x-client-id header found, using fallback API key") else: logger.info("ℹ️ No HTTP request context available, using fallback API key") except RuntimeError: # This happens when not in HTTP context (e.g., stdio transport) logger.info("ℹ️ Not in HTTP context, using fallback API key") except Exception as e: logger.error(f"❌ Error extracting client ID: {e}") # Use fallback API key if FALLBACK_API_KEY: logger.info("🔄 Using fallback API key from environment") return FALLBACK_API_KEY else: # Try to get default key from secrets manager default_key = secrets_manager.get_api_key("default") if default_key: logger.info("🔄 Using default API key from secrets manager") return default_key else: raise ValueError( "No API key available: neither client-specific, fallback, nor default key found" ) async def get_http_headers(ctx: Context = None) -> dict[str, Any]: """ FastMCP 2.0 tool to access HTTP headers directly using the new dependency system. This tool demonstrates how to get HTTP request information including auth headers. Returns: Dict[str, Any]: HTTP request information including headers """ if not ctx: return {"error": "No context available"} result = { "fastmcp_version": "2.0", "tool_name": "get_http_headers", "server": "fininfo", "timestamp": str(asyncio.get_event_loop().time()), } try: # Use FastMCP 2.0's dependency function to get HTTP request http_request = get_http_request() if http_request: # Extract all headers all_headers = dict(http_request.headers) # Separate auth-related headers for easy viewing auth_headers = {} other_headers = {} for key, value in all_headers.items(): key_lower = key.lower() if key_lower in [ "authorization", "x-user-pool-id", "x-client-id", "x-region", "cookie", "x-api-key", "x-scopes", "x-user", "x-username", "x-auth-method", ]: if key_lower == "authorization": # Show type of auth but not full token if value.startswith("Bearer "): auth_headers[key] = f"Bearer (length: {len(value)})" else: auth_headers[key] = f" (length: {len(value)})" elif key_lower == "cookie": # Show cookie names but hide values cookies = [c.split("=")[0] for c in value.split(";")] auth_headers[key] = f"Cookies: {', '.join(cookies)}" else: auth_headers[key] = value else: other_headers[key] = value result.update( { "http_request_available": True, "method": http_request.method, "url": str(http_request.url), "path": http_request.url.path, "query_params": dict(http_request.query_params), "client_info": { "host": http_request.client.host if http_request.client else "Unknown", "port": http_request.client.port if http_request.client else "Unknown", }, "auth_headers": auth_headers, "other_headers": other_headers, "total_headers_count": len(all_headers), } ) # Log the auth headers for server-side debugging logger.info(f"🔐 HTTP Headers Debug - Auth Headers Found: {list(auth_headers.keys())}") if auth_headers: for key, value in auth_headers.items(): logger.info(f" {key}: {value}") else: logger.info(" No auth-related headers found") else: result.update( {"http_request_available": False, "error": "No HTTP request context available"} ) logger.warning( "No HTTP request context available - may be running in non-HTTP transport mode" ) except RuntimeError as e: # This happens when not in HTTP context (e.g., stdio transport) result.update( { "http_request_available": False, "error": f"Not in HTTP context: {str(e)}", "transport_mode": "Likely STDIO or other non-HTTP transport", } ) logger.info(f"Not in HTTP context - this is expected for STDIO transport: {e}") except Exception as e: result.update( {"http_request_available": False, "error": f"Error accessing HTTP request: {str(e)}"} ) logger.error(f"Error accessing HTTP request: {e}") logger.error(f"Error in get_http_headers: {e}", exc_info=True) return result async def print_all_http_headers(ctx: Context = None) -> str: """ Helper function to print out all HTTP request headers in a formatted string. This function can be called internally by other tools to display HTTP headers. Args: ctx: FastMCP Context object Returns: str: Formatted string containing all HTTP headers """ if not ctx: return "Error: No context available" output = [] output.append("=== HTTP Request Headers ===") output.append("Server: fininfo") output.append(f"Timestamp: {asyncio.get_event_loop().time()}") output.append("") try: # Use FastMCP 2.0's dependency function to get HTTP request http_request = get_http_request() if http_request: # Extract all headers all_headers = dict(http_request.headers) output.append(f"Total Headers: {len(all_headers)}") output.append(f"HTTP Method: {http_request.method}") output.append(f"URL: {http_request.url}") output.append(f"Path: {http_request.url.path}") output.append("") output.append("Headers:") output.append("-" * 50) # Sort headers for consistent output for key in sorted(all_headers.keys()): value = all_headers[key] # Mask sensitive headers if key.lower() in ["authorization", "cookie"]: if key.lower() == "authorization": if value.startswith("Bearer "): masked_value = f"Bearer (length: {len(value)})" else: masked_value = f" (length: {len(value)})" else: # cookie cookie_names = [c.split("=")[0] for c in value.split(";")] masked_value = f": {', '.join(cookie_names)}" output.append(f"{key}: {masked_value}") else: output.append(f"{key}: {value}") # Log to server logs logger.info(f"📋 Printed all HTTP headers - Total: {len(all_headers)}") else: output.append("No HTTP request context available") output.append("This may occur when using STDIO transport") logger.warning("No HTTP request context available") except RuntimeError as e: output.append(f"Not in HTTP context: {str(e)}") output.append("This is expected for STDIO transport") logger.info(f"Not in HTTP context - this is expected for STDIO transport: {e}") except Exception as e: output.append(f"Error accessing HTTP request: {str(e)}") logger.error(f"Error accessing HTTP request: {e}") logger.error(f"Error in print_all_http_headers: {e}", exc_info=True) return "\n".join(output) async def _fetch_stock_data( stock_ticker: str, multiplier: int, timespan: str, from_date: str, to_date: str, adjusted: bool = True, sort: str | None = None, limit: int = 5000, ctx: Context = None, ) -> dict[str, Any]: """ Private function to fetch stock aggregate data from Polygon.io API. This function is shared by both get_stock_aggregates and print_stock_data. Args: stock_ticker: Case-sensitive ticker symbol (e.g., 'AAPL') multiplier: Size of the timespan multiplier timespan: Size of the time window (minute, hour, day, week, month, quarter, year) from_date: Start date in YYYY-MM-DD format or millisecond timestamp to_date: End date in YYYY-MM-DD format or millisecond timestamp adjusted: Whether results are adjusted for splits (default: True) sort: Sort results by timestamp ('asc' or 'desc', default: None) limit: Maximum number of base aggregates (max 50000, default: 5000) ctx: FastMCP Context object Returns: Dict[str, Any]: Response data from Polygon API Raises: ValueError: If input parameters are invalid requests.RequestException: If API call fails after retries """ # Log request information logger.info(f"🔍 Getting stock aggregates for {stock_ticker} from {from_date} to {to_date}") # Use the helper function to print HTTP headers for debugging if ctx: try: headers_info = await print_all_http_headers(ctx) logger.info(f"📋 HTTP Headers Debug:\n{headers_info}") except Exception as e: logger.warning(f"Could not print HTTP headers: {e}") # Validate timespan valid_timespans = ["minute", "hour", "day", "week", "month", "quarter", "year"] if timespan not in valid_timespans: raise ValueError(f"Invalid timespan. Must be one of {valid_timespans}") # Validate sort if sort is not None and sort not in ["asc", "desc"]: raise ValueError("Sort must be either 'asc', 'desc', or None") # Validate limit if limit > 50000: raise ValueError("Limit cannot exceed 50000") # Get the appropriate API key for this request api_key = get_api_key_for_request() # Build URL and parameters base_url = "https://api.polygon.io" endpoint = f"/v2/aggs/ticker/{stock_ticker}/range/{multiplier}/{timespan}/{from_date}/{to_date}" url = f"{base_url}{endpoint}" # Prepare query parameters query_params = {"adjusted": str(adjusted).lower(), "apiKey": api_key} if sort: query_params["sort"] = sort if limit != 5000: # Only add if not the default query_params["limit"] = limit # Make the API request with retries retry_count = 0 while retry_count < Constants.MAX_RETRIES: try: response = requests.get(url, params=query_params, timeout=10) response.raise_for_status() # Raise exception for 4XX/5XX responses # Return the JSON response return response.json() except requests.RequestException as e: retry_count += 1 # If this was our last retry, raise the exception if retry_count == Constants.MAX_RETRIES: raise logger.warning( f"Request failed (attempt {retry_count}/{Constants.MAX_RETRIES}): {str(e)}" ) logger.info(f"Retrying in {Constants.RETRY_DELAY} seconds...") # Wait before retrying time.sleep(Constants.RETRY_DELAY) @mcp.tool() async def get_stock_aggregates( stock_ticker: Annotated[ str, Field(..., description="Case-sensitive ticker symbol (e.g., 'AAPL')") ], multiplier: Annotated[int, Field(..., description="Size of the timespan multiplier")], timespan: Annotated[str, Field(..., description="Size of the time window")], from_date: Annotated[ str, Field(..., description="Start date in YYYY-MM-DD format or millisecond timestamp") ], to_date: Annotated[ str, Field(..., description="End date in YYYY-MM-DD format or millisecond timestamp") ], adjusted: Annotated[ bool, Field(True, description="Whether results are adjusted for splits") ] = True, sort: Annotated[ str | None, Field(None, description="Sort results by timestamp ('asc' or 'desc')") ] = None, limit: Annotated[ int, Field(5000, description="Maximum number of base aggregates (max 50000)") ] = 5000, ctx: Context = None, ) -> dict[str, Any]: """ Retrieve stock aggregate data from Polygon.io API. Args: stock_ticker: Case-sensitive ticker symbol (e.g., 'AAPL') multiplier: Size of the timespan multiplier timespan: Size of the time window (minute, hour, day, week, month, quarter, year) from_date: Start date in YYYY-MM-DD format or millisecond timestamp to_date: End date in YYYY-MM-DD format or millisecond timestamp adjusted: Whether results are adjusted for splits (default: True) sort: Sort results by timestamp ('asc' or 'desc', default: None) limit: Maximum number of base aggregates (max 50000, default: 5000) Returns: Dict[str, Any]: Response data from Polygon API Raises: ValueError: If input parameters are invalid requests.RequestException: If API call fails after retries """ return await _fetch_stock_data( stock_ticker=stock_ticker, multiplier=multiplier, timespan=timespan, from_date=from_date, to_date=to_date, adjusted=adjusted, sort=sort, limit=limit, ctx=ctx, ) @mcp.tool() async def print_stock_data( stock_ticker: Annotated[ str, Field(..., description="Case-sensitive ticker symbol (e.g., 'AAPL')") ], multiplier: Annotated[int, Field(..., description="Size of the timespan multiplier")], timespan: Annotated[str, Field(..., description="Size of the time window")], from_date: Annotated[ str, Field(..., description="Start date in YYYY-MM-DD format or millisecond timestamp") ], to_date: Annotated[ str, Field(..., description="End date in YYYY-MM-DD format or millisecond timestamp") ], adjusted: Annotated[ bool, Field(True, description="Whether results are adjusted for splits") ] = True, sort: Annotated[ str | None, Field(None, description="Sort results by timestamp ('asc' or 'desc')") ] = None, limit: Annotated[ int, Field(5000, description="Maximum number of base aggregates (max 50000)") ] = 5000, ctx: Context = None, ) -> str: """ Format all fields from the Polygon.io stock aggregate response as a string. Args: stock_ticker: Case-sensitive ticker symbol (e.g., 'AAPL') multiplier: Size of the timespan multiplier timespan: Size of the time window (minute, hour, day, week, month, quarter, year) from_date: Start date in YYYY-MM-DD format or millisecond timestamp to_date: End date in YYYY-MM-DD format or millisecond timestamp adjusted: Whether results are adjusted for splits (default: True) sort: Sort results by timestamp ('asc' or 'desc', default: None) limit: Maximum number of base aggregates (max 50000, default: 5000) Returns: str: Formatted string containing all stock data """ # Initialize an empty string to collect all output output = [] response_data = await _fetch_stock_data( stock_ticker=stock_ticker, multiplier=multiplier, timespan=timespan, from_date=from_date, to_date=to_date, adjusted=adjusted, sort=sort, limit=limit, ctx=ctx, ) if not response_data: return "No data available" # Add response metadata output.append("\n=== Stock Aggregate Data ===") output.append(f"Ticker: {response_data.get('ticker', 'N/A')}") output.append(f"Adjusted: {response_data.get('adjusted', 'N/A')}") output.append(f"Query Count: {response_data.get('queryCount', 'N/A')}") output.append(f"Request ID: {response_data.get('request_id', 'N/A')}") output.append(f"Results Count: {response_data.get('resultsCount', 'N/A')}") output.append(f"Status: {response_data.get('status', 'N/A')}") # Add next_url if available if "next_url" in response_data: output.append(f"Next URL: {response_data.get('next_url')}") # Add detailed results results = response_data.get("results", []) if not results: output.append("\nNo result data available") return "\n".join(output) output.append(f"\nFound {len(results)} data points:") output.append( "\n{:<12} {:<10} {:<10} {:<10} {:<10} {:<12} {:<12} {:<10} {:<12}".format( "Timestamp", "Open", "High", "Low", "Close", "Volume", "VWAP", "Transactions", "OTC", ) ) output.append("-" * 105) for data in results: # Convert timestamp to readable date timestamp = data.get("t", 0) date_str = time.strftime("%Y-%m-%d %H:%M", time.localtime(timestamp / 1000)) # Format all the aggregate fields open_price = data.get("o", "N/A") high_price = data.get("h", "N/A") low_price = data.get("l", "N/A") close_price = data.get("c", "N/A") volume = data.get("v", "N/A") vwap = data.get("vw", "N/A") transactions = data.get("n", "N/A") otc = data.get("otc", False) output.append( "{:<12} {:<10.2f} {:<10.2f} {:<10.2f} {:<10.2f} {:<12.0f} {:<12.2f} {:<10} {:<12}".format( date_str, open_price if open_price != "N/A" else 0.0, high_price if high_price != "N/A" else 0.0, low_price if low_price != "N/A" else 0.0, close_price if close_price != "N/A" else 0.0, volume if volume != "N/A" else 0, vwap if vwap != "N/A" else 0.0, transactions if transactions != "N/A" else "N/A", otc, ) ) # Join all lines and return as a single string return "\n".join(output) @mcp.resource("config://app") def get_config() -> str: """Static configuration data""" return "App configuration here" def main(): # Run the server with the specified transport from command line args # FastMCP 2.0 handles port and host in the run method logger.info(f"Starting fininfo server on port {args.port} with transport {args.transport}") # Example server - binds to 0.0.0.0 for demonstration purposes only. # In production, bind to 127.0.0.1 or specific IP with proper firewall rules. mcp.run(transport=args.transport, host="0.0.0.0", port=int(args.port), path="/sse") # nosec B104 - example/demo server if __name__ == "__main__": main() ================================================ FILE: servers/mcpgw/.dockerignore ================================================ # Python cache __pycache__/ *.py[cod] *$py.class *.so # Virtual environments .venv/ venv/ env/ # IDE .vscode/ .idea/ *.swp *.swo # OS .DS_Store Thumbs.db # Documentation *.md README* # Tests *_test.py test_*.py tests/ # Git .git/ .gitignore # Logs *.log # Temporary files *.tmp tmp/ temp/ ================================================ FILE: servers/mcpgw/models.py ================================================ """Pydantic models for mcpgw MCP server. These models define the data structures returned by the registry API and used by the MCP tools. """ from pydantic import BaseModel, Field class ServerInfo(BaseModel): """Information about a registered MCP server.""" model_config = {"populate_by_name": True} server_name: str | None = Field( None, alias="display_name", description="Display name of the server" ) path: str = Field(..., description="URL path for the server (e.g., '/fininfo')") description: str | None = Field(None, description="Server description") enabled: bool = Field(..., alias="is_enabled", description="Whether the server is enabled") tags: list[str] = Field(default_factory=list, description="Server tags") tool_count: int | None = Field(None, alias="num_tools", description="Number of tools provided") class AgentInfo(BaseModel): """Information about a registered agent.""" name: str | None = Field(None, description="Name of the agent") description: str | None = Field(None, description="Agent description") tags: list[str] = Field(default_factory=list, description="Agent tags") created_at: str | None = Field(None, description="Creation timestamp") class SkillInfo(BaseModel): """Information about a registered skill.""" path: str = Field(..., description="Skill path") name: str | None = Field(None, description="Name of the skill") description: str | None = Field(None, description="Skill description") skill_md_url: str | None = Field(None, description="URL to the SKILL.md file") skill_md_raw_url: str | None = Field(None, description="Raw URL for fetching SKILL.md content") tags: list[str] = Field(default_factory=list, description="Skill tags") target_agents: list[str] = Field(default_factory=list, description="Target agent platforms") created_at: str | None = Field(None, description="Creation timestamp") class ToolSearchResult(BaseModel): """Search result for semantic tool search.""" tool_name: str = Field(..., description="Name of the tool") server_name: str = Field(..., description="Server providing the tool") description: str | None = Field(None, description="Tool description") score: float | None = Field(None, description="Relevance score (0-1)") path: str | None = Field(None, description="Server path") class RegistryStats(BaseModel): """Registry statistics and health information. Accepts any fields from the health endpoint response. """ class Config: extra = "allow" class ErrorResponse(BaseModel): """Error response model.""" error: str = Field(..., description="Error message") status: str = Field(default="failed", description="Status indicator") details: dict | None = Field(None, description="Additional error details") ================================================ FILE: servers/mcpgw/pyproject.toml ================================================ [project] name = "mcpgw-mcp-server" version = "0.1.0" description = "MCP server to interact with the MCP Gateway Registry API" readme = "README.md" requires-python = ">=3.14" dependencies = [ "fastmcp>=2.0.0", "pydantic>=2.11.3", "httpx>=0.27.0", "python-dotenv>=1.0.0", "cryptography>=46.0.7", ] [tool.uv] # Local-only project - never resolve from PyPI package = false ================================================ FILE: servers/mcpgw/server.py ================================================ """MCP Gateway Interaction Server (mcpgw). This MCP server provides tools to interact with the MCP Gateway Registry API. It acts as a thin protocol adapter, translating MCP tool calls into registry HTTP requests. Supports two auth modes: - OAuth (OAuthProxy + Keycloak): set OIDC_ENABLED=true and provide Keycloak env vars. Exposes /.well-known/oauth-protected-resource for MCP clients (Cursor, VS Code). - Legacy bearer token: pass a Keycloak JWT via Authorization header directly. """ import logging import os import time from typing import Any import httpx from fastmcp import Context, FastMCP from models import AgentInfo, RegistryStats, ServerInfo, SkillInfo, ToolSearchResult logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) REGISTRY_URL = os.getenv("REGISTRY_BASE_URL", "http://localhost") MAX_QUERY_LENGTH: int = 500 MIN_TOP_N: int = 1 MAX_TOP_N: int = 50 logger.info(f"Registry URL: {REGISTRY_URL}") # --------------------------------------------------------------------------- # OAuth configuration (optional – enable via OIDC_ENABLED=true) # --------------------------------------------------------------------------- OIDC_ENABLED = os.getenv("OIDC_ENABLED", "").lower() in ("true", "1", "yes") KEYCLOAK_INTERNAL_URL = os.getenv("KEYCLOAK_INTERNAL_URL", "http://keycloak:8080") KEYCLOAK_EXTERNAL_URL = os.getenv("KEYCLOAK_EXTERNAL_URL", "http://localhost:18080") KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "mcp-gateway") OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID", "mcp-gateway-web") OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET", "") M2M_CLIENT_ID = os.getenv("M2M_CLIENT_ID", "mcp-gateway-m2m") M2M_CLIENT_SECRET = os.getenv("M2M_CLIENT_SECRET", "") MCPGW_BASE_URL = os.getenv("MCPGW_BASE_URL", "http://localhost:18003") REGISTRY_API_TOKEN = os.getenv("REGISTRY_API_TOKEN", "") class _M2MTokenManager: """Fetches and caches a Keycloak M2M token via client_credentials grant.""" def __init__(self, token_url: str, client_id: str, client_secret: str) -> None: self._token_url = token_url self._client_id = client_id self._client_secret = client_secret self._token: str | None = None self._expires_at: float = 0 async def get_token(self) -> str: if self._token and time.monotonic() < self._expires_at - 60: return self._token async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.post( self._token_url, data={ "grant_type": "client_credentials", "client_id": self._client_id, "client_secret": self._client_secret, }, ) resp.raise_for_status() data = resp.json() self._token = data["access_token"] self._expires_at = time.monotonic() + data.get("expires_in", 300) logger.info("Obtained fresh M2M token (expires_in=%s)", data.get("expires_in")) return self._token _auth_provider = None _m2m_manager: _M2MTokenManager | None = None _realm_path = f"/realms/{KEYCLOAK_REALM}/protocol/openid-connect" if M2M_CLIENT_ID and M2M_CLIENT_SECRET: _m2m_manager = _M2MTokenManager( token_url=f"{KEYCLOAK_INTERNAL_URL}{_realm_path}/token", client_id=M2M_CLIENT_ID, client_secret=M2M_CLIENT_SECRET, ) logger.info("M2M token manager enabled (client=%s)", M2M_CLIENT_ID) if OIDC_ENABLED: from fastmcp.server.auth.oauth_proxy import OAuthProxy from fastmcp.server.auth.providers.jwt import JWTVerifier _auth_provider = OAuthProxy( upstream_authorization_endpoint=f"{KEYCLOAK_EXTERNAL_URL}{_realm_path}/auth", upstream_token_endpoint=f"{KEYCLOAK_INTERNAL_URL}{_realm_path}/token", upstream_revocation_endpoint=f"{KEYCLOAK_INTERNAL_URL}{_realm_path}/revoke", upstream_client_id=OIDC_CLIENT_ID, upstream_client_secret=OIDC_CLIENT_SECRET, token_verifier=JWTVerifier( jwks_uri=f"{KEYCLOAK_INTERNAL_URL}{_realm_path}/certs", issuer=f"{KEYCLOAK_EXTERNAL_URL}/realms/{KEYCLOAK_REALM}", ), base_url=MCPGW_BASE_URL, allowed_client_redirect_uris=[ "http://localhost:*", "http://127.0.0.1:*", "cursor://anysphere.cursor-mcp/*", "vscode://anysphere.cursor-mcp/*", ], require_authorization_consent=False, ) logger.info("OAuth enabled (OAuthProxy → Keycloak %s, realm=%s)", KEYCLOAK_EXTERNAL_URL, KEYCLOAK_REALM) else: logger.info("OAuth disabled – using bearer-token passthrough with M2M for registry calls") mcp = FastMCP("mcpgw", auth=_auth_provider) if _auth_provider: from starlette.responses import RedirectResponse @mcp.custom_route("/.well-known/oauth-protected-resource", methods=["GET"]) async def _redirect_protected_resource(_): # noqa: ANN001 """Redirect root well-known to the MCP-prefixed path (FastMCP path-prefix workaround).""" return RedirectResponse( url="/.well-known/oauth-protected-resource/mcp", status_code=302 ) def _validate_top_n(top_n: int) -> int: """Validate top_n parameter is within acceptable bounds. Args: top_n: Number of results to return Returns: Validated top_n value Raises: ValueError: If top_n is out of bounds """ if not isinstance(top_n, int) or top_n < MIN_TOP_N or top_n > MAX_TOP_N: raise ValueError(f"top_n must be an integer between {MIN_TOP_N} and {MAX_TOP_N}") return top_n def _validate_query(query: str) -> str: """Validate query parameter. Args: query: Search query string Returns: Validated and trimmed query Raises: ValueError: If query is empty or too long """ if not query or not query.strip(): raise ValueError("Query cannot be empty") if len(query) > MAX_QUERY_LENGTH: raise ValueError(f"Query exceeds maximum length of {MAX_QUERY_LENGTH} characters") return query.strip() def _extract_bearer_token(ctx: Context | None) -> str: """Extract bearer token from FastMCP context (legacy / no-OAuth mode). Supports both standard Authorization header and MCP Gateway's X-Authorization header. """ if not ctx: raise ValueError("Authentication required: Context is None") try: if hasattr(ctx, "request_context") and ctx.request_context: request = ctx.request_context.request if request and hasattr(request, "headers"): auth_header = request.headers.get("authorization") if not auth_header: auth_header = request.headers.get("x-authorization") if auth_header and auth_header.lower().startswith("bearer "): return auth_header.split(" ", 1)[1] raise ValueError("Bearer token not found in Authorization or X-Authorization header") raise ValueError("Request object or headers not found in request_context") raise ValueError("request_context not available in Context") except ValueError: raise except Exception as e: logger.error(f"Failed to extract token: {e}", exc_info=True) raise ValueError(f"Failed to extract bearer token: {e}") from e async def _get_registry_headers(ctx: Context | None) -> dict[str, str]: """Return headers for internal registry API calls. Priority: static API token > M2M service token > caller bearer token. """ if REGISTRY_API_TOKEN: return {"Authorization": f"Bearer {REGISTRY_API_TOKEN}"} if _m2m_manager: token = await _m2m_manager.get_token() return {"X-Authorization": f"Bearer {token}"} token = _extract_bearer_token(ctx) return {"X-Authorization": f"Bearer {token}"} @mcp.tool() async def list_services(ctx: Context | None = None) -> dict[str, Any]: """ List all MCP servers registered in the gateway. Returns: Dictionary containing services, total_count, enabled_count, and status """ logger.info("list_services called") try: headers = await _get_registry_headers(ctx) async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get(f"{REGISTRY_URL}/api/servers", headers=headers) response.raise_for_status() data = response.json() if isinstance(data, dict) and "servers" in data: servers = data["servers"] elif isinstance(data, list): servers = data else: servers = [] services = [] for s in servers: try: services.append(ServerInfo(**s).model_dump()) except Exception as e: logger.warning(f"Failed to parse server {s.get('path', 'unknown')}: {e}") enabled_count = sum(1 for s in services if s.get("enabled")) return { "services": services, "total_count": len(services), "enabled_count": enabled_count, "status": "success", } except ValueError as e: logger.error(f"Validation error: {e}") return { "services": [], "total_count": 0, "error": str(e), "status": "failed", } except httpx.HTTPStatusError as e: logger.error(f"HTTP error: {e.response.status_code}") return { "services": [], "total_count": 0, "error": f"Registry API error: {e.response.status_code}", "status": "failed", } except Exception as e: logger.error(f"Failed to list services: {e}") return { "services": [], "total_count": 0, "error": str(e), "status": "failed", } @mcp.tool() async def list_agents(ctx: Context | None = None) -> dict[str, Any]: """ List all agents registered in the gateway. Returns: Dictionary containing agents, total_count, and status """ logger.info("list_agents called") try: headers = await _get_registry_headers(ctx) async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get(f"{REGISTRY_URL}/api/agents", headers=headers) response.raise_for_status() data = response.json() agents = data.get("agents", []) if isinstance(data, dict) else data agent_list = [AgentInfo(**a).model_dump() for a in agents] return { "agents": agent_list, "total_count": len(agent_list), "status": "success", } except ValueError as e: logger.error(f"Validation error: {e}") return { "agents": [], "total_count": 0, "error": str(e), "status": "failed", } except httpx.HTTPStatusError as e: logger.error(f"HTTP error: {e.response.status_code}") return { "agents": [], "total_count": 0, "error": f"Registry API error: {e.response.status_code}", "status": "failed", } except Exception as e: logger.error(f"Failed to list agents: {e}") return { "agents": [], "total_count": 0, "error": str(e), "status": "failed", } @mcp.tool() async def list_skills(ctx: Context | None = None) -> dict[str, Any]: """ List all skills registered in the gateway. Returns: Dictionary containing skills, total_count, and status """ logger.info("list_skills called") try: headers = await _get_registry_headers(ctx) async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get(f"{REGISTRY_URL}/api/skills", headers=headers) response.raise_for_status() data = response.json() skills = data.get("skills", []) if isinstance(data, dict) else data skill_list = [SkillInfo(**s).model_dump() for s in skills] return { "skills": skill_list, "total_count": len(skill_list), "status": "success", } except ValueError as e: logger.error(f"Validation error: {e}") return { "skills": [], "total_count": 0, "error": str(e), "status": "failed", } except httpx.HTTPStatusError as e: logger.error(f"HTTP error: {e.response.status_code}") return { "skills": [], "total_count": 0, "error": f"Registry API error: {e.response.status_code}", "status": "failed", } except Exception as e: logger.error(f"Failed to list skills: {e}") return { "skills": [], "total_count": 0, "error": str(e), "status": "failed", } @mcp.tool() async def get_skill_content( skill_name: str, resource_path: str | None = None, ctx: Context | None = None, ) -> dict[str, Any]: """ Fetch skill content from the registry. Without resource_path: returns the full SKILL.md markdown and resource manifest. With resource_path: returns the content of a companion file (reference doc, script, agent config, etc.) validated against the stored manifest. Use this after list_skills or intelligent_tool_finder to retrieve the complete workflow instructions for a skill, or to read companion resources listed in the manifest. Args: skill_name: Name of the skill (e.g. "gerrit-workflow") resource_path: Optional relative path to a companion resource (e.g. "references/architecture.md") Returns: Dictionary containing the skill name, content, source URL, and status """ logger.info( "get_skill_content called: skill_name=%s resource_path=%s", skill_name, resource_path, ) if not skill_name or not skill_name.strip(): return {"error": "skill_name cannot be empty", "status": "failed"} skill_name = skill_name.strip() try: headers = await _get_registry_headers(ctx) url = f"{REGISTRY_URL}/api/skills/{skill_name}/content" params: dict[str, str] = {} if resource_path: params["resource"] = resource_path async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get(url, headers=headers, params=params) response.raise_for_status() data = response.json() result: dict[str, Any] = { "skill_name": skill_name, "source_url": data.get("url", ""), "content": data.get("content", ""), "status": "success", } if resource_path: result["resource_path"] = data.get("path", resource_path) result["resource_type"] = data.get("type", "") else: manifest = data.get("resource_manifest") if manifest: result["resources"] = manifest return result except httpx.HTTPStatusError as e: logger.error("HTTP error fetching skill content: %s", e.response.status_code) return {"skill_name": skill_name, "error": f"HTTP {e.response.status_code}", "status": "failed"} except Exception as e: logger.error("Failed to get skill content: %s", e) return {"skill_name": skill_name, "error": str(e), "status": "failed"} @mcp.tool() async def intelligent_tool_finder( query: str, top_n: int = 5, ctx: Context | None = None, ) -> dict[str, Any]: """ Search for tools using natural language semantic search. Args: query: Natural language description of what you want to do top_n: Number of results to return (default: 5, max: 50) Returns: Dictionary containing results, query, total_results, and status """ logger.info(f"intelligent_tool_finder called: query={query}, top_n={top_n}") try: query = _validate_query(query) top_n = _validate_top_n(top_n) headers = await _get_registry_headers(ctx) async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( f"{REGISTRY_URL}/api/search/semantic", headers=headers, json={ "query": query, "entity_types": ["mcp_server", "tool", "virtual_server"], "max_results": top_n, }, ) response.raise_for_status() data = response.json() # Extract servers array from response servers = data.get("servers", []) if isinstance(data, dict) else [] # Flatten matching_tools from all servers into ToolSearchResult objects result_list = [] for server in servers: server_path = server.get("path", "") server_name = server.get("server_name", "") for tool in server.get("matching_tools", []): result_list.append( ToolSearchResult( tool_name=tool.get("tool_name", ""), server_name=server_name, description=tool.get("description"), score=tool.get("relevance_score"), path=server_path, ).model_dump() ) # Enforce client-side limit (safety net in case registry returns more) result_list = result_list[:top_n] return { "results": result_list, "query": query, "total_results": len(result_list), "status": "success", } except ValueError as e: logger.error(f"Validation error: {e}") return { "results": [], "query": query, "total_results": 0, "error": str(e), "status": "failed", } except httpx.HTTPStatusError as e: logger.error(f"HTTP error: {e.response.status_code}") return { "results": [], "query": query, "total_results": 0, "error": f"Registry API error: {e.response.status_code}", "status": "failed", } except Exception as e: logger.error(f"Failed to search tools: {e}") return { "results": [], "query": query, "total_results": 0, "error": str(e), "status": "failed", } @mcp.tool() async def healthcheck(ctx: Context | None = None) -> dict[str, Any]: """ Get registry health status and statistics. Returns: Dictionary containing health stats and status """ logger.info("healthcheck called") try: headers = await _get_registry_headers(ctx) async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get(f"{REGISTRY_URL}/api/servers/health", headers=headers) response.raise_for_status() data = response.json() stats = RegistryStats(**data) return {**stats.model_dump(), "status": "success"} except ValueError as e: logger.error(f"Validation error: {e}") return { "health_status": "error", "error": str(e), "status": "failed", } except httpx.HTTPStatusError as e: logger.error(f"HTTP error: {e.response.status_code}") return { "health_status": "error", "error": f"Registry API error: {e.response.status_code}", "status": "failed", } except Exception as e: logger.error(f"Failed to get health status: {e}") return { "health_status": "error", "error": str(e), "status": "failed", } if __name__ == "__main__": import os logger.info("Starting mcpgw server") # Use HTTP transport if PORT is set (Docker container), otherwise stdio port = os.environ.get("PORT") if port: # Use configurable host with secure default (127.0.0.1) # Set HOST=0.0.0.0 in environment for Docker deployments host = os.environ.get("HOST", "127.0.0.1") logger.info(f"Running in HTTP mode on {host}:{port}") mcp.run(transport="streamable-http", host=host, port=int(port)) else: logger.info("Running in stdio mode") mcp.run(transport="stdio") ================================================ FILE: servers/realserverfaketools/.dockerignore ================================================ # Python cache __pycache__/ *.py[cod] *$py.class *.so # Virtual environments .venv/ venv/ env/ # IDE .vscode/ .idea/ *.swp *.swo # OS .DS_Store Thumbs.db # Documentation *.md README* # Tests *_test.py test_*.py tests/ # Git .git/ .gitignore # Logs *.log # Temporary files *.tmp tmp/ temp/ ================================================ FILE: servers/realserverfaketools/README.md ================================================ # Real Server Fake Tools MCP Server This is an MCP server that provides a collection of fake tools with interesting names that take different types of parameters. These tools are stubbed out and return mock responses for demonstration purposes. ## Tools The server provides the following tools: 1. **quantum_flux_analyzer** - Analyzes quantum flux patterns with configurable energy levels and stabilization. 2. **neural_pattern_synthesizer** - Synthesizes neural patterns into coherent structures. 3. **hyper_dimensional_mapper** - Maps geographical coordinates to hyper-dimensional space. 4. **temporal_anomaly_detector** - Detects temporal anomalies within a specified timeframe. 5. **user_profile_analyzer** - Analyzes a user profile with configurable analysis options. 6. **synthetic_data_generator** - Generates synthetic data based on a provided schema. ## Resources The server provides the following resources: 1. **config://app** - Static configuration data for the fake tools server. 2. **docs://tools** - Documentation for the fake tools. ## Prompts The server provides the following prompts: 1. **system_prompt_for_agent** - Generates a system prompt for an AI Agent that wants to use the real_server_fake_tools MCP server. ## Installation ```bash # Clone the repository git clone # Navigate to the server directory cd servers/real_server_fake_tools # Install dependencies pip install -e . ``` ## Usage ### Running the Server ```bash # Run the server with default settings python server.py # Run the server with custom port and transport python server.py --port 8001 --transport streamable-http ``` ### Using the Client ```bash # Run the client with default settings (connects to localhost:8001) python client.py # Run the client with custom host and port python client.py --host example.com --port 8001 ``` ## Example Tool Usage ### Quantum Flux Analyzer ```python result = await session.call_tool( "quantum_flux_analyzer", arguments={ "energy_level": 7, "stabilization_factor": 0.85, "enable_temporal_shift": True } ) ``` ### Neural Pattern Synthesizer ```python result = await session.call_tool( "neural_pattern_synthesizer", arguments={ "input_patterns": ["alpha", "beta", "gamma"], "coherence_threshold": 0.8, "dimensions": 5 } ) ``` ### Hyper Dimensional Mapper ```python result = await session.call_tool( "hyper_dimensional_mapper", arguments={ "coordinates": { "latitude": 37.7749, "longitude": -122.4194, "altitude": 10 }, "dimension_count": 6, "reality_anchoring": 0.9 } ) ``` ## License [MIT License](LICENSE) ================================================ FILE: servers/realserverfaketools/pyproject.toml ================================================ [project] name = "real-server-fake-tools-mcp" version = "0.1.0" description = "MCP server with fake tools that take different parameter types" readme = "README.md" requires-python = ">=3.14" dependencies = [ "fastmcp>=2.0.0", "pydantic>=2.11.3", "httpx>=0.27.0", "python-dotenv>=1.0.0", ] [tool.uv] # Local-only project - never resolve from PyPI package = false ================================================ FILE: servers/realserverfaketools/server.py ================================================ """ This server provides a collection of fake tools with interesting names that take different types of parameters. These tools are stubbed out and return mock responses for demonstration purposes. """ import argparse import json import logging import os import secrets # Replaced random with secrets import time from datetime import datetime from typing import Annotated, Any, ClassVar from fastmcp import FastMCP from pydantic import BaseModel, Field # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Helper functions for replacing random functions with secrets equivalents def secure_uniform(min_val, max_val, precision=2): """Generate a secure random float between min_val and max_val with specified precision""" range_val = int((max_val - min_val) * (10**precision)) return min_val + (secrets.randbelow(range_val + 1) / (10**precision)) def secure_random(): """Generate a secure random float between 0 and 1""" return secrets.randbelow(10000) / 10000 def secure_choice(sequence): """Select a random element from a sequence using cryptographically secure randomness""" return sequence[secrets.randbelow(len(sequence))] def secure_sample(population, k): """Select k unique elements from a population using cryptographically secure randomness""" result = [] population_copy = list(population) for i in range(min(k, len(population_copy))): idx = secrets.randbelow(len(population_copy)) result.append(population_copy.pop(idx)) return result class Constants(BaseModel): # Using ClassVar to define class-level constants DESCRIPTION: ClassVar[str] = "Real Server Fake Tools MCP Server" DEFAULT_MCP_TRANSPORT: ClassVar[str] = "streamable-http" DEFAULT_MCP_SERVER_LISTEN_PORT: ClassVar[str] = "8001" REQUEST_TIMEOUT: ClassVar[float] = 15.0 # Disable instance creation - optional but recommended for constants class Config: frozen = True # Make instances immutable def parse_arguments(): """Parse command line arguments with defaults matching environment variables.""" parser = argparse.ArgumentParser(description=Constants.DESCRIPTION) parser.add_argument( "--port", type=str, default=os.environ.get("MCP_SERVER_LISTEN_PORT", Constants.DEFAULT_MCP_SERVER_LISTEN_PORT), help=f"Port for the MCP server to listen on (default: {Constants.DEFAULT_MCP_SERVER_LISTEN_PORT})", ) parser.add_argument( "--transport", type=str, default=os.environ.get("MCP_TRANSPORT", Constants.DEFAULT_MCP_TRANSPORT), choices=["streamable-http"], help=f"Transport type for the MCP server (default: {Constants.DEFAULT_MCP_TRANSPORT})", ) return parser.parse_args() # Parse arguments at module level to make them available args = parse_arguments() # Log parsed arguments for debugging logger.info(f"Parsed arguments - port: {args.port}, transport: {args.transport}") logger.info( f"Environment variables - MCP_TRANSPORT: {os.environ.get('MCP_TRANSPORT', 'NOT SET')}, MCP_SERVER_LISTEN_PORT: {os.environ.get('MCP_SERVER_LISTEN_PORT', 'NOT SET')}" ) # Initialize FastMCP server mcp = FastMCP("RealServerFakeTools") # Define some Pydantic models for complex parameter types class GeoCoordinates(BaseModel): latitude: float = Field(..., description="Latitude coordinate") longitude: float = Field(..., description="Longitude coordinate") altitude: float | None = Field(None, description="Altitude in meters (optional)") class UserProfile(BaseModel): username: str = Field(..., description="User's username") email: str = Field(..., description="User's email address") age: int | None = Field(None, description="User's age (optional)") interests: list[str] = Field(default_factory=list, description="List of user interests") class AnalysisOptions(BaseModel): depth: int = Field(3, description="Depth of analysis (1-10)") include_metadata: bool = Field(True, description="Whether to include metadata") filters: dict[str, Any] = Field(default_factory=dict, description="Filters to apply") @mcp.prompt() def system_prompt_for_agent(task_description: str) -> str: """ Generates a system prompt for an AI Agent that wants to use the real_server_fake_tools MCP server. Args: task_description (str): Description of the task the agent wants to accomplish. Returns: str: A formatted system prompt for the AI Agent. """ system_prompt = f""" You are an expert AI agent that wants to use the real_server_fake_tools MCP server. This server provides a collection of fake tools with interesting names that take different types of parameters. The task you need to accomplish is: {task_description} You can use any of the available tools provided by the real_server_fake_tools MCP server to accomplish this task. """ return system_prompt @mcp.tool() def quantum_flux_analyzer( energy_level: Annotated[ int, Field(ge=1, le=10, description="Energy level for quantum analysis (1-10)") ] = 5, stabilization_factor: Annotated[ float, Field(description="Stabilization factor for quantum flux") ] = 0.75, enable_temporal_shift: Annotated[ bool, Field(description="Whether to enable temporal shifting in the analysis") ] = False, ) -> str: """ Analyzes quantum flux patterns with configurable energy levels and stabilization. Args: energy_level: Energy level for quantum analysis (1-10) stabilization_factor: Stabilization factor for quantum flux enable_temporal_shift: Whether to enable temporal shifting in the analysis Returns: str: JSON response with mock quantum flux analysis results """ # Simulate processing time time.sleep(secure_uniform(0.5, 1.5)) # Generate mock response result = { "analysis_id": f"QFA-{10000 + secrets.randbelow(90000)}", "timestamp": datetime.now().isoformat(), "energy_level": energy_level, "stabilization_factor": stabilization_factor, "temporal_shift_enabled": enable_temporal_shift, "flux_patterns": [ { "pattern_id": f"P{i}", "intensity": secure_uniform(0.1, 0.9), "stability": secure_uniform(0.2, 1.0), } for i in range(1, energy_level + 3) ], "analysis_summary": "Quantum flux patterns analyzed successfully with simulated data.", "confidence_score": secure_uniform(0.65, 0.98), } return json.dumps(result, indent=2) @mcp.tool() def neural_pattern_synthesizer( input_patterns: Annotated[ list[str], Field(description="List of neural patterns to synthesize") ], coherence_threshold: Annotated[ float, Field(ge=0.0, le=1.0, description="Threshold for pattern coherence (0.0-1.0)") ] = 0.7, dimensions: Annotated[ int, Field(ge=1, le=10, description="Number of dimensions for synthesis (1-10)") ] = 3, ) -> dict[str, Any]: """ Synthesizes neural patterns into coherent structures. Args: input_patterns: List of neural patterns to synthesize coherence_threshold: Threshold for pattern coherence (0.0-1.0) dimensions: Number of dimensions for synthesis (1-10) Returns: Dict[str, Any]: Dictionary with mock neural pattern synthesis results """ # Simulate processing time time.sleep(secure_uniform(0.8, 2.0)) # Generate mock response pattern_count = len(input_patterns) result = { "synthesis_id": f"NPS-{10000 + secrets.randbelow(90000)}", "timestamp": datetime.now().isoformat(), "input_pattern_count": pattern_count, "coherence_threshold": coherence_threshold, "dimensions": dimensions, "synthesized_patterns": [ { "original": pattern, "synthesized": f"syn_{pattern}_{100 + secrets.randbelow(900)}", "coherence_score": secure_uniform( coherence_threshold - 0.2, coherence_threshold + 0.2 ), "dimensional_stability": [secure_uniform(0.5, 0.95) for _ in range(dimensions)], } for pattern in input_patterns ], "overall_synthesis_quality": secure_uniform(0.6, 0.95), "recommended_adjustments": [ "Increase pattern diversity", "Adjust coherence threshold", "Consider higher dimensional analysis", ] if secure_random() > 0.5 else [], } return result @mcp.tool() def hyper_dimensional_mapper( coordinates: Annotated[ GeoCoordinates, Field(description="Geographical coordinates to map to hyper-dimensions") ], dimension_count: Annotated[ int, Field(ge=4, le=11, description="Number of hyper-dimensions to map to (4-11)") ] = 5, reality_anchoring: Annotated[ float, Field(ge=0.1, le=1.0, description="Reality anchoring factor (0.1-1.0)") ] = 0.8, ) -> str: """ Maps geographical coordinates to hyper-dimensional space. Args: coordinates: Geographical coordinates to map dimension_count: Number of hyper-dimensions to map to (4-11) reality_anchoring: Reality anchoring factor (0.1-1.0) Returns: str: JSON response with mock hyper-dimensional mapping results """ # Simulate processing time time.sleep(secure_uniform(1.0, 2.5)) # Generate mock response hyper_coords = [secure_uniform(-100, 100) for _ in range(dimension_count)] result = { "mapping_id": f"HDM-{10000 + secrets.randbelow(90000)}", "timestamp": datetime.now().isoformat(), "source_coordinates": { "latitude": coordinates.latitude, "longitude": coordinates.longitude, "altitude": coordinates.altitude if coordinates.altitude is not None else "not provided", }, "hyper_dimensional_coordinates": { f"d{i + 1}": coord for i, coord in enumerate(hyper_coords) }, "reality_anchoring_factor": reality_anchoring, "stability_assessment": { "temporal_stability": secure_uniform(0.5, 0.9), "spatial_coherence": secure_uniform(0.6, 0.95), "dimensional_bleed": secure_uniform(0.05, 0.3), }, "navigation_safety": "GREEN" if secure_random() > 0.7 else "YELLOW", "estimated_mapping_accuracy": f"{secure_uniform(85, 99):.2f}%", } return json.dumps(result, indent=2) @mcp.tool() def temporal_anomaly_detector( timeframe: Annotated[ dict[str, str], Field(description="Start and end times for anomaly detection") ], sensitivity: Annotated[ int, Field(ge=1, le=10, description="Sensitivity level for detection (1-10)") ] = 7, anomaly_types: Annotated[list[str], Field(description="Types of anomalies to detect")] = [ "temporal_shift", "causal_loop", "timeline_divergence", ], ) -> dict[str, Any]: """ Detects temporal anomalies within a specified timeframe. Args: timeframe: Dictionary with 'start' and 'end' times for anomaly detection sensitivity: Sensitivity level for detection (1-10) anomaly_types: Types of anomalies to detect Returns: Dict[str, Any]: Dictionary with mock temporal anomaly detection results """ # Simulate processing time time.sleep(secure_uniform(1.2, 3.0)) # Generate mock response anomaly_count = secrets.randbelow(sensitivity + 1) result = { "detection_id": f"TAD-{10000 + secrets.randbelow(90000)}", "timestamp": datetime.now().isoformat(), "timeframe": timeframe, "sensitivity_level": sensitivity, "anomaly_types_monitored": anomaly_types, "anomalies_detected": anomaly_count, "anomaly_details": [ { "anomaly_id": f"A{1000 + secrets.randbelow(9000)}", "type": secure_choice(anomaly_types), "severity": secure_uniform(0.1, 1.0), "temporal_coordinates": { "t": secure_uniform(-10, 10), "x": secure_uniform(-5, 5), "y": secure_uniform(-5, 5), "z": secure_uniform(-5, 5), }, "causality_impact": secure_choice(["LOW", "MEDIUM", "HIGH", "CRITICAL"]), "recommended_action": secure_choice( ["Monitor", "Investigate", "Contain", "Neutralize", "Temporal reset required"] ), } for _ in range(anomaly_count) ], "background_temporal_stability": f"{secure_uniform(85, 99.9):.2f}%", "detection_confidence": secure_uniform(0.7, 0.98), } return result @mcp.tool() def user_profile_analyzer( profile: Annotated[UserProfile, Field(description="User profile to analyze")], analysis_options: Annotated[ AnalysisOptions, Field(description="Options for the analysis") ] = AnalysisOptions(), ) -> str: """ Analyzes a user profile with configurable analysis options. Args: profile: User profile to analyze analysis_options: Options for the analysis Returns: str: JSON response with mock user profile analysis results """ # Simulate processing time time.sleep(secure_uniform(0.7, 1.8)) # Generate mock response result = { "analysis_id": f"UPA-{10000 + secrets.randbelow(90000)}", "timestamp": datetime.now().isoformat(), "user": { "username": profile.username, "email": profile.email, "age": profile.age if profile.age is not None else "not provided", "interest_count": len(profile.interests), }, "analysis_depth": analysis_options.depth, "metadata_included": analysis_options.include_metadata, "applied_filters": analysis_options.filters if analysis_options.filters else "none", "analysis_results": { "engagement_score": secure_uniform(0, 100), "activity_pattern": secure_choice(["Regular", "Sporadic", "Intensive", "Declining"]), "interest_clusters": [ { "cluster_name": f"Cluster {i + 1}", "interests": secure_sample( profile.interests, min(len(profile.interests), 1 + secrets.randbelow(3)) ), "relevance_score": secure_uniform(0.5, 0.95), } for i in range(min(3, len(profile.interests))) ] if profile.interests else [], "behavioral_insights": [ "Prefers morning engagement", "Shows interest in technical topics", "Likely to respond to visual content", ], "recommendation_categories": [ "Technical documentation", "Interactive tutorials", "Community discussions", ], }, "analysis_quality": f"{secure_uniform(85, 98):.1f}%", } return json.dumps(result, indent=2) @mcp.tool() def synthetic_data_generator( schema: Annotated[ dict[str, Any], Field(description="Schema defining the structure of synthetic data") ], record_count: Annotated[ int, Field(ge=1, le=1000, description="Number of synthetic records to generate (1-1000)") ] = 10, seed: Annotated[ int | None, Field(description="Random seed for reproducibility (optional)") ] = None, ) -> dict[str, Any]: """ Generates synthetic data based on a provided schema. Args: schema: Schema defining the structure of synthetic data record_count: Number of synthetic records to generate (1-1000) seed: Random seed for reproducibility (optional) Returns: Dict[str, Any]: Dictionary with mock synthetic data generation results """ # Note: Using seed with secrets is not appropriate as it's designed for cryptographic randomness # For this demo, we'll acknowledge the seed parameter but not use it, as secrets doesn't support seeding # Simulate processing time time.sleep(secure_uniform(0.5, 2.0)) # Generate mock response result = { "generation_id": f"SDG-{10000 + secrets.randbelow(90000)}", "timestamp": datetime.now().isoformat(), "schema_fields": list(schema.keys()), "record_count": record_count, "seed_used": seed if seed is not None else "not provided", "generated_data": [ { field: f"synthetic_{field}_{i}_{1000 + secrets.randbelow(9000)}" for field in schema.keys() } for i in range(record_count) ], "data_quality_metrics": { "completeness": secure_uniform(0.95, 1.0), "uniqueness": secure_uniform(0.9, 1.0), "consistency": secure_uniform(0.92, 0.99), }, "generation_time_ms": 50 + secrets.randbelow(451), } return result @mcp.resource("config://app") def get_config() -> str: """Static configuration data for the fake tools server""" config = { "server_name": "real_server_fake_tools", "version": "0.1.0", "description": "A collection of fake tools with interesting names", "max_concurrent_requests": 10, "default_timeout_seconds": 30, "supported_features": [ "quantum_analysis", "neural_synthesis", "hyper_mapping", "temporal_detection", "user_analysis", "synthetic_generation", ], "environment": "development", } return json.dumps(config, indent=2) @mcp.resource("docs://tools") def get_tools_documentation() -> str: """Documentation for the fake tools""" docs = { "quantum_flux_analyzer": { "description": "Analyzes quantum flux patterns with configurable energy levels and stabilization.", "use_cases": [ "Quantum computing simulation", "Particle physics research", "Energy field analysis", ], "example_usage": { "energy_level": 7, "stabilization_factor": 0.85, "enable_temporal_shift": True, }, }, "neural_pattern_synthesizer": { "description": "Synthesizes neural patterns into coherent structures.", "use_cases": [ "AI model training", "Neural network optimization", "Pattern recognition systems", ], "example_usage": { "input_patterns": ["alpha", "beta", "gamma"], "coherence_threshold": 0.8, "dimensions": 5, }, }, "hyper_dimensional_mapper": { "description": "Maps geographical coordinates to hyper-dimensional space.", "use_cases": [ "Advanced navigation systems", "Spatial analysis", "Dimensional research", ], "example_usage": { "coordinates": {"latitude": 37.7749, "longitude": -122.4194, "altitude": 10}, "dimension_count": 6, "reality_anchoring": 0.9, }, }, "temporal_anomaly_detector": { "description": "Detects temporal anomalies within a specified timeframe.", "use_cases": ["Time series analysis", "Anomaly detection", "Predictive modeling"], "example_usage": { "timeframe": {"start": "2023-01-01T00:00:00Z", "end": "2023-01-31T23:59:59Z"}, "sensitivity": 8, "anomaly_types": ["temporal_shift", "causal_loop"], }, }, "user_profile_analyzer": { "description": "Analyzes a user profile with configurable analysis options.", "use_cases": [ "User behavior analysis", "Personalization systems", "Marketing targeting", ], "example_usage": { "profile": { "username": "user123", "email": "user@example.com", "age": 30, "interests": ["technology", "science", "art"], }, "analysis_options": { "depth": 5, "include_metadata": True, "filters": {"exclude_inactive": True}, }, }, }, "synthetic_data_generator": { "description": "Generates synthetic data based on a provided schema.", "use_cases": [ "Testing environments", "Machine learning training", "Privacy-preserving analytics", ], "example_usage": { "schema": {"name": "string", "age": "integer", "email": "email"}, "record_count": 50, "seed": 12345, }, }, } return json.dumps(docs, indent=2) def main(): # Log transport and endpoint information endpoint = "/mcp" # streamable-http always uses /mcp endpoint logger.info( f"Starting RealServerFakeTools server on port {args.port} with transport {args.transport}" ) logger.info(f"Server will be available at: http://localhost:{args.port}{endpoint}") # Run the server with the specified transport from command line args # Example server - binds to 0.0.0.0 for demonstration purposes only. # In production, bind to 127.0.0.1 or specific IP with proper firewall rules. mcp.run(transport=args.transport, host="0.0.0.0", port=int(args.port)) # nosec B104 - example/demo server if __name__ == "__main__": main() ================================================ FILE: start_token_refresher.sh ================================================ #!/bin/bash # Token Refresher Launcher Script # This script starts the OAuth token refresher service in the background set -e # Get the directory where this script is located SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOKEN_REFRESHER_SCRIPT="$SCRIPT_DIR/credentials-provider/token_refresher.py" # Configuration CHECK_INTERVAL=${TOKEN_REFRESH_INTERVAL:-300} # 5 minutes default EXPIRY_BUFFER=${TOKEN_EXPIRY_BUFFER:-3600} # 1 hour default # Log file location LOG_FILE="$SCRIPT_DIR/token_refresher.log" echo "Starting OAuth Token Refresher Service..." echo "Check interval: ${CHECK_INTERVAL} seconds" echo "Expiry buffer: ${EXPIRY_BUFFER} seconds" echo "Log file: ${LOG_FILE}" # Check if token refresher is already running if pgrep -f "token_refresher.py" > /dev/null; then echo "WARNING: Token refresher service appears to be already running" echo "Existing processes:" pgrep -fl "token_refresher.py" read -p "Kill existing processes and restart? (y/N): " -n 1 -r echo if [[ $REPLY =~ ^[Yy]$ ]]; then echo "Killing existing token refresher processes..." pkill -f "token_refresher.py" || true sleep 2 else echo "ERROR: Aborted - token refresher service already running" exit 1 fi fi # Start the token refresher service in background echo "Starting token refresher service..." nohup uv run python "$TOKEN_REFRESHER_SCRIPT" \ --interval "$CHECK_INTERVAL" \ --buffer "$EXPIRY_BUFFER" \ > "$LOG_FILE" 2>&1 & TOKEN_REFRESHER_PID=$! echo "Token refresher service started with PID: $TOKEN_REFRESHER_PID" # Wait a moment and check if it's still running sleep 2 if kill -0 "$TOKEN_REFRESHER_PID" 2>/dev/null; then echo "Service is running successfully" echo "Monitor logs with: tail -f $LOG_FILE" echo "Stop service with: pkill -f token_refresher.py" else echo "ERROR: Service failed to start - check logs:" tail "$LOG_FILE" exit 1 fi # Show first few lines of output echo "" echo "Recent log output:" echo "====================" tail -n 10 "$LOG_FILE" || echo "No log output yet" ================================================ FILE: terraform/README.md ================================================ # MCP Gateway Registry Terraform Configurations This directory contains Terraform infrastructure-as-code for deploying the MCP Gateway Registry on AWS. ## Available Deployments ### AWS ECS (Available) Deploy the MCP Gateway Registry on AWS ECS using Fargate for serverless container orchestration. **Location:** [`aws-ecs/`](aws-ecs/) **Features:** - Serverless containers with AWS Fargate - Application Load Balancer (ALB) for traffic routing - Amazon EFS for persistent storage - AWS Secrets Manager for credential management - Amazon ECR for container images - CloudWatch for logging and monitoring - Auto-scaling ECS services - VPC with public/private subnets - NAT Gateway for outbound connectivity **Quick Start:** ```bash cd terraform/aws-ecs terraform init terraform plan terraform apply ``` See [`aws-ecs/README.md`](aws-ecs/README.md) for detailed instructions. ### AWS EKS (Recommended: Use ai-on-eks) For Kubernetes deployments on Amazon EKS, we recommend using the Helm charts with an EKS cluster provisioned via [AWS AI/ML on Amazon EKS](https://github.com/awslabs/ai-on-eks). **Why not Terraform for EKS here?** The [awslabs/ai-on-eks](https://github.com/awslabs/ai-on-eks) project provides Terraform blueprints specifically designed for AI/ML workloads on EKS. Rather than duplicate this excellent work, we recommend: 1. **Provision EKS cluster** using ai-on-eks blueprints: ```bash git clone https://github.com/awslabs/ai-on-eks.git # Until https://github.com/awslabs/ai-on-eks/pull/232 is merged, the custom stack can be used cd ai-on-eks/infra/custom ./install.sh ``` 2. **Deploy MCP Gateway Registry** using Helm charts: ```bash cd /path/to/mcp-gateway-registry/charts/mcp-gateway-registry-stack helm dependency build && helm dependency update helm install mcp-gateway-registry . -n mcp-gateway --create-namespace --set global.domain "YOUR DOMAIN" --set global.secretKey "CHANGEME" ``` This approach provides: - GPU support for AI/ML workloads - Karpenter for efficient auto-scaling - EKS-optimized AMIs - Security best practices - Observability with Prometheus/Grafana - ArgoCD for GitOps workflows - Proven blueprints maintained by AWS Labs **Reference:** - ai-on-eks Repository: https://github.com/awslabs/ai-on-eks - ai-on-eks Blueprints: https://github.com/awslabs/ai-on-eks/tree/main/blueprints - MCP Gateway Helm Charts: [`/charts`](../charts/) ## Deployment Comparison | Feature | AWS ECS (Terraform) | AWS EKS (ai-on-eks + Helm) | |---------|---------------------|---------------------------| | **Container Orchestration** | AWS Fargate | Kubernetes (EKS) | | **Provisioning Tool** | Terraform (this repo) | Terraform (ai-on-eks) | | **Application Deployment** | Terraform | Helm charts (this repo) | | **Infrastructure Complexity** | Lower | Higher | | **Kubernetes Knowledge** | Not required | Required | | **Multi-cloud Portability** | No | Yes | | **GPU Support** | Limited | Excellent (via ai-on-eks) | | **Auto-scaling** | ECS Service Scaling | Karpenter + HPA | | **Cost Model** | Pay-per-task | Cluster + pods | | **Best For** | AWS-native, simpler deployments | Advanced K8s users, multi-cloud | ## Choosing Your Deployment Method ### Use AWS ECS (Terraform) if: - You want the simplest AWS-native deployment - Your team is familiar with AWS services but not Kubernetes - You prefer managed infrastructure with less operational overhead - You don't need Kubernetes-specific features - You're already using ECS in your organization ### Use AWS EKS (ai-on-eks + Helm) if: - You need Kubernetes for portability or multi-cloud strategy - Your team has Kubernetes expertise - You require GPU support for AI/ML workloads - You want to leverage the broader Kubernetes ecosystem - You need advanced scaling with Karpenter - You're already using Kubernetes in your organization ## Directory Structure ``` terraform/ ├── README.md # This file └── aws-ecs/ # ECS deployment with Terraform ├── README.md # ECS-specific documentation ├── main.tf # Main ECS configuration ├── modules/ # ECS Terraform modules └── terraform.tfvars.example ``` For Kubernetes deployments, see the [`/charts`](../charts/) directory. ## Additional Resources - AWS ECS Documentation: https://docs.aws.amazon.com/ecs/ - AWS EKS Documentation: https://docs.aws.amazon.com/eks/ - AI on EKS: https://github.com/awslabs/ai-on-eks - Terraform Registry: https://registry.terraform.io/ - Helm Documentation: https://helm.sh/docs/ ================================================ FILE: terraform/aws-ecs/.gitignore ================================================ # Terraform files .terraform/ .terraform.lock.hcl terraform.tfstate terraform.tfstate.backup *.tfvars !terraform.tfvars.example # Provider-specific tfvars files (not committed - copy to terraform.tfvars to use) # terraform.tfvars.keycloak # terraform.tfvars.entra # terraform.tfvars.okta # Crash logs crash.log crash.*.log # Override files override.tf override.tf.json *_override.tf *_override.tf.json # CLI configuration .terraformrc terraform.rc *.tfstate* *.backup *.backup # Terraform plan files (contain sensitive data) *.tfplan *.tfplan.json ================================================ FILE: terraform/aws-ecs/OPERATIONS.md ================================================ # Operations and Maintenance This document covers day-to-day operations and maintenance tasks for the MCP Gateway ECS deployment. ## Accessing ECS Tasks ### SSH into Running Tasks Use the provided script to get shell access to any running ECS task: ```bash cd terraform/aws-ecs # Connect to Registry task ./scripts/ecs-ssh.sh registry # Connect to Auth Server task ./scripts/ecs-ssh.sh auth-server # Connect to Keycloak task ./scripts/ecs-ssh.sh keycloak # Specify custom cluster or region ./scripts/ecs-ssh.sh registry mcp-gateway-ecs-cluster us-east-1 ``` The script automatically: - Finds the first running task for the specified service - Establishes an interactive session using AWS Systems Manager - No SSH keys or bastion hosts required **Requirements:** - Session Manager plugin installed: `aws ssm install-plugin` - IAM permissions for `ecs:ExecuteCommand` and `ssm:StartSession` - ECS tasks must have `enableExecuteCommand` enabled (already configured) ### Manual ECS Access ```bash # List all tasks in cluster aws ecs list-tasks --cluster mcp-gateway-ecs-cluster --region us-east-1 # Get specific task details aws ecs describe-tasks \ --cluster mcp-gateway-ecs-cluster \ --tasks TASK_ARN \ --region us-east-1 # Execute command in running task aws ecs execute-command \ --cluster mcp-gateway-ecs-cluster \ --task TASK_ARN \ --container registry \ --interactive \ --command "/bin/bash" \ --region us-east-1 ``` ## Viewing Logs ### Using CloudWatch Logs Script ```bash cd terraform/aws-ecs # Basic usage - last 30 minutes, all components ./scripts/view-cloudwatch-logs.sh # Component-specific logs ./scripts/view-cloudwatch-logs.sh --component keycloak ./scripts/view-cloudwatch-logs.sh --component registry ./scripts/view-cloudwatch-logs.sh --component auth-server # Custom time range ./scripts/view-cloudwatch-logs.sh --minutes 60 # Last hour ./scripts/view-cloudwatch-logs.sh --minutes 5 # Last 5 minutes # Live tail (real-time streaming) ./scripts/view-cloudwatch-logs.sh --follow # Filter by pattern (regex) ./scripts/view-cloudwatch-logs.sh --filter "ERROR|WARN" ./scripts/view-cloudwatch-logs.sh --filter "database connection" # Specific time range ./scripts/view-cloudwatch-logs.sh \ --start-time 2024-01-15T10:00:00Z \ --end-time 2024-01-15T11:00:00Z # Combine options ./scripts/view-cloudwatch-logs.sh \ --component registry \ --minutes 15 \ --filter "ERROR" ``` ### Direct CloudWatch Access ```bash # List log groups aws logs describe-log-groups \ --log-group-name-prefix "/aws/ecs/mcp-gateway" \ --region us-east-1 # Get specific log streams aws logs describe-log-streams \ --log-group-name "/aws/ecs/mcp-gateway-registry" \ --order-by LastEventTime \ --descending \ --max-items 5 \ --region us-east-1 # Tail logs in real-time aws logs tail "/aws/ecs/mcp-gateway-registry" \ --follow \ --region us-east-1 # Filter and query logs aws logs filter-log-events \ --log-group-name "/aws/ecs/mcp-gateway-registry" \ --start-time $(date -u -d '30 minutes ago' +%s)000 \ --filter-pattern "ERROR" \ --region us-east-1 ``` ## Container Build and Deployment ### Understanding the Build System The repository uses a unified container build system with `build-config.yaml` as the **single source of truth**. **All Container Images:** | Image Name | Purpose | Size | Build Time | |------------|---------|------|------------| | `registry` | MCP Gateway with nginx, FAISS, ML models | ~4.6GB | ~8 min | | `mcpgw` | MCP Gateway core server | ~4.1GB | ~7 min | | `auth_server` | OAuth2/OIDC authentication server | ~244MB | ~3 min | | `currenttime` | Example MCP server (current time) | ~230MB | ~2 min | | `realserverfaketools` | Testing MCP server | ~230MB | ~2 min | | `flight_booking_agent` | A2A agent for flight booking | ~170MB | ~2 min | | `travel_assistant_agent` | A2A agent for travel assistance | ~170MB | ~2 min | **Total:** ~9.8GB across 7 images, ~25-30 minutes for complete build. ### Building Container Images **Prerequisites:** ```bash # Verify Docker is running docker ps # Set target region export AWS_REGION=us-east-1 # Verify AWS credentials aws sts get-caller-identity ``` **Build Commands:** ```bash # From repository root cd /path/to/mcp-gateway-registry # ============================================================================== # BUILD ONLY (Local Testing) # ============================================================================== # Build all 12 images locally (no push) make build # Build specific image make build IMAGE=registry make build IMAGE=auth_server make build IMAGE=keycloak # Build multiple specific images make build IMAGE=registry && make build IMAGE=auth_server # ============================================================================== # PUSH ONLY (After Local Build) # ============================================================================== # Push all built images to ECR make push # Push specific image make push IMAGE=registry # ============================================================================== # BUILD + PUSH (Recommended for Deployment) # ============================================================================== # Build and push all images (full deployment) make build-push # Build and push specific image (faster updates) make build-push IMAGE=registry make build-push IMAGE=auth_server make build-push IMAGE=metrics_service # ============================================================================== # AGENT-SPECIFIC BUILDS # ============================================================================== # Build both A2A agents make build-agents # Push both A2A agents make push-agents ``` **What Happens During `make build-push`:** ``` 1. Reads build-config.yaml for image definitions 2. Authenticates with ECR: aws ecr get-login-password 3. Creates ECR repositories (if don't exist) 4. For each image: a. Builds Docker image with specified dockerfile and context b. Tags with latest and optional custom tags c. Pushes to ECR repository 5. Displays summary with all ECR URIs ``` **Example Output:** ``` [INFO] AWS Account: 123456789012 [INFO] ECR Registry: 123456789012.dkr.ecr.us-east-1.amazonaws.com [INFO] AWS Region: us-east-1 [INFO] Build Action: build-push [INFO] Processing all 12 images... [INFO] ========================================== [INFO] Processing: registry (mcp-gateway-registry) [INFO] ========================================== [INFO] Building registry... [+] Building 480.2s (20/20) FINISHED => [internal] load build definition => [internal] load .dockerignore => [internal] load metadata for docker.io/library/python:3.14-slim ... [INFO] Successfully built registry [INFO] Pushing registry to ECR... [INFO] Successfully pushed: 123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-registry:latest ... [INFO] ========================================== [INFO] Build Summary [INFO] ========================================== [INFO] Successfully processed 12/12 images [INFO] Total build time: 28 minutes 15 seconds ``` ### Updating Running Services After pushing a new container image to ECR, trigger a deployment to update running ECS tasks. **Service Deployment Mapping:** | Service Name | ECS Cluster | Container Image | Typical Update Reason | |--------------|-------------|-----------------|----------------------| | `mcp-gateway-v2-registry` | `mcp-gateway-ecs-cluster` | `registry` | API changes, bug fixes | | `mcp-gateway-v2-auth` | `mcp-gateway-ecs-cluster` | `auth_server` | Auth logic updates | | `keycloak` | `keycloak` | `keycloak` | Custom Keycloak config | **Update Commands:** ```bash # Set region export AWS_REGION=us-east-1 # ============================================================================ # UPDATE REGISTRY SERVICE # ============================================================================ aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --force-new-deployment \ --region $AWS_REGION \ --output table # ============================================================================ # UPDATE AUTH SERVER SERVICE # ============================================================================ aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-auth \ --force-new-deployment \ --region $AWS_REGION \ --output table # ============================================================================ # UPDATE KEYCLOAK SERVICE # ============================================================================ aws ecs update-service \ --cluster keycloak \ --service keycloak \ --force-new-deployment \ --region $AWS_REGION \ --output table ``` **What `--force-new-deployment` does:** 1. Stops existing tasks gracefully (30 second drain period) 2. Pulls latest image from ECR (even if tag is same) 3. Starts new tasks with new container 4. Waits for health checks to pass 5. Continues rolling deployment until all tasks updated **Monitor Deployment Progress:** ```bash # Method 1: Watch service status (auto-refreshing) watch -n 5 'aws ecs describe-services \ --cluster mcp-gateway-ecs-cluster \ --services mcp-gateway-v2-registry \ --region us-east-1 \ --query "services[0].{Running:runningCount,Desired:desiredCount,Status:status,Deployment:deployments[0].status}" \ --output table' # Exit watch with Ctrl+C when Running = Desired # Method 2: Check deployment status once aws ecs describe-services \ --cluster mcp-gateway-ecs-cluster \ --services mcp-gateway-v2-registry \ --region $AWS_REGION \ --query 'services[0].{ServiceName:serviceName,Status:status,RunningCount:runningCount,DesiredCount:desiredCount,Deployments:deployments[*].{Status:status,Running:runningCount,Desired:desiredCount,TaskDef:taskDefinition}}' \ --output json # Method 3: View recent service events aws ecs describe-services \ --cluster mcp-gateway-ecs-cluster \ --services mcp-gateway-v2-registry \ --region $AWS_REGION \ --query 'services[0].events[:10]' \ --output table # Method 4: List all running tasks aws ecs list-tasks \ --cluster mcp-gateway-ecs-cluster \ --service-name mcp-gateway-v2-registry \ --region $AWS_REGION # Method 5: Get specific task details aws ecs describe-tasks \ --cluster mcp-gateway-ecs-cluster \ --tasks TASK_ARN \ --region $AWS_REGION \ --query 'tasks[0].{TaskArn:taskArn,Status:lastStatus,Health:healthStatus,StartedAt:startedAt,Containers:containers[*].{Name:name,Status:lastStatus,Health:healthStatus}}' ``` ### Complete Developer Workflow **Scenario:** You fixed a bug in the Registry API and want to deploy it. ```bash # ============================================================================ # STEP 1: Make Code Changes # ============================================================================ cd /path/to/mcp-gateway-registry vim registry/api/server_routes.py # Fix bug # ============================================================================ # STEP 2: Test Locally (Optional but Recommended) # ============================================================================ # Build image locally docker build -f docker/Dockerfile.registry -t registry:test . # Run locally docker run -p 7860:7860 registry:test # Test endpoint curl http://localhost:7860/health # Stop test container docker stop $(docker ps -q --filter ancestor=registry:test) # ============================================================================ # STEP 3: Build and Push to ECR # ============================================================================ export AWS_REGION=us-east-1 make build-push IMAGE=registry # Verify push succeeded aws ecr describe-images \ --repository-name mcp-gateway-registry \ --region $AWS_REGION \ --query 'imageDetails[0].{Tags:imageTags,Pushed:imagePushedAt,Size:imageSizeInBytes}' # ============================================================================ # STEP 4: Deploy to ECS # ============================================================================ aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --force-new-deployment \ --region $AWS_REGION # ============================================================================ # STEP 5: Monitor Deployment # ============================================================================ # Watch logs in real-time cd terraform/aws-ecs ./scripts/view-cloudwatch-logs.sh --component registry --follow # In another terminal, check service status watch -n 10 'aws ecs describe-services \ --cluster mcp-gateway-ecs-cluster \ --services mcp-gateway-v2-registry \ --region us-east-1 \ --query "services[0].{Running:runningCount,Desired:desiredCount}" \ --output table' # ============================================================================ # STEP 6: Verify Deployment # ============================================================================ # Test health endpoint curl https://registry.us-east-1.your.domain/health # Test your specific fix curl https://registry.us-east-1.your.domain/api/your-fixed-endpoint # Check for errors in logs (last 5 minutes) ./scripts/view-cloudwatch-logs.sh --component registry --minutes 5 --filter "ERROR" ``` ### Deployment Troubleshooting **Deployment stuck / tasks not starting:** ```bash # Check service events for errors aws ecs describe-services \ --cluster mcp-gateway-ecs-cluster \ --services mcp-gateway-v2-registry \ --region $AWS_REGION \ --query 'services[0].events[:15]' \ --output table # Common issues: # - "Ecouldn't pull image" -> ECR permissions or wrong image URI # - "CannotPullContainerError" -> Image doesn't exist in ECR # - "Task failed container health checks" -> Application not starting correctly # - "Service is unable to place a task" -> No capacity or resource constraints # Check stopped tasks for failure reason aws ecs list-tasks \ --cluster mcp-gateway-ecs-cluster \ --service-name mcp-gateway-v2-registry \ --desired-status STOPPED \ --region $AWS_REGION \ --max-items 5 aws ecs describe-tasks \ --cluster mcp-gateway-ecs-cluster \ --tasks STOPPED_TASK_ARN \ --region $AWS_REGION \ --query 'tasks[0].{StoppedReason:stoppedReason,Containers:containers[*].{Name:name,Reason:reason,ExitCode:exitCode}}' ``` ### Rolling Back Deployments **Quick rollback to previous working version:** ```bash # Method 1: Rollback to specific task definition revision # List recent task definitions aws ecs list-task-definitions \ --family-prefix mcp-gateway-registry \ --sort DESC \ --max-items 10 \ --region $AWS_REGION # Deploy specific (previous) revision aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --task-definition mcp-gateway-registry:42 \ --region $AWS_REGION # Method 2: Redeploy current task definition (if image was bad) # First, rebuild and push fixed image with same tag make build-push IMAGE=registry # Then force new deployment to pull updated image aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --force-new-deployment \ --region $AWS_REGION # Method 3: Emergency rollback script cat > rollback-registry.sh << 'EOF' #!/bin/bash set -e export AWS_REGION=us-east-1 echo "Rolling back registry service..." PREVIOUS_REVISION=$(aws ecs describe-services \ --cluster mcp-gateway-ecs-cluster \ --services mcp-gateway-v2-registry \ --region $AWS_REGION \ --query 'services[0].deployments[1].taskDefinition' \ --output text) aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --task-definition $PREVIOUS_REVISION \ --region $AWS_REGION echo "Rollback initiated to: $PREVIOUS_REVISION" EOF chmod +x rollback-registry.sh ./rollback-registry.sh ``` ### Blue/Green Deployment Strategy For zero-downtime updates with instant rollback capability: ```bash # 1. Update service with new task definition (auto blue/green) aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --force-new-deployment \ --region $AWS_REGION # ECS automatically performs rolling update: # - Starts new task (green) # - Waits for health check # - Drains old task (blue) # - Removes old task # - Repeats for remaining tasks # 2. Monitor health during deployment watch -n 5 'curl -s https://registry.us-east-1.your.domain/health | jq .' # 3. If issues detected, rollback immediately aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --task-definition \ --region $AWS_REGION ``` ================================================ FILE: terraform/aws-ecs/README.md ================================================ # MCP Gateway Registry - AWS ECS Infrastructure Production-grade infrastructure for the MCP Gateway Registry using AWS ECS Fargate, Aurora Serverless, and Keycloak authentication. [![Infrastructure](https://img.shields.io/badge/infrastructure-terraform-purple)](https://www.terraform.io/) [![AWS ECS](https://img.shields.io/badge/compute-ECS%20Fargate-orange)](https://aws.amazon.com/ecs/) [![Database](https://img.shields.io/badge/database-Aurora%20Serverless%20v2-blue)](https://aws.amazon.com/rds/aurora/) ## Table of Contents - [Architecture](#architecture) - [Deployment Modes](#deployment-modes) - [Quick Start](#quick-start) - [Post-Deployment](#post-deployment) - [Operations and Maintenance](#operations-and-maintenance) - [Troubleshooting](#troubleshooting) - [Cost Optimization](#cost-optimization) - [Security](#security-considerations) ## Architecture ![Architecture Diagram](img/architecture-ecs.png) ### Network Architecture The infrastructure is deployed within a dedicated VPC spanning two availability zones for redundancy. User traffic enters through Route 53 DNS resolution, directing requests to either the Main ALB (for Registry and Auth Server) or the Keycloak ALB (for identity management). AWS Certificate Manager provisions and manages SSL/TLS certificates for secure HTTPS communication. ### Application Load Balancers **Main ALB (Internet-Facing)** - Deployed in public subnets across both availability zones - Routes traffic to Registry and Auth Server tasks - SSL termination with ACM certificates - Health checks to ensure task availability - Target groups with dynamic port mapping **Keycloak ALB (Private Subnets)*** - Internal load balancer for Keycloak services - Isolated from direct internet access - Dedicated SSL certificate for Keycloak domain - Health check endpoint monitoring *Currently deployed in public subnets for initial setup and management. Will be updated soon to use internal ALB with a bastion host in the VPC for secure Keycloak admin console access from within the VPC for management purposes. ### ECS Cluster and Services The infrastructure runs on an ECS cluster with Fargate launch type, eliminating server management. Three primary service types run as containerized tasks: **Registry Tasks** provide the core MCP server registry and discovery service. An auto-scaling group manages task count based on CPU and memory utilization, with tasks deployed across both availability zones for redundancy. The registry retrieves secrets from AWS Secrets Manager for secure credential management, writes logs to CloudWatch Logs for centralized monitoring, and stores server metadata in DocumentDB for persistent, distributed access with native vector search capabilities. **Auth Server Tasks** handle OAuth2/OIDC authentication and authorization for the entire platform. These tasks manage user sessions and token validation, integrate with Keycloak for identity federation, and auto-scale based on demand. User data and session information is stored in Aurora PostgreSQL Serverless for reliable, scalable persistence. **Keycloak Tasks** serve as the identity and access management layer, providing user authentication, single sign-on (SSO), and an admin console for user management. Keycloak connects to Aurora PostgreSQL for data persistence, providing reliable session management and user credential storage. ### Data Layer **Amazon Aurora PostgreSQL Serverless v2** provides a fully managed, auto-scaling database with capacity ranging from 0.5 to 2 ACUs based on workload demands. The database stores user credentials, session data, and application state with automatic backups and point-in-time recovery capabilities. Deployed in a multi-AZ configuration for redundancy, Aurora uses RDS Proxy for efficient connection pooling and management across ECS tasks. **Amazon DocumentDB** (MongoDB-compatible) serves as the primary data store for the MCP Gateway Registry. DocumentDB provides distributed, scalable storage for server metadata, agent registrations, scopes, and security scan results. With native HNSW vector search support, DocumentDB enables sub-100ms semantic queries for server and agent discovery. The cluster automatically scales storage and replicates data across multiple availability zones for redundancy and durability. ### Observability **Amazon Managed Prometheus (AMP) + Grafana** provides an optional metrics pipeline when `enable_observability = true`. A metrics-service container with an AWS Distro for OpenTelemetry (ADOT) sidecar scrapes application metrics and remote-writes them to an AMP workspace. Grafana OSS (pinned to v12.3.1) is deployed as an ECS service with pre-provisioned AMP datasource and dashboards, accessible at `https:///grafana/`. Anonymous access is disabled by default; login requires the admin password configured via `grafana_admin_password` in `terraform.tfvars`. The `aps:*` IAM permission is required for the deploying role when this feature is enabled. **CloudWatch Logs** provides centralized logging for all ECS tasks with separate log groups created for each service to organize and isolate log streams. Log retention policies automatically expire old logs after a configurable period, and the logs integrate with CloudWatch Alarms to trigger alerts based on specific patterns or error rates found in the log data. **CloudWatch Alarms** continuously monitor key infrastructure and application metrics including CPU and memory utilization across all ECS tasks, database connection counts and pool exhaustion, and HTTP error rates from the load balancers. When alarm thresholds are breached, notifications are sent through Amazon SNS to configured endpoints such as email, SMS, or other automated incident response systems. **AWS Secrets Manager** provides secure storage and lifecycle management for sensitive credentials including Keycloak admin passwords, database connection strings, and API keys. ECS tasks retrieve these secrets at runtime as environment variables, eliminating the need to hardcode credentials in container images or configuration files. Secrets Manager supports automatic rotation of credentials on a scheduled basis to enhance security posture. --- ## Deployment Modes MCP Gateway supports three deployment modes. Choose based on your requirements: | Mode | Best For | Custom Domain Required? | Configuration (in `terraform.tfvars`) | |------|----------|------------------------|---------------------------------------| | **CloudFront Only** | Workshops, demos, evaluations, quick setup | No | `enable_cloudfront=true`, `enable_route53_dns=false` | | **Custom Domain** | Production with brand consistency | Yes (Route53) | `enable_cloudfront=false`, `enable_route53_dns=true` | | **CloudFront + Custom Domain** | Production with CDN benefits | Yes (Route53) | `enable_cloudfront=true`, `enable_route53_dns=true` | ### Recommended Deployment Path **Mode 1: CloudFront Only (Easiest - No Custom Domain Required):** - No custom domain or Route53 hosted zone required - Get HTTPS URLs immediately (`https://d1234abcd.cloudfront.net`) - Perfect for workshops, demos, evaluations, or any deployment where custom DNS isn't available - Simply set `enable_cloudfront = true` and `enable_route53_dns = false` **Mode 2: Custom Domain Only:** - Custom branded URLs without CloudFront - Direct ALB access with ACM certificates - Simpler architecture if CDN isn't needed - Set `enable_cloudfront = false` and `enable_route53_dns = true` **Mode 3: CloudFront + Custom Domain (Production Recommended):** - Custom branded URLs (`https://registry.us-east-1.yourdomain.com`) - CloudFront CDN for global edge caching and DDoS protection - Requires a Route53 hosted zone for your domain - Set `enable_cloudfront = true` and `enable_route53_dns = true` For detailed configuration and troubleshooting, see [Deployment Modes Guide](../../docs/deployment-modes.md). --- ## Quick Start **Total Time:** ~60-90 minutes for first deployment > **IMPORTANT:** We recommend running this deployment from an EC2 instance with an IAM instance profile attached (preferably with `AdministratorAccess` policy). This eliminates credential management complexity and ensures all AWS CLI commands work seamlessly. For more restrictive IAM permissions, see [IAM Permissions](#iam-permissions). > > While these instructions should work on macOS or other development environments, you will need to have AWS credentials configured via `aws configure` or an AWS profile. ### Step 1: Prerequisites #### Step 1.1: Domain Configuration You need a domain with a Route53 hosted zone for SSL certificates and DNS routing. The domain can be registered with **any registrar** (GoDaddy, Namecheap, Google Domains, Cloudflare, etc.) - you just need to create a hosted zone in Route53 and point your domain's nameservers to Route53. **Option A: Domain registered with Route53** If you register your domain directly through Route53, a hosted zone is created automatically. ```bash # Go to Route53 console > Registered domains > Register domain # The hosted zone will be created automatically ``` **Option B: Domain registered with another provider (GoDaddy, Namecheap, Cloudflare, etc.)** If your domain is registered elsewhere, create a hosted zone in Route53 and update your registrar's nameservers: ```bash # 1. Create hosted zone in Route53 aws route53 create-hosted-zone \ --name your.domain \ --caller-reference $(date +%s) # 2. Get the nameservers assigned by Route53 aws route53 list-hosted-zones --query 'HostedZones[?Name==`your.domain.`]' # The output will show the hosted zone ID. Get the nameservers: aws route53 get-hosted-zone --id --query 'DelegationSet.NameServers' # Example output: # [ # "ns-1234.awsdns-12.org", # "ns-567.awsdns-34.com", # "ns-890.awsdns-56.co.uk", # "ns-123.awsdns-78.net" # ] # 3. Update nameservers at your domain registrar: # - GoDaddy: My Products > DNS > Nameservers > Change > Enter my own nameservers # - Namecheap: Domain List > Manage > Nameservers > Custom DNS # - Cloudflare: DNS > Records > (remove from Cloudflare, use external nameservers) # - Google Domains: DNS > Custom name servers # # Enter all 4 Route53 nameservers from step 2 # 4. Wait for DNS propagation (can take up to 48 hours, usually 15-30 minutes) dig NS your.domain ``` When `use_regional_domains = true` (default), subdomains are automatically created based on region: - Keycloak: `kc.{region}.{base_domain}` (e.g., `kc.us-east-1.your.domain`) - Registry: `registry.{region}.{base_domain}` (e.g., `registry.us-east-1.your.domain`) #### Step 1.2: Install Prerequisites | Tool | Minimum Version | Installation | |------|----------------|--------------| | Terraform | >= 1.5.0 | [terraform.io/downloads](https://www.terraform.io/downloads) | | AWS CLI | >= 2.0 | [docs.aws.amazon.com/cli](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) | | Docker | >= 20.10 | [docs.docker.com/engine/install](https://docs.docker.com/engine/install/) | | Docker Buildx | Latest | See below | | Session Manager Plugin | Latest | See below | | uv | Latest | [astral.sh/uv](https://docs.astral.sh/uv/getting-started/installation/) | | Python | >= 3.12 | Via uv or [python.org](https://www.python.org/downloads/) | **Install Docker Buildx (Ubuntu/Debian):** ```bash sudo apt-get update && sudo apt-get install -y docker-buildx-plugin docker buildx version ``` **Install AWS Session Manager Plugin (Ubuntu/Debian):** ```bash curl "https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb" -o "/tmp/session-manager-plugin.deb" sudo dpkg -i /tmp/session-manager-plugin.deb session-manager-plugin --version ``` **Install uv (Python Package Manager):** ```bash curl -LsSf https://astral.sh/uv/install.sh | sh source $HOME/.local/bin/env uv --version ``` **Install Terraform (Ubuntu/Debian):** ```bash wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list sudo apt update && sudo apt install terraform terraform version ``` **Setup Python environment:** ```bash cd mcp-gateway-registry uv sync source .venv/bin/activate aws --version ``` **Configure AWS CLI:** ```bash aws configure # AWS Access Key ID: YOUR_ACCESS_KEY # AWS Secret Access Key: YOUR_SECRET_KEY # Default region: us-east-1 # Default output format: json # Verify credentials aws sts get-caller-identity ``` ### Step 2: Build and Push Container Images (~30 min) ```bash # Set your target AWS region export AWS_REGION=us-east-1 # cd to the directory where you cloned this repo # Build and push all images make build-push ``` ### Step 3: Configure terraform.tfvars ```bash cd terraform/aws-ecs cp terraform.tfvars.example terraform.tfvars ``` **Edit the following parameters in `terraform.tfvars`:** **Common Parameters (Required for ALL modes):** | Parameter | Description | |-----------|-------------| | `aws_region` | AWS region (must match where you pushed ECR images) | | `ingress_cidr_blocks` | IP addresses allowed to access the ALB | | `keycloak_admin_password` | Keycloak admin password (min 12 chars) | | `keycloak_database_password` | Database password (min 12 chars) | | `session_cookie_secure` | Set to `true` for HTTPS (all modes except development) | | `grafana_admin_password` | Grafana admin password (required when `enable_observability = true`) | | 7 ECR image URIs | Container image URIs with your account ID and region | **Mode-Specific Parameters:** | Mode | Required Parameters | |------|---------------------| | **Mode 1: CloudFront Only** | `enable_cloudfront = true`
`enable_route53_dns = false`
`session_cookie_domain = ""` | | **Mode 2: Custom Domain** | `enable_cloudfront = false`
`enable_route53_dns = true`
`base_domain = "your.domain"`
`session_cookie_domain = ".your.domain"` | | **Mode 3: CloudFront + Custom Domain** | `enable_cloudfront = true`
`enable_route53_dns = true`
`base_domain = "your.domain"`
`session_cookie_domain = ".your.domain"` | **Note:** For Mode 1 (CloudFront Only), `base_domain` is not required since URLs use `*.cloudfront.net`. **Helper commands to get your configuration values:** These commands have been tested on EC2 Ubuntu. If you are on a different development environment, you may need to edit the file manually if these commands don't work for you. ```bash # Get your public IP address curl -s ifconfig.me # Get your AWS account ID aws sts get-caller-identity --query Account --output text # Get your AWS region echo $AWS_REGION ``` **Auto-configure ECR image URIs with sed:** ```bash # Set your values export AWS_REGION=us-east-1 export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) # Update all 7 ECR image URIs in terraform.tfvars sed -i "s/YOUR_ACCOUNT_ID/${AWS_ACCOUNT_ID}/g" terraform.tfvars sed -i "s/YOUR_AWS_REGION/${AWS_REGION}/g" terraform.tfvars ``` **Configure ingress_cidr_blocks:** ```bash # Get your IP address MY_IP=$(curl -s ifconfig.me) echo "Your IP: ${MY_IP}/32" ``` If you are running this from an EC2 instance, you may also want to run `curl -s ifconfig.me` on your laptop so you can access the registry from both the EC2 instance and your laptop. **Warning:** Setting `ingress_cidr_blocks` to `["0.0.0.0/0"]` opens access to anyone on the internet. While authentication (username/password) is still required, this is not recommended for production environments. **Example terraform.tfvars for Mode 1 (CloudFront Only - Easiest):** ```hcl # AWS Region (must match where you pushed ECR images) aws_region = "us-east-1" # Deployment Mode: CloudFront Only (no custom domain required) enable_cloudfront = true enable_route53_dns = false # IP addresses allowed to access the ALB ingress_cidr_blocks = [ "203.0.113.10/32", # Your EC2 instance IP "198.51.100.25/32", # Your laptop IP ] # Keycloak credentials (CHANGE THESE) keycloak_admin_password = "YourSecurePassword123!" keycloak_database_password = "YourDBPassword456!" # Session cookie configuration session_cookie_secure = true # Always true for HTTPS session_cookie_domain = "" # Empty for CloudFront mode # ECR image URIs (after running sed commands above) registry_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-registry:latest" auth_server_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-auth-server:latest" currenttime_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-currenttime:latest" mcpgw_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-mcpgw:latest" realserverfaketools_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-realserverfaketools:latest" flight_booking_agent_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-flight-booking-agent:latest" travel_assistant_agent_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-travel-assistant-agent:latest" # Observability (optional - creates AMP workspace, metrics-service, Grafana) # enable_observability = true # metrics_service_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-metrics-service:latest" # grafana_image_uri = "123456789012.dkr.ecr.us-east-1.amazonaws.com/mcp-gateway-grafana:latest" # Grafana admin password (REQUIRED when enable_observability = true) # IMPORTANT: Do NOT use "admin" or any weak default. Generate a strong random password. # Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(24))" # grafana_admin_password = "YOUR-STRONG-RANDOM-PASSWORD" ``` **Example terraform.tfvars for Mode 2 or 3 (Custom Domain):** ```hcl # For Mode 2 (Custom Domain Only): enable_cloudfront = false enable_route53_dns = true # For Mode 3 (CloudFront + Custom Domain): # enable_cloudfront = true # enable_route53_dns = true # Required for custom domain modes base_domain = "your.domain" session_cookie_domain = ".your.domain" # ... plus all common parameters from Mode 1 example above ``` ### Step 4: Deploy Infrastructure (~20 min) **First-time deployments require a two-stage process due to SSL certificate dependencies.** ```bash # Initialize Terraform terraform init -upgrade # Stage 1: Create SSL certificates first terraform apply \ -target=aws_acm_certificate.keycloak \ -target=aws_acm_certificate.registry \ -target=aws_acm_certificate_validation.keycloak \ -target=aws_acm_certificate_validation.registry # Stage 2: Deploy all remaining infrastructure terraform apply ``` ### Step 5: Post-Deployment Setup See [Post-Deployment](#post-deployment) section for: - Initializing Keycloak - Running scopes initialization - Restarting ECS tasks - Accessing the Web UI --- ## Important Notes - **Cost Warning:** This infrastructure incurs AWS charges (~$110-250/month). See [Cost Optimization](#cost-optimization) for details. - **Deployment Time:** First deployment takes 15-20 minutes (RDS provisioning is the slowest part). - **Region Considerations:** All resources (ECR images, infrastructure) must be in the same AWS region. - **State Management:** Terraform state is stored locally by default. For production, use S3 backend (see [Security](#security-considerations)). ## Post-Deployment Critical steps to complete **after** `terraform apply` finishes successfully. ### Step 1: Automated Post-Deployment Setup (Recommended) The automated setup script handles all post-deployment tasks in sequence: ```bash cd terraform/aws-ecs # Set required environment variables export AWS_REGION=us-east-1 export INITIAL_ADMIN_PASSWORD="YourSecureRealmAdminPassword" # Password for 'admin' user in mcp-gateway realm # Run the automated post-deployment setup ./scripts/post-deployment-setup.sh ``` **What the script does:** 1. Saves terraform outputs to JSON file 2. Validates all required resources were created 3. Waits for DNS propagation (up to 10 minutes) 4. Verifies ECS services are running and healthy 5. Initializes Keycloak (realm, clients, users, groups, scopes) 6. Initializes DocumentDB collections, indexes, and MCP scopes 7. Restarts registry and auth services to pick up new configuration 8. Verifies all endpoints are responding **Expected output:** ``` ========================================== MCP Gateway Post-Deployment Setup ========================================== Step 1: Saving Terraform Outputs [SUCCESS] Terraform outputs saved Step 2: Validating Terraform Outputs [SUCCESS] Found: vpc_id = vpc-xxx [SUCCESS] Found: ecs_cluster_name = mcp-gateway-ecs-cluster [SUCCESS] Found: keycloak_url = https://kc.us-east-1.YOUR.DOMAIN ... Step 3: Waiting for DNS Propagation [SUCCESS] DNS resolved: kc.us-east-1.YOUR.DOMAIN [SUCCESS] DNS resolved: registry.us-east-1.YOUR.DOMAIN Step 4: Verifying ECS Services [SUCCESS] mcp-gateway-v2-registry: 2/2 running [SUCCESS] mcp-gateway-v2-auth: 2/2 running [SUCCESS] keycloak-service: 2/2 running Step 5: Initializing Keycloak [SUCCESS] Keycloak initialized successfully! Step 6: Initializing DocumentDB [SUCCESS] DocumentDB collections and scopes initialized! Step 7: Restarting Registry and Auth Services [SUCCESS] All services restarted successfully! Step 8: Verifying Application Endpoints [SUCCESS] Registry Health: HTTP 200 [SUCCESS] Keycloak Admin: HTTP 200 ========================================== Post-Deployment Setup Summary ========================================== Total Steps: 8 Passed: 8 Failed: 0 Skipped: 0 Post-deployment setup completed successfully! ``` ### Step 2: Access Web UI and Register Example Servers/Agents First, extract URLs from your terraform outputs: ```bash # Load URLs from terraform outputs OUTPUTS_FILE="scripts/terraform-outputs.json" if [[ ! -f "$OUTPUTS_FILE" ]]; then echo "Run ./scripts/save-terraform-outputs.sh first" exit 1 fi # Extract URLs REGISTRY_URL=$(jq -r '.registry_url.value' "$OUTPUTS_FILE") KEYCLOAK_URL=$(jq -r '.keycloak_url.value' "$OUTPUTS_FILE") KEYCLOAK_ADMIN_URL=$(jq -r '.keycloak_admin_console.value' "$OUTPUTS_FILE") echo "Registry URL: $REGISTRY_URL" echo "Keycloak URL: $KEYCLOAK_URL" echo "Keycloak Admin Console: $KEYCLOAK_ADMIN_URL" ``` **Open the Registry UI in your browser:** ```bash # Open using the extracted URL open "$REGISTRY_URL" ``` You should see the login page. Login with the admin credentials for the **mcp-gateway** realm: - **Username**: `admin` - **Password**: The password you set via `INITIAL_ADMIN_PASSWORD` environment variable when running init-keycloak.sh **Important Password Distinction**: - **Realm Admin Password** (`INITIAL_ADMIN_PASSWORD`): Used to log into the MCP Gateway Registry - **Keycloak Master Admin Password** (`keycloak_admin_password` from terraform.tfvars): Used to access the Keycloak admin console ![MCP Gateway Registry First Login](img/MCP-Gateway-Registry-first-login.png) After successful login, you'll see the empty Registry dashboard showing 0 servers and 0 agents. **Access Keycloak Admin Console:** ```bash # Open Keycloak admin console open "$KEYCLOAK_ADMIN_URL" ``` **Register Example MCP Servers:** Now let's register some example MCP servers using the CLI tool: ```bash cd ../../mcp-gateway-registry # Load URLs from terraform outputs (both REGISTRY_URL and KEYCLOAK_URL are required) OUTPUTS_FILE="terraform/aws-ecs/scripts/terraform-outputs.json" export REGISTRY_URL=$(jq -r '.registry_url.value' "$OUTPUTS_FILE") export KEYCLOAK_URL=$(jq -r '.keycloak_url.value' "$OUTPUTS_FILE") echo "Registry URL: $REGISTRY_URL" echo "Keycloak URL: $KEYCLOAK_URL" # Register Cloudflare Docs server uv run python api/registry_management.py register \ --config cli/examples/cloudflare-docs-server-config.json # Register Context7 server uv run python api/registry_management.py register \ --config cli/examples/context7-server-config.json # Register MCPGW server (registry management tools) uv run python api/registry_management.py register \ --config cli/examples/mcpgw.json # Register CurrentTime server uv run python api/registry_management.py register \ --config cli/examples/currenttime.json ``` **Register Example A2A Agents:** ```bash # Register Flight Booking Agent uv run python api/registry_management.py agent-register \ --config cli/examples/flight_booking_agent_card.json # Register Travel Assistant Agent uv run python api/registry_management.py agent-register \ --config cli/examples/travel_assistant_agent_card.json ``` **Verify Registration:** Refresh the browser and you should now see: - 4 MCP servers (Cloudflare Docs, Context7, MCPGW, CurrentTime) - 2 A2A agents (Flight Booking Agent, Travel Assistant Agent) You can also verify via CLI: ```bash # List all registered servers uv run python api/registry_management.py list # List all registered agents uv run python api/registry_management.py agent-list ``` ### Step 3: Review Logs (Verify No Errors) ```bash cd terraform/aws-ecs # Check for errors across all services (last 10 minutes) ./scripts/view-cloudwatch-logs.sh --minutes 10 --filter "ERROR|FATAL|Exception" # If errors found, view full context for specific service ./scripts/view-cloudwatch-logs.sh --component registry --minutes 30 ./scripts/view-cloudwatch-logs.sh --component keycloak --minutes 30 ./scripts/view-cloudwatch-logs.sh --component auth-server --minutes 30 # Common startup errors to ignore: # - "Waiting for database..." (normal during RDS startup) # - "Connection refused" in first 2-3 minutes (normal) # - "Health check failed" during task startup (normal) # Real errors to investigate: # - "Authentication failed" # - "Database connection pool exhausted" # - "Out of memory" # - "Permission denied" ``` ### Step 4: Test Complete Workflow **Deployment Complete!** Your MCP Gateway Registry is now fully operational with example servers and agents registered. You can now: - Browse servers and agents in the Web UI - Use the "Get JWT Token" button in the UI to generate M2M tokens for API access - Test MCP server connections through the gateway - Explore semantic search for servers and agents - Manage server/agent permissions and groups via Keycloak For advanced usage, see the [Operations and Maintenance](#operations-and-maintenance) section below. ### DocumentDB Backend Setup The MCP Gateway Registry uses **DocumentDB** (MongoDB-compatible) for production storage backend. **DocumentDB provides:** - Multi-instance deployments (horizontal scaling) - High concurrent read/write operations - Distributed storage with automatic replication - ACID transactions and strong consistency **DocumentDB Setup:** The DocumentDB cluster is automatically provisioned by Terraform. To initialize the database with indexes and scopes: ```bash # 1. Run the DocumentDB initialization script ./terraform/aws-ecs/scripts/run-documentdb-init.sh # This creates: # - All required collections (servers, agents, scopes, embeddings, audit_events) # - Database indexes for optimal query performance # - TTL index on audit_events for automatic log expiration (default 7 days) # - Initial scope configurations from auth_server/scopes.yml # 2. Verify initialization completed successfully aws logs tail /ecs/mcp-gateway-v2-registry --since 5m --region us-east-1 | grep "Loaded from repository" ``` **For Entra ID Deployments:** When using Microsoft Entra ID as the authentication provider (`entra_enabled = true` in terraform.tfvars), you must specify the Entra ID Group Object ID for admin bootstrapping: ```bash # Run with Entra ID Group Object ID for admin scopes ./terraform/aws-ecs/scripts/run-documentdb-init.sh --entra-group-id "your-entra-group-object-id" # Example with actual Group Object ID: ./terraform/aws-ecs/scripts/run-documentdb-init.sh --entra-group-id "a1b2c3d4-e5f6-7890-abcd-ef1234567890" ``` To find your Entra ID Group Object ID: 1. Go to Azure Portal > Microsoft Entra ID > Groups 2. Select your admin group (e.g., "mcp-gateway-admins") 3. Copy the "Object ID" from the Overview page **Loading Scopes into DocumentDB:** ```bash # Load a scope configuration file ./terraform/aws-ecs/scripts/run-documentdb-cli.sh load-scopes cli/examples/currenttime-users.json # Or use the Python script directly (if DocumentDB credentials are in env) uv run python scripts/load-scopes.py --scopes-file cli/examples/currenttime-users.json ``` **Managing DocumentDB:** ```bash # Interactive DocumentDB CLI ./terraform/aws-ecs/scripts/run-documentdb-cli.sh # List all scopes ./terraform/aws-ecs/scripts/run-documentdb-cli.sh list-scopes # View a specific scope ./terraform/aws-ecs/scripts/run-documentdb-cli.sh get-scope currenttime-users4 ``` **Important Notes:** - Auth-server queries DocumentDB directly on every request for real-time scope validation - No cache refresh needed - scope changes are immediately effective - DocumentDB credentials are managed via AWS Secrets Manager - TLS is enabled by default with automatic CA bundle download - Both auth-server and registry connect to the same DocumentDB cluster See [terraform/aws-ecs/scripts/README-DOCUMENTDB-CLI.md](terraform/aws-ecs/scripts/README-DOCUMENTDB-CLI.md) for detailed DocumentDB CLI documentation. ## User and Group Management After deployment, the system is bootstrapped with **minimal configuration**: - **`registry-admins`** group - Administrative group with full registry access - **Admin user** - Initial administrator account - **Admin scopes** - `registry-admins` scope mapped to the admin group **All additional groups, users, and M2M service accounts must be created manually.** ### Bootstrap Differences by Provider | Provider | Bootstrap Process | |----------|-------------------| | **Keycloak** | Automatic - `init-keycloak.sh` creates realm, clients, admin user, and `registry-admins` group | | **Entra ID** | Manual - `registry-admins` group must be created in Azure Portal, Group Object ID passed to `run-documentdb-init.sh --entra-group-id` | ### Creating Groups Groups control access to MCP servers. Create a group definition JSON file: ```json { "scope_name": "public-mcp-users", "description": "Users with access to public MCP servers", "servers": [ {"server_name": "currenttime", "tools": ["*"], "access_level": "execute"} ], "create_in_idp": true } ``` Import the group: ```bash uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://registry.us-east-1.example.com \ import-group --file my-group.json ``` ### Creating Human Users Human users can log in via the web UI: ```bash uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://registry.us-east-1.example.com \ user-create-human \ --username jsmith \ --email jsmith@example.com \ --first-name John \ --last-name Smith \ --groups public-mcp-users \ --password "SecurePassword123!" ``` ### Creating M2M Service Accounts M2M accounts are used for AI agents and automated systems: ```bash uv run python api/registry_management.py \ --token-file api/.token \ --registry-url https://registry.us-east-1.example.com \ user-create-m2m \ --name my-ai-agent \ --groups public-mcp-users \ --description "AI coding assistant" ``` **Save the client secret immediately - it cannot be retrieved later.** ### Generating JWT Tokens **For Human Users:** 1. Log in to the registry web UI 2. Click the **"Get JWT Token"** button in the top-left sidebar 3. Copy and use the generated token **For M2M Accounts:** Create an agent config file (`.oauth-tokens/agent-my-ai-agent.json`): ```json { "client_id": "my-ai-agent", "client_secret": "your-client-secret", "keycloak_url": "https://kc.us-east-1.example.com", "keycloak_realm": "mcp-gateway", "auth_provider": "keycloak" } ``` Generate the token: ```bash # For Keycloak ./credentials-provider/generate_creds.sh -a keycloak -k https://kc.us-east-1.example.com # For Entra ID ./credentials-provider/generate_creds.sh -a entra -i .oauth-tokens/entra-identities.json ``` Use the generated token: ```bash uv run python api/registry_management.py \ --token-file .oauth-tokens/agent-my-ai-agent-token.json \ --registry-url https://registry.us-east-1.example.com \ list ``` For detailed user management documentation, see [docs/auth-mgmt.md](../../docs/auth-mgmt.md). ## Operations and Maintenance See [OPERATIONS.md](OPERATIONS.md) for detailed operations and maintenance documentation, including: - Accessing ECS tasks via SSH - Viewing CloudWatch logs - Container build and deployment - Updating running services - Rolling back deployments ## Troubleshooting ### Common Issues #### DNS Not Resolving ```bash # Check Route53 hosted zone aws route53 list-hosted-zones --query "HostedZones[?Name=='YOUR.DOMAIN.']" # Check DNS records aws route53 list-resource-record-sets \ --hosted-zone-id ZONE_ID \ --query "ResourceRecordSets[?Type=='CNAME']" # Wait 5-10 minutes for propagation # Test with different DNS servers dig @8.8.8.8 kc.us-east-1.YOUR.DOMAIN dig @1.1.1.1 registry.us-east-1.YOUR.DOMAIN ``` #### ECS Tasks Not Starting ```bash # Check service events aws ecs describe-services \ --cluster mcp-gateway-ecs-cluster \ --services mcp-gateway-v2-registry \ --region $AWS_REGION \ --query 'services[0].events[:10]' \ --output table # Check task stopped reason aws ecs describe-tasks \ --cluster mcp-gateway-ecs-cluster \ --tasks TASK_ARN \ --region $AWS_REGION \ --query 'tasks[0].{StoppedReason:stoppedReason,Containers:containers[*].{Name:name,Reason:reason}}' # Common causes: # - ECR image pull failure (wrong region or permissions) # - Resource limits (insufficient CPU/memory) # - Invalid environment variables # - Secrets Manager access denied ``` #### SSL Certificate Validation Pending ```bash # Check certificate status aws acm list-certificates --region $AWS_REGION # Get certificate details aws acm describe-certificate \ --certificate-arn CERT_ARN \ --region $AWS_REGION # DNS validation may take 5-30 minutes # Ensure Route53 hosted zone is correct # Check CNAME validation records exist ``` #### Database Connection Failures ```bash # Check RDS cluster status aws rds describe-db-clusters \ --db-cluster-identifier mcp-gateway-keycloak-cluster \ --region $AWS_REGION \ --query 'DBClusters[0].{Status:Status,Endpoint:Endpoint}' # Check security group rules aws ec2 describe-security-groups \ --group-ids sg-xxx \ --region $AWS_REGION # Verify database credentials in Secrets Manager aws secretsmanager get-secret-value \ --secret-id /mcp-gateway/keycloak/db-password \ --region $AWS_REGION ``` ### Getting Help Check logs first: ```bash ./scripts/view-cloudwatch-logs.sh --filter "ERROR|FATAL|Exception" ``` Review Terraform state: ```bash terraform show terraform state list terraform state show aws_ecs_service.registry ``` ## Cost Optimization ### Estimated Monthly Costs (us-east-1) | Resource | Configuration | Estimated Cost | |----------|--------------|----------------| | RDS Aurora Serverless v2 | 0.5-2 ACU, PostgreSQL | $40-100/month | | DocumentDB | 1 instance, db.t3.medium | $60-80/month | | ECS Fargate Tasks | 3 services, 0.25 vCPU, 0.5GB each | $20-50/month | | Application Load Balancers | 2 ALBs | $32-50/month | | CloudWatch Logs | 10GB/month | $5/month | | Data Transfer | 100GB/month | $9/month | | **Total** | | **~$170-330/month** | ### Cost Reduction Strategies **1. Use Aurora Serverless v2 auto-pause** ```hcl keycloak_database_min_acu = 0.5 # Scale down to minimum keycloak_database_max_acu = 1.0 # Lower max capacity ``` **2. Reduce ECS task count for non-prod** ```hcl registry_replicas = 1 # Down from 2 auth_server_replicas = 1 # Down from 2 ``` **3. Use internal ALB for Keycloak in production** ```hcl keycloak_alb_scheme = "internal" ``` **4. Enable CloudWatch log retention** ```hcl # Already configured - logs expire after 7 days ``` **5. Use Fargate Spot for non-critical workloads** ```hcl capacity_provider_strategy = { base = 1 # Keep 1 on-demand weight = 1 # Use Spot for additional tasks } ``` ## Security Considerations ### Network Security - All traffic encrypted with TLS (ACM certificates) - Security groups restrict access to approved CIDR blocks only - Keycloak ALB can be internal-only for production - NAT Gateway for outbound internet access from private subnets ### Secrets Management - All credentials stored in AWS Secrets Manager - Automatic rotation supported (configure separately) - ECS tasks retrieve secrets at runtime - Never log or expose credentials ### IAM Permissions For running Terraform and the deployment scripts, your IAM user or role needs the following permissions: ```json { "Sid": "MCPGatewayDeployment", "Effect": "Allow", "Action": [ "secretsmanager:*", "bedrock-agentcore:*", "iam:PassRole", "ec2:*", "ecs:*", "rds:*", "docdb:*", "elasticloadbalancing:*", "route53:*", "acm:*", "iam:*", "logs:*", "ecr:*", "application-autoscaling:*", "cloudwatch:*", "cloudfront:*", "sns:*", "ssm:*", "kms:*", "servicediscovery:*", "aps:*" ], "Resource": "*" } ``` **Note:** For production, consider restricting these permissions to specific resource ARNs. **Note:** The `cloudfront:*` permission is required for CloudFront deployment modes (Mode 1: CloudFront Only, Mode 3: CloudFront + Custom Domain). If you are only using Mode 2 (Custom Domain Only), you can omit this permission. **Note:** The `aps:*` permission is required when `enable_observability = true` (Amazon Managed Prometheus). If you are not using the observability pipeline, you can omit this permission. **ECS Task Role Security:** - ECS task roles follow principle of least privilege - Separate execution role for pulling images and secrets - Task role for application-specific AWS API access - Regular audit of IAM policies recommended ### Database Security - RDS in private subnets only - Encryption at rest enabled - Encryption in transit (SSL) - Automated backups enabled - Security group limits access to ECS tasks only ### Best Practices ```bash # Rotate Keycloak admin password ./scripts/rotate-keycloak-web-client-secret.sh # Enable MFA for AWS console access aws iam enable-mfa-device --user-name admin # Use IAM roles for ECS tasks (already configured) # Avoid hardcoding credentials in environment variables # Regularly update container images make build-push aws ecs update-service --cluster mcp-gateway-ecs-cluster --service mcp-gateway-v2-registry --force-new-deployment --region us-east-1 # Enable AWS CloudTrail for audit logs # Enable AWS Config for compliance monitoring # Use AWS Security Hub for security posture management ``` ## Backup and Disaster Recovery ### RDS Automated Backups ```bash # Backups enabled by default (7 day retention) # Point-in-time recovery available # Create manual snapshot aws rds create-db-cluster-snapshot \ --db-cluster-identifier mcp-gateway-keycloak-cluster \ --db-cluster-snapshot-identifier manual-backup-$(date +%Y%m%d) \ --region $AWS_REGION # List snapshots aws rds describe-db-cluster-snapshots \ --db-cluster-identifier mcp-gateway-keycloak-cluster \ --region $AWS_REGION # Restore from snapshot (requires terraform changes) ``` ### DocumentDB Backup ```bash # DocumentDB automated backups are enabled by default (7 day retention) # Create manual snapshot aws docdb create-db-cluster-snapshot \ --db-cluster-identifier mcp-gateway-documentdb-cluster \ --db-cluster-snapshot-identifier manual-backup-$(date +%Y%m%d) \ --region $AWS_REGION # List snapshots aws docdb describe-db-cluster-snapshots \ --db-cluster-identifier mcp-gateway-documentdb-cluster \ --region $AWS_REGION ``` ### Terraform State Backup ```bash # Local state - backup manually cp terraform.tfstate terraform.tfstate.backup # S3 backend (recommended for production) terraform { backend "s3" { bucket = "your-terraform-state-bucket" key = "mcp-gateway/terraform.tfstate" region = "us-east-1" encrypt = true dynamodb_table = "terraform-lock-table" } } ``` ## Additional Resources - [ECS Best Practices](https://docs.aws.amazon.com/AmazonECS/latest/bestpracticesguide/) - [Aurora Serverless v2 Documentation](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-serverless-v2.html) - [Application Load Balancer Guide](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/) - [Keycloak Documentation](https://www.keycloak.org/documentation) - [Session Manager Plugin Installation](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html) ## Quick Reference ### Common Commands Cheat Sheet ```bash # ============================================================================ # DEPLOYMENT # ============================================================================ # Initial deployment export AWS_REGION=us-east-1 make build-push # Build and push all images (~30 min) terraform init && terraform apply # Deploy infrastructure (~20 min) ./scripts/init-keycloak.sh # Initialize Keycloak # ============================================================================ # UPDATES # ============================================================================ # Update specific service make build-push IMAGE=registry aws ecs update-service --cluster mcp-gateway-ecs-cluster --service mcp-gateway-v2-registry --force-new-deployment --region us-east-1 # ============================================================================ # MONITORING # ============================================================================ # View logs ./scripts/view-cloudwatch-logs.sh --component registry --follow ./scripts/view-cloudwatch-logs.sh --filter "ERROR" # Check service status aws ecs describe-services --cluster mcp-gateway-ecs-cluster --services mcp-gateway-v2-registry --region us-east-1 --query 'services[0].{Running:runningCount,Desired:desiredCount}' --output table # ============================================================================ # DEBUGGING # ============================================================================ # SSH into running task ./scripts/ecs-ssh.sh registry # Check DNS dig +short registry.us-east-1.YOUR.DOMAIN # Test endpoints curl https://registry.us-east-1.YOUR.DOMAIN/health curl https://kc.us-east-1.YOUR.DOMAIN/health # ============================================================================ # CLEANUP # ============================================================================ # See "Destroying Resources" section below for detailed instructions ./scripts/pre-destroy-cleanup.sh # Run first to clean up blocking resources terraform destroy # Then destroy infrastructure ``` ## Destroying Resources Before running `terraform destroy`, you must run the pre-destroy cleanup script to remove resources that may block deletion: ```bash cd terraform/aws-ecs # Step 1: Run pre-destroy cleanup ./scripts/pre-destroy-cleanup.sh # Step 2: Destroy infrastructure terraform destroy ``` ### Why Pre-Destroy Cleanup is Required Terraform destroy may fail due to: - **ECS Services**: Services must be scaled to 0 and deleted before clusters can be removed - **Service Discovery Namespaces**: Must delete services within namespaces before deleting namespaces - **ECS Cluster Capacity Providers**: Clusters with active capacity providers cannot be deleted - **Secrets Manager Secrets**: Deleted secrets are scheduled for deletion (7-30 days) and block recreation with the same name **Note:** ECR repositories are intentionally NOT deleted by the pre-destroy cleanup script. Container images are preserved to avoid expensive rebuilds when redeploying. See the "ECR Repository Cleanup (Optional)" section below for manual deletion commands. ### Manual Cleanup Commands If `terraform destroy` fails, you may need to run these commands manually: ```bash export AWS_REGION=us-east-1 # ============================================================================ # ECS Services Cleanup # ============================================================================ # Scale down and delete ECS services aws ecs update-service --cluster mcp-gateway-ecs-cluster --service mcp-gateway-v2-registry --desired-count 0 --region $AWS_REGION aws ecs delete-service --cluster mcp-gateway-ecs-cluster --service mcp-gateway-v2-registry --force --region $AWS_REGION aws ecs update-service --cluster mcp-gateway-ecs-cluster --service mcp-gateway-v2-auth --desired-count 0 --region $AWS_REGION aws ecs delete-service --cluster mcp-gateway-ecs-cluster --service mcp-gateway-v2-auth --force --region $AWS_REGION aws ecs update-service --cluster keycloak --service keycloak --desired-count 0 --region $AWS_REGION aws ecs delete-service --cluster keycloak --service keycloak --force --region $AWS_REGION # Wait for tasks to stop (check with) aws ecs list-tasks --cluster mcp-gateway-ecs-cluster --region $AWS_REGION aws ecs list-tasks --cluster keycloak --region $AWS_REGION # ============================================================================ # Service Discovery Cleanup # ============================================================================ # List namespaces aws servicediscovery list-namespaces --region $AWS_REGION # Delete services in namespace first aws servicediscovery list-services --filters Name=NAMESPACE_ID,Values=ns-xxxxx --region $AWS_REGION aws servicediscovery delete-service --id srv-xxxxx --region $AWS_REGION # Then delete namespace aws servicediscovery delete-namespace --id ns-xxxxx --region $AWS_REGION # ============================================================================ # Secrets Manager Cleanup # ============================================================================ # Force delete secrets that are scheduled for deletion (required before recreating) aws secretsmanager delete-secret --secret-id "keycloak/database" --force-delete-without-recovery --region $AWS_REGION aws secretsmanager delete-secret --secret-id "mcp-gateway-keycloak-client-secret" --force-delete-without-recovery --region $AWS_REGION aws secretsmanager delete-secret --secret-id "mcp-gateway-keycloak-m2m-client-secret" --force-delete-without-recovery --region $AWS_REGION # ============================================================================ # Targeted Terraform Destroy # ============================================================================ # If full destroy fails, try targeted destroy of remaining resources terraform state list # List remaining resources terraform destroy \ -target=module.mcp_gateway.aws_service_discovery_private_dns_namespace.mcp \ -target=module.ecs_cluster.aws_ecs_cluster.this[0] \ -target=module.vpc.aws_vpc.this[0] ``` ### ECR Repository Cleanup (Optional) ECR repositories are intentionally NOT deleted by the pre-destroy cleanup script to preserve container images and avoid expensive rebuilds when redeploying. If you want to completely remove all resources including ECR repositories, run these commands manually: ```bash export AWS_REGION=us-east-1 # Delete all ECR repositories (WARNING: This deletes all container images!) aws ecr delete-repository --repository-name keycloak --force --region $AWS_REGION aws ecr delete-repository --repository-name mcp-gateway-registry --force --region $AWS_REGION aws ecr delete-repository --repository-name mcp-gateway-auth-server --force --region $AWS_REGION aws ecr delete-repository --repository-name mcp-gateway-currenttime --force --region $AWS_REGION aws ecr delete-repository --repository-name mcp-gateway-mcpgw --force --region $AWS_REGION aws ecr delete-repository --repository-name mcp-gateway-realserverfaketools --force --region $AWS_REGION aws ecr delete-repository --repository-name mcp-gateway-flight-booking-agent --force --region $AWS_REGION aws ecr delete-repository --repository-name mcp-gateway-travel-assistant-agent --force --region $AWS_REGION ``` ### File Structure Reference ``` terraform/aws-ecs/ ├── README.md # This file ├── main.tf # Main infrastructure definition ├── variables.tf # Variable definitions with defaults ├── locals.tf # Computed local values (domain logic) ├── terraform.tfvars # Your configuration (NOT in git) ├── terraform.tfvars.example # Template for terraform.tfvars ├── outputs.tf # Terraform output definitions ├── keycloak-*.tf # Keycloak-specific resources ├── registry-*.tf # Registry-specific resources ├── auth-*.tf # Auth server resources ├── network.tf # VPC, subnets, security groups ├── database.tf # RDS Aurora configuration ├── documentdb.tf # DocumentDB cluster configuration ├── img/ │ └── architecture-ecs.png # Architecture diagram └── scripts/ ├── init-keycloak.sh # Initialize Keycloak (run after terraform apply) ├── ecs-ssh.sh # SSH into ECS tasks ├── view-cloudwatch-logs.sh # View/follow CloudWatch logs ├── user_mgmt.sh # Keycloak user management ├── service_mgmt.sh # Service management utilities ├── rotate-keycloak-web-client-secret.sh # Rotate OAuth2 secrets ├── save-terraform-outputs.sh # Export terraform outputs as JSON └── pre-destroy-cleanup.sh # Run before terraform destroy ``` ### Environment Variables Reference | Variable | Purpose | Example | |----------|---------|---------| | `AWS_REGION` | Target AWS region | `us-east-1` | | `AWS_PROFILE` | AWS CLI profile | `mcp-gateway` | | `TF_VAR_aws_region` | Override terraform region | `us-west-2` | | `KEYCLOAK_ADMIN_URL` | Keycloak URL for scripts | `https://kc.us-east-1.YOUR.DOMAIN` | | `KEYCLOAK_ADMIN_PASSWORD` | Keycloak admin password | From terraform.tfvars | ### Service Port Mapping | Service | Internal Port | ALB Port | Health Check | |---------|--------------|----------|--------------| | Registry | 7860 | 443 (HTTPS) | `/health` | | Auth Server | 8888 | 443 (HTTPS) | `/auth/health` | | Keycloak | 8080 | 443 (HTTPS) | `/health` | ### Resource Naming Conventions | Resource Type | Naming Pattern | Example | |--------------|----------------|---------| | ECS Cluster | `mcp-gateway-ecs-cluster` | - | | ECS Service | `mcp-gateway-v2-{service}` | `mcp-gateway-v2-registry` | | ECR Repository | `mcp-gateway-{image}` | `mcp-gateway-registry` | | RDS Cluster | `mcp-gateway-keycloak-cluster` | - | | ALB | `mcp-gateway-{type}-alb` | `mcp-gateway-alb` | | Log Group | `/aws/ecs/mcp-gateway-{service}` | `/aws/ecs/mcp-gateway-registry` | ## Support For issues or questions: 1. **Check Logs First:** ```bash ./scripts/view-cloudwatch-logs.sh --filter "ERROR" ``` 2. **Verify Service Status:** ```bash aws ecs describe-services --cluster mcp-gateway-ecs-cluster --services mcp-gateway-v2-registry --region us-east-1 ``` 3. **Test DNS Resolution:** ```bash dig kc.us-east-1.YOUR.DOMAIN dig registry.us-east-1.YOUR.DOMAIN ``` 4. **Review Common Issues:** - See [Troubleshooting](#troubleshooting) section above - Check [AWS ECS Troubleshooting Guide](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/troubleshooting.html) 5. **Community Support:** - [GitHub Issues](https://github.com/agentic-community/mcp-gateway-registry/issues) ================================================ FILE: terraform/aws-ecs/alb-logging.tf ================================================ # # ALB Access Logging with S3 Security Hardening # # S3 bucket for ALB access logs #checkov:skip=CKV_AWS_18:This is a logging destination bucket - enabling access logging would create recursion #checkov:skip=CKV_AWS_144:Cross-region replication not required for logging bucket #checkov:skip=CKV_AWS_145:SSE-S3 encryption is sufficient for logging bucket #checkov:skip=CKV2_AWS_62:Event notifications not required for logging bucket resource "aws_s3_bucket" "alb_logs" { bucket = "${var.name}-${var.aws_region}-${data.aws_caller_identity.current.account_id}-alb-logs" tags = merge( local.common_tags, { Purpose = "ALB access logs" Component = "logging" } ) } # Block public access resource "aws_s3_bucket_public_access_block" "alb_logs" { bucket = aws_s3_bucket.alb_logs.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } # Enable versioning resource "aws_s3_bucket_versioning" "alb_logs" { bucket = aws_s3_bucket.alb_logs.id versioning_configuration { status = "Enabled" } } # Server-side encryption with SSE-S3 (AES256) # Using SSE-S3 instead of KMS for ALB logs per AWS best practices # KMS encryption for ALB logs requires complex permission setup and can cause access issues # SSE-S3 provides strong encryption (AES-256) without the permission complexity resource "aws_s3_bucket_server_side_encryption_configuration" "alb_logs" { bucket = aws_s3_bucket.alb_logs.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } } # Lifecycle policy - delete old logs after 90 days resource "aws_s3_bucket_lifecycle_configuration" "alb_logs" { bucket = aws_s3_bucket.alb_logs.id rule { id = "delete-old-logs" status = "Enabled" expiration { days = 90 } } } # Bucket policy for ALB logging with TLS enforcement # Using modern service principal approach (recommended by AWS) # https://docs.aws.amazon.com/elasticloadbalancing/latest/application/enable-access-logging.html resource "aws_s3_bucket_policy" "alb_logs" { bucket = aws_s3_bucket.alb_logs.id # Ensure all bucket configurations are applied before the policy # This includes encryption, versioning, and public access blocks depends_on = [ aws_s3_bucket_public_access_block.alb_logs, aws_s3_bucket_server_side_encryption_configuration.alb_logs, aws_s3_bucket_versioning.alb_logs ] policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "EnforceTLS" Effect = "Deny" Principal = "*" Action = "s3:*" Resource = [ aws_s3_bucket.alb_logs.arn, "${aws_s3_bucket.alb_logs.arn}/*" ] Condition = { Bool = { "aws:SecureTransport" = "false" } } }, { Sid = "AWSLogDeliveryWrite" Effect = "Allow" Principal = { Service = "logdelivery.elasticloadbalancing.amazonaws.com" } Action = "s3:PutObject" Resource = "${aws_s3_bucket.alb_logs.arn}/*" Condition = { StringEquals = { "s3:x-amz-acl" = "bucket-owner-full-control" } } }, { Sid = "AWSLogDeliveryAclCheck" Effect = "Allow" Principal = { Service = "logdelivery.elasticloadbalancing.amazonaws.com" } Action = "s3:GetBucketAcl" Resource = aws_s3_bucket.alb_logs.arn } ] }) } # Wait for S3 bucket policy propagation before enabling ALB logging # AWS S3 bucket policies can take up to 15-30 seconds to propagate # Without this delay, ALBs may fail to enable logging due to permission check failures resource "time_sleep" "wait_for_bucket_policy" { depends_on = [aws_s3_bucket_policy.alb_logs] create_duration = "30s" } # Output for reference output "alb_logs_bucket" { description = "S3 bucket for ALB access logs" value = aws_s3_bucket.alb_logs.id } output "alb_logs_bucket_arn" { description = "ARN of S3 bucket for ALB access logs" value = aws_s3_bucket.alb_logs.arn } ================================================ FILE: terraform/aws-ecs/build-and-push-all.sh ================================================ #!/bin/bash set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" REGION="${AWS_REGION:-us-east-1}" ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) ECR_REGISTRY="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com" echo "Building and pushing all images to ECR..." echo " Account: ${ACCOUNT_ID}" echo " Region: ${REGION}" echo " Registry: ${ECR_REGISTRY}" echo "" # Login to ECR echo "Logging into ECR..." aws ecr get-login-password --region "${REGION}" | docker login --username AWS --password-stdin "${ECR_REGISTRY}" # Image definitions: name|dockerfile|context (relative to repo root) # Using | as delimiter since Dockerfile paths contain no pipes IMAGES=( "mcp-gateway-registry|docker/Dockerfile.registry|." "mcp-gateway-auth-server|docker/Dockerfile.auth|." "mcp-gateway-currenttime|docker/Dockerfile.mcp-server|servers/currenttime" "mcp-gateway-mcpgw|docker/Dockerfile.mcp-server|servers/mcpgw" "mcp-gateway-realserverfaketools|docker/Dockerfile.mcp-server|servers/realserverfaketools" "mcp-gateway-flight-booking-agent|agents/a2a/src/flight-booking-agent/Dockerfile|agents/a2a/src/flight-booking-agent" "mcp-gateway-travel-assistant-agent|agents/a2a/src/travel-assistant-agent/Dockerfile|agents/a2a/src/travel-assistant-agent" "mcp-gateway-metrics-service|metrics-service/Dockerfile|metrics-service" "mcp-gateway-grafana|terraform/aws-ecs/grafana/Dockerfile|terraform/aws-ecs/grafana" ) cd "${REPO_ROOT}" FAILED=() for IMAGE_INFO in "${IMAGES[@]}"; do IFS='|' read -r REPO_NAME DOCKERFILE CONTEXT <<< "${IMAGE_INFO}" echo "" echo "=========================================" echo "Building: ${REPO_NAME}" echo " Dockerfile: ${DOCKERFILE}" echo " Context: ${CONTEXT}" echo "=========================================" # Create ECR repository if it doesn't exist aws ecr create-repository --repository-name "${REPO_NAME}" --region "${REGION}" 2>/dev/null || true # Build, tag, and push if docker build --platform linux/amd64 -f "${DOCKERFILE}" -t "${REPO_NAME}:latest" "${CONTEXT}"; then docker tag "${REPO_NAME}:latest" "${ECR_REGISTRY}/${REPO_NAME}:latest" docker push "${ECR_REGISTRY}/${REPO_NAME}:latest" echo "Done: ${REPO_NAME}" else echo "FAILED: ${REPO_NAME}" FAILED+=("${REPO_NAME}") fi done echo "" echo "=========================================" if [ ${#FAILED[@]} -eq 0 ]; then echo "All images built and pushed to ECR!" else echo "WARNING: ${#FAILED[@]} image(s) failed to build:" for name in "${FAILED[@]}"; do echo " - ${name}" done fi echo "=========================================" echo "" echo "Now run: cd terraform/aws-ecs && terraform apply" ================================================ FILE: terraform/aws-ecs/build-minimal.sh ================================================ #!/bin/bash set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" REGION="${AWS_REGION:-us-east-1}" ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) ECR_REGISTRY="${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com" echo "Building minimal images for testing..." echo " Account: ${ACCOUNT_ID}" echo " Region: ${REGION}" echo " Registry: ${ECR_REGISTRY}" echo "" # Login to ECR echo "Logging into ECR..." aws ecr get-login-password --region "${REGION}" | docker login --username AWS --password-stdin "${ECR_REGISTRY}" # Only build essential images IMAGES=( "mcp-gateway-registry|docker/Dockerfile.registry|." "mcp-gateway-currenttime|docker/Dockerfile.mcp-server|servers/currenttime" ) cd "${REPO_ROOT}" for IMAGE_INFO in "${IMAGES[@]}"; do IFS='|' read -r REPO_NAME DOCKERFILE CONTEXT <<< "${IMAGE_INFO}" echo "" echo "Building: ${REPO_NAME}" aws ecr create-repository --repository-name "${REPO_NAME}" --region "${REGION}" 2>/dev/null || true docker build --platform linux/amd64 -f "${DOCKERFILE}" -t "${REPO_NAME}:latest" "${CONTEXT}" docker tag "${REPO_NAME}:latest" "${ECR_REGISTRY}/${REPO_NAME}:latest" docker push "${ECR_REGISTRY}/${REPO_NAME}:latest" echo "Done: ${REPO_NAME}" done echo "" echo "Essential images ready!" echo "" echo "Now run: cd terraform/aws-ecs && terraform apply" ================================================ FILE: terraform/aws-ecs/cloudfront-acm.tf ================================================ # # ACM Certificates in us-east-1 for CloudFront Custom Domains # # CloudFront requires certificates to be in us-east-1 regardless of where # the origin resources are deployed. These certificates are only created # when both CloudFront AND Route53 DNS are enabled (Mode 3: Custom Domain → CloudFront) # # Provider alias for us-east-1 (required for CloudFront certificates) provider "aws" { alias = "us_east_1" region = "us-east-1" } # ACM Certificate for Registry custom domain on CloudFront resource "aws_acm_certificate" "registry_cloudfront" { count = var.enable_cloudfront && var.enable_route53_dns ? 1 : 0 provider = aws.us_east_1 domain_name = "registry.${local.root_domain}" validation_method = "DNS" tags = merge( local.common_tags, { Name = "${var.name}-registry-cloudfront-cert" Component = "registry" Purpose = "CloudFront custom domain" } ) lifecycle { create_before_destroy = true } } # DNS validation records for Registry CloudFront certificate resource "aws_route53_record" "registry_cloudfront_cert_validation" { for_each = var.enable_cloudfront && var.enable_route53_dns ? { for dvo in aws_acm_certificate.registry_cloudfront[0].domain_validation_options : dvo.domain_name => { name = dvo.resource_record_name record = dvo.resource_record_value type = dvo.resource_record_type } } : {} allow_overwrite = true name = each.value.name records = [each.value.record] ttl = 60 type = each.value.type zone_id = data.aws_route53_zone.registry_root[0].zone_id } # Wait for Registry CloudFront certificate validation resource "aws_acm_certificate_validation" "registry_cloudfront" { count = var.enable_cloudfront && var.enable_route53_dns ? 1 : 0 provider = aws.us_east_1 certificate_arn = aws_acm_certificate.registry_cloudfront[0].arn timeouts { create = "10m" } validation_record_fqdns = [for record in aws_route53_record.registry_cloudfront_cert_validation : record.fqdn] } # ACM Certificate for Keycloak custom domain on CloudFront resource "aws_acm_certificate" "keycloak_cloudfront" { count = var.enable_cloudfront && var.enable_route53_dns ? 1 : 0 provider = aws.us_east_1 domain_name = local.keycloak_domain validation_method = "DNS" tags = merge( local.common_tags, { Name = "${var.name}-keycloak-cloudfront-cert" Component = "keycloak" Purpose = "CloudFront custom domain" } ) lifecycle { create_before_destroy = true } } # DNS validation records for Keycloak CloudFront certificate resource "aws_route53_record" "keycloak_cloudfront_cert_validation" { for_each = var.enable_cloudfront && var.enable_route53_dns ? { for dvo in aws_acm_certificate.keycloak_cloudfront[0].domain_validation_options : dvo.domain_name => { name = dvo.resource_record_name record = dvo.resource_record_value type = dvo.resource_record_type } } : {} allow_overwrite = true name = each.value.name records = [each.value.record] ttl = 60 type = each.value.type zone_id = data.aws_route53_zone.root[0].zone_id } # Wait for Keycloak CloudFront certificate validation resource "aws_acm_certificate_validation" "keycloak_cloudfront" { count = var.enable_cloudfront && var.enable_route53_dns ? 1 : 0 provider = aws.us_east_1 certificate_arn = aws_acm_certificate.keycloak_cloudfront[0].arn timeouts { create = "10m" } validation_record_fqdns = [for record in aws_route53_record.keycloak_cloudfront_cert_validation : record.fqdn] } ================================================ FILE: terraform/aws-ecs/cloudfront-logging.tf ================================================ # # CloudFront Access Logging Infrastructure # # This configuration creates an S3 bucket for CloudFront access logs # with security hardening (public access block, encryption, lifecycle). # # # S3 Bucket for CloudFront Logs # #checkov:skip=CKV_AWS_18:This is a logging destination bucket - enabling access logging would create recursion #checkov:skip=CKV_AWS_144:Cross-region replication not required for logging bucket #checkov:skip=CKV_AWS_145:SSE-S3 encryption is sufficient for logging bucket #checkov:skip=CKV2_AWS_62:Event notifications not required for logging bucket resource "aws_s3_bucket" "cloudfront_logs" { bucket = "ai-registry-${var.aws_region}-${data.aws_caller_identity.current.account_id}-cloudfront-logs" tags = merge( local.common_tags, { Purpose = "CloudFront access logs" Component = "logging" } ) } # # Block Public Access # resource "aws_s3_bucket_public_access_block" "cloudfront_logs" { bucket = aws_s3_bucket.cloudfront_logs.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } # # Server-Side Encryption # resource "aws_s3_bucket_server_side_encryption_configuration" "cloudfront_logs" { bucket = aws_s3_bucket.cloudfront_logs.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } } # # Lifecycle Policy - Delete logs after 90 days # resource "aws_s3_bucket_lifecycle_configuration" "cloudfront_logs" { bucket = aws_s3_bucket.cloudfront_logs.id rule { id = "delete-old-logs" status = "Enabled" expiration { days = 90 } } } # # Ownership Controls (required for CloudFront logging) # # CloudFront uses the awslogsdelivery account to write logs, # so we need BucketOwnerPreferred to ensure the bucket owner # gets full control of the objects written by CloudFront. # #checkov:skip=CKV2_AWS_65:Access point policy not applicable for CloudFront logging bucket resource "aws_s3_bucket_ownership_controls" "cloudfront_logs" { bucket = aws_s3_bucket.cloudfront_logs.id rule { object_ownership = "BucketOwnerPreferred" } } # # Versioning (optional, for additional protection) # resource "aws_s3_bucket_versioning" "cloudfront_logs" { bucket = aws_s3_bucket.cloudfront_logs.id versioning_configuration { status = "Enabled" } } ================================================ FILE: terraform/aws-ecs/cloudfront.tf ================================================ # # CloudFront Distributions for HTTPS # # Supports three deployment modes: # 1. CloudFront-only: Use *.cloudfront.net URLs directly (no custom domain) # 2. Custom Domain → ALB: Traditional setup with ACM certificates (CloudFront disabled) # 3. Custom Domain → CloudFront: Route53 points to CloudFront (best of both) # # When enable_cloudfront=true AND enable_route53_dns=true (Mode 3), CloudFront # is configured with custom domain aliases and ACM certificates from us-east-1. # Route53 points to CloudFront instead of ALBs. # # Data sources for managed CloudFront policies # Only fetched when CloudFront is enabled data "aws_cloudfront_cache_policy" "caching_disabled" { count = var.enable_cloudfront ? 1 : 0 name = "Managed-CachingDisabled" } data "aws_cloudfront_origin_request_policy" "all_viewer" { count = var.enable_cloudfront ? 1 : 0 name = "Managed-AllViewer" } # CloudFront distribution for MCP Gateway ALB #checkov:skip=CKV2_AWS_32:Response headers policy managed at application level #checkov:skip=CKV2_AWS_46:Origin failover not required for this distribution #checkov:skip=CKV2_AWS_47:WAF integration managed separately resource "aws_cloudfront_distribution" "mcp_gateway" { count = var.enable_cloudfront ? 1 : 0 enabled = true comment = "${var.name} MCP Gateway Registry CloudFront Distribution" default_root_object = "" price_class = "PriceClass_100" # CloudFront access logs logging_config { bucket = aws_s3_bucket.cloudfront_logs.bucket_domain_name prefix = "mcp-gateway/" include_cookies = false } # Custom domain alias when Route53 is also enabled (Mode 3) aliases = var.enable_route53_dns ? ["registry.${local.root_domain}"] : [] origin { domain_name = module.mcp_gateway.alb_dns_name origin_id = "mcp-gateway-alb" custom_origin_config { http_port = 80 https_port = 443 origin_protocol_policy = "http-only" origin_ssl_protocols = ["TLSv1.2"] } # Custom header to tell backend the original protocol was HTTPS # Note: We use X-Forwarded-Proto directly - ALB won't overwrite origin custom headers custom_header { name = "X-Forwarded-Proto" value = "https" } # Custom header to indicate this request came through CloudFront # The auth server uses this for reliable HTTPS detection custom_header { name = "X-Cloudfront-Forwarded-Proto" value = "https" } } default_cache_behavior { allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] cached_methods = ["GET", "HEAD"] target_origin_id = "mcp-gateway-alb" # Disable caching for dynamic content cache_policy_id = data.aws_cloudfront_cache_policy.caching_disabled[0].id # Forward all headers to origin origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer[0].id viewer_protocol_policy = "redirect-to-https" compress = true } restrictions { geo_restriction { restriction_type = "none" } } # Use ACM certificate from us-east-1 when custom domain is configured (Mode 3) # Otherwise use default CloudFront certificate (Mode 1) viewer_certificate { cloudfront_default_certificate = var.enable_route53_dns ? false : true acm_certificate_arn = var.enable_route53_dns ? aws_acm_certificate.registry_cloudfront[0].arn : null ssl_support_method = var.enable_route53_dns ? "sni-only" : null minimum_protocol_version = var.enable_route53_dns ? "TLSv1.2_2021" : null } # Ensure certificate is validated before CloudFront uses it depends_on = [aws_acm_certificate_validation.registry_cloudfront] tags = merge( local.common_tags, { Name = "${var.name}-mcp-gateway-cloudfront" Component = "mcp-gateway" } ) } # CloudFront distribution for Keycloak ALB #checkov:skip=CKV2_AWS_32:Response headers policy managed at application level #checkov:skip=CKV2_AWS_46:Origin failover not required for this distribution #checkov:skip=CKV2_AWS_47:WAF integration managed separately resource "aws_cloudfront_distribution" "keycloak" { count = var.enable_cloudfront ? 1 : 0 enabled = true comment = "${var.name} Keycloak CloudFront Distribution" price_class = "PriceClass_100" # CloudFront access logs logging_config { bucket = aws_s3_bucket.cloudfront_logs.bucket_domain_name prefix = "keycloak/" include_cookies = false } # Custom domain alias when Route53 is also enabled (Mode 3) aliases = var.enable_route53_dns ? [local.keycloak_domain] : [] origin { domain_name = aws_lb.keycloak.dns_name origin_id = "keycloak-alb" custom_origin_config { http_port = 80 https_port = 443 # Always use HTTP to ALB - the ALB HTTP listener is configured to forward # (not redirect) when CloudFront is enabled. Using HTTPS would fail because # the ALB cert is for the custom domain, not the ALB DNS name. origin_protocol_policy = "http-only" origin_ssl_protocols = ["TLSv1.2"] } # Custom header to tell Keycloak the original protocol was HTTPS custom_header { name = "X-Forwarded-Proto" value = "https" } } default_cache_behavior { allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] cached_methods = ["GET", "HEAD"] target_origin_id = "keycloak-alb" cache_policy_id = data.aws_cloudfront_cache_policy.caching_disabled[0].id origin_request_policy_id = data.aws_cloudfront_origin_request_policy.all_viewer[0].id viewer_protocol_policy = "redirect-to-https" compress = true } restrictions { geo_restriction { restriction_type = "none" } } # Use ACM certificate from us-east-1 when custom domain is configured (Mode 3) # Otherwise use default CloudFront certificate (Mode 1) viewer_certificate { cloudfront_default_certificate = var.enable_route53_dns ? false : true acm_certificate_arn = var.enable_route53_dns ? aws_acm_certificate.keycloak_cloudfront[0].arn : null ssl_support_method = var.enable_route53_dns ? "sni-only" : null minimum_protocol_version = var.enable_route53_dns ? "TLSv1.2_2021" : null } # Ensure certificate is validated before CloudFront uses it depends_on = [aws_acm_certificate_validation.keycloak_cloudfront] tags = merge( local.common_tags, { Name = "${var.name}-keycloak-cloudfront" Component = "keycloak" } ) } ================================================ FILE: terraform/aws-ecs/cloudwatch-alarms.tf ================================================ # # CloudWatch Alarms for Infrastructure Monitoring # # This file contains CloudWatch alarms for monitoring security components: # - WAF blocked requests and rate limiting # - KMS API throttling # - DocumentDB audit log failures # - S3 bucket size monitoring # # # WAF Monitoring Alarms # # CloudWatch Alarm: WAF Blocked Requests High (MCP Gateway) resource "aws_cloudwatch_metric_alarm" "waf_blocked_requests_high_mcp_gateway" { count = var.enable_waf ? 1 : 0 alarm_name = "${var.name}-waf-blocked-requests-high-mcp-gateway" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "BlockedRequests" namespace = "AWS/WAFV2" period = 300 # 5 minutes statistic = "Sum" threshold = 100 alarm_description = "WAF blocking >100 requests in 5 minutes - potential attack on MCP Gateway" treat_missing_data = "notBreaching" dimensions = { WebACL = aws_wafv2_web_acl.mcp_gateway[0].name Region = var.aws_region Rule = "ALL" } alarm_actions = var.alarm_sns_topic_arn != "" ? [var.alarm_sns_topic_arn] : [] tags = merge( local.common_tags, { Purpose = "WAF attack detection" Component = "monitoring" Service = "mcp-gateway" } ) } # CloudWatch Alarm: WAF Blocked Requests High (Keycloak) resource "aws_cloudwatch_metric_alarm" "waf_blocked_requests_high_keycloak" { count = var.enable_waf ? 1 : 0 alarm_name = "${var.name}-waf-blocked-requests-high-keycloak" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "BlockedRequests" namespace = "AWS/WAFV2" period = 300 # 5 minutes statistic = "Sum" threshold = 100 alarm_description = "WAF blocking >100 requests in 5 minutes - potential attack on Keycloak" treat_missing_data = "notBreaching" dimensions = { WebACL = aws_wafv2_web_acl.keycloak[0].name Region = var.aws_region Rule = "ALL" } alarm_actions = var.alarm_sns_topic_arn != "" ? [var.alarm_sns_topic_arn] : [] tags = merge( local.common_tags, { Purpose = "WAF attack detection" Component = "monitoring" Service = "keycloak" } ) } # CloudWatch Alarm: WAF Rate Limit Triggered (MCP Gateway) resource "aws_cloudwatch_metric_alarm" "waf_rate_limit_triggered_mcp_gateway" { count = var.enable_waf ? 1 : 0 alarm_name = "${var.name}-waf-rate-limit-triggered-mcp-gateway" comparison_operator = "GreaterThanThreshold" evaluation_periods = 1 metric_name = "BlockedRequests" namespace = "AWS/WAFV2" period = 60 # 1 minute statistic = "Sum" threshold = 50 alarm_description = "WAF rate limit triggered for MCP Gateway - potential DDoS" treat_missing_data = "notBreaching" dimensions = { WebACL = aws_wafv2_web_acl.mcp_gateway[0].name Region = var.aws_region Rule = "RateLimitRule" } alarm_actions = var.alarm_sns_topic_arn != "" ? [var.alarm_sns_topic_arn] : [] tags = merge( local.common_tags, { Purpose = "Rate limit monitoring" Component = "monitoring" Service = "mcp-gateway" } ) } # CloudWatch Alarm: WAF Rate Limit Triggered (Keycloak) resource "aws_cloudwatch_metric_alarm" "waf_rate_limit_triggered_keycloak" { count = var.enable_waf ? 1 : 0 alarm_name = "${var.name}-waf-rate-limit-triggered-keycloak" comparison_operator = "GreaterThanThreshold" evaluation_periods = 1 metric_name = "BlockedRequests" namespace = "AWS/WAFV2" period = 60 # 1 minute statistic = "Sum" threshold = 50 alarm_description = "WAF rate limit triggered for Keycloak - potential DDoS" treat_missing_data = "notBreaching" dimensions = { WebACL = aws_wafv2_web_acl.keycloak[0].name Region = var.aws_region Rule = "RateLimitRule" } alarm_actions = var.alarm_sns_topic_arn != "" ? [var.alarm_sns_topic_arn] : [] tags = merge( local.common_tags, { Purpose = "Rate limit monitoring" Component = "monitoring" Service = "keycloak" } ) } # # KMS Monitoring Alarms # # CloudWatch Alarm: KMS Throttling (DocumentDB Key) resource "aws_cloudwatch_metric_alarm" "kms_throttling_documentdb" { alarm_name = "${var.name}-kms-throttling-documentdb" comparison_operator = "GreaterThanThreshold" evaluation_periods = 1 metric_name = "UserErrorCount" namespace = "AWS/KMS" period = 60 statistic = "Sum" threshold = 10 alarm_description = "KMS API throttling detected for DocumentDB key - secrets may be inaccessible" treat_missing_data = "notBreaching" dimensions = { KeyId = aws_kms_key.documentdb.id } alarm_actions = var.alarm_sns_topic_arn != "" ? [var.alarm_sns_topic_arn] : [] tags = merge( local.common_tags, { Purpose = "KMS availability monitoring" Component = "monitoring" Service = "documentdb" } ) } # CloudWatch Alarm: KMS Throttling (RDS Key) resource "aws_cloudwatch_metric_alarm" "kms_throttling_rds" { alarm_name = "${var.name}-kms-throttling-rds" comparison_operator = "GreaterThanThreshold" evaluation_periods = 1 metric_name = "UserErrorCount" namespace = "AWS/KMS" period = 60 statistic = "Sum" threshold = 10 alarm_description = "KMS API throttling detected for RDS key - secrets may be inaccessible" treat_missing_data = "notBreaching" dimensions = { KeyId = aws_kms_key.rds.id } alarm_actions = var.alarm_sns_topic_arn != "" ? [var.alarm_sns_topic_arn] : [] tags = merge( local.common_tags, { Purpose = "KMS availability monitoring" Component = "monitoring" Service = "keycloak" } ) } # # DocumentDB Audit Log Monitoring # # CloudWatch Alarm: DocumentDB Audit Log Failures resource "aws_cloudwatch_metric_alarm" "documentdb_audit_log_failures" { alarm_name = "${var.name}-documentdb-audit-log-failures" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "AuditLogFailures" namespace = "AWS/DocDB" period = 300 # 5 minutes statistic = "Sum" threshold = 10 alarm_description = "DocumentDB audit logging failures - compliance gap" treat_missing_data = "notBreaching" dimensions = { DBClusterIdentifier = aws_docdb_cluster.registry.id } alarm_actions = var.alarm_sns_topic_arn != "" ? [var.alarm_sns_topic_arn] : [] tags = merge( local.common_tags, { Purpose = "Audit log reliability" Component = "monitoring" Service = "documentdb" } ) } # # S3 Bucket Monitoring # # CloudWatch Alarm: S3 ALB Logs Bucket Size High resource "aws_cloudwatch_metric_alarm" "s3_alb_logs_size_high" { alarm_name = "${var.name}-s3-alb-logs-size-high" comparison_operator = "GreaterThanThreshold" evaluation_periods = 1 metric_name = "BucketSizeBytes" namespace = "AWS/S3" period = 86400 # 1 day statistic = "Average" threshold = 107374182400 # 100 GB alarm_description = "ALB logs bucket exceeds 100GB - check lifecycle policy" treat_missing_data = "notBreaching" dimensions = { BucketName = aws_s3_bucket.alb_logs.id StorageType = "StandardStorage" } alarm_actions = var.alarm_sns_topic_arn != "" ? [var.alarm_sns_topic_arn] : [] tags = merge( local.common_tags, { Purpose = "Cost control" Component = "monitoring" Service = "alb-logging" } ) } # CloudWatch Alarm: S3 CloudFront Logs Bucket Size High resource "aws_cloudwatch_metric_alarm" "s3_cloudfront_logs_size_high" { alarm_name = "${var.name}-s3-cloudfront-logs-size-high" comparison_operator = "GreaterThanThreshold" evaluation_periods = 1 metric_name = "BucketSizeBytes" namespace = "AWS/S3" period = 86400 # 1 day statistic = "Average" threshold = 107374182400 # 100 GB alarm_description = "CloudFront logs bucket exceeds 100GB - check lifecycle policy" treat_missing_data = "notBreaching" dimensions = { BucketName = aws_s3_bucket.cloudfront_logs.id StorageType = "StandardStorage" } alarm_actions = var.alarm_sns_topic_arn != "" ? [var.alarm_sns_topic_arn] : [] tags = merge( local.common_tags, { Purpose = "Cost control" Component = "monitoring" Service = "cloudfront-logging" } ) } ================================================ FILE: terraform/aws-ecs/codebuild.tf ================================================ # # CodeBuild Project for Building Container Images # Set create_codebuild = true in terraform.tfvars to enable # # This creates: # - ECR repositories for all service images # - S3 bucket for buildspec storage # - CodeBuild project that builds all containers in parallel # - IAM role with ECR push and CloudWatch Logs permissions # variable "create_codebuild" { description = "Whether to create CodeBuild resources (ECR repos, build project) for building container images" type = bool default = false } # ============================================================================= # ECR REPOSITORIES # ============================================================================= locals { # All service images that CodeBuild will build and push. # Keycloak is excluded — it has its own resource in keycloak-ecr.tf. ecr_repositories = toset([ "mcp-gateway-registry", "mcp-gateway-auth-server", "mcp-gateway-currenttime", "mcp-gateway-mcpgw", "mcp-gateway-realserverfaketools", "mcp-gateway-flight-booking-agent", "mcp-gateway-travel-assistant-agent", "mcp-gateway-scopes-init", "mcp-gateway-metrics-service", "mcp-gateway-grafana", ]) } #checkov:skip=CKV_AWS_51:Mutable tags required for latest tag workflow in CI/CD pipeline resource "aws_ecr_repository" "services" { for_each = var.create_codebuild ? local.ecr_repositories : toset([]) name = each.key image_tag_mutability = "MUTABLE" force_delete = true image_scanning_configuration { scan_on_push = true } tags = merge( local.common_tags, { Name = each.key } ) } resource "aws_ecr_lifecycle_policy" "services" { for_each = var.create_codebuild ? local.ecr_repositories : toset([]) repository = aws_ecr_repository.services[each.key].name policy = jsonencode({ rules = [ { rulePriority = 10 description = "Keep last 10 tagged images" selection = { tagStatus = "tagged" tagPrefixList = ["sha-"] countType = "imageCountMoreThan" countNumber = 10 } action = { type = "expire" } }, { rulePriority = 20 description = "Expire untagged images older than 7 days" selection = { tagStatus = "untagged" countType = "sinceImagePushed" countUnit = "days" countNumber = 7 } action = { type = "expire" } } ] }) } # ============================================================================= # S3 BUCKET FOR CODEBUILD ARTIFACTS # ============================================================================= #checkov:skip=CKV_AWS_18:This is a build artifacts bucket - access logging not required #checkov:skip=CKV_AWS_144:Cross-region replication not required for build artifacts #checkov:skip=CKV_AWS_145:SSE-S3 encryption is sufficient for build artifacts #checkov:skip=CKV2_AWS_62:Event notifications not required for build artifacts bucket resource "aws_s3_bucket" "codebuild" { count = var.create_codebuild ? 1 : 0 bucket = "mcp-gateway-terraform-${data.aws_caller_identity.current.account_id}" tags = merge( local.common_tags, { Name = "mcp-gateway-codebuild" } ) } resource "aws_s3_bucket_versioning" "codebuild" { count = var.create_codebuild ? 1 : 0 bucket = aws_s3_bucket.codebuild[0].id versioning_configuration { status = "Enabled" } } resource "aws_s3_bucket_public_access_block" "codebuild" { count = var.create_codebuild ? 1 : 0 bucket = aws_s3_bucket.codebuild[0].id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } resource "aws_s3_bucket_server_side_encryption_configuration" "codebuild" { count = var.create_codebuild ? 1 : 0 bucket = aws_s3_bucket.codebuild[0].id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } } resource "aws_s3_bucket_policy" "codebuild_tls" { count = var.create_codebuild ? 1 : 0 bucket = aws_s3_bucket.codebuild[0].id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Sid = "EnforceTLS" Effect = "Deny" Principal = "*" Action = "s3:*" Resource = [ aws_s3_bucket.codebuild[0].arn, "${aws_s3_bucket.codebuild[0].arn}/*" ] Condition = { Bool = { "aws:SecureTransport" = "false" } } }] }) } # Lifecycle policy - delete old artifacts after 90 days resource "aws_s3_bucket_lifecycle_configuration" "codebuild" { count = var.create_codebuild ? 1 : 0 bucket = aws_s3_bucket.codebuild[0].id rule { id = "delete-old-artifacts" status = "Enabled" expiration { days = 90 } noncurrent_version_expiration { noncurrent_days = 30 } } } # ============================================================================= # BUILDSPEC (inline, uploaded to S3) # ============================================================================= resource "aws_s3_object" "upstream_buildspec" { count = var.create_codebuild ? 1 : 0 bucket = aws_s3_bucket.codebuild[0].id key = "buildspecs/upstream-buildspec.yaml" content = <<-EOF version: 0.2 env: variables: DOCKER_BUILDKIT: "1" phases: pre_build: commands: - echo "=== Building MCP Gateway container images ===" - echo "Source version - $CODEBUILD_RESOLVED_SOURCE_VERSION" - export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) - export ECR_REGISTRY="$${AWS_ACCOUNT_ID}.dkr.ecr.$${AWS_DEFAULT_REGION}.amazonaws.com" - export IMAGE_TAG="sha-$${CODEBUILD_RESOLVED_SOURCE_VERSION:0:7}" - echo "ECR Registry - $ECR_REGISTRY" - echo "Image tag - $IMAGE_TAG" - aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $ECR_REGISTRY - echo "Pre-pulling base images for layer caching..." - docker pull public.ecr.aws/docker/library/python:3.14-slim || true - docker tag public.ecr.aws/docker/library/python:3.14-slim python:3.14-slim - docker pull quay.io/keycloak/keycloak:23.0 || true - docker pull grafana/grafana:12.3.1 || true - echo "Pulling existing images for cache..." - for repo in mcp-gateway-registry mcp-gateway-auth-server keycloak mcp-gateway-currenttime mcp-gateway-mcpgw mcp-gateway-realserverfaketools mcp-gateway-flight-booking-agent mcp-gateway-travel-assistant-agent mcp-gateway-scopes-init mcp-gateway-metrics-service mcp-gateway-grafana; do docker pull $ECR_REGISTRY/$repo:latest 2>/dev/null || true; done - echo "Setting up A2A agent dependencies..." - mkdir -p agents/a2a/src/flight-booking-agent/.tmp agents/a2a/src/travel-assistant-agent/.tmp - cp agents/a2a/pyproject.toml agents/a2a/uv.lock agents/a2a/src/flight-booking-agent/.tmp/ 2>/dev/null || true - cp agents/a2a/pyproject.toml agents/a2a/uv.lock agents/a2a/src/travel-assistant-agent/.tmp/ 2>/dev/null || true build: commands: - echo "=== Building all container images in parallel ===" - | build_and_push() { local name=$1 local dockerfile=$2 local context=$3 echo "Starting build: $name" if docker build --cache-from $ECR_REGISTRY/$name:latest \ -t $ECR_REGISTRY/$name:$IMAGE_TAG \ --build-arg BUILD_VERSION=$IMAGE_TAG \ -f $dockerfile $context && \ docker tag $ECR_REGISTRY/$name:$IMAGE_TAG $ECR_REGISTRY/$name:latest && \ docker push $ECR_REGISTRY/$name:$IMAGE_TAG && \ docker push $ECR_REGISTRY/$name:latest; then echo "Completed: $name" else echo "FAILED: $name" return 1 fi } # Core services build_and_push mcp-gateway-registry docker/Dockerfile.registry-cpu . & build_and_push mcp-gateway-auth-server docker/Dockerfile.auth . & build_and_push keycloak docker/keycloak/Dockerfile docker/keycloak & # MCP servers build_and_push mcp-gateway-currenttime docker/Dockerfile.mcp-server servers/currenttime & (docker build --cache-from $ECR_REGISTRY/mcp-gateway-mcpgw:latest \ -t $ECR_REGISTRY/mcp-gateway-mcpgw:$IMAGE_TAG \ --build-arg SERVER_DIR=servers/mcpgw --build-arg BUILD_VERSION=$IMAGE_TAG \ -f docker/Dockerfile.mcp-server-cpu . && \ docker tag $ECR_REGISTRY/mcp-gateway-mcpgw:$IMAGE_TAG $ECR_REGISTRY/mcp-gateway-mcpgw:latest && \ docker push $ECR_REGISTRY/mcp-gateway-mcpgw:$IMAGE_TAG && \ docker push $ECR_REGISTRY/mcp-gateway-mcpgw:latest && \ echo "Completed: mcp-gateway-mcpgw" || { echo "FAILED: mcp-gateway-mcpgw"; exit 1; }) & build_and_push mcp-gateway-realserverfaketools docker/Dockerfile.mcp-server servers/realserverfaketools & # A2A agents build_and_push mcp-gateway-flight-booking-agent agents/a2a/src/flight-booking-agent/Dockerfile agents/a2a/src/flight-booking-agent & build_and_push mcp-gateway-travel-assistant-agent agents/a2a/src/travel-assistant-agent/Dockerfile agents/a2a/src/travel-assistant-agent & # Utilities build_and_push mcp-gateway-scopes-init docker/Dockerfile.scopes-init . & # Observability pipeline build_and_push mcp-gateway-metrics-service metrics-service/Dockerfile metrics-service & build_and_push mcp-gateway-grafana terraform/aws-ecs/grafana/Dockerfile terraform/aws-ecs/grafana & # Wait for all background jobs FAILED=0 for job in $(jobs -p); do wait $job || FAILED=$((FAILED+1)) done if [ $FAILED -gt 0 ]; then echo "$FAILED build(s) failed" exit 1 fi echo "All builds completed successfully" post_build: commands: - echo "Build completed on $(date)" - echo "All images pushed to $ECR_REGISTRY with tags $IMAGE_TAG and latest" EOF tags = local.common_tags } # ============================================================================= # IAM ROLE FOR CODEBUILD # ============================================================================= resource "aws_iam_role" "codebuild" { count = var.create_codebuild ? 1 : 0 name = "mcp-gateway-tf-codebuild-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Principal = { Service = "codebuild.amazonaws.com" } Action = "sts:AssumeRole" } ] }) tags = local.common_tags } resource "aws_iam_role_policy" "codebuild" { count = var.create_codebuild ? 1 : 0 name = "mcp-gateway-tf-codebuild-policy" role = aws_iam_role.codebuild[0].id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] Resource = "*" }, { Effect = "Allow" Action = [ "ecr:GetAuthorizationToken" ] Resource = "*" }, { Effect = "Allow" Action = [ "ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:PutImage", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart", "ecr:CompleteLayerUpload" ] Resource = "arn:aws:ecr:${var.aws_region}:${data.aws_caller_identity.current.account_id}:repository/*" }, { Effect = "Allow" Action = [ "s3:GetObject", "s3:GetObjectVersion" ] Resource = "${aws_s3_bucket.codebuild[0].arn}/*" }, { Effect = "Allow" Action = [ "sts:GetCallerIdentity" ] Resource = "*" } ] }) } # ============================================================================= # CODEBUILD PROJECT # ============================================================================= resource "aws_codebuild_project" "upstream" { count = var.create_codebuild ? 1 : 0 name = "mcp-gateway-upstream-build-tf" description = "Build MCP Gateway container images (all services + observability pipeline)" build_timeout = 60 service_role = aws_iam_role.codebuild[0].arn artifacts { type = "NO_ARTIFACTS" } environment { compute_type = "BUILD_GENERAL1_LARGE" image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" type = "LINUX_CONTAINER" privileged_mode = true image_pull_credentials_type = "CODEBUILD" } source { type = "GITHUB" location = "https://github.com/agentic-community/mcp-gateway-registry.git" buildspec = aws_s3_object.upstream_buildspec[0].content git_clone_depth = 1 git_submodules_config { fetch_submodules = false } } source_version = "main" cache { type = "LOCAL" modes = ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_SOURCE_CACHE"] } tags = local.common_tags } # ============================================================================= # OUTPUTS # ============================================================================= output "codebuild_project_upstream" { description = "CodeBuild project for building from upstream" value = var.create_codebuild ? aws_codebuild_project.upstream[0].name : null } output "codebuild_s3_bucket" { description = "S3 bucket for CodeBuild artifacts" value = var.create_codebuild ? aws_s3_bucket.codebuild[0].id : null } output "ecr_repository_urls" { description = "ECR repository URLs for all service images (use as *_image_uri variable values)" value = var.create_codebuild ? { for k, v in aws_ecr_repository.services : k => "${v.repository_url}:latest" } : {} } ================================================ FILE: terraform/aws-ecs/docs/observability-architecture.md ================================================ # MCP Gateway Observability Architecture for AWS ECS (Terraform) This document describes the observability architecture for the MCP Gateway Registry when deployed on AWS ECS using Terraform. ## Executive Summary The Terraform ECS deployment uses the existing **metrics-service** to aggregate application metrics from the registry and auth-server, with AWS-native services (Amazon Managed Prometheus, Grafana OSS on ECS) providing durable storage and visualization. The ADOT collector runs as a sidecar in the metrics-service task, scraping its Prometheus endpoint and remote-writing to AMP. All observability resources are gated by `var.enable_observability` (default: `true`) and can be fully disabled with a single variable. ## Architecture Overview The pipeline reuses the same metrics-service that runs in local docker-compose development. The registry and auth-server already emit metrics to `METRICS_SERVICE_URL` via HTTP POST -- no application code changes are required. In the AWS deployment, AMP replaces the local Prometheus container as the durable time-series store, and Grafana OSS on ECS replaces the local Grafana container. The same Grafana dashboards work in both environments because they query identical Prometheus metric names. ### ADOT Sidecar Pattern The ADOT collector runs as a sidecar container within the metrics-service ECS task definition, scraping `localhost:9465`. This is necessary because the Terraform deployment uses the `terraform-aws-modules/ecs` module, which creates HTTP-type Cloud Map services. HTTP-type services register in Cloud Map but do **not** create Route53 A records -- DNS resolution is handled by the ECS Service Connect Envoy sidecar proxy. A standalone ADOT service cannot resolve Service Connect hostnames via system DNS, but co-locating as a sidecar eliminates the DNS dependency entirely. See [issue #496](https://github.com/agentic-community/mcp-gateway-registry/issues/496) for the broader discussion on Service Connect DNS behavior. ### Ephemeral SQLite The metrics-service uses SQLite for local buffering but ECS Fargate task storage is ephemeral. AMP serves as the durable store (150-day default retention), replacing SQLite's historical analysis role. SQLite data loss on task restart has no impact on the metrics pipeline. ## Architecture ``` +---------------------------------------------------------------------+ | ECS Services | | | | +-----------------+ +-----------------+ +---------------------+ | | | Registry | | Auth Server | | Other Services | | | | | | | | (MCP Servers) | | | | METRICS_SERVICE | | METRICS_SERVICE | | No custom metrics | | | | _URL=http:// | | _URL=http:// | | (CloudWatch only) | | | | metrics:8890 | | metrics:8890 | | | | | | METRICS_API_KEY | | METRICS_API_KEY | | | | | +--------+--------+ +--------+--------+ +---------------------+ | | | | | | +--------------------+ | | | | +--------------------------------+-------------------------------------+ | | HTTP POST /metrics | Header: X-API-Key: v +---------------------------------------------------+ | metrics-service ECS Task (512 CPU, 1024 MB) | | | | +---------------------------------------------+ | | | metrics-service container | | | | | | | | +---------------------------------------+ | | | | | FastAPI Application | | | | | | - Receives metrics via HTTP API | | | | | | - API key auth (METRICS_API_KEY_*) | | | | | | - Rate limiting (1000 req/min) | | | | | | - Request validation | | | | | | - In-memory buffering (5s flush) | | | | | +---------------------------------------+ | | | | | | | | +---------------------------------------+ | | | | | OpenTelemetry Instrumentation | | | | | | - Counters: auth, tool, discovery | | | | | | - Histograms: latency, duration | | | | | | - Custom bucket boundaries (5ms-300s) | | | | | | - Prometheus exporter :9465 | | | | | +---------------------------------------+ | | | | | | | | Ports: 8890 (API), 9465 (Prometheus) | | | +---------------------------------------------+ | | | | +---------------------------------------------+ | | | adot-collector sidecar container | | | | | | | | +---------------------------------------+ | | | | | Prometheus Receiver | | | | | | - Scrapes localhost:9465 | | | | | | - 15s scrape interval | | | | | +---------------------------------------+ | | | | | | | | +---------------------------------------+ | | | | | Prometheus Remote Write Exporter | | | | | | - SigV4 authentication | | | | | | - Writes to AMP workspace | | | | | +---------------------------------------+ | | | | | | | | Health check: :13133 | | | | essential: false (metrics-service can run | | | | without ADOT; metrics just won't reach AMP)| | | +---------------------------------------------+ | | | +------------------------+--------------------------+ | | Remote Write (SigV4) | https://aps-workspaces.region.amazonaws.com v +---------------------------------------------------+ | Amazon Managed Prometheus (AMP) | | | | - Fully managed Prometheus-compatible | | - Automatic scaling | | - 150-day default retention | | - PromQL query support | | - SigV4 authentication | | - No infrastructure to manage | | | | Alert Rules: | | - MCPHighErrorRate (>10% for 5 min) | | - MCPRegistryDown (no requests for 5 min) | | - MCPHighLatency (P95 > 5s for 5 min) | +------------------------+--------------------------+ | | PromQL Queries (SigV4) v +---------------------------------------------------+ | Grafana OSS ECS Task (512 CPU, 1024 MB) | | | | +---------------------------------------------+ | | | Pre-configured Datasource | | | | - Amazon Managed Prometheus (AMP) | | | | - SigV4 auth via IAM task role | | | +---------------------------------------------+ | | | | +---------------------------------------------+ | | | Pre-loaded Dashboard: MCP Analytics | | | | - Real-time Protocol Activity | | | | - Authentication Flow Analysis | | | | - Active MCP Servers | | | | - Tool Executions per Hour | | | | - MCP Latency P95 (by Server & Method) | | | | - Server Performance Dashboard | | | | - Tool Usage Rankings | | | | - Error Rate Analysis | | | | - Client Applications Distribution | | | | - 19 panels total | | | +---------------------------------------------+ | | | | Access: https:///grafana/ | | Auth: admin / grafana_admin_password (from tfvars)| +---------------------------------------------------+ ``` ## Component Details ### Services Emitting Metrics The following services emit custom metrics to the metrics-service: | Service | Metrics Emitted | Configuration | |---------|-----------------|---------------| | **Registry** | Tool discovery, registry operations, health checks | `METRICS_SERVICE_URL` + `METRICS_API_KEY` env vars | | **Auth-server** | Authentication requests (via `/validate` subrequest), session operations | `METRICS_SERVICE_URL` + `METRICS_API_KEY` env vars | | **Nginx (Lua)** | MCP tool execution counters and duration histograms | `METRICS_API_KEY_NGINX` env var. See PR #488. | **Note**: MCP servers (CurrentTime, MCPGW, RealServerFakeTools, etc.) do not emit custom metrics directly. However, nginx emits tool execution metrics on their behalf via `log_by_lua` -- capturing method, tool name, duration, and success/failure for all MCP protocol traffic flowing through nginx location blocks. **MCP data-plane metrics**: MCP protocol traffic (initialize, tools/list, tools/call) is handled by nginx location blocks and proxied directly to backend servers, bypassing FastAPI entirely. The middleware in `registry/metrics/middleware.py` never observes these requests. The auth-server sees every request via `auth_request /validate`, but the auth check fires *before* `proxy_pass` -- so it captures auth latency but cannot observe tool execution duration, success/failure, or which tool was called. The nginx Lua metrics pipeline (`emit_metrics.lua` + `flush_metrics.lua`, PR #488) fills this gap. The metrics emission flow: 1. **Registry/Auth-server** (control plane): Instantiate `MetricsClient` from `registry/metrics/client.py`. The client reads `METRICS_SERVICE_URL` and `METRICS_API_KEY` from environment variables. Metrics are sent via HTTP POST to `{METRICS_SERVICE_URL}/metrics` with `X-API-Key` header. 2. **Nginx** (data plane, PR #488): `emit_metrics.lua` runs in `log_by_lua` phase after each MCP request, writing metrics to `lua_shared_dict metrics_buffer 10m` (no network I/O). A background timer in `flush_metrics.lua` (`init_worker_by_lua`) batch-POSTs buffered metrics to metrics-service every 5-10 seconds, authenticating with `METRICS_API_KEY_NGINX`. ### API Key Authentication Configuration The metrics-service uses a dual naming convention for API keys: **Client Side** (registry, auth-server): - Environment variable: `METRICS_API_KEY` - Used to authenticate when sending metrics to metrics-service - In Terraform: sourced from `aws_secretsmanager_secret.metrics_api_key`, auto-generated via `random_password` **Server Side** (metrics-service): - Environment variable pattern: `METRICS_API_KEY_` - The `setup_preshared_api_keys()` function in `metrics-service/app/main.py` discovers all environment variables matching `METRICS_API_KEY_*` on startup - Each key is automatically registered with the service name derived from the suffix (e.g., `METRICS_API_KEY_REGISTRY` registers key for service `registry`) **Terraform Implementation**: - `random_password.metrics_api_key` generates a 32-character key - `aws_secretsmanager_secret.metrics_api_key` stores it in Secrets Manager - Registry and auth-server task definitions reference the secret as `METRICS_API_KEY` - metrics-service receives the same secret as both `METRICS_API_KEY_REGISTRY` and `METRICS_API_KEY_AUTH` - All secret resources are gated by `var.enable_observability` To rotate the API key: update the secret in Secrets Manager and force redeploy the affected ECS services. ### metrics-service The metrics-service is deployed as an ECS Fargate task: | Configuration | Value | Notes | |--------------|-------|-------| | Image | `var.metrics_service_image_uri` | Built via CodeBuild or provided | | CPU | 512 | 0.5 vCPU (shared with ADOT sidecar) | | Memory | 1024 | 1 GB (shared with ADOT sidecar) | | Port 8890 | HTTP API | Receives metrics from services | | Port 9465 | Prometheus | Scraped by ADOT sidecar on localhost | | Health Check | `GET /health` | 30s interval, 30s start period | | Service Connect | `metrics-service:8890` | Discoverable by registry and auth-server | Environment variables: ``` METRICS_SERVICE_HOST=0.0.0.0 PORT=8890 OTEL_SERVICE_NAME=mcp-metrics-service OTEL_PROMETHEUS_ENABLED=true OTEL_PROMETHEUS_PORT=9465 METRICS_RATE_LIMIT=1000 HISTOGRAM_BUCKET_BOUNDARIES=0.005,0.01,0.025,0.05,0.075,0.1,0.25,0.5,0.75,1.0,2.5,5.0,7.5,10.0,30.0,60.0,120.0,300.0 SQLITE_DB_PATH=/tmp/metrics.db METRICS_API_KEY_REGISTRY= METRICS_API_KEY_AUTH= ``` ### ADOT Collector (Sidecar) AWS Distro for OpenTelemetry collector runs as a sidecar in the metrics-service task: | Configuration | Value | Notes | |--------------|-------|-------| | Image | `public.ecr.aws/aws-observability/aws-otel-collector:latest` | AWS-managed | | CPU | 256 | Allocated within the 512 task CPU | | Memory | 512 | Allocated within the 1024 task memory | | essential | false | metrics-service continues if ADOT fails | | Health Check | `:13133` | ADOT health extension | | Dependency | metrics-service HEALTHY | Waits for metrics-service to start | Configuration (embedded YAML via `AOT_CONFIG_CONTENT` env var): ```yaml receivers: prometheus: config: scrape_configs: - job_name: 'mcp-metrics-service' scrape_interval: 15s static_configs: - targets: ['localhost:9465'] exporters: prometheusremotewrite: endpoint: https://aps-workspaces..amazonaws.com/workspaces//api/v1/remote_write auth: authenticator: sigv4auth extensions: sigv4auth: region: health_check: endpoint: 0.0.0.0:13133 service: extensions: [sigv4auth, health_check] pipelines: metrics: receivers: [prometheus] exporters: [prometheusremotewrite] ``` ### Grafana OSS Pre-configured Grafana container: | Configuration | Value | |--------------|-------| | Image | `var.grafana_image_uri` | | CPU | 512 | | Memory | 1024 | | Port | 3000 | | Root URL | `/grafana/` | | Auth | Login required (admin / `grafana_admin_password`) | | ALB Path | `/grafana/*` | **Note**: Anonymous access is disabled by default. The admin password is configured via `grafana_admin_password` in `terraform.tfvars` (marked as `sensitive` to prevent exposure in plan output). Generate a strong random password with: `python3 -c "import secrets; print(secrets.token_urlsafe(24))"` **Critical Environment Variables for SigV4 Authentication:** | Variable | Value | Purpose | |----------|-------|---------| | `AWS_REGION` | `` | AWS region for SDK | | `GF_AUTH_SIGV4_AUTH_ENABLED` | `true` | Enables SigV4 signing for AWS datasources | | `GF_AWS_ALLOWED_AUTH_PROVIDERS` | `default,ec2_iam_role` | Allows ECS task role credential chain | Without `GF_AUTH_SIGV4_AUTH_ENABLED=true`, Grafana will not sign requests to AMP even if `sigV4Auth: true` is set in the datasource configuration. Without `GF_AWS_ALLOWED_AUTH_PROVIDERS`, Grafana on ECS Fargate will reject the task role credentials. Both are required. Datasource (provisioned): - **Amazon Managed Prometheus** -- Default datasource, SigV4 auth via IAM task role Dashboard (provisioned): - **MCP Analytics Comprehensive** -- 19 panels covering MCP protocol metrics (see "Grafana Dashboard Panels" below) ### Prometheus Alert Rules Three alert rules are configured in the AMP workspace: | Alert | Condition | Duration | |-------|-----------|----------| | MCPHighErrorRate | Error rate > 10% | 5 minutes | | MCPRegistryDown | No requests received | 5 minutes | | MCPHighLatency | P95 latency > 5 seconds | 5 minutes | ## Terraform Configuration ### Enabling Observability (default) ```hcl enable_observability = true metrics_service_image_uri = ".dkr.ecr..amazonaws.com/mcp-gateway-metrics-service:latest" grafana_image_uri = ".dkr.ecr..amazonaws.com/mcp-gateway-grafana:latest" ``` ### Disabling Observability ```hcl enable_observability = false # No image URIs needed -- all observability resources are skipped ``` When `enable_observability = false`: - Zero observability resources are created - No AMP workspace, no metrics-service, no ADOT, no Grafana - Registry and auth-server deploy without `METRICS_SERVICE_URL` or `METRICS_API_KEY` - No cost impact from observability - Existing functionality is completely unaffected ### Resource Gating All observability resources use `count = var.enable_observability ? 1 : 0`: | Resource | File | |----------|------| | `aws_prometheus_workspace.mcp` | `observability.tf` | | `module.ecs_service_metrics` | `observability.tf` | | `aws_iam_policy.adot_amp_write` | `observability.tf` | | `aws_iam_policy.grafana_amp_query` | `observability.tf` | | `aws_lb_target_group.grafana` | `observability.tf` | | `aws_lb_listener_rule.grafana` | `observability.tf` | | `aws_lb_listener_rule.grafana_https` | `observability.tf` (also gated by `enable_https`) | | `module.ecs_service_grafana` | `observability.tf` | | `random_password.metrics_api_key` | `secrets.tf` | | `aws_secretsmanager_secret.metrics_api_key` | `secrets.tf` | Conditional references in `ecs-services.tf` for the registry and auth-server environment variables use the same gate to avoid referencing resources that do not exist when observability is disabled. ## Grafana Dashboard Panels The pre-provisioned "MCP Gateway - Analytics Dashboard" contains 19 panels: | Panel | Type | Description | |-------|------|-------------| | Real-time Protocol Activity | timeseries | Live MCP request/response volume | | Authentication Flow Analysis | timeseries | Auth method breakdown over time | | Authentication Success Rate | stat | Current auth success percentage | | Active MCP Servers | stat | Count of registered, enabled servers | | Tool Executions per Hour | stat | Aggregate tool call volume | | Most Popular Tool | stat | Highest-traffic tool name | | MCP Latency P95 (by Server & Method) | timeseries | Tail latency per server and method | | Request Volume Over Time | timeseries | Total request throughput | | Error Rate Analysis | timeseries | Error percentage with threshold | | Average Response Times | timeseries | Mean latency trends | | Server Performance Dashboard | table | Per-server request counts, error rates, avg latency | | Tool Usage Rankings | table | Most-called tools across all servers | | MCP Protocol Methods Distribution | bargauge | Breakdown by MCP method type | | Tool Usage by Call Count | barchart | Tool call volume comparison | | Client Applications Distribution | bargauge | Traffic by MCP client | | MCP Protocol Flow Analysis | table | Protocol step timing | | Authentication Methods Distribution | bargauge | Auth method usage | | Tool Execution Success Rate | timeseries | Success/failure ratio over time | | Session Activity by Client | bargauge | Session counts per client | Metrics begin appearing within 1-2 minutes of the first MCP request passing through the gateway. ## Metric Types Collected ### Authentication Metrics - `mcp_auth_requests_total` -- Counter by success, method, server - `mcp_auth_request_duration_seconds` -- Histogram of auth latency ### Tool Execution Metrics - `mcp_tool_executions_total` -- Counter by tool, server, success - `mcp_tool_execution_duration_seconds` -- Histogram of execution time ### Discovery Metrics - `mcp_tool_discovery_total` -- Counter of semantic search requests - `mcp_discovery_duration_seconds` -- Histogram of search latency ### Protocol Flow Metrics - `mcp_protocol_latency_seconds` -- Time between protocol steps - initialize -> tools/list - tools/list -> tools/call - initialize -> tools/call (full flow) ### Histogram Bucket Boundaries The default OTel SDK bucket boundaries have a smallest non-zero boundary of 5 seconds. Since most MCP responses are sub-second, `histogram_quantile(0.95, ...)` interpolates within the 0-5s bucket and reports misleading values (e.g., ~4.75s P95 for a 50ms response). This deployment configures `ExplicitBucketHistogramAggregation` with boundaries from 5ms to 300s: ``` 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0, 30.0, 60.0, 120.0, 300.0 ``` Configurable via the `HISTOGRAM_BUCKET_BOUNDARIES` environment variable on the metrics-service. ## Security Considerations ### Network Security - metrics-service is deployed in private subnets - Only accessible via Service Connect (internal) or from registry/auth-server security groups - ADOT sidecar communicates with metrics-service on localhost (no network hop) - Grafana is exposed via ALB path `/grafana/*` behind CloudFront (when enabled) - No direct public internet exposure for metrics-service or ADOT ### Authentication - Service-to-metrics-service: API key authentication (auto-generated, stored in Secrets Manager) - ADOT-to-AMP: IAM task role with SigV4 - Grafana-to-AMP: IAM task role with SigV4 - User-to-Grafana: Login required (admin / `grafana_admin_password` from `terraform.tfvars`) ### IAM Roles **metrics-service Task Role**: - `SecretsManagerAccess` -- read metrics API key - `EcsExecTask` -- ECS Exec for debugging - `AMPRemoteWrite` -- ADOT sidecar writes to AMP **Grafana Task Role**: ```json { "Effect": "Allow", "Action": [ "aps:QueryMetrics", "aps:GetMetricMetadata", "aps:GetSeries", "aps:GetLabels" ], "Resource": "arn:aws:aps:::workspace/" } ``` ## Cost Considerations | Component | Estimated Monthly Cost | Notes | |-----------|----------------------|-------| | AMP | $0.90/10M samples ingested | ~$5-10/month typical | | metrics-service + ADOT (Fargate) | ~$15/month | 512 CPU, 1024 MB (shared task) | | Grafana OSS (Fargate) | ~$15/month | 512 CPU, 1024 MB | | Secrets Manager | ~$0.40/month | 1 secret | | **Total** | **~$35-40/month** | For full observability stack | Setting `enable_observability = false` reduces this to $0. ## Differences from CloudFormation Deployment | Aspect | CloudFormation | Terraform | |--------|---------------|-----------| | ADOT deployment | Standalone ECS service | Sidecar in metrics-service task | | ADOT scrape target | `metrics-service.internal:9465` | `localhost:9465` | | Service discovery | DNS-type Cloud Map (Route53 A records) | HTTP-type Cloud Map (no Route53) | | API key management | CloudFormation parameter (static default) | Secrets Manager (auto-generated) | | Resource gating | Separate nested stack | `count` on each resource | | Grafana datasources | AMP + CloudWatch | AMP only | | Grafana dashboards | MCP Analytics + AWS Infrastructure | MCP Analytics | The sidecar pattern used in Terraform is a direct consequence of the HTTP-type Cloud Map limitation. See [issue #496](https://github.com/agentic-community/mcp-gateway-registry/issues/496) for details. ## References - [MCP Gateway Metrics Architecture](../../../docs/metrics-architecture.md) - [metrics-service Deployment Guide](../../../metrics-service/docs/deployment.md) - [metrics-service API Reference](../../../metrics-service/docs/api-reference.md) - [AWS ADOT Documentation](https://aws-otel.github.io/docs/introduction) - [Amazon Managed Prometheus User Guide](https://docs.aws.amazon.com/prometheus/latest/userguide/) - [Issue #496: Health gate blocks nginx routing to reachable servers](https://github.com/agentic-community/mcp-gateway-registry/issues/496) ================================================ FILE: terraform/aws-ecs/documentdb-elastic.tf.disabled ================================================ # # Amazon DocumentDB Elastic Cluster Infrastructure for MCP Gateway Registry # # This configuration creates a DocumentDB Elastic Cluster with VPC access # for the MCP Gateway Registry backend storage and vector search. # # # DocumentDB Elastic Cluster # resource "aws_docdbelastic_cluster" "registry" { name = "${var.name}-registry" # Authentication admin_user_name = var.documentdb_admin_username admin_user_password = var.documentdb_admin_password auth_type = "PLAIN_TEXT" # Capacity shard_capacity = var.documentdb_shard_capacity shard_count = var.documentdb_shard_count # Network configuration vpc_security_group_ids = [aws_security_group.documentdb.id] subnet_ids = module.vpc.private_subnets # Backup configuration backup_retention_period = 7 preferred_backup_window = "02:00-04:00" preferred_maintenance_window = "sun:04:00-sun:05:00" # Encryption kms_key_id = aws_kms_key.documentdb.arn # Tags temporarily removed due to IAM permission issue # Add docdb-elastic:TagResource permission to IAM role to enable tagging # tags = merge( # local.common_tags, # { # Name = "${var.name}-registry-docdb" # Component = "documentdb" # Environment = "production" # Service = "mcp-gateway-registry" # } # ) } # # Security Group for DocumentDB # resource "aws_security_group" "documentdb" { name = "${var.name}-v2-documentdb-sg" description = "Security group for DocumentDB Elastic Cluster" vpc_id = module.vpc.vpc_id tags = merge( local.common_tags, { Name = "${var.name}-v2-documentdb-sg" Component = "documentdb" } ) } # Ingress from Registry service resource "aws_vpc_security_group_ingress_rule" "documentdb_from_registry" { security_group_id = aws_security_group.documentdb.id referenced_security_group_id = module.mcp_gateway.ecs_security_group_ids.registry from_port = 27017 to_port = 27017 ip_protocol = "tcp" description = "Allow MongoDB protocol from Registry ECS service to DocumentDB" tags = merge( local.common_tags, { Name = "documentdb-from-registry" } ) } # Ingress from Auth service (if auth service needs DocumentDB access) resource "aws_vpc_security_group_ingress_rule" "documentdb_from_auth" { security_group_id = aws_security_group.documentdb.id referenced_security_group_id = module.mcp_gateway.ecs_security_group_ids.auth from_port = 27017 to_port = 27017 ip_protocol = "tcp" description = "Allow MongoDB protocol from Auth ECS service to DocumentDB" tags = merge( local.common_tags, { Name = "documentdb-from-auth" } ) } # Egress (DocumentDB doesn't need outbound, but best practice to allow) resource "aws_vpc_security_group_egress_rule" "documentdb_egress" { security_group_id = aws_security_group.documentdb.id cidr_ipv4 = "0.0.0.0/0" ip_protocol = "-1" description = "Allow all outbound traffic" tags = merge( local.common_tags, { Name = "documentdb-egress-all" } ) } # # Security Group Rules for Registry and Auth services to reach DocumentDB # # Registry -> DocumentDB resource "aws_vpc_security_group_egress_rule" "registry_to_documentdb" { security_group_id = module.mcp_gateway.ecs_security_group_ids.registry referenced_security_group_id = aws_security_group.documentdb.id from_port = 27017 to_port = 27017 ip_protocol = "tcp" description = "Allow Registry service to connect to DocumentDB" tags = merge( local.common_tags, { Name = "registry-to-documentdb" } ) } # Auth -> DocumentDB resource "aws_vpc_security_group_egress_rule" "auth_to_documentdb" { security_group_id = module.mcp_gateway.ecs_security_group_ids.auth referenced_security_group_id = aws_security_group.documentdb.id from_port = 27017 to_port = 27017 ip_protocol = "tcp" description = "Allow Auth service to connect to DocumentDB" tags = merge( local.common_tags, { Name = "auth-to-documentdb" } ) } # # KMS Key for DocumentDB Encryption # resource "aws_kms_key" "documentdb" { description = "KMS key for DocumentDB Elastic Cluster encryption" deletion_window_in_days = 7 enable_key_rotation = true tags = merge( local.common_tags, { Name = "${var.name}-documentdb-key" Component = "documentdb" } ) } resource "aws_kms_alias" "documentdb" { name = "alias/${var.name}-documentdb" target_key_id = aws_kms_key.documentdb.key_id } # # Secrets Manager Secret for DocumentDB Credentials # resource "aws_secretsmanager_secret" "documentdb_credentials" { name = "${var.name}/documentdb/credentials" description = "DocumentDB Elastic Cluster admin credentials" recovery_window_in_days = 7 tags = merge( local.common_tags, { Component = "documentdb" } ) } resource "aws_secretsmanager_secret_version" "documentdb_credentials" { secret_id = aws_secretsmanager_secret.documentdb_credentials.id secret_string = jsonencode({ username = var.documentdb_admin_username password = var.documentdb_admin_password engine = "docdb" }) } # # SSM Parameters for Application Configuration # resource "aws_ssm_parameter" "documentdb_endpoint" { name = "/${var.name}/documentdb/endpoint" description = "DocumentDB Elastic Cluster endpoint" type = "String" value = aws_docdbelastic_cluster.registry.endpoint tags = merge( local.common_tags, { Component = "documentdb" } ) } resource "aws_ssm_parameter" "documentdb_connection_string" { name = "/${var.name}/documentdb/connection_string" description = "DocumentDB Elastic Cluster connection string" type = "SecureString" value = format( "mongodb://%s:%s@%s:27017/?tls=true&tlsCAFile=global-bundle.pem&replicaSet=rs0&readPreference=secondaryPreferred&retryWrites=false", var.documentdb_admin_username, var.documentdb_admin_password, aws_docdbelastic_cluster.registry.endpoint ) tags = merge( local.common_tags, { Component = "documentdb" } ) } ================================================ FILE: terraform/aws-ecs/documentdb.tf ================================================ # # Amazon DocumentDB (Regular) Cluster Infrastructure for MCP Gateway Registry # # This configuration creates a regular DocumentDB Cluster (instance-based) with VPC access # for the MCP Gateway Registry backend storage and vector search. # # This replaces DocumentDB Elastic to enable vector search support with HNSW indexes. # # # Security Group for DocumentDB # resource "aws_security_group" "documentdb" { name = "${var.name}-v2-documentdb-sg" description = "Security group for DocumentDB Elastic Cluster" # Keep original description to avoid recreation vpc_id = module.vpc.vpc_id tags = merge( local.common_tags, { Name = "${var.name}-v2-documentdb-sg" Component = "documentdb" } ) lifecycle { ignore_changes = [description] # Ignore description changes to avoid forcing recreation } } # Ingress from Registry service resource "aws_vpc_security_group_ingress_rule" "documentdb_from_registry" { security_group_id = aws_security_group.documentdb.id referenced_security_group_id = module.mcp_gateway.ecs_security_group_ids.registry from_port = 27017 to_port = 27017 ip_protocol = "tcp" description = "Allow MongoDB protocol from Registry ECS service to DocumentDB" tags = merge( local.common_tags, { Name = "documentdb-from-registry" } ) } # Ingress from Auth service resource "aws_vpc_security_group_ingress_rule" "documentdb_from_auth" { security_group_id = aws_security_group.documentdb.id referenced_security_group_id = module.mcp_gateway.ecs_security_group_ids.auth from_port = 27017 to_port = 27017 ip_protocol = "tcp" description = "Allow MongoDB protocol from Auth ECS service to DocumentDB" tags = merge( local.common_tags, { Name = "documentdb-from-auth" } ) } # Egress resource "aws_vpc_security_group_egress_rule" "documentdb_egress" { security_group_id = aws_security_group.documentdb.id cidr_ipv4 = "0.0.0.0/0" ip_protocol = "-1" description = "Allow all outbound traffic" tags = merge( local.common_tags, { Name = "documentdb-egress-all" } ) } # Registry -> DocumentDB resource "aws_vpc_security_group_egress_rule" "registry_to_documentdb" { security_group_id = module.mcp_gateway.ecs_security_group_ids.registry referenced_security_group_id = aws_security_group.documentdb.id from_port = 27017 to_port = 27017 ip_protocol = "tcp" description = "Allow Registry service to connect to DocumentDB" tags = merge( local.common_tags, { Name = "registry-to-documentdb" } ) } # Auth -> DocumentDB resource "aws_vpc_security_group_egress_rule" "auth_to_documentdb" { security_group_id = module.mcp_gateway.ecs_security_group_ids.auth referenced_security_group_id = aws_security_group.documentdb.id from_port = 27017 to_port = 27017 ip_protocol = "tcp" description = "Allow Auth service to connect to DocumentDB" tags = merge( local.common_tags, { Name = "auth-to-documentdb" } ) } # # KMS Key for DocumentDB Encryption # resource "aws_kms_key" "documentdb" { description = "KMS key for DocumentDB Cluster and secrets encryption" deletion_window_in_days = 7 enable_key_rotation = true policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "Enable IAM User Permissions" Effect = "Allow" Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" } Action = "kms:*" Resource = "*" }, { Sid = "Allow ECS Task Execution Role to Decrypt" Effect = "Allow" Principal = { AWS = "*" } Action = [ "kms:Decrypt", "kms:DescribeKey" ] Resource = "*" Condition = { StringEquals = { "aws:PrincipalAccount" = data.aws_caller_identity.current.account_id } StringLike = { "aws:PrincipalArn" = [ # ECS task execution roles (e.g., mcp-gateway-task-exec-role) "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/*task-exec*", # ECS task roles for v2 deployments (e.g., mcp-gateway-v2-registry-task-role) "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/mcp-gateway-v2-*", ] } } }, { Sid = "Allow CloudWatch Logs" Effect = "Allow" Principal = { Service = "logs.${data.aws_region.current.name}.amazonaws.com" } Action = [ "kms:Encrypt", "kms:Decrypt", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:CreateGrant", "kms:DescribeKey" ] Resource = "*" Condition = { ArnLike = { "kms:EncryptionContext:aws:logs:arn" = "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:*" } } } ] }) tags = merge( local.common_tags, { Name = "${var.name}-documentdb-key" Component = "documentdb" } ) } resource "aws_kms_alias" "documentdb" { name = "alias/${var.name}-documentdb" target_key_id = aws_kms_key.documentdb.key_id } # # Secrets Manager Secret for DocumentDB Credentials # #checkov:skip=CKV2_AWS_57:Secret rotation managed externally via dedicated rotation Lambda resource "aws_secretsmanager_secret" "documentdb_credentials" { name = "${var.name}/documentdb/credentials" description = "DocumentDB Cluster admin credentials" recovery_window_in_days = 0 kms_key_id = aws_kms_key.documentdb.id tags = merge( local.common_tags, { Component = "documentdb" } ) } resource "aws_secretsmanager_secret_version" "documentdb_credentials" { secret_id = aws_secretsmanager_secret.documentdb_credentials.id secret_string = jsonencode({ username = var.documentdb_admin_username password = var.documentdb_admin_password engine = "docdb" }) } # # DocumentDB Subnet Group # resource "aws_docdb_subnet_group" "registry" { name = "${var.name}-registry-subnet-group" subnet_ids = module.vpc.private_subnets tags = merge( local.common_tags, { Name = "${var.name}-registry-subnet-group" Component = "documentdb" } ) } # # DocumentDB Cluster Parameter Group # resource "aws_docdb_cluster_parameter_group" "registry" { family = "docdb5.0" name = "${var.name}-registry-params" description = "DocumentDB cluster parameter group for MCP Gateway Registry" # Enable TLS parameter { name = "tls" value = "enabled" } # Audit logs - enabled for compliance and security monitoring parameter { name = "audit_logs" value = "enabled" } # TTL monitor (for automatic document expiration) parameter { name = "ttl_monitor" value = "enabled" } tags = merge( local.common_tags, { Name = "${var.name}-registry-params" Component = "documentdb" } ) } # # DocumentDB Cluster # resource "aws_docdb_cluster" "registry" { cluster_identifier = "${var.name}-registry" # Engine engine = "docdb" engine_version = "5.0.0" # Authentication master_username = var.documentdb_admin_username master_password = var.documentdb_admin_password # Network configuration db_subnet_group_name = aws_docdb_subnet_group.registry.name vpc_security_group_ids = [aws_security_group.documentdb.id] port = 27017 # Backup configuration backup_retention_period = 7 preferred_backup_window = "02:00-04:00" preferred_maintenance_window = "sun:04:00-sun:05:00" skip_final_snapshot = false final_snapshot_identifier = "${var.name}-registry-final-snapshot" # Encryption storage_encrypted = true kms_key_id = aws_kms_key.documentdb.arn # Parameter group db_cluster_parameter_group_name = aws_docdb_cluster_parameter_group.registry.name # Deletion protection (enable for production) deletion_protection = false # Enable CloudWatch logs enabled_cloudwatch_logs_exports = ["audit", "profiler"] tags = merge( local.common_tags, { Name = "${var.name}-registry-docdb" Component = "documentdb" Environment = "production" Service = "mcp-gateway-registry" } ) } # # DocumentDB Cluster Instances # # Primary instance resource "aws_docdb_cluster_instance" "registry_primary" { identifier = "${var.name}-registry-primary" cluster_identifier = aws_docdb_cluster.registry.id # Instance class (can be adjusted based on needs) # db.t3.medium = 2 vCPU, 4 GB RAM - good starting point # db.r5.large = 2 vCPU, 16 GB RAM - for larger workloads instance_class = var.documentdb_instance_class # Monitoring auto_minor_version_upgrade = true enable_performance_insights = false # Not available for DocumentDB yet promotion_tier = 0 tags = merge( local.common_tags, { Name = "${var.name}-registry-primary" Component = "documentdb" Role = "primary" } ) } # Read replica instance (optional, for high availability) # Uncomment to enable a read replica # resource "aws_docdb_cluster_instance" "registry_replica" { # count = var.documentdb_replica_count # identifier = "${var.name}-registry-replica-${count.index + 1}" # cluster_identifier = aws_docdb_cluster.registry.id # # instance_class = var.documentdb_instance_class # # auto_minor_version_upgrade = true # promotion_tier = count.index + 1 # # tags = merge( # local.common_tags, # { # Name = "${var.name}-registry-replica-${count.index + 1}" # Component = "documentdb" # Role = "replica" # } # ) # } # # Update SSM Parameters with new cluster endpoints # #checkov:skip=CKV2_AWS_34:SSM parameter stores non-sensitive endpoint hostname, SecureString not required resource "aws_ssm_parameter" "documentdb_endpoint" { name = "/${var.name}/documentdb/endpoint" description = "DocumentDB Cluster endpoint" type = "String" value = aws_docdb_cluster.registry.endpoint overwrite = true tags = merge( local.common_tags, { Component = "documentdb" } ) } #checkov:skip=CKV2_AWS_34:SSM parameter stores non-sensitive endpoint hostname, SecureString not required resource "aws_ssm_parameter" "documentdb_reader_endpoint" { name = "/${var.name}/documentdb/reader_endpoint" description = "DocumentDB Cluster reader endpoint" type = "String" value = aws_docdb_cluster.registry.reader_endpoint tags = merge( local.common_tags, { Component = "documentdb" } ) } resource "aws_ssm_parameter" "documentdb_connection_string" { name = "/${var.name}/documentdb/connection_string" description = "DocumentDB Cluster connection string" type = "SecureString" key_id = aws_kms_key.documentdb.id # AWS DocumentDB only supports SCRAM-SHA-1 (not SCRAM-SHA-256 as of v5.0) # TODO: Update to SCRAM-SHA-256 when AWS DocumentDB adds support value = format( "mongodb://%s:%s@%s:27017/?authMechanism=SCRAM-SHA-1&authSource=admin&tls=true&tlsCAFile=global-bundle.pem&replicaSet=rs0&readPreference=secondaryPreferred&retryWrites=false", var.documentdb_admin_username, var.documentdb_admin_password, aws_docdb_cluster.registry.endpoint ) overwrite = true tags = merge( local.common_tags, { Component = "documentdb" } ) } ================================================ FILE: terraform/aws-ecs/ecs.tf ================================================ data "aws_region" "current" {} data "aws_partition" "current" {} # ECS Cluster using terraform-aws-modules/ecs/aws//modules/cluster #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "ecs_cluster" { source = "terraform-aws-modules/ecs/aws//modules/cluster" version = "~> 6.0" name = "${var.name}-ecs-cluster" # Enable Service Connect at cluster level service_connect_defaults = { namespace = module.mcp_gateway.service_discovery_namespace_arn } configuration = { execute_command_configuration = { logging = "OVERRIDE" log_configuration = { cloud_watch_log_group_name = "/aws/ecs/${var.name}" } } } # Enable containerInsights setting = [ { name = "containerInsights" value = "enabled" } ] # Cluster capacity providers - Fargate only default_capacity_provider_strategy = { FARGATE = { weight = 50 base = 1 } } # Create task execution role create_task_exec_iam_role = true task_exec_iam_role_name = "${var.name}-task-execution" # Additional policies for task execution role task_exec_iam_role_policies = { AmazonECSTaskExecutionRolePolicy = "arn:${data.aws_partition.current.partition}:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" } tags = { Name = "${var.name} ECS Cluster" } } # IAM policy for task execution role to access DocumentDB credentials resource "aws_iam_role_policy" "task_execution_documentdb_secrets" { count = var.storage_backend == "documentdb" ? 1 : 0 name = "${var.name}-task-execution-documentdb-secrets" role = module.ecs_cluster.task_exec_iam_role_name policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "secretsmanager:GetSecretValue" ] Resource = [ aws_secretsmanager_secret.documentdb_credentials.arn ] } ] }) } ================================================ FILE: terraform/aws-ecs/grafana/Dockerfile ================================================ # Grafana OSS for MCP Gateway Observability Pipeline # Pinned to stable version to avoid breaking changes FROM grafana/grafana:12.4.3 # Switch to root to set up directories USER root # Install wget for health checks # apk upgrade ensures latest Alpine security patches (openssl, zlib, musl) RUN apk update && apk upgrade --no-cache && apk add --no-cache wget # Copy provisioning configurations COPY provisioning/datasources /etc/grafana/provisioning/datasources COPY provisioning/dashboards /etc/grafana/provisioning/dashboards # Copy dashboard JSON files COPY dashboards /var/lib/grafana/dashboards # Set ownership RUN chown -R grafana:root /etc/grafana/provisioning && \ chown -R grafana:root /var/lib/grafana/dashboards && \ chmod -R 755 /var/lib/grafana/dashboards # Switch back to grafana user USER grafana # Authentication defaults (ECS task definition overrides GF_SECURITY_ADMIN_PASSWORD at runtime) ENV GF_AUTH_ANONYMOUS_ENABLED=false ENV GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer ENV GF_AUTH_DISABLE_LOGIN_FORM=false ENV GF_USERS_ALLOW_SIGN_UP=false # Server settings for ALB path-based routing ENV GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s/grafana/ ENV GF_SERVER_SERVE_FROM_SUB_PATH=true # Logging ENV GF_LOG_MODE=console ENV GF_LOG_LEVEL=info # Performance settings ENV GF_DASHBOARDS_MIN_REFRESH_INTERVAL=10s EXPOSE 3000 # Health check HEALTHCHECK --interval=10s --timeout=5s --retries=3 \ CMD wget -q --spider http://localhost:3000/api/health || exit 1 ================================================ FILE: terraform/aws-ecs/grafana/dashboards/mcp-analytics-comprehensive.json ================================================ { "id": null, "title": "MCP Gateway - Analytics Dashboard", "tags": [ "mcp", "analytics", "auth", "tools" ], "timezone": "browser", "refresh": "30s", "time": { "from": "now-1h", "to": "now" }, "panels": [ { "id": 1, "title": "Real-time Protocol Activity", "type": "timeseries", "targets": [ { "legendFormat": "Initialize Rate", "expr": "sum(increase(mcp_tool_executions_total{method=\"initialize\", success=\"true\"}[1m]))" }, { "legendFormat": "Tools List Rate", "expr": "sum(increase(mcp_tool_executions_total{method=\"tools/list\", success=\"true\"}[1m]))" }, { "legendFormat": "Tool Call Rate", "expr": "sum(increase(mcp_tool_executions_total{method=\"tools/call\", success=\"true\"}[1m]))" }, { "legendFormat": "Auth Success Rate", "expr": "sum(increase(mcp_auth_requests_total{success=\"true\"}[1m]))" } ], "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, "options": { "legend": { "displayMode": "table", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Requests per Second", "axisPlacement": "left" }, "unit": "reqps" } } }, { "id": 2, "title": "Authentication Flow Analysis", "type": "timeseries", "targets": [ { "expr": "sum(rate(mcp_auth_requests_total{success=\"true\"}[5m]))", "legendFormat": "Successful Auth" }, { "expr": "sum(rate(mcp_auth_requests_total{success=\"false\"}[5m]))", "legendFormat": "Failed Auth" } ], "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, "options": { "legend": { "displayMode": "table", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Auth Requests per Second", "axisPlacement": "left" }, "unit": "reqps" } } }, { "id": 3, "title": "Authentication Success Rate", "type": "stat", "targets": [ { "expr": "sum(mcp_auth_requests_total{success=\"true\"}) / sum(mcp_auth_requests_total) * 100", "legendFormat": "Success Rate %" } ], "gridPos": { "h": 4, "w": 6, "x": 0, "y": 8 }, "options": { "colorMode": "background", "graphMode": "area" }, "fieldConfig": { "defaults": { "unit": "percent", "thresholds": { "steps": [ { "color": "red", "value": 0 }, { "color": "orange", "value": 85 }, { "color": "green", "value": 95 } ] } } } }, { "id": 4, "title": "Active MCP Servers", "type": "stat", "targets": [ { "expr": "count(count by (server_name)(mcp_tool_executions_total))", "legendFormat": "Active Servers" } ], "gridPos": { "h": 4, "w": 6, "x": 6, "y": 8 }, "options": { "colorMode": "background", "graphMode": "area" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "unit": "short", "thresholds": { "steps": [ { "color": "blue", "value": 0 }, { "color": "green", "value": 3 }, { "color": "green", "value": 10 } ] } } } }, { "id": 5, "title": "Tool Executions per Hour", "type": "stat", "targets": [ { "expr": "sum(increase(mcp_tool_executions_total[1h]))", "legendFormat": "Tools/Hour" } ], "gridPos": { "h": 4, "w": 6, "x": 12, "y": 8 }, "options": { "colorMode": "background", "graphMode": "area" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "unit": "short", "thresholds": { "steps": [ { "color": "blue", "value": 0 }, { "color": "blue", "value": 50 }, { "color": "blue", "value": 100 } ] } } } }, { "id": 6, "title": "Most Popular Tool", "type": "stat", "targets": [ { "expr": "topk(1, sum(mcp_tool_executions_total{method=\"tools/call\"}) by (tool_name))", "legendFormat": "{{tool_name}}" } ], "gridPos": { "h": 4, "w": 6, "x": 18, "y": 8 }, "options": { "colorMode": "background", "textMode": "name" }, "fieldConfig": { "defaults": { "color": { "mode": "thresholds" }, "thresholds": { "steps": [ { "color": "purple", "value": 0 } ] } } } }, { "id": 7, "title": "MCP Latency P95 (by Server & Method)", "type": "timeseries", "targets": [ { "expr": "histogram_quantile(0.95, sum by (le, server_name)(rate(mcp_tool_execution_duration_seconds_bucket[5m])))", "legendFormat": "{{server_name}} P95" }, { "expr": "histogram_quantile(0.95, sum by (le, method)(rate(mcp_tool_execution_duration_seconds_bucket[5m])))", "legendFormat": "{{method}} P95" } ], "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, "options": { "legend": { "displayMode": "table", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Latency (seconds)", "axisPlacement": "left" }, "unit": "s" } } }, { "id": 8, "title": "Request Volume Over Time", "type": "timeseries", "targets": [ { "expr": "sum(rate(mcp_tool_executions_total[5m])) by (method)", "legendFormat": "{{method}}" } ], "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, "options": { "legend": { "displayMode": "table", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Requests per Second", "axisPlacement": "left" }, "unit": "reqps" } } }, { "id": 9, "title": "Error Rate Analysis", "type": "timeseries", "targets": [ { "legendFormat": "Auth Error Rate", "expr": "sum(increase(mcp_auth_requests_total{success=\"false\"}[5m])) / sum(increase(mcp_auth_requests_total[5m])) * 100" }, { "legendFormat": "Tool Execution Error Rate", "expr": "sum(increase(mcp_tool_executions_total{success=\"false\"}[5m])) / sum(increase(mcp_tool_executions_total[5m])) * 100" } ], "gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 }, "options": { "legend": { "displayMode": "table", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Error Rate (%)", "axisPlacement": "left" }, "unit": "percent", "thresholds": { "steps": [ { "color": "green", "value": 0 }, { "color": "yellow", "value": 1 }, { "color": "red", "value": 5 } ] } } } }, { "id": 10, "title": "Average Response Times", "type": "timeseries", "targets": [ { "expr": "avg(rate(mcp_auth_request_duration_seconds_sum[5m])) / avg(rate(mcp_auth_request_duration_seconds_count[5m]))", "legendFormat": "Auth Avg Response Time" }, { "expr": "avg(rate(mcp_tool_execution_duration_seconds_sum[5m])) / avg(rate(mcp_tool_execution_duration_seconds_count[5m]))", "legendFormat": "Tool Exec Avg Response Time" } ], "gridPos": { "h": 8, "w": 12, "x": 12, "y": 20 }, "options": { "legend": { "displayMode": "table", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Response Time (seconds)", "axisPlacement": "left" }, "unit": "s" } } }, { "id": 11, "title": "Server Performance Dashboard", "type": "table", "targets": [ { "expr": "sum(mcp_tool_executions_total) by (server_name)", "legendFormat": "{{server_name}}_total_calls", "format": "table", "instant": true }, { "expr": "sum(increase(mcp_tool_executions_total[1h])) by (server_name)", "legendFormat": "{{server_name}}_calls_per_hour", "format": "table", "instant": true }, { "expr": "count(count by (server_name, tool_name)(mcp_tool_executions_total)) by (server_name)", "legendFormat": "{{server_name}}_unique_tools", "format": "table", "instant": true } ], "gridPos": { "h": 8, "w": 12, "x": 0, "y": 28 }, "transformations": [ { "id": "merge", "options": {} }, { "id": "organize", "options": { "excludeByName": { "Time": true }, "renameByName": { "server_name": "Server", "Value #A": "Total Calls", "Value #B": "Calls/Hour", "Value #C": "Unique Tools" } } } ] }, { "id": 12, "title": "Tool Usage Rankings", "type": "table", "targets": [ { "expr": "sum(mcp_tool_executions_total{method=\"tools/call\"}) by (tool_name)", "legendFormat": "{{tool_name}}", "format": "table", "instant": true }, { "expr": "sum(increase(mcp_tool_executions_total{method=\"tools/call\"}[1h])) by (tool_name)", "legendFormat": "{{tool_name}}_rate", "format": "table", "instant": true } ], "gridPos": { "h": 8, "w": 12, "x": 12, "y": 28 }, "transformations": [ { "id": "merge", "options": {} }, { "id": "organize", "options": { "excludeByName": { "Time": true }, "renameByName": { "tool_name": "Tool Name", "Value #A": "Total Calls", "Value #B": "Calls/Hour" } } } ], "options": { "sortBy": [ { "desc": true, "displayName": "Total Calls" } ] } }, { "id": 13, "title": "MCP Protocol Methods Distribution", "type": "bargauge", "targets": [ { "expr": "topk(10, sum(mcp_tool_executions_total{method!=\"tools/call\"}) by (method))", "legendFormat": "{{method}}" } ], "gridPos": { "h": 8, "w": 8, "x": 0, "y": 36 }, "options": { "orientation": "horizontal", "displayMode": "gradient" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisLabel": "Request Count" }, "unit": "short" } } }, { "id": 14, "title": "Tool Usage by Call Count", "type": "barchart", "targets": [ { "expr": "topk(10, sum(mcp_tool_executions_total{method=\"tools/call\"}) by (tool_name))", "legendFormat": "{{tool_name}}", "instant": true, "format": "table" } ], "gridPos": { "h": 8, "w": 8, "x": 8, "y": 36 }, "options": { "orientation": "vertical", "xTickLabelRotation": -45, "legend": { "displayMode": "hidden" } }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisLabel": "Tool Call Count", "axisPlacement": "left" }, "unit": "short" } } }, { "id": 15, "title": "Client Applications Distribution", "type": "bargauge", "targets": [ { "expr": "topk(10, sum(mcp_tool_executions_total) by (client_name))", "legendFormat": "{{client_name}}" } ], "gridPos": { "h": 8, "w": 8, "x": 16, "y": 36 }, "options": { "orientation": "horizontal", "displayMode": "gradient" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisLabel": "Total Executions" }, "unit": "short" } } }, { "id": 16, "title": "MCP Protocol Flow Analysis", "type": "table", "targets": [ { "expr": "sum(mcp_tool_executions_total{method=\"initialize\"}) by (client_name)", "legendFormat": "{{client_name}}_init", "format": "table", "instant": true }, { "expr": "sum(mcp_tool_executions_total{method=\"tools/list\"}) by (client_name)", "legendFormat": "{{client_name}}_list", "format": "table", "instant": true }, { "expr": "sum(mcp_tool_executions_total{method=\"tools/call\"}) by (client_name)", "legendFormat": "{{client_name}}_call", "format": "table", "instant": true } ], "gridPos": { "h": 8, "w": 12, "x": 0, "y": 44 }, "transformations": [ { "id": "merge", "options": {} }, { "id": "organize", "options": { "excludeByName": { "Time": true }, "renameByName": { "client_name": "Client", "Value #A": "Initialize", "Value #B": "Tools List", "Value #C": "Tool Calls" } } } ] }, { "id": 17, "title": "Authentication Methods Distribution", "type": "bargauge", "targets": [ { "expr": "sum(mcp_auth_requests_total) by (method)", "legendFormat": "{{method}}" } ], "gridPos": { "h": 8, "w": 12, "x": 12, "y": 44 }, "options": { "orientation": "horizontal", "displayMode": "gradient" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, "custom": { "axisLabel": "Request Count" }, "unit": "short" } } }, { "id": 18, "title": "Tool Execution Success Rate", "type": "timeseries", "targets": [ { "legendFormat": "Success Rate", "expr": "sum(increase(mcp_tool_executions_total{success=\"true\"}[5m])) / sum(increase(mcp_tool_executions_total[5m])) * 100" } ], "gridPos": { "h": 8, "w": 12, "x": 0, "y": 52 }, "options": { "legend": { "displayMode": "list", "placement": "bottom" } }, "fieldConfig": { "defaults": { "custom": { "axisLabel": "Success Rate (%)", "axisPlacement": "left" }, "unit": "percent", "min": 0, "max": 100, "thresholds": { "mode": "absolute", "steps": [ { "value": 0, "color": "red" }, { "value": 90, "color": "yellow" }, { "value": 95, "color": "green" } ] } } } }, { "id": 19, "title": "Session Activity by Client", "type": "bargauge", "targets": [ { "expr": "topk(15, sum(rate(mcp_tool_executions_total[5m])) by (client_name))", "legendFormat": "{{client_name}}" } ], "gridPos": { "h": 8, "w": 12, "x": 12, "y": 52 }, "options": { "orientation": "horizontal", "displayMode": "gradient" }, "fieldConfig": { "defaults": { "color": { "mode": "continuous-GrYlRd" }, "custom": { "axisLabel": "Activity Rate (req/s)" }, "unit": "reqps" } } } ], "templating": { "list": [ { "name": "server", "type": "query", "query": "label_values(mcp_auth_requests_total, server)", "refresh": 1, "includeAll": true, "allValue": ".*" }, { "name": "client", "type": "query", "query": "label_values(mcp_tool_executions_total, client_name)", "refresh": 1, "includeAll": true, "allValue": ".*" }, { "name": "method", "type": "query", "query": "label_values(mcp_tool_executions_total, method)", "refresh": 1, "includeAll": true, "allValue": ".*" } ] }, "schemaVersion": 16, "version": 1 } ================================================ FILE: terraform/aws-ecs/grafana/provisioning/dashboards/dashboards.yaml ================================================ apiVersion: 1 providers: - name: 'MCP Gateway Dashboards' orgId: 1 folder: 'MCP Gateway' folderUid: 'mcp-gateway' type: file disableDeletion: false updateIntervalSeconds: 30 allowUiUpdates: true options: path: /var/lib/grafana/dashboards ================================================ FILE: terraform/aws-ecs/grafana/provisioning/datasources/datasources.yaml ================================================ apiVersion: 1 datasources: # Amazon Managed Prometheus (AMP) - uses SigV4 authentication via IAM task role - name: Amazon Managed Prometheus type: prometheus access: proxy url: ${AMP_ENDPOINT} isDefault: true editable: false jsonData: httpMethod: POST timeInterval: "15s" sigV4Auth: true sigV4AuthType: ec2_iam_role sigV4Region: ${AWS_REGION} ================================================ FILE: terraform/aws-ecs/keycloak-alb.tf ================================================ # # Keycloak Application Load Balancer # #checkov:skip=CKV_AWS_150:Deletion protection managed at deployment level #checkov:skip=CKV2_AWS_76:Target group is attached to ALB listener and ECS service resource "aws_lb" "keycloak" { name = "keycloak-alb" internal = false load_balancer_type = "application" drop_invalid_header_fields = true enable_deletion_protection = false security_groups = concat( [aws_security_group.keycloak_lb.id], local.cloudfront_prefix_list_name != "" ? [aws_security_group.keycloak_lb_cloudfront[0].id] : [] ) subnets = module.vpc.public_subnets access_logs { bucket = aws_s3_bucket.alb_logs.id enabled = true prefix = "keycloak-alb" } tags = merge( local.common_tags, { Name = "keycloak-alb" } ) # Wait for S3 bucket policy to propagate (30s delay) # This prevents "Access Denied" errors when ALB tests write permissions depends_on = [time_sleep.wait_for_bucket_policy] } # Random suffix for target group name (required by AWS) resource "random_string" "alb_tg_suffix" { length = 3 special = false upper = false } # Target Group #checkov:skip=CKV_AWS_378:HTTP backend protocol is intentional - TLS terminates at ALB resource "aws_lb_target_group" "keycloak" { name = "keycloak-tg-${random_string.alb_tg_suffix.result}" port = 8080 protocol = "HTTP" target_type = "ip" vpc_id = module.vpc.vpc_id deregistration_delay = 30 health_check { enabled = true healthy_threshold = 2 unhealthy_threshold = 3 timeout = 5 interval = 30 path = "/" matcher = "200-399" protocol = "HTTP" } stickiness { type = "lb_cookie" enabled = true cookie_duration = 86400 } tags = merge( local.common_tags, { Name = "keycloak-tg" } ) lifecycle { create_before_destroy = true ignore_changes = [ stickiness[0].cookie_name ] } } # HTTPS Listener (only when Route53 DNS is enabled with ACM certificate) resource "aws_lb_listener" "keycloak_https" { count = var.enable_route53_dns ? 1 : 0 load_balancer_arn = aws_lb.keycloak.arn port = "443" protocol = "HTTPS" ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" certificate_arn = aws_acm_certificate.keycloak[0].arn default_action { type = "forward" target_group_arn = aws_lb_target_group.keycloak.arn } tags = local.common_tags } # HTTP Listener - behavior depends on deployment mode # Mode 2 (Custom Domain → ALB): redirect to HTTPS # Mode 1 & 3 (CloudFront enabled): forward to target (CloudFront handles HTTPS) resource "aws_lb_listener" "keycloak_http" { load_balancer_arn = aws_lb.keycloak.arn port = "80" protocol = "HTTP" # Redirect to HTTPS only when Route53 is enabled WITHOUT CloudFront (Mode 2) # When CloudFront is enabled (Mode 1 or 3), forward to target group default_action { type = var.enable_route53_dns && !var.enable_cloudfront ? "redirect" : "forward" target_group_arn = var.enable_route53_dns && !var.enable_cloudfront ? null : aws_lb_target_group.keycloak.arn dynamic "redirect" { for_each = var.enable_route53_dns && !var.enable_cloudfront ? [1] : [] content { port = "443" protocol = "HTTPS" status_code = "HTTP_301" } } } tags = local.common_tags } ================================================ FILE: terraform/aws-ecs/keycloak-database.tf ================================================ # # Keycloak Aurora MySQL Database (Serverless v2) # # RDS Proxy for connection pooling resource "aws_db_proxy" "keycloak" { name = "keycloak-proxy" engine_family = "MYSQL" auth { auth_scheme = "SECRETS" secret_arn = aws_secretsmanager_secret.keycloak_db_secret.arn client_password_auth_type = "MYSQL_CACHING_SHA2_PASSWORD" iam_auth = "DISABLED" } role_arn = aws_iam_role.rds_proxy_role.arn vpc_subnet_ids = module.vpc.private_subnets vpc_security_group_ids = [aws_security_group.keycloak_db.id] require_tls = false tags = local.common_tags depends_on = [ aws_secretsmanager_secret_version.keycloak_db_secret ] } # RDS Proxy Target resource "aws_db_proxy_target" "keycloak" { db_proxy_name = aws_db_proxy.keycloak.name target_group_name = "default" db_cluster_identifier = aws_rds_cluster.keycloak.cluster_identifier depends_on = [ aws_rds_cluster_instance.keycloak ] } # Aurora MySQL Serverless v2 Cluster #checkov:skip=CKV_AWS_139:Deletion protection configured per environment #checkov:skip=CKV_AWS_162:IAM database authentication not used - Keycloak uses password auth #checkov:skip=CKV_AWS_324:CloudWatch log exports not enabled for Keycloak database - log volume is low and Keycloak application logs provide sufficient observability #checkov:skip=CKV_AWS_325:Preferred backup window is configured on this resource #checkov:skip=CKV_AWS_326:Serverless v2 scaling configuration is present on this resource #checkov:skip=CKV2_AWS_8:Backup retention period of 7 days is configured on this resource resource "aws_rds_cluster" "keycloak" { cluster_identifier = "keycloak" engine = "aurora-mysql" engine_version = "8.0.mysql_aurora.3.10.3" database_name = "keycloak" master_username = var.keycloak_database_username master_password = var.keycloak_database_password db_subnet_group_name = aws_db_subnet_group.keycloak.name db_cluster_parameter_group_name = aws_rds_cluster_parameter_group.keycloak.name vpc_security_group_ids = [aws_security_group.keycloak_db.id] # Backup and maintenance backup_retention_period = 7 preferred_backup_window = "02:00-04:00" preferred_maintenance_window = "sun:04:00-sun:05:00" copy_tags_to_snapshot = true # Encryption storage_encrypted = true kms_key_id = aws_kms_key.rds.arn # Deletion protection deletion_protection = false skip_final_snapshot = true # Serverless v2 scaling serverlessv2_scaling_configuration { max_capacity = var.keycloak_database_max_acu min_capacity = var.keycloak_database_min_acu } tags = local.common_tags } # Aurora Cluster Instance (Serverless v2) #checkov:skip=CKV_AWS_118:Enhanced monitoring configured per environment requirements #checkov:skip=CKV_AWS_353:Performance insights configured per environment requirements resource "aws_rds_cluster_instance" "keycloak" { cluster_identifier = aws_rds_cluster.keycloak.id instance_class = "db.serverless" engine = aws_rds_cluster.keycloak.engine engine_version = aws_rds_cluster.keycloak.engine_version auto_minor_version_upgrade = true performance_insights_enabled = false tags = local.common_tags } # DB Subnet Group resource "aws_db_subnet_group" "keycloak" { name = "keycloak-subnet-group" subnet_ids = module.vpc.private_subnets tags = merge( local.common_tags, { Name = "keycloak-subnet-group" } ) } # RDS Cluster Parameter Group resource "aws_rds_cluster_parameter_group" "keycloak" { family = "aurora-mysql8.0" name = "keycloak-params" description = "Keycloak Aurora MySQL parameter group" parameter { name = "character_set_server" value = "utf8mb4" } parameter { name = "collation_server" value = "utf8mb4_unicode_ci" } tags = local.common_tags } # KMS Key for RDS Encryption resource "aws_kms_key" "rds" { description = "KMS key for RDS and secrets encryption" deletion_window_in_days = 7 enable_key_rotation = true policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "Enable IAM User Permissions" Effect = "Allow" Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" } Action = "kms:*" Resource = "*" }, { Sid = "Allow ECS Task Execution Role to Decrypt" Effect = "Allow" Principal = { AWS = "*" } Action = [ "kms:Decrypt", "kms:DescribeKey" ] Resource = "*" Condition = { StringEquals = { "aws:PrincipalAccount" = data.aws_caller_identity.current.account_id } StringLike = { "aws:PrincipalArn" = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/*task-exec*" } } }, { Sid = "Allow RDS Service" Effect = "Allow" Principal = { Service = "rds.amazonaws.com" } Action = [ "kms:Decrypt", "kms:DescribeKey", "kms:CreateGrant" ] Resource = "*" Condition = { StringEquals = { "kms:ViaService" = "rds.${data.aws_region.current.name}.amazonaws.com" } } }, { Sid = "Allow CloudWatch Logs" Effect = "Allow" Principal = { Service = "logs.${data.aws_region.current.name}.amazonaws.com" } Action = [ "kms:Encrypt", "kms:Decrypt", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:CreateGrant", "kms:DescribeKey" ] Resource = "*" Condition = { ArnLike = { "kms:EncryptionContext:aws:logs:arn" = "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:*" } } } ] }) tags = local.common_tags } resource "aws_kms_alias" "rds" { name = "alias/keycloak-rds" target_key_id = aws_kms_key.rds.key_id } # IAM Role for RDS Proxy resource "aws_iam_role" "rds_proxy_role" { name = "keycloak-rds-proxy-role-${var.aws_region}" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "rds.amazonaws.com" } } ] }) tags = local.common_tags } # IAM Policy for RDS Proxy resource "aws_iam_role_policy" "rds_proxy_policy" { name = "keycloak-rds-proxy-policy" role = aws_iam_role.rds_proxy_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "secretsmanager:GetSecretValue" ] Resource = aws_secretsmanager_secret.keycloak_db_secret.arn } ] }) } # Secrets Manager Secret for Database Credentials #checkov:skip=CKV2_AWS_57:Secret rotation managed externally via dedicated rotation Lambda resource "aws_secretsmanager_secret" "keycloak_db_secret" { name = "keycloak/database" description = "Keycloak database credentials" recovery_window_in_days = 0 kms_key_id = aws_kms_key.rds.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "keycloak_db_secret" { secret_id = aws_secretsmanager_secret.keycloak_db_secret.id secret_string = jsonencode({ username = var.keycloak_database_username password = var.keycloak_database_password }) } # SSM Parameters for Database Connection resource "aws_ssm_parameter" "keycloak_database_url" { name = "/keycloak/database/url" type = "SecureString" key_id = aws_kms_key.rds.id value = "jdbc:mysql://${aws_rds_cluster.keycloak.endpoint}:3306/keycloak" tags = local.common_tags } resource "aws_ssm_parameter" "keycloak_database_username" { name = "/keycloak/database/username" type = "SecureString" key_id = aws_kms_key.rds.id value = var.keycloak_database_username tags = local.common_tags } resource "aws_ssm_parameter" "keycloak_database_password" { name = "/keycloak/database/password" type = "SecureString" key_id = aws_kms_key.rds.id value = var.keycloak_database_password tags = local.common_tags } ================================================ FILE: terraform/aws-ecs/keycloak-dns.tf ================================================ # # Keycloak DNS and SSL Certificate # # These resources are only created when enable_route53_dns = true # # Use existing hosted zone for the root domain data "aws_route53_zone" "root" { count = var.enable_route53_dns ? 1 : 0 name = local.hosted_zone_domain private_zone = false } # Create SSL certificate for Keycloak domain resource "aws_acm_certificate" "keycloak" { count = var.enable_route53_dns ? 1 : 0 domain_name = local.keycloak_domain validation_method = "DNS" tags = merge( local.common_tags, { Name = "keycloak-cert" } ) lifecycle { create_before_destroy = true } } # Create DNS validation records resource "aws_route53_record" "keycloak_certificate_validation" { for_each = var.enable_route53_dns ? { for dvo in aws_acm_certificate.keycloak[0].domain_validation_options : dvo.domain_name => { name = dvo.resource_record_name record = dvo.resource_record_value type = dvo.resource_record_type } } : {} allow_overwrite = true name = each.value.name records = [each.value.record] ttl = 60 type = each.value.type zone_id = data.aws_route53_zone.root[0].zone_id } # Wait for certificate validation resource "aws_acm_certificate_validation" "keycloak" { count = var.enable_route53_dns ? 1 : 0 certificate_arn = aws_acm_certificate.keycloak[0].arn timeouts { create = "5m" } validation_record_fqdns = [for record in aws_route53_record.keycloak_certificate_validation : record.fqdn] } # Create A record for Keycloak subdomain # Points to CloudFront when both CloudFront and Route53 are enabled (Mode 3) # Points to ALB when only Route53 is enabled (Mode 2) resource "aws_route53_record" "keycloak" { count = var.enable_route53_dns ? 1 : 0 zone_id = data.aws_route53_zone.root[0].zone_id name = local.keycloak_domain type = "A" alias { # Mode 3: Route53 → CloudFront (when both enabled) # Mode 2: Route53 → ALB (when only Route53 enabled) name = var.enable_cloudfront ? aws_cloudfront_distribution.keycloak[0].domain_name : aws_lb.keycloak.dns_name zone_id = var.enable_cloudfront ? aws_cloudfront_distribution.keycloak[0].hosted_zone_id : aws_lb.keycloak.zone_id evaluate_target_health = true } } ================================================ FILE: terraform/aws-ecs/keycloak-ecr.tf ================================================ # # Keycloak ECR Repository # #checkov:skip=CKV_AWS_51:Mutable tags required for latest tag workflow in CI/CD pipeline resource "aws_ecr_repository" "keycloak" { name = "keycloak" image_tag_mutability = "MUTABLE" force_delete = true image_scanning_configuration { scan_on_push = true } tags = merge( local.common_tags, { Name = "keycloak" } ) } # ECR Lifecycle Policy resource "aws_ecr_lifecycle_policy" "keycloak" { repository = aws_ecr_repository.keycloak.name policy = jsonencode({ rules = [ { rulePriority = 10 description = "Keep last 10 git SHA tagged images" selection = { tagStatus = "tagged" tagPrefixList = ["sha-"] countType = "imageCountMoreThan" countNumber = 10 } action = { type = "expire" } }, { rulePriority = 20 description = "Expire untagged images older than 7 days" selection = { tagStatus = "untagged" countType = "sinceImagePushed" countUnit = "days" countNumber = 7 } action = { type = "expire" } } ] }) } # ECR Repository Policy (allow ECS to pull images) resource "aws_ecr_repository_policy" "keycloak" { repository = aws_ecr_repository.keycloak.name policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "AllowECSPull" Effect = "Allow" Principal = { Service = "ecs-tasks.amazonaws.com" } Action = [ "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability" ] }, { Sid = "AllowPush" Effect = "Allow" Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" } Action = [ "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability", "ecr:PutImage", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart", "ecr:CompleteLayerUpload" ] } ] }) } # Data source for current AWS account data "aws_caller_identity" "current" {} ================================================ FILE: terraform/aws-ecs/keycloak-ecs.tf ================================================ # # Keycloak ECS Service # locals { # Determine Keycloak hostname based on deployment mode # CloudFront mode: use CloudFront domain, Custom DNS mode: use keycloak_domain keycloak_hostname = var.enable_cloudfront && !var.enable_route53_dns ? ( var.enable_cloudfront ? aws_cloudfront_distribution.keycloak[0].domain_name : local.keycloak_domain ) : local.keycloak_domain # Full HTTPS URL for Keycloak (required for KC_HOSTNAME_URL and KC_HOSTNAME_ADMIN_URL) keycloak_hostname_url = "https://${local.keycloak_hostname}" keycloak_container_env = [ { name = "AWS_REGION" value = var.aws_region }, { name = "KC_PROXY" value = "edge" }, { name = "KC_PROXY_ADDRESS_FORWARDING" value = "true" }, { # KC_HOSTNAME_URL tells Keycloak the full external URL including protocol # This is required for CloudFront mode where Keycloak needs to know it's behind HTTPS name = "KC_HOSTNAME_URL" value = local.keycloak_hostname_url }, { # KC_HOSTNAME_ADMIN_URL for admin console access name = "KC_HOSTNAME_ADMIN_URL" value = local.keycloak_hostname_url }, { name = "KC_HOSTNAME_STRICT" value = "false" }, { # HTTPS strict mode - Keycloak will require HTTPS for all requests name = "KC_HOSTNAME_STRICT_HTTPS" value = "true" }, { name = "KC_HEALTH_ENABLED" value = "true" }, { name = "KC_METRICS_ENABLED" value = "true" }, { name = "KEYCLOAK_LOGLEVEL" value = var.keycloak_log_level } ] keycloak_container_secrets = [ { name = "KEYCLOAK_ADMIN" valueFrom = aws_ssm_parameter.keycloak_admin.arn }, { name = "KEYCLOAK_ADMIN_PASSWORD" valueFrom = aws_ssm_parameter.keycloak_admin_password.arn }, { name = "KC_DB_URL" valueFrom = aws_ssm_parameter.keycloak_database_url.arn }, { name = "KC_DB_USERNAME" valueFrom = aws_ssm_parameter.keycloak_database_username.arn }, { name = "KC_DB_PASSWORD" valueFrom = aws_ssm_parameter.keycloak_database_password.arn } ] } # ECS Cluster for Keycloak resource "aws_ecs_cluster" "keycloak" { name = "keycloak" setting { name = "containerInsights" value = "enabled" } tags = local.common_tags } resource "aws_ecs_cluster_capacity_providers" "keycloak" { cluster_name = aws_ecs_cluster.keycloak.name capacity_providers = ["FARGATE", "FARGATE_SPOT"] default_capacity_provider_strategy { base = 1 weight = 100 capacity_provider = "FARGATE" } } # CloudWatch Log Group #checkov:skip=CKV_AWS_158:KMS encryption for CloudWatch logs not required in this deployment resource "aws_cloudwatch_log_group" "keycloak" { name = "/ecs/keycloak" retention_in_days = 7 tags = local.common_tags } # ECS Task Execution Role resource "aws_iam_role" "keycloak_task_exec_role" { name = "keycloak-task-exec-role-${var.aws_region}" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "ecs-tasks.amazonaws.com" } } ] }) tags = local.common_tags } # Attach default ECS task execution policy resource "aws_iam_role_policy_attachment" "keycloak_task_exec_role_policy" { role = aws_iam_role.keycloak_task_exec_role.name policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" } # Policy to read from SSM Parameter Store #checkov:skip=CKV_AWS_290:kms:Decrypt requires wildcard resource as KMS key ARN is determined at runtime by SSM #checkov:skip=CKV_AWS_355:kms:Decrypt requires wildcard resource as KMS key ARN is determined at runtime by SSM resource "aws_iam_role_policy" "keycloak_task_exec_ssm_policy" { name = "keycloak-task-exec-ssm-policy" role = aws_iam_role.keycloak_task_exec_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "ssm:GetParameter", "ssm:GetParameters" ] Resource = [ aws_ssm_parameter.keycloak_admin.arn, aws_ssm_parameter.keycloak_admin_password.arn, aws_ssm_parameter.keycloak_database_url.arn, aws_ssm_parameter.keycloak_database_username.arn, aws_ssm_parameter.keycloak_database_password.arn ] }, { Effect = "Allow" Action = [ "kms:Decrypt" ] Resource = "*" } ] }) } # Policy to write logs to CloudWatch resource "aws_iam_role_policy" "keycloak_task_exec_logs_policy" { name = "keycloak-task-exec-logs-policy" role = aws_iam_role.keycloak_task_exec_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "logs:CreateLogStream", "logs:PutLogEvents" ] Resource = "${aws_cloudwatch_log_group.keycloak.arn}:*" } ] }) } # ECS Task Role resource "aws_iam_role" "keycloak_task_role" { name = "keycloak-task-role-${var.aws_region}" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "ecs-tasks.amazonaws.com" } } ] }) tags = local.common_tags } # Policy for SSM Session Manager #checkov:skip=CKV_AWS_290:SSM Session Manager actions require wildcard resource per AWS documentation #checkov:skip=CKV_AWS_355:SSM Session Manager actions require wildcard resource per AWS documentation #checkov:skip=CKV_AWS_336:ECS Exec requires ssmmessages permissions which cannot be scoped to specific resources resource "aws_iam_role_policy" "keycloak_task_ssm_policy" { name = "keycloak-task-ssm-policy" role = aws_iam_role.keycloak_task_role.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "ssmmessages:CreateControlChannel", "ssmmessages:CreateDataChannel", "ssmmessages:OpenControlChannel", "ssmmessages:OpenDataChannel" ] Resource = "*" } ] }) } # ECS Task Definition resource "aws_ecs_task_definition" "keycloak" { family = "keycloak" network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] cpu = "1024" memory = "2048" execution_role_arn = aws_iam_role.keycloak_task_exec_role.arn task_role_arn = aws_iam_role.keycloak_task_role.arn container_definitions = jsonencode([ { name = "keycloak" image = "${aws_ecr_repository.keycloak.repository_url}:latest" versionConsistency = "disabled" essential = true portMappings = [ { name = "keycloak" containerPort = 8080 hostPort = 8080 protocol = "tcp" }, { name = "keycloak-management" containerPort = 9000 hostPort = 9000 protocol = "tcp" } ] environment = local.keycloak_container_env secrets = local.keycloak_container_secrets logConfiguration = { logDriver = "awslogs" options = { "awslogs-group" = aws_cloudwatch_log_group.keycloak.name "awslogs-region" = var.aws_region "awslogs-stream-prefix" = "ecs" } } readonlyRootFilesystem = false healthCheck = { command = ["CMD-SHELL", "exit 0"] interval = 30 timeout = 5 retries = 3 startPeriod = 60 } } ]) tags = local.common_tags } # ECS Service resource "aws_ecs_service" "keycloak" { name = "keycloak" cluster = aws_ecs_cluster.keycloak.id task_definition = aws_ecs_task_definition.keycloak.arn desired_count = 1 launch_type = "FARGATE" network_configuration { subnets = module.vpc.private_subnets security_groups = [aws_security_group.keycloak_ecs.id] assign_public_ip = false } load_balancer { target_group_arn = aws_lb_target_group.keycloak.arn container_name = "keycloak" container_port = 8080 } depends_on = [ aws_lb_listener.keycloak_https, aws_iam_role_policy.keycloak_task_exec_ssm_policy, aws_iam_role_policy.keycloak_task_exec_logs_policy ] tags = local.common_tags } # Auto Scaling Target resource "aws_appautoscaling_target" "keycloak" { max_capacity = 4 min_capacity = 1 resource_id = "service/${aws_ecs_cluster.keycloak.name}/${aws_ecs_service.keycloak.name}" scalable_dimension = "ecs:service:DesiredCount" service_namespace = "ecs" tags = local.common_tags } # Auto Scaling Policy - CPU resource "aws_appautoscaling_policy" "keycloak_cpu" { name = "keycloak-cpu-autoscaling" policy_type = "TargetTrackingScaling" resource_id = aws_appautoscaling_target.keycloak.resource_id scalable_dimension = aws_appautoscaling_target.keycloak.scalable_dimension service_namespace = aws_appautoscaling_target.keycloak.service_namespace target_tracking_scaling_policy_configuration { predefined_metric_specification { predefined_metric_type = "ECSServiceAverageCPUUtilization" } target_value = 70.0 } } # Auto Scaling Policy - Memory resource "aws_appautoscaling_policy" "keycloak_memory" { name = "keycloak-memory-autoscaling" policy_type = "TargetTrackingScaling" resource_id = aws_appautoscaling_target.keycloak.resource_id scalable_dimension = aws_appautoscaling_target.keycloak.scalable_dimension service_namespace = aws_appautoscaling_target.keycloak.service_namespace target_tracking_scaling_policy_configuration { predefined_metric_specification { predefined_metric_type = "ECSServiceAverageMemoryUtilization" } target_value = 80.0 } } # SSM Parameters for Keycloak Credentials resource "aws_ssm_parameter" "keycloak_admin" { name = "/keycloak/admin" type = "SecureString" key_id = aws_kms_key.rds.id value = var.keycloak_admin tags = local.common_tags } resource "aws_ssm_parameter" "keycloak_admin_password" { name = "/keycloak/admin_password" type = "SecureString" key_id = aws_kms_key.rds.id value = var.keycloak_admin_password tags = local.common_tags } ================================================ FILE: terraform/aws-ecs/keycloak-security-groups.tf ================================================ # # Keycloak Security Groups # # ECS Security Group resource "aws_security_group" "keycloak_ecs" { name = "keycloak-ecs" description = "Security group for Keycloak ECS tasks" vpc_id = module.vpc.vpc_id tags = merge( local.common_tags, { Name = "keycloak-ecs" } ) } # ECS Egress to Internet (HTTPS) resource "aws_security_group_rule" "keycloak_ecs_egress_internet" { description = "Egress from Keycloak ECS task to internet (HTTPS)" type = "egress" from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.keycloak_ecs.id } # ECS Egress to DNS resource "aws_security_group_rule" "keycloak_ecs_egress_dns" { description = "Egress from Keycloak ECS task for DNS" type = "egress" from_port = 53 to_port = 53 protocol = "udp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.keycloak_ecs.id } # ECS Egress to Database resource "aws_security_group_rule" "keycloak_ecs_egress_db" { description = "Egress from Keycloak ECS task to database" type = "egress" from_port = 3306 to_port = 3306 protocol = "tcp" security_group_id = aws_security_group.keycloak_ecs.id source_security_group_id = aws_security_group.keycloak_db.id } # ECS Ingress from Load Balancer resource "aws_security_group_rule" "keycloak_ecs_ingress_lb" { description = "Ingress from load balancer to Keycloak ECS task" type = "ingress" from_port = 8080 to_port = 8080 protocol = "tcp" security_group_id = aws_security_group.keycloak_ecs.id source_security_group_id = aws_security_group.keycloak_lb.id } # ECS Ingress from CloudFront Load Balancer SG (when CloudFront is enabled) resource "aws_security_group_rule" "keycloak_ecs_ingress_lb_cloudfront" { count = local.cloudfront_prefix_list_name != "" ? 1 : 0 description = "Ingress from CloudFront LB security group to Keycloak ECS task" type = "ingress" from_port = 8080 to_port = 8080 protocol = "tcp" security_group_id = aws_security_group.keycloak_ecs.id source_security_group_id = aws_security_group.keycloak_lb_cloudfront[0].id } # Load Balancer Security Group resource "aws_security_group" "keycloak_lb" { name = "keycloak-lb" description = "Security group for Keycloak load balancer" vpc_id = module.vpc.vpc_id tags = merge( local.common_tags, { Name = "keycloak-lb" } ) } # Load Balancer Ingress from allowed CIDR blocks (HTTP) #checkov:skip=CKV_AWS_260:HTTP ingress is intentional - ALB redirects to HTTPS or CloudFront terminates TLS resource "aws_security_group_rule" "keycloak_lb_ingress_http" { description = "Ingress from allowed CIDR blocks to load balancer (HTTP)" type = "ingress" from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = var.ingress_cidr_blocks security_group_id = aws_security_group.keycloak_lb.id } # Load Balancer Ingress from prefix list (HTTP) - optional, for CloudFront or other CDN # Default prefix list is AWS CloudFront origin-facing IPs (com.amazonaws.global.cloudfront.origin-facing) # CloudFront terminates HTTPS and connects to ALB over HTTP # Note: CloudFront prefix list has ~45 entries which count against SG rules limit, # so we create a separate security group to avoid hitting the 60 rules/SG limit data "aws_ec2_managed_prefix_list" "cloudfront" { count = local.cloudfront_prefix_list_name != "" ? 1 : 0 name = local.cloudfront_prefix_list_name } resource "aws_security_group" "keycloak_lb_cloudfront" { count = local.cloudfront_prefix_list_name != "" ? 1 : 0 name = "keycloak-lb-cloudfront" description = "Security group for CloudFront access to Keycloak ALB" vpc_id = module.vpc.vpc_id tags = merge( local.common_tags, { Name = "keycloak-lb-cloudfront" } ) } resource "aws_security_group_rule" "keycloak_lb_cloudfront_ingress" { count = local.cloudfront_prefix_list_name != "" ? 1 : 0 description = "Ingress from prefix list to load balancer (HTTP) - default: CloudFront origin-facing IPs" type = "ingress" from_port = 80 to_port = 80 protocol = "tcp" prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront[0].id] security_group_id = aws_security_group.keycloak_lb_cloudfront[0].id } resource "aws_security_group_rule" "keycloak_lb_cloudfront_egress" { count = local.cloudfront_prefix_list_name != "" ? 1 : 0 description = "Egress from CloudFront SG to Keycloak ECS task" type = "egress" from_port = 8080 to_port = 8080 protocol = "tcp" security_group_id = aws_security_group.keycloak_lb_cloudfront[0].id source_security_group_id = aws_security_group.keycloak_ecs.id } # Load Balancer Ingress from allowed CIDR blocks (HTTPS) resource "aws_security_group_rule" "keycloak_lb_ingress_https" { description = "Ingress from allowed CIDR blocks to load balancer (HTTPS)" type = "ingress" from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = var.ingress_cidr_blocks security_group_id = aws_security_group.keycloak_lb.id } # Load Balancer Ingress from MCP Gateway Auth Server (HTTPS) # Note: This rule is for direct VPC traffic. For traffic via NAT gateway, # see keycloak_lb_ingress_nat_gateway rule below. resource "aws_security_group_rule" "keycloak_lb_ingress_auth_server" { description = "Ingress from MCP Gateway Auth Server to Keycloak load balancer (HTTPS)" type = "ingress" from_port = 443 to_port = 443 protocol = "tcp" security_group_id = aws_security_group.keycloak_lb.id source_security_group_id = module.mcp_gateway.ecs_security_group_ids.auth } # Load Balancer Ingress from NAT Gateways (for ECS tasks making HTTPS requests to Keycloak public URL) # When ECS tasks in private subnets call Keycloak's public DNS name, traffic goes through NAT gateway. # The source IP becomes the NAT gateway's public IP, not the ECS task's security group. resource "aws_security_group_rule" "keycloak_lb_ingress_nat_gateway" { description = "Ingress from NAT gateways to Keycloak load balancer (HTTPS)" type = "ingress" from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = [for ip in module.vpc.nat_public_ips : "${ip}/32"] security_group_id = aws_security_group.keycloak_lb.id } # Load Balancer Ingress from MCP Gateway Registry (HTTPS) resource "aws_security_group_rule" "keycloak_lb_ingress_registry" { description = "Ingress from MCP Gateway Registry to Keycloak load balancer (HTTPS)" type = "ingress" from_port = 443 to_port = 443 protocol = "tcp" security_group_id = aws_security_group.keycloak_lb.id source_security_group_id = module.mcp_gateway.ecs_security_group_ids.registry } # Load Balancer Egress to ECS resource "aws_security_group_rule" "keycloak_lb_egress_ecs" { description = "Egress from load balancer to Keycloak ECS task" type = "egress" from_port = 8080 to_port = 8080 protocol = "tcp" security_group_id = aws_security_group.keycloak_lb.id source_security_group_id = aws_security_group.keycloak_ecs.id } # Database Security Group resource "aws_security_group" "keycloak_db" { name = "keycloak-db" description = "Security group for Keycloak database" vpc_id = module.vpc.vpc_id tags = merge( local.common_tags, { Name = "keycloak-db" } ) } # Database Ingress from ECS resource "aws_security_group_rule" "keycloak_db_ingress_ecs" { description = "Ingress to database from Keycloak ECS task" type = "ingress" from_port = 3306 to_port = 3306 protocol = "tcp" security_group_id = aws_security_group.keycloak_db.id source_security_group_id = aws_security_group.keycloak_ecs.id } # Database Ingress from RDS Proxy resource "aws_security_group_rule" "keycloak_db_ingress_proxy" { description = "Ingress to database from RDS Proxy" type = "ingress" from_port = 3306 to_port = 3306 protocol = "tcp" security_group_id = aws_security_group.keycloak_db.id source_security_group_id = aws_security_group.keycloak_db.id } ================================================ FILE: terraform/aws-ecs/lambda/README.md ================================================ # AWS Lambda Functions for Secret Rotation This directory contains Lambda functions that implement AWS Secrets Manager rotation protocol for database credentials. ## Overview The Lambda functions implement the standard AWS Secrets Manager rotation process: 1. **createSecret**: Generate a new random password and create an AWSPENDING version 2. **setSecret**: Update the database with the new password 3. **testSecret**: Verify the new password works 4. **finishSecret**: Promote AWSPENDING to AWSCURRENT ## Functions ### rotate-documentdb/ Rotates DocumentDB cluster master password. **Files:** - `index.py`: Main Lambda handler implementing 4-step rotation - `requirements.txt`: Python dependencies (boto3) **Environment Variables:** - `SECRETS_MANAGER_ENDPOINT`: Secrets Manager API endpoint - `EXCLUDE_CHARACTERS`: Characters to exclude from generated passwords (default: `/@"'\`) **IAM Permissions Required:** - `secretsmanager:DescribeSecret` - `secretsmanager:GetSecretValue` - `secretsmanager:PutSecretValue` - `secretsmanager:UpdateSecretVersionStage` - `secretsmanager:GetRandomPassword` - `kms:Decrypt` - `kms:GenerateDataKey` - `docdb:DescribeDBClusters` - `docdb:ModifyDBCluster` - VPC networking permissions for private subnet access **Network Configuration:** - Deployed in VPC private subnets - Security group allows egress to DocumentDB on port 27017 - Security group allows egress to HTTPS (443) for AWS API calls ### rotate-rds/ Rotates RDS Aurora MySQL cluster master password (Keycloak database). **Files:** - `index.py`: Main Lambda handler implementing 4-step rotation - `requirements.txt`: Python dependencies (boto3) **Environment Variables:** - `SECRETS_MANAGER_ENDPOINT`: Secrets Manager API endpoint - `EXCLUDE_CHARACTERS`: Characters to exclude from generated passwords (default: `/@"'\`) **IAM Permissions Required:** - `secretsmanager:DescribeSecret` - `secretsmanager:GetSecretValue` - `secretsmanager:PutSecretValue` - `secretsmanager:UpdateSecretVersionStage` - `secretsmanager:GetRandomPassword` - `kms:Decrypt` - `kms:GenerateDataKey` - `rds:DescribeDBClusters` - `rds:ModifyDBCluster` - VPC networking permissions for private subnet access **Network Configuration:** - Deployed in VPC private subnets - Security group allows egress to RDS on port 3306 - Security group allows egress to HTTPS (443) for AWS API calls ## Secret Format ### DocumentDB Secret ```json { "username": "admin", "password": "randomly-generated-32-char-password", "engine": "docdb", "cluster_id": "mcp-gateway-registry" } ``` ### RDS Secret ```json { "username": "keycloak", "password": "randomly-generated-32-char-password", "cluster_id": "keycloak" } ``` ## Deployment The Lambda functions are automatically deployed via Terraform: ```hcl # Deploy from terraform/aws-ecs/ terraform init terraform plan terraform apply ``` The deployment process: 1. Creates ZIP archives from Lambda source code 2. Uploads Lambda functions to AWS 3. Configures IAM roles and policies 4. Sets up VPC networking and security groups 5. Enables rotation on secrets with 30-day interval ## Rotation Schedule Secrets are automatically rotated every 30 days. You can also trigger manual rotation: ```bash aws secretsmanager rotate-secret --secret-id ``` ## Monitoring Lambda execution logs are sent to CloudWatch Logs: - `/aws/lambda/mcp-gateway-rotate-documentdb` - `/aws/lambda/mcp-gateway-rotate-rds` Log retention: 30 days ## Testing To test rotation without waiting 30 days: ```bash # Rotate DocumentDB secret aws secretsmanager rotate-secret --secret-id mcp-gateway/documentdb/credentials # Rotate RDS secret aws secretsmanager rotate-secret --secret-id keycloak/database ``` Monitor the rotation: ```bash # Check secret status aws secretsmanager describe-secret --secret-id # View Lambda logs aws logs tail /aws/lambda/mcp-gateway-rotate-documentdb --follow aws logs tail /aws/lambda/mcp-gateway-rotate-rds --follow ``` ## Security Considerations 1. **Password Complexity**: 32 characters, alphanumeric + special characters 2. **Excluded Characters**: `/@"'\` to avoid shell/SQL escaping issues 3. **Encryption**: Secrets encrypted with KMS customer-managed keys 4. **Network Isolation**: Lambda functions run in private subnets only 5. **Least Privilege**: IAM roles grant only required permissions 6. **Audit Trail**: All rotations logged to CloudWatch ## Troubleshooting ### Rotation Fails at setSecret Step Check: - Lambda has network access to database (security groups) - Database cluster is in `available` state - IAM role has `ModifyDBCluster` permission ### Rotation Fails at testSecret Step Check: - Database cluster status after password change - CloudWatch Logs for detailed error messages ### Lambda Timeout Default timeout: 300 seconds (5 minutes) If rotation takes longer: 1. Check database cluster is not under heavy load 2. Verify network latency between Lambda and database 3. Review CloudWatch Logs for bottlenecks ## References - [AWS Secrets Manager Rotation](https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets.html) - [DocumentDB Security](https://docs.aws.amazon.com/documentdb/latest/developerguide/security.html) - [RDS Aurora Security](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/UsingWithRDS.html) - [Lambda VPC Configuration](https://docs.aws.amazon.com/lambda/latest/dg/configuration-vpc.html) ================================================ FILE: terraform/aws-ecs/lambda/rotate-documentdb/index.py ================================================ """ AWS Secrets Manager Rotation Handler for DocumentDB This Lambda function implements the AWS Secrets Manager rotation protocol for DocumentDB credentials. It rotates the master password following AWS best practices for secret rotation. Rotation Steps: 1. createSecret: Generate new random password and create AWSPENDING version 2. setSecret: Update DocumentDB cluster with new password 3. testSecret: Verify connection with new password 4. finishSecret: Move AWSCURRENT to AWSPREVIOUS and AWSPENDING to AWSCURRENT References: - https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets.html - https://docs.aws.amazon.com/documentdb/latest/developerguide/security.html """ import json import logging import os import boto3 from botocore.exceptions import ClientError logger = logging.getLogger() logger.setLevel(logging.INFO) secretsmanager = boto3.client("secretsmanager") docdb = boto3.client("docdb") def lambda_handler(event: dict, context: dict) -> dict: """ Lambda handler for DocumentDB secret rotation. Args: event: Lambda event containing SecretId, ClientRequestToken, and Step context: Lambda context object Returns: Success response dict Raises: ValueError: If rotation is not enabled or step is invalid ClientError: If AWS API calls fail """ arn = event["SecretId"] token = event["ClientRequestToken"] step = event["Step"] logger.info(f"Processing rotation step: {step} for secret: {arn}") metadata = secretsmanager.describe_secret(SecretId=arn) if not metadata.get("RotationEnabled"): error_msg = f"Secret {arn} is not enabled for rotation" logger.error(error_msg) raise ValueError(error_msg) if step == "createSecret": _create_secret(arn, token) elif step == "setSecret": _set_secret(arn, token) elif step == "testSecret": _test_secret(arn, token) elif step == "finishSecret": _finish_secret(arn, token) else: error_msg = f"Invalid step parameter: {step}" logger.error(error_msg) raise ValueError(error_msg) logger.info(f"Successfully completed rotation step: {step}") return {"statusCode": 200, "body": json.dumps("Success")} def _create_secret(arn: str, token: str) -> None: """ Generate new password and create AWSPENDING version. Args: arn: Secret ARN token: Client request token for this rotation """ logger.info("Step 1: Creating new secret version") current = secretsmanager.get_secret_value(SecretId=arn, VersionStage="AWSCURRENT") current_dict = json.loads(current["SecretString"]) try: secretsmanager.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING") logger.info("AWSPENDING version already exists, skipping creation") return except ClientError as e: if e.response["Error"]["Code"] != "ResourceNotFoundException": raise exclude_chars = os.environ.get("EXCLUDE_CHARACTERS", "/@\"'\\") logger.info(f"Generating new password (excluding: {exclude_chars})") passwd = secretsmanager.get_random_password(ExcludeCharacters=exclude_chars, PasswordLength=32) current_dict["password"] = passwd["RandomPassword"] secretsmanager.put_secret_value( SecretId=arn, ClientRequestToken=token, SecretString=json.dumps(current_dict), VersionStages=["AWSPENDING"], ) logger.info("Successfully created AWSPENDING version with new password") def _set_secret(arn: str, token: str) -> None: """ Update DocumentDB cluster with new password. Args: arn: Secret ARN token: Client request token for this rotation """ logger.info("Step 2: Setting new password in DocumentDB") pending = secretsmanager.get_secret_value( SecretId=arn, VersionId=token, VersionStage="AWSPENDING" ) pending_dict = json.loads(pending["SecretString"]) metadata = secretsmanager.describe_secret(SecretId=arn) secret_name = metadata["Name"] cluster_id = pending_dict.get("cluster_id") if not cluster_id: logger.info("No cluster_id in secret, attempting to derive from name") parts = secret_name.split("/") if len(parts) >= 2: cluster_id = f"{parts[0]}-registry" logger.info(f"Derived cluster_id: {cluster_id}") else: error_msg = "Cannot determine DocumentDB cluster ID from secret name structure" logger.error(error_msg) raise ValueError(error_msg) logger.info(f"Updating DocumentDB cluster: {cluster_id}") try: docdb.modify_db_cluster( DBClusterIdentifier=cluster_id, MasterUserPassword=pending_dict["password"], ApplyImmediately=True, ) logger.info("Successfully updated DocumentDB master password") except ClientError as e: logger.error(f"Failed to update DocumentDB password: {e}") raise def _test_secret(arn: str, token: str) -> None: """ Test new password by verifying cluster status. Note: We cannot easily test DocumentDB connection from Lambda without installing pymongo and SSL certificates. Instead, we verify the cluster is available and modification was successful. Args: arn: Secret ARN token: Client request token for this rotation """ logger.info("Step 3: Testing new secret") pending = secretsmanager.get_secret_value( SecretId=arn, VersionId=token, VersionStage="AWSPENDING" ) pending_dict = json.loads(pending["SecretString"]) metadata = secretsmanager.describe_secret(SecretId=arn) secret_name = metadata["Name"] cluster_id = pending_dict.get("cluster_id") if not cluster_id: parts = secret_name.split("/") if len(parts) >= 2: cluster_id = f"{parts[0]}-registry" else: error_msg = "Cannot determine DocumentDB cluster ID from secret name structure" logger.error(error_msg) raise ValueError(error_msg) try: response = docdb.describe_db_clusters(DBClusterIdentifier=cluster_id) cluster = response["DBClusters"][0] status = cluster["Status"] logger.info(f"DocumentDB cluster status: {status}") if status not in ["available", "modifying"]: error_msg = f"DocumentDB cluster in unexpected state: {status}" logger.error(error_msg) raise ValueError(error_msg) logger.info("Successfully verified DocumentDB cluster status") except ClientError as e: logger.error(f"Failed to verify DocumentDB cluster: {e}") raise def _finish_secret(arn: str, token: str) -> None: """ Move AWSCURRENT to AWSPREVIOUS and AWSPENDING to AWSCURRENT. Args: arn: Secret ARN token: Client request token for this rotation """ logger.info("Step 4: Finishing rotation") metadata = secretsmanager.describe_secret(SecretId=arn) current_version = None for version_id, stages in metadata["VersionIdsToStages"].items(): if "AWSCURRENT" in stages: current_version = version_id break logger.info("Promoting AWSPENDING to AWSCURRENT") secretsmanager.update_secret_version_stage( SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version, ) logger.info("Successfully finished rotation - new password is now active") ================================================ FILE: terraform/aws-ecs/lambda/rotate-documentdb/requirements.txt ================================================ boto3>=1.26.0 botocore>=1.29.0 ================================================ FILE: terraform/aws-ecs/lambda/rotate-rds/index.py ================================================ """ AWS Secrets Manager Rotation Handler for RDS Aurora MySQL This Lambda function implements the AWS Secrets Manager rotation protocol for RDS Aurora MySQL credentials (Keycloak database). It rotates the master password following AWS best practices for secret rotation. Rotation Steps: 1. createSecret: Generate new random password and create AWSPENDING version 2. setSecret: Update RDS cluster with new password 3. testSecret: Verify connection with new password 4. finishSecret: Move AWSCURRENT to AWSPREVIOUS and AWSPENDING to AWSCURRENT References: - https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets.html - https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Managing.html """ import json import logging import os import boto3 from botocore.exceptions import ClientError logger = logging.getLogger() logger.setLevel(logging.INFO) secretsmanager = boto3.client("secretsmanager") rds = boto3.client("rds") def lambda_handler(event: dict, context: dict) -> dict: """ Lambda handler for RDS Aurora MySQL secret rotation. Args: event: Lambda event containing SecretId, ClientRequestToken, and Step context: Lambda context object Returns: Success response dict Raises: ValueError: If rotation is not enabled or step is invalid ClientError: If AWS API calls fail """ arn = event["SecretId"] token = event["ClientRequestToken"] step = event["Step"] logger.info(f"Processing rotation step: {step} for secret: {arn}") metadata = secretsmanager.describe_secret(SecretId=arn) if not metadata.get("RotationEnabled"): error_msg = f"Secret {arn} is not enabled for rotation" logger.error(error_msg) raise ValueError(error_msg) if step == "createSecret": _create_secret(arn, token) elif step == "setSecret": _set_secret(arn, token) elif step == "testSecret": _test_secret(arn, token) elif step == "finishSecret": _finish_secret(arn, token) else: error_msg = f"Invalid step parameter: {step}" logger.error(error_msg) raise ValueError(error_msg) logger.info(f"Successfully completed rotation step: {step}") return {"statusCode": 200, "body": json.dumps("Success")} def _create_secret(arn: str, token: str) -> None: """ Generate new password and create AWSPENDING version. Args: arn: Secret ARN token: Client request token for this rotation """ logger.info("Step 1: Creating new secret version") current = secretsmanager.get_secret_value(SecretId=arn, VersionStage="AWSCURRENT") current_dict = json.loads(current["SecretString"]) try: secretsmanager.get_secret_value(SecretId=arn, VersionId=token, VersionStage="AWSPENDING") logger.info("AWSPENDING version already exists, skipping creation") return except ClientError as e: if e.response["Error"]["Code"] != "ResourceNotFoundException": raise exclude_chars = os.environ.get("EXCLUDE_CHARACTERS", "/@\"'\\") logger.info(f"Generating new password (excluding: {exclude_chars})") passwd = secretsmanager.get_random_password(ExcludeCharacters=exclude_chars, PasswordLength=32) current_dict["password"] = passwd["RandomPassword"] secretsmanager.put_secret_value( SecretId=arn, ClientRequestToken=token, SecretString=json.dumps(current_dict), VersionStages=["AWSPENDING"], ) logger.info("Successfully created AWSPENDING version with new password") def _set_secret(arn: str, token: str) -> None: """ Update RDS Aurora cluster with new password. Args: arn: Secret ARN token: Client request token for this rotation """ logger.info("Step 2: Setting new password in RDS Aurora") pending = secretsmanager.get_secret_value( SecretId=arn, VersionId=token, VersionStage="AWSPENDING" ) pending_dict = json.loads(pending["SecretString"]) metadata = secretsmanager.describe_secret(SecretId=arn) secret_name = metadata["Name"] cluster_id = pending_dict.get("cluster_id") if not cluster_id: logger.info("No cluster_id in secret, attempting to derive from name") if "keycloak" in secret_name.lower(): cluster_id = "keycloak" logger.info(f"Derived cluster_id: {cluster_id}") else: error_msg = f"Cannot determine RDS cluster ID from secret: {secret_name}" logger.error(error_msg) raise ValueError(error_msg) logger.info(f"Updating RDS Aurora cluster: {cluster_id}") try: rds.modify_db_cluster( DBClusterIdentifier=cluster_id, MasterUserPassword=pending_dict["password"], ApplyImmediately=True, ) logger.info("Successfully updated RDS Aurora master password") except ClientError as e: logger.error(f"Failed to update RDS password: {e}") raise def _test_secret(arn: str, token: str) -> None: """ Test new password by verifying cluster status. Note: We cannot easily test MySQL connection from Lambda without installing pymysql library. Instead, we verify the cluster is available and modification was successful. Args: arn: Secret ARN token: Client request token for this rotation """ logger.info("Step 3: Testing new secret") pending = secretsmanager.get_secret_value( SecretId=arn, VersionId=token, VersionStage="AWSPENDING" ) pending_dict = json.loads(pending["SecretString"]) metadata = secretsmanager.describe_secret(SecretId=arn) secret_name = metadata["Name"] cluster_id = pending_dict.get("cluster_id") if not cluster_id: if "keycloak" in secret_name.lower(): cluster_id = "keycloak" else: error_msg = f"Cannot determine RDS cluster ID from secret: {secret_name}" logger.error(error_msg) raise ValueError(error_msg) try: response = rds.describe_db_clusters(DBClusterIdentifier=cluster_id) cluster = response["DBClusters"][0] status = cluster["Status"] logger.info(f"RDS Aurora cluster status: {status}") if status not in ["available", "modifying"]: error_msg = f"RDS cluster in unexpected state: {status}" logger.error(error_msg) raise ValueError(error_msg) logger.info("Successfully verified RDS Aurora cluster status") except ClientError as e: logger.error(f"Failed to verify RDS cluster: {e}") raise def _finish_secret(arn: str, token: str) -> None: """ Move AWSCURRENT to AWSPREVIOUS and AWSPENDING to AWSCURRENT. Args: arn: Secret ARN token: Client request token for this rotation """ logger.info("Step 4: Finishing rotation") metadata = secretsmanager.describe_secret(SecretId=arn) current_version = None for version_id, stages in metadata["VersionIdsToStages"].items(): if "AWSCURRENT" in stages: current_version = version_id break logger.info(f"Current version: {current_version}, New version: {token}") secretsmanager.update_secret_version_stage( SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version, ) logger.info("Successfully finished rotation - new password is now active") ================================================ FILE: terraform/aws-ecs/lambda/rotate-rds/requirements.txt ================================================ boto3>=1.26.0 botocore>=1.29.0 ================================================ FILE: terraform/aws-ecs/lambda/verify-deployment.sh ================================================ #!/bin/bash # # Verification script for Lambda-based secret rotation deployment # This script checks that all components are properly deployed and configured # set -e echo "===================================================================" echo "Secret Rotation Deployment Verification" echo "===================================================================" echo "" # Colors GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # Get deployment name from terraform DEPLOYMENT_NAME="${TF_VAR_name:-mcp-gateway}" AWS_REGION="${TF_VAR_aws_region:-us-west-2}" echo "Deployment: ${DEPLOYMENT_NAME}" echo "Region: ${AWS_REGION}" echo "" # Check Lambda functions echo "Checking Lambda Functions..." echo "-------------------------------------------------------------------" DOCUMENTDB_LAMBDA="${DEPLOYMENT_NAME}-rotate-documentdb" RDS_LAMBDA="${DEPLOYMENT_NAME}-rotate-rds" if aws lambda get-function --function-name "$DOCUMENTDB_LAMBDA" --region "$AWS_REGION" &>/dev/null; then echo -e "${GREEN}✓${NC} Lambda function exists: $DOCUMENTDB_LAMBDA" else echo -e "${RED}✗${NC} Lambda function NOT found: $DOCUMENTDB_LAMBDA" fi if aws lambda get-function --function-name "$RDS_LAMBDA" --region "$AWS_REGION" &>/dev/null; then echo -e "${GREEN}✓${NC} Lambda function exists: $RDS_LAMBDA" else echo -e "${RED}✗${NC} Lambda function NOT found: $RDS_LAMBDA" fi echo "" # Check CloudWatch Log Groups echo "Checking CloudWatch Log Groups..." echo "-------------------------------------------------------------------" DOCUMENTDB_LOG_GROUP="/aws/lambda/${DOCUMENTDB_LAMBDA}" RDS_LOG_GROUP="/aws/lambda/${RDS_LAMBDA}" if aws logs describe-log-groups --log-group-name-prefix "$DOCUMENTDB_LOG_GROUP" --region "$AWS_REGION" --query 'logGroups[0].logGroupName' --output text | grep -q "$DOCUMENTDB_LAMBDA"; then echo -e "${GREEN}✓${NC} Log group exists: $DOCUMENTDB_LOG_GROUP" else echo -e "${RED}✗${NC} Log group NOT found: $DOCUMENTDB_LOG_GROUP" fi if aws logs describe-log-groups --log-group-name-prefix "$RDS_LOG_GROUP" --region "$AWS_REGION" --query 'logGroups[0].logGroupName' --output text | grep -q "$RDS_LAMBDA"; then echo -e "${GREEN}✓${NC} Log group exists: $RDS_LOG_GROUP" else echo -e "${RED}✗${NC} Log group NOT found: $RDS_LOG_GROUP" fi echo "" # Check Secrets Manager rotation configuration echo "Checking Secrets Manager Rotation..." echo "-------------------------------------------------------------------" DOCUMENTDB_SECRET="${DEPLOYMENT_NAME}/documentdb/credentials" RDS_SECRET="keycloak/database" # Check DocumentDB secret if aws secretsmanager describe-secret --secret-id "$DOCUMENTDB_SECRET" --region "$AWS_REGION" &>/dev/null; then ROTATION_ENABLED=$(aws secretsmanager describe-secret --secret-id "$DOCUMENTDB_SECRET" --region "$AWS_REGION" --query 'RotationEnabled' --output text) if [ "$ROTATION_ENABLED" = "True" ]; then echo -e "${GREEN}✓${NC} Rotation enabled: $DOCUMENTDB_SECRET" ROTATION_LAMBDA=$(aws secretsmanager describe-secret --secret-id "$DOCUMENTDB_SECRET" --region "$AWS_REGION" --query 'RotationRules.RotationLambdaARN' --output text 2>/dev/null || echo "N/A") echo " Lambda ARN: $ROTATION_LAMBDA" else echo -e "${YELLOW}⚠${NC} Rotation NOT enabled: $DOCUMENTDB_SECRET" fi else echo -e "${RED}✗${NC} Secret NOT found: $DOCUMENTDB_SECRET" fi # Check RDS secret if aws secretsmanager describe-secret --secret-id "$RDS_SECRET" --region "$AWS_REGION" &>/dev/null; then ROTATION_ENABLED=$(aws secretsmanager describe-secret --secret-id "$RDS_SECRET" --region "$AWS_REGION" --query 'RotationEnabled' --output text) if [ "$ROTATION_ENABLED" = "True" ]; then echo -e "${GREEN}✓${NC} Rotation enabled: $RDS_SECRET" ROTATION_LAMBDA=$(aws secretsmanager describe-secret --secret-id "$RDS_SECRET" --region "$AWS_REGION" --query 'RotationRules.RotationLambdaARN' --output text 2>/dev/null || echo "N/A") echo " Lambda ARN: $ROTATION_LAMBDA" else echo -e "${YELLOW}⚠${NC} Rotation NOT enabled: $RDS_SECRET" fi else echo -e "${RED}✗${NC} Secret NOT found: $RDS_SECRET" fi echo "" echo "===================================================================" echo "Verification Complete" echo "===================================================================" echo "" echo "To manually trigger rotation:" echo " aws secretsmanager rotate-secret --secret-id $DOCUMENTDB_SECRET --region $AWS_REGION" echo " aws secretsmanager rotate-secret --secret-id $RDS_SECRET --region $AWS_REGION" echo "" echo "To view Lambda logs:" echo " aws logs tail $DOCUMENTDB_LOG_GROUP --follow --region $AWS_REGION" echo " aws logs tail $RDS_LOG_GROUP --follow --region $AWS_REGION" echo "" ================================================ FILE: terraform/aws-ecs/locals.tf ================================================ locals { # Dynamic domain construction based on region # Format: kc.{region}.mycorp.click and registry.{region}.mycorp.click keycloak_domain = var.use_regional_domains ? "kc.${var.aws_region}.${var.base_domain}" : var.keycloak_domain root_domain = var.use_regional_domains ? "${var.aws_region}.${var.base_domain}" : var.root_domain # Hosted zone domain - the actual Route53 hosted zone to look up # When using regional domains, this is the base domain (e.g., mycorp.click) # When not using regional domains, this is the root_domain hosted_zone_domain = var.use_regional_domains ? var.base_domain : var.root_domain # Computed prefix list name for ALB security groups # If explicitly set, use that value; otherwise use CloudFront prefix list when CloudFront is enabled cloudfront_prefix_list_name = var.cloudfront_prefix_list_name != "" ? var.cloudfront_prefix_list_name : (var.enable_cloudfront ? "com.amazonaws.global.cloudfront.origin-facing" : "") common_tags = { Project = "mcp-gateway-registry" Component = "keycloak" Environment = "production" ManagedBy = "terraform" CreatedAt = timestamp() } } ================================================ FILE: terraform/aws-ecs/main.tf ================================================ # MCP Gateway Registry - AWS ECS Deployment # This Terraform configuration deploys the MCP Gateway to AWS ECS Fargate terraform { required_version = ">= 1.0" required_providers { aws = { source = "hashicorp/aws" version = ">= 5.0" } } } provider "aws" { region = var.aws_region } # MCP Gateway Module module "mcp_gateway" { source = "./modules/mcp-gateway" # Basic configuration name = "${var.name}-v2" # Network configuration vpc_id = module.vpc.vpc_id private_subnet_ids = module.vpc.private_subnets public_subnet_ids = module.vpc.public_subnets ingress_cidr_blocks = var.ingress_cidr_blocks # ALB logging alb_logs_bucket = aws_s3_bucket.alb_logs.id # ECS configuration ecs_cluster_arn = module.ecs_cluster.arn ecs_cluster_name = module.ecs_cluster.name task_execution_role_arn = module.ecs_cluster.task_exec_iam_role_arn # HTTPS configuration - only use certificate when Route53 DNS is enabled (without CloudFront) # When CloudFront is enabled, HTTPS termination happens at CloudFront, not ALB enable_https = var.enable_route53_dns && !var.enable_cloudfront certificate_arn = var.enable_route53_dns && !var.enable_cloudfront ? aws_acm_certificate.registry[0].arn : "" # Domain name for the registry - determines REGISTRY_URL and OAuth redirect URIs # Simplified to 3 modes (no dual-access): # Mode 1: CloudFront-only - use CloudFront domain # Mode 2: Custom Domain → ALB - use custom domain # Mode 3: Custom Domain → CloudFront - use custom domain (traffic flows through CloudFront) domain_name = var.enable_route53_dns ? "registry.${local.root_domain}" : ( var.enable_cloudfront ? aws_cloudfront_distribution.mcp_gateway[0].domain_name : "" ) # Additional server names for nginx - no longer needed with simplified modes # Each deployment has a single entry point (either custom domain or CloudFront domain) additional_server_names = "" # Keycloak configuration # Mode 1: CloudFront-only - use CloudFront domain # Mode 2 & 3: Custom domain (Route53 enabled) - use custom domain keycloak_domain = var.enable_route53_dns ? local.keycloak_domain : ( var.enable_cloudfront ? aws_cloudfront_distribution.keycloak[0].domain_name : local.keycloak_domain ) # CloudFront configuration - allows CloudFront IPs to reach ALB enable_cloudfront = var.enable_cloudfront cloudfront_prefix_list_name = local.cloudfront_prefix_list_name # Container images registry_image_uri = var.registry_image_uri auth_server_image_uri = var.auth_server_image_uri currenttime_image_uri = var.currenttime_image_uri mcpgw_image_uri = var.mcpgw_image_uri realserverfaketools_image_uri = var.realserverfaketools_image_uri flight_booking_agent_image_uri = var.flight_booking_agent_image_uri travel_assistant_agent_image_uri = var.travel_assistant_agent_image_uri # Service replicas currenttime_replicas = var.currenttime_replicas mcpgw_replicas = var.mcpgw_replicas realserverfaketools_replicas = var.realserverfaketools_replicas flight_booking_agent_replicas = var.flight_booking_agent_replicas travel_assistant_agent_replicas = var.travel_assistant_agent_replicas # Auto-scaling configuration enable_autoscaling = true autoscaling_min_capacity = 1 autoscaling_max_capacity = 4 autoscaling_target_cpu = 70 autoscaling_target_memory = 80 # Monitoring configuration enable_monitoring = var.enable_monitoring alarm_email = var.alarm_email # Embeddings configuration embeddings_provider = var.embeddings_provider embeddings_model_name = var.embeddings_model_name embeddings_model_dimensions = var.embeddings_model_dimensions embeddings_aws_region = var.embeddings_aws_region embeddings_api_key = var.embeddings_api_key # Keycloak admin credentials (for Management API) keycloak_admin_password = var.keycloak_admin_password # Session cookie security configuration session_cookie_secure = var.session_cookie_secure session_cookie_domain = var.session_cookie_domain # DocumentDB configuration storage_backend = var.storage_backend documentdb_endpoint = aws_docdb_cluster.registry.endpoint documentdb_database = var.documentdb_database documentdb_namespace = var.documentdb_namespace documentdb_use_tls = var.documentdb_use_tls documentdb_use_iam = var.documentdb_use_iam documentdb_credentials_secret_arn = var.storage_backend == "documentdb" ? aws_secretsmanager_secret.documentdb_credentials.arn : "" # Security scanning configuration security_scan_enabled = var.security_scan_enabled security_scan_on_registration = var.security_scan_on_registration security_block_unsafe_servers = var.security_block_unsafe_servers security_analyzers = var.security_analyzers security_scan_timeout = var.security_scan_timeout security_add_pending_tag = var.security_add_pending_tag # Microsoft Entra ID configuration entra_enabled = var.entra_enabled entra_tenant_id = var.entra_tenant_id entra_client_id = var.entra_client_id entra_client_secret = var.entra_client_secret idp_group_filter_prefix = var.idp_group_filter_prefix # Okta configuration okta_enabled = var.okta_enabled okta_domain = var.okta_domain okta_client_id = var.okta_client_id okta_client_secret = var.okta_client_secret okta_m2m_client_id = var.okta_m2m_client_id okta_m2m_client_secret = var.okta_m2m_client_secret okta_api_token = var.okta_api_token okta_auth_server_id = var.okta_auth_server_id # Auth0 configuration auth0_enabled = var.auth0_enabled auth0_domain = var.auth0_domain auth0_client_id = var.auth0_client_id auth0_client_secret = var.auth0_client_secret auth0_audience = var.auth0_audience auth0_groups_claim = var.auth0_groups_claim auth0_m2m_client_id = var.auth0_m2m_client_id auth0_m2m_client_secret = var.auth0_m2m_client_secret auth0_management_api_token = var.auth0_management_api_token # OAuth token storage oauth_store_tokens_in_session = var.oauth_store_tokens_in_session # Registry static token auth registry_static_token_auth_enabled = var.registry_static_token_auth_enabled registry_api_token = var.registry_api_token registry_api_keys = var.registry_api_keys max_tokens_per_user_per_hour = var.max_tokens_per_user_per_hour # Registration webhook (issue #742) registration_webhook_url = var.registration_webhook_url registration_webhook_auth_header = var.registration_webhook_auth_header registration_webhook_auth_token = var.registration_webhook_auth_token registration_webhook_timeout_seconds = var.registration_webhook_timeout_seconds # Registration gate / admission control (issue #809) registration_gate_enabled = var.registration_gate_enabled registration_gate_url = var.registration_gate_url registration_gate_auth_type = var.registration_gate_auth_type registration_gate_auth_credential = var.registration_gate_auth_credential registration_gate_auth_header_name = var.registration_gate_auth_header_name registration_gate_timeout_seconds = var.registration_gate_timeout_seconds registration_gate_max_retries = var.registration_gate_max_retries # M2M direct client registration (issue #851) m2m_direct_registration_enabled = var.m2m_direct_registration_enabled # Federation configuration (peer-to-peer registry sync) registry_id = var.registry_id federation_static_token_auth_enabled = var.federation_static_token_auth_enabled federation_static_token = var.federation_static_token federation_encryption_key = var.federation_encryption_key # AWS Agent Registry federation configuration aws_registry_federation_enabled = var.aws_registry_federation_enabled # ANS (Agent Name Service) configuration ans_integration_enabled = var.ans_integration_enabled ans_api_endpoint = var.ans_api_endpoint ans_api_key = var.ans_api_key ans_api_secret = var.ans_api_secret ans_api_timeout_seconds = var.ans_api_timeout_seconds ans_sync_interval_hours = var.ans_sync_interval_hours ans_verification_cache_ttl_seconds = var.ans_verification_cache_ttl_seconds # Registry card configuration (federation metadata) registry_name = var.registry_name registry_organization_name = var.registry_organization_name registry_description = var.registry_description registry_contact_email = var.registry_contact_email registry_contact_url = var.registry_contact_url # Audit logging configuration audit_log_enabled = var.audit_log_enabled audit_log_ttl_days = var.audit_log_ttl_days # Application log configuration app_log_centralized_enabled = var.app_log_centralized_enabled app_log_centralized_ttl_days = var.app_log_centralized_ttl_days app_log_level = var.app_log_level app_log_excluded_loggers = var.app_log_excluded_loggers # Deployment mode configuration deployment_mode = var.deployment_mode registry_mode = var.registry_mode # Tab visibility overrides show_servers_tab = var.show_servers_tab show_virtual_servers_tab = var.show_virtual_servers_tab show_skills_tab = var.show_skills_tab show_agents_tab = var.show_agents_tab # Observability configuration enable_observability = var.enable_observability metrics_service_image_uri = var.metrics_service_image_uri grafana_image_uri = var.grafana_image_uri grafana_admin_password = var.grafana_admin_password otel_otlp_endpoint = var.otel_otlp_endpoint otel_exporter_otlp_headers = var.otel_exporter_otlp_headers otel_otlp_export_interval_ms = var.otel_otlp_export_interval_ms otel_exporter_otlp_metrics_temporality_preference = var.otel_exporter_otlp_metrics_temporality_preference # Telemetry configuration mcp_telemetry_disabled = var.mcp_telemetry_disabled mcp_telemetry_opt_out = var.mcp_telemetry_opt_out mcp_telemetry_heartbeat_interval_minutes = var.mcp_telemetry_heartbeat_interval_minutes telemetry_debug = var.telemetry_debug # Demo server configuration disable_ai_registry_tools_server = var.disable_ai_registry_tools_server # GitHub private repo auth github_pat = var.github_pat github_app_id = var.github_app_id github_app_installation_id = var.github_app_installation_id github_app_private_key = var.github_app_private_key github_extra_hosts = var.github_extra_hosts github_api_base_url = var.github_api_base_url # Wait for S3 bucket policy to propagate (30s delay) # This prevents "Access Denied" errors when ALB tests write permissions depends_on = [time_sleep.wait_for_bucket_policy] } # ============================================================================= # CloudFront Configuration Warnings # ============================================================================= # Warning for dual ingress configuration (both CloudFront and custom domain) resource "null_resource" "dual_ingress_warning" { count = var.enable_cloudfront && var.enable_route53_dns ? 1 : 0 triggers = { always_run = timestamp() } provisioner "local-exec" { command = <<-EOT echo "" echo "============================================================" echo "INFO: Custom Domain → CloudFront Configuration (Mode 3)" echo "============================================================" echo "Both CloudFront (enable_cloudfront=true) and Route53 DNS" echo "(enable_route53_dns=true) are enabled." echo "" echo "Traffic flow: Custom Domain → CloudFront → ALB → ECS" echo "" echo "Access URL: https://registry.${local.root_domain}" echo "" echo "Benefits:" echo " - Custom branded domain" echo " - CloudFront edge caching and DDoS protection" echo " - Single entry point (no dual-access confusion)" echo "============================================================" echo "" EOT } } ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/data.tf ================================================ # Data sources for MCP Gateway Registry Module data "aws_region" "current" {} data "aws_caller_identity" "current" {} # Get VPC data data "aws_vpc" "vpc" { id = var.vpc_id } ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/ecs-services.tf ================================================ # ECS Services for MCP Gateway Registry # ECS Service: Auth Server #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "ecs_service_auth" { source = "terraform-aws-modules/ecs/aws//modules/service" version = "~> 6.0" name = "${local.name_prefix}-auth" cluster_arn = var.ecs_cluster_arn cpu = tonumber(var.cpu) memory = tonumber(var.memory) desired_count = var.enable_autoscaling ? var.autoscaling_min_capacity : var.auth_replicas enable_autoscaling = var.enable_autoscaling autoscaling_min_capacity = var.autoscaling_min_capacity autoscaling_max_capacity = var.autoscaling_max_capacity autoscaling_policies = var.enable_autoscaling ? { cpu = { policy_type = "TargetTrackingScaling" target_tracking_scaling_policy_configuration = { predefined_metric_specification = { predefined_metric_type = "ECSServiceAverageCPUUtilization" } target_value = var.autoscaling_target_cpu } } memory = { policy_type = "TargetTrackingScaling" target_tracking_scaling_policy_configuration = { predefined_metric_specification = { predefined_metric_type = "ECSServiceAverageMemoryUtilization" } target_value = var.autoscaling_target_memory } } } : {} enable_execute_command = true requires_compatibilities = ["FARGATE", "EC2"] capacity_provider_strategy = { FARGATE = { capacity_provider = "FARGATE" weight = 100 base = 1 } } # Task roles create_task_exec_iam_role = true task_exec_iam_role_policies = { SecretsManagerAccess = aws_iam_policy.ecs_secrets_access.arn EcsExecTaskExecution = aws_iam_policy.ecs_exec_task_execution.arn } create_tasks_iam_role = true tasks_iam_role_policies = { SecretsManagerAccess = aws_iam_policy.ecs_secrets_access.arn EcsExecTask = aws_iam_policy.ecs_exec_task.arn } # Enable Service Connect service_connect_configuration = { namespace = aws_service_discovery_private_dns_namespace.mcp.arn service = [{ client_alias = { port = 8888 dns_name = "auth-server" } port_name = "auth-server" discovery_name = "auth-server" }] } # Container definitions container_definitions = { auth-server = { cpu = tonumber(var.cpu) memory = tonumber(var.memory) essential = true image = var.auth_server_image_uri versionConsistency = "disabled" readonlyRootFilesystem = false portMappings = [ { name = "auth-server" containerPort = 8888 protocol = "tcp" } ] environment = [ { name = "REGISTRY_URL" value = "https://${var.domain_name}" }, { name = "AUTH_SERVER_URL" value = "http://auth-server:8888" }, { name = "AUTH_SERVER_EXTERNAL_URL" value = "https://${var.domain_name}" }, { name = "AWS_REGION" value = data.aws_region.current.id }, { name = "AUTH_PROVIDER" value = var.auth0_enabled ? "auth0" : (var.okta_enabled ? "okta" : (var.entra_enabled ? "entra" : (var.keycloak_domain != "" ? "keycloak" : "default"))) }, { name = "KEYCLOAK_URL" value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}" : "" }, { name = "KEYCLOAK_EXTERNAL_URL" value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}" : "" }, { name = "KEYCLOAK_REALM" value = "mcp-gateway" }, { name = "KEYCLOAK_CLIENT_ID" value = "mcp-gateway-web" }, { name = "KEYCLOAK_M2M_CLIENT_ID" value = "mcp-gateway-m2m" }, { name = "ENTRA_ENABLED" value = tostring(var.entra_enabled) }, { name = "ENTRA_TENANT_ID" value = var.entra_tenant_id }, { name = "ENTRA_CLIENT_ID" value = var.entra_client_id }, { name = "IDP_GROUP_FILTER_PREFIX" value = var.idp_group_filter_prefix }, # Okta configuration { name = "OKTA_ENABLED" value = tostring(var.okta_enabled) }, { name = "OKTA_DOMAIN" value = var.okta_domain }, { name = "OKTA_CLIENT_ID" value = var.okta_client_id }, { name = "OKTA_M2M_CLIENT_ID" value = var.okta_m2m_client_id }, { name = "OKTA_AUTH_SERVER_ID" value = var.okta_auth_server_id }, { name = "AUTH0_DOMAIN" value = var.auth0_domain }, { name = "AUTH0_CLIENT_ID" value = var.auth0_client_id }, { name = "AUTH0_AUDIENCE" value = var.auth0_audience }, { name = "AUTH0_GROUPS_CLAIM" value = var.auth0_groups_claim }, { name = "AUTH0_M2M_CLIENT_ID" value = var.auth0_m2m_client_id }, { name = "AUTH0_MANAGEMENT_API_TOKEN" value = var.auth0_management_api_token }, { name = "AUTH0_ENABLED" value = tostring(var.auth0_enabled) }, { name = "SCOPES_CONFIG_PATH" value = "/efs/auth_config/auth_config/scopes.yml" }, { name = "SESSION_COOKIE_SECURE" value = tostring(var.session_cookie_secure) }, { name = "SESSION_COOKIE_DOMAIN" value = var.session_cookie_domain }, { name = "OAUTH_STORE_TOKENS_IN_SESSION" value = tostring(var.oauth_store_tokens_in_session) }, { name = "REGISTRY_STATIC_TOKEN_AUTH_ENABLED" value = tostring(var.registry_static_token_auth_enabled) }, { name = "REGISTRY_API_TOKEN" value = var.registry_api_token }, { name = "REGISTRY_API_KEYS" value = var.registry_api_keys }, # M2M direct client registration (issue #851) { name = "M2M_DIRECT_REGISTRATION_ENABLED" value = tostring(var.m2m_direct_registration_enabled) }, # Federation configuration (peer-to-peer registry sync) { name = "REGISTRY_ID" value = var.registry_id }, { name = "FEDERATION_STATIC_TOKEN_AUTH_ENABLED" value = tostring(var.federation_static_token_auth_enabled) }, { name = "FEDERATION_STATIC_TOKEN" value = var.federation_static_token }, { name = "FEDERATION_ENCRYPTION_KEY" value = var.federation_encryption_key }, { name = "ANS_INTEGRATION_ENABLED" value = tostring(var.ans_integration_enabled) }, { name = "ANS_API_ENDPOINT" value = var.ans_api_endpoint }, { name = "ANS_API_KEY" value = var.ans_api_key }, { name = "ANS_API_SECRET" value = var.ans_api_secret }, { name = "ANS_API_TIMEOUT_SECONDS" value = tostring(var.ans_api_timeout_seconds) }, { name = "ANS_SYNC_INTERVAL_HOURS" value = tostring(var.ans_sync_interval_hours) }, { name = "ANS_VERIFICATION_CACHE_TTL_SECONDS" value = tostring(var.ans_verification_cache_ttl_seconds) }, { name = "STORAGE_BACKEND" value = var.storage_backend }, { name = "DOCUMENTDB_HOST" value = var.documentdb_endpoint }, { name = "DOCUMENTDB_PORT" value = "27017" }, { name = "DOCUMENTDB_DATABASE" value = var.documentdb_database }, { name = "DOCUMENTDB_NAMESPACE" value = var.documentdb_namespace }, { name = "DOCUMENTDB_USE_TLS" value = tostring(var.documentdb_use_tls) }, { name = "DOCUMENTDB_USE_IAM" value = tostring(var.documentdb_use_iam) }, { name = "DOCUMENTDB_TLS_CA_FILE" value = "/app/certs/global-bundle.pem" }, { name = "AUDIT_LOG_ENABLED" value = tostring(var.audit_log_enabled) }, { name = "AUDIT_LOG_MONGODB_TTL_DAYS" value = tostring(var.audit_log_ttl_days) }, { name = "APP_LOG_CENTRALIZED_ENABLED" value = tostring(var.app_log_centralized_enabled) }, { name = "APP_LOG_CENTRALIZED_TTL_DAYS" value = tostring(var.app_log_centralized_ttl_days) }, { name = "APP_LOG_LEVEL" value = var.app_log_level }, { name = "APP_LOG_EXCLUDED_LOGGERS" value = var.app_log_excluded_loggers }, # Metrics pipeline (only wired when observability is enabled) { name = "METRICS_SERVICE_URL" value = var.enable_observability ? "http://metrics-service:8890" : "" } ] secrets = concat( [ { name = "SECRET_KEY" valueFrom = aws_secretsmanager_secret.secret_key.arn }, { name = "KEYCLOAK_CLIENT_SECRET" valueFrom = "${aws_secretsmanager_secret.keycloak_client_secret.arn}:client_secret::" }, { name = "KEYCLOAK_M2M_CLIENT_SECRET" valueFrom = "${aws_secretsmanager_secret.keycloak_m2m_client_secret.arn}:client_secret::" }, { name = "DOCUMENTDB_USERNAME" valueFrom = "${var.documentdb_credentials_secret_arn}:username::" }, { name = "DOCUMENTDB_PASSWORD" valueFrom = "${var.documentdb_credentials_secret_arn}:password::" } ], var.entra_enabled ? [ { name = "ENTRA_CLIENT_SECRET" valueFrom = aws_secretsmanager_secret.entra_client_secret[0].arn } ] : [], var.okta_enabled ? [ { name = "OKTA_CLIENT_SECRET" valueFrom = aws_secretsmanager_secret.okta_client_secret[0].arn }, { name = "OKTA_M2M_CLIENT_SECRET" valueFrom = aws_secretsmanager_secret.okta_m2m_client_secret[0].arn }, { name = "OKTA_API_TOKEN" valueFrom = aws_secretsmanager_secret.okta_api_token[0].arn } ] : [], var.auth0_enabled ? [ { name = "AUTH0_CLIENT_SECRET" valueFrom = aws_secretsmanager_secret.auth0_client_secret[0].arn }, { name = "AUTH0_M2M_CLIENT_SECRET" valueFrom = aws_secretsmanager_secret.auth0_m2m_client_secret[0].arn } ] : [], var.enable_observability ? [ { name = "METRICS_API_KEY" valueFrom = aws_secretsmanager_secret.metrics_api_key[0].arn } ] : [] ) mountPoints = [ { sourceVolume = "mcp-logs" containerPath = "/app/logs" readOnly = false }, { sourceVolume = "auth-config" containerPath = "/efs/auth_config" readOnly = false } ] enable_cloudwatch_logging = true cloudwatch_log_group_name = "/ecs/${local.name_prefix}-auth-server" cloudwatch_log_group_retention_in_days = 30 healthCheck = { command = ["CMD-SHELL", "curl -f http://localhost:8888/health || exit 1"] interval = 30 timeout = 5 retries = 3 startPeriod = 60 } } } volume = { mcp-logs = { efs_volume_configuration = { file_system_id = module.efs.id access_point_id = module.efs.access_points["logs"].id transit_encryption = "ENABLED" } } auth-config = { efs_volume_configuration = { file_system_id = module.efs.id access_point_id = module.efs.access_points["auth_config"].id transit_encryption = "ENABLED" } } } load_balancer = { service = { target_group_arn = module.alb.target_groups["auth"].arn container_name = "auth-server" container_port = 8888 } } subnet_ids = var.private_subnet_ids security_group_ingress_rules = { alb_8888 = { description = "Auth server port from ALB" from_port = 8888 to_port = 8888 ip_protocol = "tcp" referenced_security_group_id = module.alb.security_group_id } } security_group_egress_rules = { all = { ip_protocol = "-1" cidr_ipv4 = "0.0.0.0/0" } } tags = local.common_tags } # ECS Service: Registry (Main service with nginx, SSL, FAISS, models) #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "ecs_service_registry" { source = "terraform-aws-modules/ecs/aws//modules/service" version = "~> 6.0" name = "${local.name_prefix}-registry" cluster_arn = var.ecs_cluster_arn cpu = tonumber(var.cpu) memory = tonumber(var.memory) desired_count = var.enable_autoscaling ? var.autoscaling_min_capacity : var.registry_replicas enable_autoscaling = var.enable_autoscaling autoscaling_min_capacity = var.autoscaling_min_capacity autoscaling_max_capacity = var.autoscaling_max_capacity autoscaling_policies = var.enable_autoscaling ? { cpu = { policy_type = "TargetTrackingScaling" target_tracking_scaling_policy_configuration = { predefined_metric_specification = { predefined_metric_type = "ECSServiceAverageCPUUtilization" } target_value = var.autoscaling_target_cpu } } memory = { policy_type = "TargetTrackingScaling" target_tracking_scaling_policy_configuration = { predefined_metric_specification = { predefined_metric_type = "ECSServiceAverageMemoryUtilization" } target_value = var.autoscaling_target_memory } } } : {} enable_execute_command = true requires_compatibilities = ["FARGATE", "EC2"] capacity_provider_strategy = { FARGATE = { capacity_provider = "FARGATE" weight = 100 base = 1 } } # Task roles create_task_exec_iam_role = true task_exec_iam_role_policies = { SecretsManagerAccess = aws_iam_policy.ecs_secrets_access.arn EcsExecTaskExecution = aws_iam_policy.ecs_exec_task_execution.arn } create_tasks_iam_role = true tasks_iam_role_policies = merge( { SecretsManagerAccess = aws_iam_policy.ecs_secrets_access.arn EcsExecTask = aws_iam_policy.ecs_exec_task.arn }, var.aws_registry_federation_enabled ? { BedrockAgentCoreAccess = aws_iam_policy.bedrock_agentcore_access[0].arn } : {} ) # Enable Service Connect service_connect_configuration = { namespace = aws_service_discovery_private_dns_namespace.mcp.arn service = [{ client_alias = { port = 8080 # Non-root nginx listens on 8080 dns_name = "registry" } port_name = "http" discovery_name = "registry" }] } # Container definitions container_definitions = { registry = { cpu = tonumber(var.cpu) memory = tonumber(var.memory) essential = true image = var.registry_image_uri versionConsistency = "disabled" readonlyRootFilesystem = false portMappings = [ { name = "http" containerPort = 8080 # Non-root nginx listens on 8080 protocol = "tcp" }, { name = "https" containerPort = 8443 # Non-root nginx listens on 8443 protocol = "tcp" }, { name = "registry" containerPort = 7860 protocol = "tcp" } ] environment = [ { name = "REGISTRY_URL" value = var.domain_name != "" ? "https://${var.domain_name}" : "http://${module.alb.dns_name}" }, { name = "GATEWAY_ADDITIONAL_SERVER_NAMES" value = join(" ", compact([var.domain_name, var.additional_server_names])) }, { name = "EC2_PUBLIC_DNS" value = var.domain_name != "" ? var.domain_name : module.alb.dns_name }, { name = "AUTH_SERVER_URL" value = "http://auth-server:8888" }, { name = "AUTH_SERVER_EXTERNAL_URL" value = var.domain_name != "" ? "https://${var.domain_name}" : "http://${module.alb.dns_name}" }, { name = "KEYCLOAK_URL" value = var.keycloak_domain != "" ? "https://${var.keycloak_domain}" : "" }, { name = "KEYCLOAK_ENABLED" value = var.keycloak_domain != "" ? "true" : "false" }, { name = "KEYCLOAK_REALM" value = "mcp-gateway" }, { name = "KEYCLOAK_CLIENT_ID" value = "mcp-gateway-web" }, { name = "AUTH_PROVIDER" value = var.auth0_enabled ? "auth0" : (var.okta_enabled ? "okta" : (var.entra_enabled ? "entra" : (var.keycloak_domain != "" ? "keycloak" : "default"))) }, { name = "ENTRA_ENABLED" value = tostring(var.entra_enabled) }, { name = "ENTRA_TENANT_ID" value = var.entra_tenant_id }, { name = "ENTRA_CLIENT_ID" value = var.entra_client_id }, { name = "IDP_GROUP_FILTER_PREFIX" value = var.idp_group_filter_prefix }, # Okta configuration { name = "OKTA_ENABLED" value = tostring(var.okta_enabled) }, { name = "OKTA_DOMAIN" value = var.okta_domain }, { name = "OKTA_CLIENT_ID" value = var.okta_client_id }, { name = "OKTA_M2M_CLIENT_ID" value = var.okta_m2m_client_id }, { name = "OKTA_AUTH_SERVER_ID" value = var.okta_auth_server_id }, { name = "AUTH0_ENABLED" value = tostring(var.auth0_enabled) }, { name = "AUTH0_DOMAIN" value = var.auth0_domain }, { name = "AUTH0_CLIENT_ID" value = var.auth0_client_id }, { name = "AUTH0_AUDIENCE" value = var.auth0_audience }, { name = "AUTH0_GROUPS_CLAIM" value = var.auth0_groups_claim }, { name = "AUTH0_M2M_CLIENT_ID" value = var.auth0_m2m_client_id }, { name = "AUTH0_MANAGEMENT_API_TOKEN" value = var.auth0_management_api_token }, { name = "AWS_REGION" value = data.aws_region.current.id }, { name = "SCOPES_CONFIG_PATH" value = "/app/auth_server/scopes.yml" }, { name = "EMBEDDINGS_PROVIDER" value = var.embeddings_provider }, { name = "EMBEDDINGS_MODEL_NAME" value = var.embeddings_model_name }, { name = "EMBEDDINGS_MODEL_DIMENSIONS" value = tostring(var.embeddings_model_dimensions) }, { name = "EMBEDDINGS_AWS_REGION" value = var.embeddings_aws_region }, { name = "SESSION_COOKIE_SECURE" value = tostring(var.session_cookie_secure) }, { name = "SESSION_COOKIE_DOMAIN" value = var.session_cookie_domain }, { name = "SECURITY_SCAN_ENABLED" value = tostring(var.security_scan_enabled) }, { name = "SECURITY_SCAN_ON_REGISTRATION" value = tostring(var.security_scan_on_registration) }, { name = "SECURITY_BLOCK_UNSAFE_SERVERS" value = tostring(var.security_block_unsafe_servers) }, { name = "SECURITY_ANALYZERS" value = var.security_analyzers }, { name = "SECURITY_SCAN_TIMEOUT" value = tostring(var.security_scan_timeout) }, { name = "SECURITY_ADD_PENDING_TAG" value = tostring(var.security_add_pending_tag) }, { name = "KEYCLOAK_ADMIN" value = "admin" }, { name = "STORAGE_BACKEND" value = var.storage_backend }, { name = "DOCUMENTDB_HOST" value = var.documentdb_endpoint }, { name = "DOCUMENTDB_PORT" value = "27017" }, { name = "DOCUMENTDB_DATABASE" value = var.documentdb_database }, { name = "DOCUMENTDB_NAMESPACE" value = var.documentdb_namespace }, { name = "DOCUMENTDB_USE_TLS" value = tostring(var.documentdb_use_tls) }, { name = "DOCUMENTDB_USE_IAM" value = tostring(var.documentdb_use_iam) }, { name = "DOCUMENTDB_TLS_CA_FILE" value = "/app/certs/global-bundle.pem" }, { name = "REGISTRY_ID" value = var.registry_id }, { name = "REGISTRY_NAME" value = var.registry_name }, { name = "REGISTRY_ORGANIZATION_NAME" value = var.registry_organization_name }, { name = "REGISTRY_DESCRIPTION" value = var.registry_description }, { name = "REGISTRY_CONTACT_EMAIL" value = var.registry_contact_email }, { name = "REGISTRY_CONTACT_URL" value = var.registry_contact_url }, { name = "FEDERATION_STATIC_TOKEN_AUTH_ENABLED" value = tostring(var.federation_static_token_auth_enabled) }, { name = "FEDERATION_STATIC_TOKEN" value = var.federation_static_token }, { name = "FEDERATION_ENCRYPTION_KEY" value = var.federation_encryption_key }, # AWS Agent Registry federation configuration { name = "AWS_REGISTRY_FEDERATION_ENABLED" value = tostring(var.aws_registry_federation_enabled) }, { name = "ANS_INTEGRATION_ENABLED" value = tostring(var.ans_integration_enabled) }, { name = "ANS_API_ENDPOINT" value = var.ans_api_endpoint }, { name = "ANS_API_KEY" value = var.ans_api_key }, { name = "ANS_API_SECRET" value = var.ans_api_secret }, { name = "ANS_API_TIMEOUT_SECONDS" value = tostring(var.ans_api_timeout_seconds) }, { name = "ANS_SYNC_INTERVAL_HOURS" value = tostring(var.ans_sync_interval_hours) }, { name = "ANS_VERIFICATION_CACHE_TTL_SECONDS" value = tostring(var.ans_verification_cache_ttl_seconds) }, { name = "AUDIT_LOG_ENABLED" value = tostring(var.audit_log_enabled) }, { name = "AUDIT_LOG_MONGODB_TTL_DAYS" value = tostring(var.audit_log_ttl_days) }, { name = "APP_LOG_CENTRALIZED_ENABLED" value = tostring(var.app_log_centralized_enabled) }, { name = "APP_LOG_CENTRALIZED_TTL_DAYS" value = tostring(var.app_log_centralized_ttl_days) }, { name = "APP_LOG_LEVEL" value = var.app_log_level }, { name = "APP_LOG_EXCLUDED_LOGGERS" value = var.app_log_excluded_loggers }, { name = "DEPLOYMENT_MODE" value = var.deployment_mode }, { name = "REGISTRY_MODE" value = var.registry_mode }, { name = "SHOW_SERVERS_TAB" value = tostring(var.show_servers_tab) }, { name = "SHOW_VIRTUAL_SERVERS_TAB" value = tostring(var.show_virtual_servers_tab) }, { name = "SHOW_SKILLS_TAB" value = tostring(var.show_skills_tab) }, { name = "SHOW_AGENTS_TAB" value = tostring(var.show_agents_tab) }, { name = "OAUTH_STORE_TOKENS_IN_SESSION" value = tostring(var.oauth_store_tokens_in_session) }, { name = "REGISTRY_STATIC_TOKEN_AUTH_ENABLED" value = tostring(var.registry_static_token_auth_enabled) }, { name = "REGISTRY_API_TOKEN" value = var.registry_api_token }, { name = "REGISTRY_API_KEYS" value = var.registry_api_keys }, { name = "MAX_TOKENS_PER_USER_PER_HOUR" value = tostring(var.max_tokens_per_user_per_hour) }, # M2M direct client registration (issue #851) { name = "M2M_DIRECT_REGISTRATION_ENABLED" value = tostring(var.m2m_direct_registration_enabled) }, # Registration webhook (issue #742) { name = "REGISTRATION_WEBHOOK_URL" value = var.registration_webhook_url }, { name = "REGISTRATION_WEBHOOK_AUTH_HEADER" value = var.registration_webhook_auth_header }, { name = "REGISTRATION_WEBHOOK_AUTH_TOKEN" value = var.registration_webhook_auth_token }, { name = "REGISTRATION_WEBHOOK_TIMEOUT_SECONDS" value = tostring(var.registration_webhook_timeout_seconds) }, # Registration gate / admission control (issue #809) { name = "REGISTRATION_GATE_ENABLED" value = tostring(var.registration_gate_enabled) }, { name = "REGISTRATION_GATE_URL" value = var.registration_gate_url }, { name = "REGISTRATION_GATE_AUTH_TYPE" value = var.registration_gate_auth_type }, { name = "REGISTRATION_GATE_AUTH_CREDENTIAL" value = var.registration_gate_auth_credential }, { name = "REGISTRATION_GATE_AUTH_HEADER_NAME" value = var.registration_gate_auth_header_name }, { name = "REGISTRATION_GATE_TIMEOUT_SECONDS" value = tostring(var.registration_gate_timeout_seconds) }, { name = "REGISTRATION_GATE_MAX_RETRIES" value = tostring(var.registration_gate_max_retries) }, # Telemetry configuration { name = "MCP_TELEMETRY_DISABLED" value = var.mcp_telemetry_disabled }, { name = "MCP_TELEMETRY_OPT_OUT" value = var.mcp_telemetry_opt_out }, { name = "MCP_TELEMETRY_HEARTBEAT_INTERVAL_MINUTES" value = var.mcp_telemetry_heartbeat_interval_minutes }, { name = "TELEMETRY_DEBUG" value = var.telemetry_debug }, # Demo server configuration { name = "DISABLE_AI_REGISTRY_TOOLS_SERVER" value = var.disable_ai_registry_tools_server }, # Metrics pipeline (only wired when observability is enabled) { name = "METRICS_SERVICE_URL" value = var.enable_observability ? "http://metrics-service:8890" : "" }, # Service Connect namespace for FQDN alias injection in entrypoint. # Enables Python health checker to resolve both short names and FQDNs. { name = "SERVICE_CONNECT_NAMESPACE" value = aws_service_discovery_private_dns_namespace.mcp.name }, # GitHub private repo auth (SKILL.md fetching) { name = "GITHUB_PAT" value = var.github_pat }, { name = "GITHUB_APP_ID" value = var.github_app_id }, { name = "GITHUB_APP_INSTALLATION_ID" value = var.github_app_installation_id }, { name = "GITHUB_APP_PRIVATE_KEY" value = var.github_app_private_key }, { name = "GITHUB_EXTRA_HOSTS" value = var.github_extra_hosts }, { name = "GITHUB_API_BASE_URL" value = var.github_api_base_url }, ] secrets = concat( [ { name = "SECRET_KEY" valueFrom = aws_secretsmanager_secret.secret_key.arn }, { name = "KEYCLOAK_CLIENT_SECRET" valueFrom = "${aws_secretsmanager_secret.keycloak_client_secret.arn}:client_secret::" }, { name = "KEYCLOAK_M2M_CLIENT_SECRET" valueFrom = "${aws_secretsmanager_secret.keycloak_m2m_client_secret.arn}:client_secret::" }, { name = "KEYCLOAK_ADMIN_PASSWORD" valueFrom = aws_secretsmanager_secret.keycloak_admin_password.arn }, { name = "EMBEDDINGS_API_KEY" valueFrom = aws_secretsmanager_secret.embeddings_api_key.arn } ], var.storage_backend == "documentdb" ? [ { name = "DOCUMENTDB_USERNAME" valueFrom = "${var.documentdb_credentials_secret_arn}:username::" }, { name = "DOCUMENTDB_PASSWORD" valueFrom = "${var.documentdb_credentials_secret_arn}:password::" } ] : [], var.entra_enabled ? [ { name = "ENTRA_CLIENT_SECRET" valueFrom = aws_secretsmanager_secret.entra_client_secret[0].arn } ] : [], var.okta_enabled ? [ { name = "OKTA_CLIENT_SECRET" valueFrom = aws_secretsmanager_secret.okta_client_secret[0].arn }, { name = "OKTA_M2M_CLIENT_SECRET" valueFrom = aws_secretsmanager_secret.okta_m2m_client_secret[0].arn }, { name = "OKTA_API_TOKEN" valueFrom = aws_secretsmanager_secret.okta_api_token[0].arn } ] : [], var.auth0_enabled ? [ { name = "AUTH0_CLIENT_SECRET" valueFrom = aws_secretsmanager_secret.auth0_client_secret[0].arn }, { name = "AUTH0_M2M_CLIENT_SECRET" valueFrom = aws_secretsmanager_secret.auth0_m2m_client_secret[0].arn } ] : [], var.enable_observability ? [ { name = "METRICS_API_KEY" valueFrom = aws_secretsmanager_secret.metrics_api_key[0].arn } ] : [] ) # EFS volumes removed - registry now uses ephemeral storage and DocumentDB for persistence # Logs go to CloudWatch only mountPoints = [] enable_cloudwatch_logging = true cloudwatch_log_group_name = "/ecs/${local.name_prefix}-registry" cloudwatch_log_group_retention_in_days = 30 healthCheck = { command = ["CMD-SHELL", "curl -f http://localhost:7860/health || exit 1"] interval = 30 timeout = 5 retries = 3 startPeriod = 60 } } } # EFS volumes removed - registry uses ephemeral storage and DocumentDB for persistence volume = {} load_balancer = { http = { target_group_arn = module.alb.target_groups["registry"].arn container_name = "registry" container_port = 8080 # Non-root nginx listens on 8080 } gradio = { target_group_arn = module.alb.target_groups["gradio"].arn container_name = "registry" container_port = 7860 } } subnet_ids = var.private_subnet_ids security_group_ingress_rules = { alb_8080 = { description = "HTTP port (non-root nginx)" from_port = 8080 to_port = 8080 ip_protocol = "tcp" referenced_security_group_id = module.alb.security_group_id } alb_8443 = { description = "HTTPS port (non-root nginx)" from_port = 8443 to_port = 8443 ip_protocol = "tcp" referenced_security_group_id = module.alb.security_group_id } alb_7860 = { description = "Gradio port" from_port = 7860 to_port = 7860 ip_protocol = "tcp" referenced_security_group_id = module.alb.security_group_id } mcpgw_internal = { description = "HTTP from mcpgw for internal API calls (non-root nginx)" from_port = 8080 to_port = 8080 ip_protocol = "tcp" referenced_security_group_id = module.ecs_service_mcpgw.security_group_id } } security_group_egress_rules = { all = { ip_protocol = "-1" cidr_ipv4 = "0.0.0.0/0" } } tags = local.common_tags } # Allow mcpgw to communicate with registry on port 7860 resource "aws_vpc_security_group_ingress_rule" "mcpgw_to_registry" { security_group_id = module.ecs_service_registry.security_group_id referenced_security_group_id = module.ecs_service_mcpgw.security_group_id from_port = 7860 to_port = 7860 ip_protocol = "tcp" description = "Allow mcpgw to access registry API" tags = local.common_tags } # Allow registry to communicate with auth server on port 8888 resource "aws_vpc_security_group_ingress_rule" "registry_to_auth" { security_group_id = module.ecs_service_auth.security_group_id referenced_security_group_id = module.ecs_service_registry.security_group_id from_port = 8888 to_port = 8888 ip_protocol = "tcp" description = "Allow registry to access auth server" tags = local.common_tags } # ECS Service: CurrentTime MCP Server #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "ecs_service_currenttime" { source = "terraform-aws-modules/ecs/aws//modules/service" version = "~> 6.0" name = "${local.name_prefix}-currenttime" cluster_arn = var.ecs_cluster_arn cpu = "512" memory = "1024" desired_count = var.enable_autoscaling ? var.autoscaling_min_capacity : var.currenttime_replicas enable_autoscaling = var.enable_autoscaling autoscaling_min_capacity = var.autoscaling_min_capacity autoscaling_max_capacity = var.autoscaling_max_capacity autoscaling_policies = var.enable_autoscaling ? { cpu = { policy_type = "TargetTrackingScaling" target_tracking_scaling_policy_configuration = { predefined_metric_specification = { predefined_metric_type = "ECSServiceAverageCPUUtilization" } target_value = var.autoscaling_target_cpu } } } : {} enable_execute_command = true requires_compatibilities = ["FARGATE", "EC2"] capacity_provider_strategy = { FARGATE = { capacity_provider = "FARGATE" weight = 100 base = 1 } } create_task_exec_iam_role = true task_exec_iam_role_policies = { EcsExecTaskExecution = aws_iam_policy.ecs_exec_task_execution.arn } create_tasks_iam_role = true tasks_iam_role_policies = { EcsExecTask = aws_iam_policy.ecs_exec_task.arn } service_connect_configuration = { namespace = aws_service_discovery_private_dns_namespace.mcp.arn service = [{ client_alias = { port = 8000 dns_name = "currenttime-server" } port_name = "currenttime" discovery_name = "currenttime-server" }] } container_definitions = { currenttime-server = { cpu = 512 memory = 1024 essential = true image = var.currenttime_image_uri versionConsistency = "disabled" readonlyRootFilesystem = false portMappings = [ { name = "currenttime" containerPort = 8000 protocol = "tcp" } ] environment = [ { name = "PORT" value = "8000" }, { name = "MCP_TRANSPORT" value = "streamable-http" } ] enable_cloudwatch_logging = true cloudwatch_log_group_name = "/ecs/${local.name_prefix}-currenttime" cloudwatch_log_group_retention_in_days = 30 healthCheck = { command = ["CMD-SHELL", "nc -z localhost 8000 || exit 1"] interval = 30 timeout = 5 retries = 3 startPeriod = 30 } } } subnet_ids = var.private_subnet_ids security_group_ingress_rules = { service_connect = { description = "Service Connect from registry" from_port = 8000 to_port = 8000 ip_protocol = "tcp" referenced_security_group_id = module.ecs_service_registry.security_group_id } } security_group_egress_rules = { all = { ip_protocol = "-1" cidr_ipv4 = "0.0.0.0/0" } } tags = local.common_tags } # ECS Service: MCPGW MCP Server #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "ecs_service_mcpgw" { source = "terraform-aws-modules/ecs/aws//modules/service" version = "~> 6.0" name = "${local.name_prefix}-mcpgw" cluster_arn = var.ecs_cluster_arn cpu = "512" memory = "1024" desired_count = var.enable_autoscaling ? var.autoscaling_min_capacity : var.mcpgw_replicas enable_autoscaling = var.enable_autoscaling autoscaling_min_capacity = var.autoscaling_min_capacity autoscaling_max_capacity = var.autoscaling_max_capacity autoscaling_policies = var.enable_autoscaling ? { cpu = { policy_type = "TargetTrackingScaling" target_tracking_scaling_policy_configuration = { predefined_metric_specification = { predefined_metric_type = "ECSServiceAverageCPUUtilization" } target_value = var.autoscaling_target_cpu } } } : {} enable_execute_command = true requires_compatibilities = ["FARGATE", "EC2"] capacity_provider_strategy = { FARGATE = { capacity_provider = "FARGATE" weight = 100 base = 1 } } create_task_exec_iam_role = true task_exec_iam_role_policies = { SecretsManagerAccess = aws_iam_policy.ecs_secrets_access.arn EcsExecTaskExecution = aws_iam_policy.ecs_exec_task_execution.arn } create_tasks_iam_role = true tasks_iam_role_policies = { SecretsManagerAccess = aws_iam_policy.ecs_secrets_access.arn EcsExecTask = aws_iam_policy.ecs_exec_task.arn } service_connect_configuration = { namespace = aws_service_discovery_private_dns_namespace.mcp.arn service = [{ client_alias = { port = 8003 dns_name = "mcpgw-server" } port_name = "mcpgw" discovery_name = "mcpgw-server" }] } container_definitions = { mcpgw-server = { cpu = 512 memory = 1024 essential = true image = var.mcpgw_image_uri versionConsistency = "disabled" readonlyRootFilesystem = false portMappings = [ { name = "mcpgw" containerPort = 8003 protocol = "tcp" } ] environment = [ { name = "PORT" value = "8003" }, { name = "REGISTRY_BASE_URL" value = "http://registry:8080" }, { name = "REGISTRY_USERNAME" value = "admin" } ] secrets = [] mountPoints = [ { sourceVolume = "mcpgw-data" containerPath = "/app/data" readOnly = false } ] enable_cloudwatch_logging = true cloudwatch_log_group_name = "/ecs/${local.name_prefix}-mcpgw" cloudwatch_log_group_retention_in_days = 30 healthCheck = { command = ["CMD-SHELL", "nc -z localhost 8003 || exit 1"] interval = 30 timeout = 5 retries = 3 startPeriod = 30 } } } volume = { mcpgw-data = { efs_volume_configuration = { file_system_id = module.efs.id access_point_id = module.efs.access_points["mcpgw_data"].id transit_encryption = "ENABLED" } } } subnet_ids = var.private_subnet_ids security_group_ingress_rules = { service_connect = { description = "Service Connect from registry" from_port = 8003 to_port = 8003 ip_protocol = "tcp" referenced_security_group_id = module.ecs_service_registry.security_group_id } } security_group_egress_rules = { all = { ip_protocol = "-1" cidr_ipv4 = "0.0.0.0/0" } } tags = local.common_tags } # ECS Service: RealServerFakeTools MCP Server #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "ecs_service_realserverfaketools" { source = "terraform-aws-modules/ecs/aws//modules/service" version = "~> 6.0" name = "${local.name_prefix}-realserverfaketools" cluster_arn = var.ecs_cluster_arn cpu = "512" memory = "1024" desired_count = var.enable_autoscaling ? var.autoscaling_min_capacity : var.realserverfaketools_replicas enable_autoscaling = var.enable_autoscaling autoscaling_min_capacity = var.autoscaling_min_capacity autoscaling_max_capacity = var.autoscaling_max_capacity autoscaling_policies = var.enable_autoscaling ? { cpu = { policy_type = "TargetTrackingScaling" target_tracking_scaling_policy_configuration = { predefined_metric_specification = { predefined_metric_type = "ECSServiceAverageCPUUtilization" } target_value = var.autoscaling_target_cpu } } } : {} enable_execute_command = true requires_compatibilities = ["FARGATE", "EC2"] capacity_provider_strategy = { FARGATE = { capacity_provider = "FARGATE" weight = 100 base = 1 } } create_task_exec_iam_role = true task_exec_iam_role_policies = { EcsExecTaskExecution = aws_iam_policy.ecs_exec_task_execution.arn } create_tasks_iam_role = true tasks_iam_role_policies = { EcsExecTask = aws_iam_policy.ecs_exec_task.arn } service_connect_configuration = { namespace = aws_service_discovery_private_dns_namespace.mcp.arn service = [{ client_alias = { port = 8002 dns_name = "realserverfaketools-server" } port_name = "realserverfaketools" discovery_name = "realserverfaketools-server" }] } container_definitions = { realserverfaketools-server = { cpu = 512 memory = 1024 essential = true image = var.realserverfaketools_image_uri versionConsistency = "disabled" readonlyRootFilesystem = false portMappings = [ { name = "realserverfaketools" containerPort = 8002 protocol = "tcp" } ] environment = [ { name = "PORT" value = "8002" }, { name = "MCP_TRANSPORT" value = "streamable-http" } ] enable_cloudwatch_logging = true cloudwatch_log_group_name = "/ecs/${local.name_prefix}-realserverfaketools" cloudwatch_log_group_retention_in_days = 30 healthCheck = { command = ["CMD-SHELL", "nc -z localhost 8002 || exit 1"] interval = 30 timeout = 5 retries = 3 startPeriod = 30 } } } subnet_ids = var.private_subnet_ids security_group_ingress_rules = { service_connect = { description = "Service Connect from registry" from_port = 8002 to_port = 8002 ip_protocol = "tcp" referenced_security_group_id = module.ecs_service_registry.security_group_id } } security_group_egress_rules = { all = { ip_protocol = "-1" cidr_ipv4 = "0.0.0.0/0" } } tags = local.common_tags } # ECS Service: Flight Booking A2A Agent #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "ecs_service_flight_booking_agent" { source = "terraform-aws-modules/ecs/aws//modules/service" version = "~> 6.0" name = "${local.name_prefix}-flight-booking-agent" cluster_arn = var.ecs_cluster_arn cpu = "512" memory = "1024" desired_count = var.enable_autoscaling ? var.autoscaling_min_capacity : var.flight_booking_agent_replicas enable_autoscaling = var.enable_autoscaling autoscaling_min_capacity = var.autoscaling_min_capacity autoscaling_max_capacity = var.autoscaling_max_capacity autoscaling_policies = var.enable_autoscaling ? { cpu = { policy_type = "TargetTrackingScaling" target_tracking_scaling_policy_configuration = { predefined_metric_specification = { predefined_metric_type = "ECSServiceAverageCPUUtilization" } target_value = var.autoscaling_target_cpu } } } : {} enable_execute_command = true requires_compatibilities = ["FARGATE", "EC2"] capacity_provider_strategy = { FARGATE = { capacity_provider = "FARGATE" weight = 100 base = 1 } } create_task_exec_iam_role = true task_exec_iam_role_policies = { EcsExecTaskExecution = aws_iam_policy.ecs_exec_task_execution.arn } create_tasks_iam_role = true tasks_iam_role_policies = { EcsExecTask = aws_iam_policy.ecs_exec_task.arn } service_connect_configuration = { namespace = aws_service_discovery_private_dns_namespace.mcp.arn service = [{ client_alias = { port = 9000 dns_name = "flight-booking-agent" } port_name = "flight-booking" discovery_name = "flight-booking-agent" }] } container_definitions = { flight-booking-agent = { cpu = 512 memory = 1024 essential = true image = var.flight_booking_agent_image_uri versionConsistency = "disabled" readonlyRootFilesystem = false portMappings = [ { name = "flight-booking" containerPort = 9000 protocol = "tcp" } ] environment = [ { name = "AWS_REGION" value = data.aws_region.current.id }, { name = "AWS_DEFAULT_REGION" value = data.aws_region.current.id } ] enable_cloudwatch_logging = true cloudwatch_log_group_name = "/ecs/${local.name_prefix}-flight-booking-agent" cloudwatch_log_group_retention_in_days = 30 healthCheck = { command = ["CMD-SHELL", "curl -f http://localhost:9000/ping || exit 1"] interval = 30 timeout = 5 retries = 3 startPeriod = 60 } } } subnet_ids = var.private_subnet_ids security_group_ingress_rules = { service_connect = { description = "Service Connect - A2A protocol" from_port = 9000 to_port = 9000 ip_protocol = "tcp" cidr_ipv4 = data.aws_vpc.vpc.cidr_block } } security_group_egress_rules = { all = { ip_protocol = "-1" cidr_ipv4 = "0.0.0.0/0" } } tags = local.common_tags } # ECS Service: Travel Assistant A2A Agent #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "ecs_service_travel_assistant_agent" { source = "terraform-aws-modules/ecs/aws//modules/service" version = "~> 6.0" name = "${local.name_prefix}-travel-assistant-agent" cluster_arn = var.ecs_cluster_arn cpu = "512" memory = "1024" desired_count = var.enable_autoscaling ? var.autoscaling_min_capacity : var.travel_assistant_agent_replicas enable_autoscaling = var.enable_autoscaling autoscaling_min_capacity = var.autoscaling_min_capacity autoscaling_max_capacity = var.autoscaling_max_capacity autoscaling_policies = var.enable_autoscaling ? { cpu = { policy_type = "TargetTrackingScaling" target_tracking_scaling_policy_configuration = { predefined_metric_specification = { predefined_metric_type = "ECSServiceAverageCPUUtilization" } target_value = var.autoscaling_target_cpu } } } : {} enable_execute_command = true requires_compatibilities = ["FARGATE", "EC2"] capacity_provider_strategy = { FARGATE = { capacity_provider = "FARGATE" weight = 100 base = 1 } } create_task_exec_iam_role = true task_exec_iam_role_policies = { EcsExecTaskExecution = aws_iam_policy.ecs_exec_task_execution.arn } create_tasks_iam_role = true tasks_iam_role_policies = { EcsExecTask = aws_iam_policy.ecs_exec_task.arn } service_connect_configuration = { namespace = aws_service_discovery_private_dns_namespace.mcp.arn service = [{ client_alias = { port = 9000 dns_name = "travel-assistant-agent" } port_name = "travel-assistant" discovery_name = "travel-assistant-agent" }] } container_definitions = { travel-assistant-agent = { cpu = 512 memory = 1024 essential = true image = var.travel_assistant_agent_image_uri versionConsistency = "disabled" readonlyRootFilesystem = false portMappings = [ { name = "travel-assistant" containerPort = 9000 protocol = "tcp" } ] environment = [ { name = "AWS_REGION" value = data.aws_region.current.id }, { name = "AWS_DEFAULT_REGION" value = data.aws_region.current.id } ] enable_cloudwatch_logging = true cloudwatch_log_group_name = "/ecs/${local.name_prefix}-travel-assistant-agent" cloudwatch_log_group_retention_in_days = 30 healthCheck = { command = ["CMD-SHELL", "curl -f http://localhost:9000/ping || exit 1"] interval = 30 timeout = 5 retries = 3 startPeriod = 60 } } } subnet_ids = var.private_subnet_ids security_group_ingress_rules = { service_connect = { description = "Service Connect - A2A protocol" from_port = 9000 to_port = 9000 ip_protocol = "tcp" cidr_ipv4 = data.aws_vpc.vpc.cidr_block } } security_group_egress_rules = { all = { ip_protocol = "-1" cidr_ipv4 = "0.0.0.0/0" } } tags = local.common_tags } ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/iam.tf ================================================ # IAM resources for MCP Gateway Registry ECS services # IAM policy for ECS tasks to access Secrets Manager resource "aws_iam_policy" "ecs_secrets_access" { name_prefix = "${local.name_prefix}-ecs-secrets-" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "secretsmanager:GetSecretValue" ] Resource = concat( [ aws_secretsmanager_secret.secret_key.arn, aws_secretsmanager_secret.keycloak_client_secret.arn, aws_secretsmanager_secret.keycloak_m2m_client_secret.arn, aws_secretsmanager_secret.embeddings_api_key.arn, aws_secretsmanager_secret.keycloak_admin_password.arn ], var.documentdb_credentials_secret_arn != "" ? [var.documentdb_credentials_secret_arn] : [], var.entra_enabled ? [aws_secretsmanager_secret.entra_client_secret[0].arn] : [], var.okta_enabled ? [ aws_secretsmanager_secret.okta_client_secret[0].arn, aws_secretsmanager_secret.okta_m2m_client_secret[0].arn, aws_secretsmanager_secret.okta_api_token[0].arn ] : [], var.auth0_enabled ? [ aws_secretsmanager_secret.auth0_client_secret[0].arn, aws_secretsmanager_secret.auth0_m2m_client_secret[0].arn ] : [], var.enable_observability ? [aws_secretsmanager_secret.metrics_api_key[0].arn] : [], var.enable_observability && var.otel_otlp_endpoint != "" ? [aws_secretsmanager_secret.otlp_exporter_headers[0].arn] : [] ) }, { Effect = "Allow" Action = [ "kms:Decrypt", "kms:DescribeKey" ] Resource = [ aws_kms_key.secrets.arn ] } ] }) tags = local.common_tags } # IAM policy for ECS Exec - task execution role resource "aws_iam_policy" "ecs_exec_task_execution" { name_prefix = "${local.name_prefix}-ecs-exec-task-exec-" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "ssmmessages:CreateControlChannel", "ssmmessages:CreateDataChannel", "ssmmessages:OpenControlChannel", "ssmmessages:OpenDataChannel" ] Resource = "*" }, { Effect = "Allow" Action = [ "logs:CreateLogStream", "logs:DescribeLogGroups", "logs:DescribeLogStreams", "logs:PutLogEvents" ] Resource = "arn:aws:logs:*:*:*" } ] }) tags = local.common_tags } # IAM policy for Amazon Bedrock AgentCore access (registry federation) resource "aws_iam_policy" "bedrock_agentcore_access" { count = var.aws_registry_federation_enabled ? 1 : 0 name_prefix = "${local.name_prefix}-bedrock-agentcore-" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "BedrockAgentCoreFullAccess" Effect = "Allow" Action = [ "bedrock-agentcore:*" ] Resource = "*" }, { Sid = "StsAssumeRoleForCrossAccount" Effect = "Allow" Action = [ "sts:AssumeRole" ] Resource = "*" Condition = { StringLike = { "iam:ResourceTag/Purpose" = "agentcore-federation" } } } ] }) tags = local.common_tags } # IAM policy for ECS Exec - task role resource "aws_iam_policy" "ecs_exec_task" { name_prefix = "${local.name_prefix}-ecs-exec-task-" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "ssmmessages:CreateControlChannel", "ssmmessages:CreateDataChannel", "ssmmessages:OpenControlChannel", "ssmmessages:OpenDataChannel" ] Resource = "*" } ] }) tags = local.common_tags } ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/locals.tf ================================================ # Local values for MCP Gateway Registry Module locals { name_prefix = var.name common_tags = merge( { stack = var.name component = "mcp-gateway-registry" }, var.additional_tags ) } ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/main.tf ================================================ # MCP Gateway Registry Module - Main Configuration # This file serves as the entry point and includes core module documentation ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/monitoring.tf ================================================ # CloudWatch Monitoring and Alarms for MCP Gateway # SNS Topic for Alarm Notifications #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "sns_alarms" { source = "terraform-aws-modules/sns/aws" version = "~> 7.0" create = var.enable_monitoring && var.alarm_email != "" name = "${local.name_prefix}-alarms-" use_name_prefix = true subscriptions = var.alarm_email != "" ? { email = { protocol = "email" endpoint = var.alarm_email } } : {} tags = local.common_tags } # ECS Service CPU Alarms resource "aws_cloudwatch_metric_alarm" "auth_cpu_high" { count = var.enable_monitoring ? 1 : 0 alarm_name = "${local.name_prefix}-auth-cpu-high" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "CPUUtilization" namespace = "AWS/ECS" period = 300 statistic = "Average" threshold = 85 alarm_description = "Auth service CPU utilization is too high" alarm_actions = var.alarm_email != "" ? [module.sns_alarms.topic_arn] : [] dimensions = { ClusterName = var.ecs_cluster_name ServiceName = module.ecs_service_auth.name } } resource "aws_cloudwatch_metric_alarm" "registry_cpu_high" { count = var.enable_monitoring ? 1 : 0 alarm_name = "${local.name_prefix}-registry-cpu-high" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "CPUUtilization" namespace = "AWS/ECS" period = 300 statistic = "Average" threshold = 85 alarm_description = "Registry service CPU utilization is too high" alarm_actions = var.alarm_email != "" ? [module.sns_alarms.topic_arn] : [] dimensions = { ClusterName = var.ecs_cluster_name ServiceName = module.ecs_service_registry.name } } # ECS Service Memory Alarms resource "aws_cloudwatch_metric_alarm" "auth_memory_high" { count = var.enable_monitoring ? 1 : 0 alarm_name = "${local.name_prefix}-auth-memory-high" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "MemoryUtilization" namespace = "AWS/ECS" period = 300 statistic = "Average" threshold = 85 alarm_description = "Auth service memory utilization is too high" alarm_actions = var.alarm_email != "" ? [module.sns_alarms.topic_arn] : [] dimensions = { ClusterName = var.ecs_cluster_name ServiceName = module.ecs_service_auth.name } } resource "aws_cloudwatch_metric_alarm" "registry_memory_high" { count = var.enable_monitoring ? 1 : 0 alarm_name = "${local.name_prefix}-registry-memory-high" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "MemoryUtilization" namespace = "AWS/ECS" period = 300 statistic = "Average" threshold = 85 alarm_description = "Registry service memory utilization is too high" alarm_actions = var.alarm_email != "" ? [module.sns_alarms.topic_arn] : [] dimensions = { ClusterName = var.ecs_cluster_name ServiceName = module.ecs_service_registry.name } } # ALB Target Health Alarms resource "aws_cloudwatch_metric_alarm" "alb_unhealthy_targets" { count = var.enable_monitoring ? 1 : 0 alarm_name = "${local.name_prefix}-alb-unhealthy-targets" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "UnHealthyHostCount" namespace = "AWS/ApplicationELB" period = 60 statistic = "Average" threshold = 0 alarm_description = "ALB has unhealthy targets" alarm_actions = var.alarm_email != "" ? [module.sns_alarms.topic_arn] : [] dimensions = { LoadBalancer = module.alb.arn_suffix } } # ALB 5XX Error Rate Alarm resource "aws_cloudwatch_metric_alarm" "alb_5xx_errors" { count = var.enable_monitoring ? 1 : 0 alarm_name = "${local.name_prefix}-alb-5xx-errors" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "HTTPCode_Target_5XX_Count" namespace = "AWS/ApplicationELB" period = 300 statistic = "Sum" threshold = 10 alarm_description = "ALB is receiving too many 5XX errors" alarm_actions = var.alarm_email != "" ? [module.sns_alarms.topic_arn] : [] dimensions = { LoadBalancer = module.alb.arn_suffix } } # ALB Response Time Alarm resource "aws_cloudwatch_metric_alarm" "alb_response_time" { count = var.enable_monitoring ? 1 : 0 alarm_name = "${local.name_prefix}-alb-response-time" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "TargetResponseTime" namespace = "AWS/ApplicationELB" period = 300 statistic = "Average" threshold = 1 alarm_description = "ALB response time is too high" alarm_actions = var.alarm_email != "" ? [module.sns_alarms.topic_arn] : [] dimensions = { LoadBalancer = module.alb.arn_suffix } } ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/networking.tf ================================================ # Networking resources for MCP Gateway Registry # Service Discovery Namespace resource "aws_service_discovery_private_dns_namespace" "mcp" { name = "${local.name_prefix}.local" description = "Service discovery namespace for MCP Gateway Registry" vpc = var.vpc_id tags = local.common_tags } # CloudFront managed prefix list (for allowing CloudFront or other CDN IPs) # Default prefix list is AWS CloudFront origin-facing IPs (com.amazonaws.global.cloudfront.origin-facing) data "aws_ec2_managed_prefix_list" "cloudfront" { count = var.cloudfront_prefix_list_name != "" ? 1 : 0 name = var.cloudfront_prefix_list_name } # Separate security group for CloudFront prefix list ingress # This avoids hitting the 60 rules per security group limit since the CloudFront # prefix list has ~55 reserved entries that count against the quota #checkov:skip=CKV2_AWS_5:Security group is attached to ALB via security_groups parameter resource "aws_security_group" "alb_cloudfront" { count = var.cloudfront_prefix_list_name != "" ? 1 : 0 name = "${local.name_prefix}-alb-cloudfront" description = "Security group for CloudFront access to MCP Gateway ALB" vpc_id = var.vpc_id tags = merge( local.common_tags, { Name = "${local.name_prefix}-alb-cloudfront" } ) } resource "aws_security_group_rule" "alb_cloudfront_ingress_http" { count = var.cloudfront_prefix_list_name != "" ? 1 : 0 description = "Ingress from CloudFront prefix list to ALB (HTTP)" type = "ingress" from_port = 80 to_port = 80 protocol = "tcp" prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront[0].id] security_group_id = aws_security_group.alb_cloudfront[0].id } # checkov:skip=CKV_AWS_382:ALB security group requires unrestricted egress to reach ECS tasks and health checks resource "aws_security_group_rule" "alb_cloudfront_egress" { count = var.cloudfront_prefix_list_name != "" ? 1 : 0 description = "Egress to all" type = "egress" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.alb_cloudfront[0].id } # Main Application Load Balancer (for registry, auth, gradio) #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "alb" { source = "terraform-aws-modules/alb/aws" version = "~> 9.0" name = "${local.name_prefix}-alb" load_balancer_type = "application" internal = var.alb_scheme == "internal" enable_deletion_protection = false vpc_id = var.vpc_id subnets = var.alb_scheme == "internal" ? var.private_subnet_ids : var.public_subnet_ids # Attach additional security groups (CloudFront SG when enabled) # This keeps CloudFront prefix list rules in a separate SG to avoid the 60 rules/SG limit security_groups = var.cloudfront_prefix_list_name != "" ? [aws_security_group.alb_cloudfront[0].id] : [] # Enable access logs access_logs = { bucket = var.alb_logs_bucket enabled = true } # Security Groups # Create dynamic ingress rules for each CIDR block and port combination # Note: CloudFront prefix list is in a separate SG (alb_cloudfront) to avoid rules limit security_group_ingress_rules = merge( merge([ for idx, cidr in var.ingress_cidr_blocks : { "http_${idx}" = { from_port = 80 to_port = 80 ip_protocol = "tcp" cidr_ipv4 = cidr } "https_${idx}" = { from_port = 443 to_port = 443 ip_protocol = "tcp" cidr_ipv4 = cidr } "auth_port_${idx}" = { from_port = 8888 to_port = 8888 ip_protocol = "tcp" cidr_ipv4 = cidr } "gradio_port_${idx}" = { from_port = 7860 to_port = 7860 ip_protocol = "tcp" cidr_ipv4 = cidr } } ]...), { } ) security_group_egress_rules = { all = { ip_protocol = "-1" cidr_ipv4 = "0.0.0.0/0" } } listeners = merge( { http = { port = 80 protocol = "HTTP" forward = { target_group_key = "registry" } } auth = { port = 8888 protocol = var.enable_https ? "HTTPS" : "HTTP" certificate_arn = var.enable_https ? var.certificate_arn : null ssl_policy = var.enable_https ? "ELBSecurityPolicy-TLS13-1-2-2021-06" : null forward = { target_group_key = "auth" } } gradio = { port = 7860 protocol = "HTTP" forward = { target_group_key = "gradio" } } }, var.enable_https ? { https = { port = 443 protocol = "HTTPS" certificate_arn = var.certificate_arn ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06" forward = { target_group_key = "registry" } } } : {} ) target_groups = { registry = { backend_protocol = "HTTP" backend_port = 8080 target_type = "ip" deregistration_delay = 5 load_balancing_cross_zone_enabled = true health_check = { enabled = true healthy_threshold = 2 interval = 30 matcher = "200" path = "/health" port = 8080 protocol = "HTTP" timeout = 5 unhealthy_threshold = 2 } create_attachment = false } auth = { backend_protocol = "HTTP" backend_port = 8888 target_type = "ip" deregistration_delay = 5 load_balancing_cross_zone_enabled = true health_check = { enabled = true healthy_threshold = 2 interval = 30 matcher = "200" path = "/health" port = "traffic-port" protocol = "HTTP" timeout = 5 unhealthy_threshold = 2 } create_attachment = false } gradio = { backend_protocol = "HTTP" backend_port = 7860 target_type = "ip" deregistration_delay = 5 load_balancing_cross_zone_enabled = true health_check = { enabled = true healthy_threshold = 2 interval = 30 matcher = "200" path = "/health" port = "traffic-port" protocol = "HTTP" timeout = 5 unhealthy_threshold = 2 } create_attachment = false } } tags = local.common_tags } ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/observability.tf ================================================ # Observability Pipeline for MCP Gateway Registry # Creates: AMP workspace, metrics-service (with ADOT sidecar), Grafana OSS # All resources gated by var.enable_observability # ============================================================================= # AMAZON MANAGED PROMETHEUS (AMP) # ============================================================================= resource "aws_prometheus_workspace" "mcp" { count = var.enable_observability ? 1 : 0 alias = "${local.name_prefix}-prometheus" tags = local.common_tags } locals { amp_remote_write_endpoint = var.enable_observability ? "${aws_prometheus_workspace.mcp[0].prometheus_endpoint}api/v1/remote_write" : "" amp_query_endpoint = var.enable_observability ? aws_prometheus_workspace.mcp[0].prometheus_endpoint : "" # ADOT collector configuration (embedded YAML) # ADOT runs as a sidecar in the metrics-service task, scrapes localhost:9465 adot_config = var.enable_observability ? yamlencode({ receivers = { prometheus = { config = { global = { scrape_interval = "15s" } scrape_configs = [ { job_name = "mcp-metrics-service" scrape_interval = "15s" metrics_path = "/metrics" static_configs = [ { targets = ["localhost:9465"] } ] } ] } } } exporters = { prometheusremotewrite = { endpoint = local.amp_remote_write_endpoint auth = { authenticator = "sigv4auth" } } } extensions = { sigv4auth = { region = data.aws_region.current.id } health_check = { endpoint = "0.0.0.0:13133" } } service = { extensions = ["sigv4auth", "health_check"] pipelines = { metrics = { receivers = ["prometheus"] exporters = ["prometheusremotewrite"] } } } }) : "" } # ============================================================================= # METRICS-SERVICE ECS SERVICE # ============================================================================= #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "ecs_service_metrics" { count = var.enable_observability ? 1 : 0 source = "terraform-aws-modules/ecs/aws//modules/service" version = "~> 6.0" name = "${local.name_prefix}-metrics-service" cluster_arn = var.ecs_cluster_arn cpu = 512 memory = 1024 desired_count = 1 enable_autoscaling = false enable_execute_command = true requires_compatibilities = ["FARGATE", "EC2"] capacity_provider_strategy = { FARGATE = { capacity_provider = "FARGATE" weight = 100 base = 1 } } create_task_exec_iam_role = true task_exec_iam_role_policies = { SecretsManagerAccess = aws_iam_policy.ecs_secrets_access.arn EcsExecTaskExecution = aws_iam_policy.ecs_exec_task_execution.arn } create_tasks_iam_role = true tasks_iam_role_policies = { SecretsManagerAccess = aws_iam_policy.ecs_secrets_access.arn EcsExecTask = aws_iam_policy.ecs_exec_task.arn AMPRemoteWrite = aws_iam_policy.adot_amp_write[0].arn } service_connect_configuration = { namespace = aws_service_discovery_private_dns_namespace.mcp.arn service = [ { client_alias = { port = 8890 dns_name = "metrics-service" } port_name = "metrics-api" discovery_name = "metrics-service" } ] } container_definitions = { metrics-service = { cpu = 256 memory = 512 essential = true image = var.metrics_service_image_uri versionConsistency = "disabled" readonlyRootFilesystem = false portMappings = [ { name = "metrics-api" containerPort = 8890 protocol = "tcp" }, { name = "prometheus-exporter" containerPort = 9465 protocol = "tcp" } ] environment = [ { name = "METRICS_SERVICE_HOST" value = "0.0.0.0" }, { name = "METRICS_SERVICE_PORT" value = "8890" }, { name = "OTEL_SERVICE_NAME" value = "mcp-metrics-service" }, { name = "OTEL_PROMETHEUS_ENABLED" value = "true" }, { name = "OTEL_PROMETHEUS_PORT" value = "9465" }, { name = "METRICS_RATE_LIMIT" value = "1000" }, { name = "HISTOGRAM_BUCKET_BOUNDARIES" value = "0.005,0.01,0.025,0.05,0.1,0.25,0.5,1.0,2.5,5.0,10.0,30.0,60.0,120.0,300.0" }, { name = "SQLITE_DB_PATH" value = "/tmp/metrics.db" }, { name = "METRICS_RETENTION_DAYS" value = "7" }, { name = "OTEL_OTLP_ENDPOINT" value = var.otel_otlp_endpoint }, { name = "OTEL_OTLP_EXPORT_INTERVAL_MS" value = tostring(var.otel_otlp_export_interval_ms) }, { name = "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE" value = var.otel_exporter_otlp_metrics_temporality_preference } ] secrets = concat( [ { name = "METRICS_API_KEY_REGISTRY" valueFrom = aws_secretsmanager_secret.metrics_api_key[0].arn }, { name = "METRICS_API_KEY_AUTH" valueFrom = aws_secretsmanager_secret.metrics_api_key[0].arn }, { name = "METRICS_API_KEY_MCPGW" valueFrom = aws_secretsmanager_secret.metrics_api_key[0].arn } ], var.otel_otlp_endpoint != "" ? [ { name = "OTEL_EXPORTER_OTLP_HEADERS" valueFrom = aws_secretsmanager_secret.otlp_exporter_headers[0].arn } ] : [] ) enable_cloudwatch_logging = true cloudwatch_log_group_name = "/ecs/${local.name_prefix}-metrics-service" cloudwatch_log_group_retention_in_days = 30 healthCheck = { command = ["CMD-SHELL", "curl -f http://localhost:8890/health || exit 1"] interval = 30 timeout = 5 retries = 3 startPeriod = 30 } } # ADOT collector sidecar — scrapes metrics-service on localhost:9465 # and remote-writes to AMP. Co-located to avoid Service Connect DNS # resolution issues (HTTP-type Cloud Map services have no Route53 records). adot-collector = { cpu = 256 memory = 512 essential = false image = "public.ecr.aws/aws-observability/aws-otel-collector:latest" versionConsistency = "disabled" readonlyRootFilesystem = false command = ["--config=env:AOT_CONFIG_CONTENT"] environment = [ { name = "AOT_CONFIG_CONTENT" value = local.adot_config }, { name = "AWS_REGION" value = data.aws_region.current.id } ] enable_cloudwatch_logging = true cloudwatch_log_group_name = "/ecs/${local.name_prefix}-adot-collector" cloudwatch_log_group_retention_in_days = 30 dependencies = [{ containerName = "metrics-service" condition = "HEALTHY" }] } } subnet_ids = var.private_subnet_ids security_group_ingress_rules = { auth_8890 = { description = "Metrics API from auth-server" from_port = 8890 to_port = 8890 ip_protocol = "tcp" referenced_security_group_id = module.ecs_service_auth.security_group_id } registry_8890 = { description = "Metrics API from registry" from_port = 8890 to_port = 8890 ip_protocol = "tcp" referenced_security_group_id = module.ecs_service_registry.security_group_id } } security_group_egress_rules = { all = { ip_protocol = "-1" cidr_ipv4 = "0.0.0.0/0" } } tags = local.common_tags } # ============================================================================= # ADOT COLLECTOR — IAM POLICY FOR AMP REMOTE WRITE # ============================================================================= # ADOT runs as a sidecar in the metrics-service task (above). # This policy is attached to the metrics-service task role. resource "aws_iam_policy" "adot_amp_write" { count = var.enable_observability ? 1 : 0 name_prefix = "${local.name_prefix}-adot-amp-write-" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "aps:RemoteWrite", "aps:GetSeries", "aps:GetLabels", "aps:GetMetricMetadata" ] Resource = aws_prometheus_workspace.mcp[0].arn } ] }) tags = local.common_tags } # ============================================================================= # GRAFANA OSS ECS SERVICE # ============================================================================= # IAM policy for Grafana to query AMP resource "aws_iam_policy" "grafana_amp_query" { count = var.enable_observability ? 1 : 0 name_prefix = "${local.name_prefix}-grafana-amp-query-" policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "aps:QueryMetrics", "aps:GetSeries", "aps:GetLabels", "aps:GetMetricMetadata" ] Resource = aws_prometheus_workspace.mcp[0].arn } ] }) tags = local.common_tags } # ALB target group for Grafana #checkov:skip=CKV_AWS_378:HTTP backend protocol is intentional - TLS terminates at ALB resource "aws_lb_target_group" "grafana" { count = var.enable_observability ? 1 : 0 name_prefix = "graf-" port = 3000 protocol = "HTTP" vpc_id = var.vpc_id target_type = "ip" deregistration_delay = 5 health_check { enabled = true healthy_threshold = 2 interval = 30 matcher = "200" path = "/api/health" port = "traffic-port" protocol = "HTTP" timeout = 5 unhealthy_threshold = 2 } tags = local.common_tags } # ALB listener rule for Grafana (path-based routing on /grafana/*) resource "aws_lb_listener_rule" "grafana_http" { count = var.enable_observability ? 1 : 0 listener_arn = module.alb.listeners["http"].arn priority = 15 action { type = "forward" target_group_arn = aws_lb_target_group.grafana[0].arn } condition { path_pattern { values = ["/grafana", "/grafana/*"] } } tags = local.common_tags } resource "aws_lb_listener_rule" "grafana_https" { count = var.enable_observability && var.enable_https ? 1 : 0 listener_arn = module.alb.listeners["https"].arn priority = 15 action { type = "forward" target_group_arn = aws_lb_target_group.grafana[0].arn } condition { path_pattern { values = ["/grafana", "/grafana/*"] } } tags = local.common_tags } #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "ecs_service_grafana" { count = var.enable_observability ? 1 : 0 source = "terraform-aws-modules/ecs/aws//modules/service" version = "~> 6.0" name = "${local.name_prefix}-grafana" cluster_arn = var.ecs_cluster_arn cpu = 512 memory = 1024 desired_count = 1 enable_autoscaling = false enable_execute_command = true requires_compatibilities = ["FARGATE", "EC2"] capacity_provider_strategy = { FARGATE = { capacity_provider = "FARGATE" weight = 100 base = 1 } } create_task_exec_iam_role = true task_exec_iam_role_policies = { EcsExecTaskExecution = aws_iam_policy.ecs_exec_task_execution.arn } create_tasks_iam_role = true tasks_iam_role_policies = { EcsExecTask = aws_iam_policy.ecs_exec_task.arn GrafanaAMPAccess = aws_iam_policy.grafana_amp_query[0].arn } service_connect_configuration = { namespace = aws_service_discovery_private_dns_namespace.mcp.arn service = [{ client_alias = { port = 3000 dns_name = "grafana" } port_name = "grafana-http" discovery_name = "grafana" }] } container_definitions = { grafana = { cpu = 512 memory = 1024 essential = true image = var.grafana_image_uri versionConsistency = "disabled" readonlyRootFilesystem = false portMappings = [ { name = "grafana-http" containerPort = 3000 protocol = "tcp" } ] environment = [ { name = "AWS_REGION" value = data.aws_region.current.id }, { name = "GF_AUTH_SIGV4_AUTH_ENABLED" value = "true" }, { name = "GF_AWS_ALLOWED_AUTH_PROVIDERS" value = "default,ec2_iam_role" }, { name = "AMP_ENDPOINT" value = local.amp_query_endpoint }, { name = "GF_SERVER_ROOT_URL" value = "%(protocol)s://%(domain)s/grafana/" }, { name = "GF_SERVER_SERVE_FROM_SUB_PATH" value = "true" }, { name = "GF_AUTH_ANONYMOUS_ENABLED" value = "false" }, { name = "GF_AUTH_ANONYMOUS_ORG_ROLE" value = "Viewer" }, { name = "GF_AUTH_DISABLE_LOGIN_FORM" value = "false" }, { name = "GF_SECURITY_ADMIN_PASSWORD" value = var.grafana_admin_password }, { name = "GF_LOG_MODE" value = "console" }, { name = "GF_LOG_LEVEL" value = "info" }, { name = "GF_DASHBOARDS_MIN_REFRESH_INTERVAL" value = "10s" } ] enable_cloudwatch_logging = true cloudwatch_log_group_name = "/ecs/${local.name_prefix}-grafana" cloudwatch_log_group_retention_in_days = 30 healthCheck = { command = ["CMD-SHELL", "wget -q --spider http://localhost:3000/api/health || exit 1"] interval = 30 timeout = 5 retries = 3 startPeriod = 30 } } } load_balancer = { grafana = { target_group_arn = aws_lb_target_group.grafana[0].arn container_name = "grafana" container_port = 3000 } } subnet_ids = var.private_subnet_ids security_group_ingress_rules = { alb_3000 = { description = "Grafana HTTP from ALB" from_port = 3000 to_port = 3000 ip_protocol = "tcp" referenced_security_group_id = module.alb.security_group_id } } security_group_egress_rules = { all = { ip_protocol = "-1" cidr_ipv4 = "0.0.0.0/0" } } tags = local.common_tags } ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/outputs.tf ================================================ # MCP Gateway Registry Module Outputs # Main ALB outputs output "alb_dns_name" { description = "DNS name of the MCP Gateway Registry ALB" value = module.alb.dns_name sensitive = false } output "alb_zone_id" { description = "Zone ID of the MCP Gateway Registry ALB" value = module.alb.zone_id sensitive = false } output "alb_arn" { description = "ARN of the MCP Gateway Registry ALB" value = module.alb.arn sensitive = false } output "alb_security_group_id" { description = "ID of the ALB security group" value = module.alb.security_group_id sensitive = false } # Service URLs output "service_urls" { description = "URLs for MCP Gateway Registry services" value = { registry = var.domain_name != "" ? "https://${var.domain_name}" : "http://${module.alb.dns_name}" auth = var.domain_name != "" ? "https://${var.domain_name}" : "http://${module.alb.dns_name}" gradio = var.domain_name != "" ? "https://${var.domain_name}" : "http://${module.alb.dns_name}" } sensitive = false } # EFS outputs output "efs_id" { description = "MCP Gateway Registry EFS file system ID" value = module.efs.id sensitive = false } output "efs_arn" { description = "MCP Gateway Registry EFS file system ARN" value = module.efs.arn sensitive = false } output "efs_access_points" { description = "EFS access point IDs" value = { servers = module.efs.access_points["servers"].id models = module.efs.access_points["models"].id logs = module.efs.access_points["logs"].id auth_config = module.efs.access_points["auth_config"].id } sensitive = false } # Service Discovery outputs output "service_discovery_namespace_id" { description = "MCP Gateway Registry service discovery namespace ID" value = aws_service_discovery_private_dns_namespace.mcp.id sensitive = false } output "service_discovery_namespace_arn" { description = "MCP Gateway Registry service discovery namespace ARN" value = aws_service_discovery_private_dns_namespace.mcp.arn sensitive = false } output "service_discovery_namespace_hosted_zone_id" { description = "MCP Gateway Registry service discovery namespace hosted zone ID" value = aws_service_discovery_private_dns_namespace.mcp.hosted_zone sensitive = false } # Secrets Manager outputs output "secret_arns" { description = "ARNs of MCP Gateway Registry secrets" value = { secret_key = aws_secretsmanager_secret.secret_key.arn } sensitive = false } # KMS Key outputs output "kms_key_arn" { description = "ARN of the KMS key used for secrets encryption" value = aws_kms_key.secrets.arn sensitive = false } output "kms_key_id" { description = "ID of the KMS key used for secrets encryption" value = aws_kms_key.secrets.id sensitive = false } # ECS Service outputs output "ecs_service_arns" { description = "ARNs of the ECS services" value = { auth = module.ecs_service_auth.id registry = module.ecs_service_registry.id } sensitive = false } output "ecs_service_names" { description = "Names of the ECS services" value = { auth = module.ecs_service_auth.name registry = module.ecs_service_registry.name } sensitive = false } # Security Group outputs output "ecs_security_group_ids" { description = "Security group IDs for ECS services" value = { auth = module.ecs_service_auth.security_group_id registry = module.ecs_service_registry.security_group_id } sensitive = false } # Monitoring outputs output "monitoring_enabled" { description = "Whether monitoring is enabled" value = var.enable_monitoring } output "sns_topic_arn" { description = "SNS topic ARN for CloudWatch alarms" value = var.enable_monitoring && var.alarm_email != "" ? module.sns_alarms.topic_arn : null } output "autoscaling_enabled" { description = "Whether auto-scaling is enabled" value = var.enable_autoscaling } output "https_enabled" { description = "Whether HTTPS is enabled" value = var.certificate_arn != "" } # Observability outputs output "observability_enabled" { description = "Whether the observability pipeline is enabled" value = var.enable_observability } output "amp_workspace_id" { description = "AMP workspace ID" value = var.enable_observability ? aws_prometheus_workspace.mcp[0].id : null } output "amp_endpoint" { description = "AMP remote write endpoint" value = var.enable_observability ? local.amp_remote_write_endpoint : null } output "amp_query_endpoint" { description = "AMP query endpoint for Grafana datasource" value = var.enable_observability ? local.amp_query_endpoint : null } output "grafana_url" { description = "Grafana dashboard URL (path-based routing via ALB)" value = var.enable_observability ? ( var.domain_name != "" ? "https://${var.domain_name}/grafana/" : "http://${module.alb.dns_name}/grafana/" ) : null } ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/secrets.tf ================================================ # Secrets Manager resources for MCP Gateway Registry # # KMS Key for Application Secrets Encryption # resource "aws_kms_key" "secrets" { description = "KMS key for MCP Gateway application secrets encryption" deletion_window_in_days = 7 enable_key_rotation = true policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "Enable IAM User Permissions" Effect = "Allow" Principal = { AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root" } Action = "kms:*" Resource = "*" }, { Sid = "Allow ECS Task Execution Role to Decrypt" Effect = "Allow" Principal = { AWS = "*" } Action = [ "kms:Decrypt", "kms:DescribeKey" ] Resource = "*" Condition = { StringEquals = { "aws:PrincipalAccount" = data.aws_caller_identity.current.account_id } StringLike = { "aws:PrincipalArn" = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/*task-exec*" } } }, { Sid = "Allow CloudWatch Logs" Effect = "Allow" Principal = { Service = "logs.${data.aws_region.current.name}.amazonaws.com" } Action = [ "kms:Encrypt", "kms:Decrypt", "kms:ReEncrypt*", "kms:GenerateDataKey*", "kms:CreateGrant", "kms:DescribeKey" ] Resource = "*" Condition = { ArnLike = { "kms:EncryptionContext:aws:logs:arn" = "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:*" } } } ] }) tags = merge( local.common_tags, { Name = "${local.name_prefix}-secrets-key" Component = "secrets" } ) } resource "aws_kms_alias" "secrets" { name = "alias/${local.name_prefix}-secrets" target_key_id = aws_kms_key.secrets.key_id } # Random passwords for application secrets resource "random_password" "secret_key" { length = 64 special = true } # Core application secrets #checkov:skip=CKV2_AWS_57:Application-generated secret key - rotation requires coordinated service restart resource "aws_secretsmanager_secret" "secret_key" { name_prefix = "${local.name_prefix}-secret-key-" description = "Secret key for MCP Gateway Registry" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "secret_key" { secret_id = aws_secretsmanager_secret.secret_key.id secret_string = random_password.secret_key.result } # Keycloak client secrets (created with placeholder, updated by init-keycloak.sh) #checkov:skip=CKV2_AWS_57:Keycloak client secret managed by Keycloak init script, not rotatable via Secrets Manager resource "aws_secretsmanager_secret" "keycloak_client_secret" { name = "mcp-gateway-keycloak-client-secret" description = "Keycloak web client secret (updated by init-keycloak.sh after deployment)" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "keycloak_client_secret" { secret_id = aws_secretsmanager_secret.keycloak_client_secret.id secret_string = jsonencode({ client_secret = "placeholder-will-be-updated-by-init-script" }) lifecycle { ignore_changes = [secret_string] } } #checkov:skip=CKV2_AWS_57:Keycloak M2M client secret managed by Keycloak init script, not rotatable via Secrets Manager resource "aws_secretsmanager_secret" "keycloak_m2m_client_secret" { name = "mcp-gateway-keycloak-m2m-client-secret" description = "Keycloak M2M client secret (updated by init-keycloak.sh after deployment)" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "keycloak_m2m_client_secret" { secret_id = aws_secretsmanager_secret.keycloak_m2m_client_secret.id secret_string = jsonencode({ client_secret = "placeholder-will-be-updated-by-init-script" }) lifecycle { ignore_changes = [secret_string] } } # Keycloak admin password secret (for Management API operations) #checkov:skip=CKV2_AWS_57:Keycloak admin password managed by Keycloak, not rotatable via Secrets Manager resource "aws_secretsmanager_secret" "keycloak_admin_password" { name_prefix = "${local.name_prefix}-keycloak-admin-password-" description = "Keycloak admin password for Management API user/group operations" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "keycloak_admin_password" { secret_id = aws_secretsmanager_secret.keycloak_admin_password.id secret_string = var.keycloak_admin_password } # Embeddings API key secret (optional - only needed for LiteLLM provider) #checkov:skip=CKV2_AWS_57:Third-party API key managed in external provider dashboard, not rotatable via Secrets Manager resource "aws_secretsmanager_secret" "embeddings_api_key" { name_prefix = "${local.name_prefix}-embeddings-api-key-" description = "API key for embeddings provider (OpenAI, Anthropic, etc.)" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "embeddings_api_key" { secret_id = aws_secretsmanager_secret.embeddings_api_key.id secret_string = var.embeddings_api_key != "" ? var.embeddings_api_key : "not-configured" lifecycle { ignore_changes = [secret_string] } } # Microsoft Entra ID client secret (for OAuth and IAM operations) #checkov:skip=CKV2_AWS_57:IdP client secret managed in Microsoft Entra ID portal, not rotatable via Secrets Manager resource "aws_secretsmanager_secret" "entra_client_secret" { count = var.entra_enabled ? 1 : 0 name_prefix = "${local.name_prefix}-entra-client-secret-" description = "Microsoft Entra ID client secret for OAuth authentication and IAM operations" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "entra_client_secret" { count = var.entra_enabled ? 1 : 0 secret_id = aws_secretsmanager_secret.entra_client_secret[0].id secret_string = var.entra_client_secret lifecycle { ignore_changes = [secret_string] } } # Okta client secret (for OAuth authentication) #checkov:skip=CKV2_AWS_57:IdP client secret managed in Okta admin console, not rotatable via Secrets Manager resource "aws_secretsmanager_secret" "okta_client_secret" { count = var.okta_enabled ? 1 : 0 name_prefix = "${local.name_prefix}-okta-client-secret-" description = "Okta client secret for OAuth authentication" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "okta_client_secret" { count = var.okta_enabled ? 1 : 0 secret_id = aws_secretsmanager_secret.okta_client_secret[0].id secret_string = var.okta_client_secret lifecycle { ignore_changes = [secret_string] } } # Okta M2M client secret (for service account operations) #checkov:skip=CKV2_AWS_57:IdP M2M client secret managed in Okta admin console, not rotatable via Secrets Manager resource "aws_secretsmanager_secret" "okta_m2m_client_secret" { count = var.okta_enabled ? 1 : 0 name_prefix = "${local.name_prefix}-okta-m2m-client-secret-" description = "Okta M2M client secret for service account operations" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "okta_m2m_client_secret" { count = var.okta_enabled ? 1 : 0 secret_id = aws_secretsmanager_secret.okta_m2m_client_secret[0].id secret_string = var.okta_m2m_client_secret lifecycle { ignore_changes = [secret_string] } } # Okta API token (for management operations) #checkov:skip=CKV2_AWS_57:IdP API token managed in Okta admin console, not rotatable via Secrets Manager resource "aws_secretsmanager_secret" "okta_api_token" { count = var.okta_enabled ? 1 : 0 name_prefix = "${local.name_prefix}-okta-api-token-" description = "Okta API token for IAM management operations" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "okta_api_token" { count = var.okta_enabled ? 1 : 0 secret_id = aws_secretsmanager_secret.okta_api_token[0].id secret_string = var.okta_api_token lifecycle { ignore_changes = [secret_string] } } # ============================================================================= # AUTH0 SECRETS # ============================================================================= # Auth0 client secret (for OAuth authentication) #checkov:skip=CKV_AWS_149:Rotation managed externally in Auth0 dashboard, not applicable for IdP client secrets #checkov:skip=CKV2_AWS_57:IdP client secret managed in Auth0 dashboard, not rotatable via Secrets Manager resource "aws_secretsmanager_secret" "auth0_client_secret" { count = var.auth0_enabled ? 1 : 0 name_prefix = "${local.name_prefix}-auth0-client-secret-" description = "Auth0 client secret for OAuth authentication" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "auth0_client_secret" { count = var.auth0_enabled ? 1 : 0 secret_id = aws_secretsmanager_secret.auth0_client_secret[0].id secret_string = var.auth0_client_secret lifecycle { ignore_changes = [secret_string] } } # Auth0 M2M client secret (for IAM Management operations) #checkov:skip=CKV_AWS_149:Rotation managed externally in Auth0 dashboard, not applicable for IdP client secrets #checkov:skip=CKV2_AWS_57:IdP M2M client secret managed in Auth0 dashboard, not rotatable via Secrets Manager resource "aws_secretsmanager_secret" "auth0_m2m_client_secret" { count = var.auth0_enabled ? 1 : 0 name_prefix = "${local.name_prefix}-auth0-m2m-client-secret-" description = "Auth0 M2M client secret for IAM Management operations" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "auth0_m2m_client_secret" { count = var.auth0_enabled ? 1 : 0 secret_id = aws_secretsmanager_secret.auth0_m2m_client_secret[0].id secret_string = var.auth0_m2m_client_secret lifecycle { ignore_changes = [secret_string] } } # Metrics API key (for metrics-service authentication) resource "random_password" "metrics_api_key" { count = var.enable_observability ? 1 : 0 length = 48 special = false } #checkov:skip=CKV2_AWS_57:Application-generated API key - rotation requires coordinated service restart resource "aws_secretsmanager_secret" "metrics_api_key" { count = var.enable_observability ? 1 : 0 name_prefix = "${local.name_prefix}-metrics-api-key-" description = "API key for metrics-service (shared by auth-server and registry)" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "metrics_api_key" { count = var.enable_observability ? 1 : 0 secret_id = aws_secretsmanager_secret.metrics_api_key[0].id secret_string = random_password.metrics_api_key[0].result } # OTLP exporter headers (e.g., dd-api-key=xxx for Datadog) # Only created when observability is enabled AND an OTLP endpoint is configured #checkov:skip=CKV2_AWS_57:Observability provider API key managed in external provider dashboard, not rotatable via Secrets Manager resource "aws_secretsmanager_secret" "otlp_exporter_headers" { count = var.enable_observability && var.otel_otlp_endpoint != "" ? 1 : 0 name_prefix = "${local.name_prefix}-otlp-exporter-headers-" description = "OTLP exporter authentication headers (e.g., Datadog API key)" recovery_window_in_days = 0 kms_key_id = aws_kms_key.secrets.id tags = local.common_tags } resource "aws_secretsmanager_secret_version" "otlp_exporter_headers" { count = var.enable_observability && var.otel_otlp_endpoint != "" ? 1 : 0 secret_id = aws_secretsmanager_secret.otlp_exporter_headers[0].id secret_string = var.otel_exporter_otlp_headers } ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/storage.tf ================================================ # EFS storage resources for MCP Gateway Registry #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "efs" { source = "terraform-aws-modules/efs/aws" version = "~> 2.0" # File system configuration name = "${local.name_prefix}-efs" creation_token = "${local.name_prefix}-efs" performance_mode = "generalPurpose" throughput_mode = var.efs_throughput_mode provisioned_throughput_in_mibps = var.efs_throughput_mode == "provisioned" ? var.efs_provisioned_throughput : null encrypted = true # Mount targets - one per private subnet mount_targets = { for idx, subnet_id in var.private_subnet_ids : "mount-${idx}" => { subnet_id = subnet_id } } # Security group configuration create_security_group = true security_group_vpc_id = var.vpc_id security_group_name = "${local.name_prefix}-efs-" security_group_use_name_prefix = true security_group_ingress_rules = { nfs = { description = "NFS from VPC" from_port = 2049 to_port = 2049 ip_protocol = "tcp" cidr_ipv4 = data.aws_vpc.vpc.cidr_block } } # Do NOT configure egress rules in module to avoid defaults # We'll add the egress rule manually below security_group_egress_rules = {} # Access points access_points = { servers = { name = "${local.name_prefix}-servers" posix_user = { gid = 1000 uid = 1000 } root_directory = { path = "/servers" creation_info = { owner_gid = 1000 owner_uid = 1000 permissions = "755" } } tags = merge(local.common_tags, { Name = "${local.name_prefix} Servers" }) } models = { name = "${local.name_prefix}-models" posix_user = { gid = 1000 uid = 1000 } root_directory = { path = "/models" creation_info = { owner_gid = 1000 owner_uid = 1000 permissions = "755" } } tags = merge(local.common_tags, { Name = "${local.name_prefix} Models" }) } logs = { name = "${local.name_prefix}-logs" posix_user = { gid = 1000 uid = 1000 } root_directory = { path = "/logs" creation_info = { owner_gid = 1000 owner_uid = 1000 permissions = "755" } } tags = merge(local.common_tags, { Name = "${local.name_prefix} Logs" }) } agents = { name = "${local.name_prefix}-agents" posix_user = { gid = 1000 uid = 1000 } root_directory = { path = "/agents" creation_info = { owner_gid = 1000 owner_uid = 1000 permissions = "755" } } tags = merge(local.common_tags, { Name = "${local.name_prefix} Agents" }) } auth_config = { name = "${local.name_prefix}-auth-config" posix_user = { gid = 1000 uid = 1000 } root_directory = { path = "/auth_config" creation_info = { owner_gid = 1000 owner_uid = 1000 permissions = "755" } } tags = merge(local.common_tags, { Name = "${local.name_prefix} Auth Config" }) } mcpgw_data = { name = "${local.name_prefix}-mcpgw-data" posix_user = { gid = 1000 uid = 1000 } root_directory = { path = "/mcpgw_data" creation_info = { owner_gid = 1000 owner_uid = 1000 permissions = "755" } } tags = merge(local.common_tags, { Name = "${local.name_prefix} MCPGW Data" }) } } tags = local.common_tags } # Manually add egress rule for all protocols without port specification # This avoids the module's default from_port/to_port of 2049 which causes # AWS InvalidParameterValue error when combined with ip_protocol = "-1" resource "aws_vpc_security_group_egress_rule" "efs_all_outbound" { security_group_id = module.efs.security_group_id description = "Allow all outbound" ip_protocol = "-1" cidr_ipv4 = "0.0.0.0/0" tags = merge( local.common_tags, { "Name" = "${local.name_prefix}-efs-all-outbound" } ) } ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/variables.tf ================================================ # MCP Gateway Registry Module Variables # Required Variables - Shared Resources variable "name" { description = "Name prefix for MCP Gateway Registry resources" type = string } variable "vpc_id" { description = "ID of the VPC where resources will be created" type = string } variable "private_subnet_ids" { description = "List of private subnet IDs for ECS services" type = list(string) } variable "public_subnet_ids" { description = "List of public subnet IDs for ALB" type = list(string) } variable "ecs_cluster_arn" { description = "ARN of the existing ECS cluster" type = string } variable "ecs_cluster_name" { description = "Name of the existing ECS cluster" type = string } variable "task_execution_role_arn" { description = "ARN of the task execution IAM role (DEPRECATED: Module now creates its own task execution roles)" type = string default = "" } # Container Image URIs (pre-built images from Docker Hub) variable "registry_image_uri" { description = "Container image URI for registry service (defaults to pre-built image from mcpgateway Docker Hub)" type = string default = "mcpgateway/registry:latest" } variable "auth_server_image_uri" { description = "Container image URI for auth server service (defaults to pre-built image from mcpgateway Docker Hub)" type = string default = "mcpgateway/auth-server:latest" } variable "currenttime_image_uri" { description = "Container image URI for currenttime MCP server" type = string default = "" } variable "mcpgw_image_uri" { description = "Container image URI for mcpgw MCP server" type = string default = "" } variable "realserverfaketools_image_uri" { description = "Container image URI for realserverfaketools MCP server" type = string default = "" } variable "flight_booking_agent_image_uri" { description = "Container image URI for flight booking A2A agent" type = string default = "" } variable "travel_assistant_agent_image_uri" { description = "Container image URI for travel assistant A2A agent" type = string default = "" } variable "dockerhub_org" { description = "Docker Hub organization for pre-built images" type = string default = "mcpgateway" } # Resource Configuration variable "cpu" { description = "CPU allocation for MCP Gateway Registry containers (in vCPU units: 256, 512, 1024, 2048, 4096)" type = string default = "1024" validation { condition = contains(["256", "512", "1024", "2048", "4096"], var.cpu) error_message = "CPU must be one of: 256, 512, 1024, 2048, 4096" } } variable "memory" { description = "Memory allocation for MCP Gateway Registry containers (in MB, must be compatible with CPU)" type = string default = "2048" } variable "registry_replicas" { description = "Number of replicas for MCP Gateway Registry main service" type = number default = 1 validation { condition = var.registry_replicas > 0 error_message = "Registry replicas must be greater than 0." } } variable "auth_replicas" { description = "Number of replicas for MCP Gateway Auth service" type = number default = 1 validation { condition = var.auth_replicas > 0 error_message = "Auth replicas must be greater than 0." } } variable "currenttime_replicas" { description = "Number of replicas for CurrentTime MCP server" type = number default = 1 validation { condition = var.currenttime_replicas > 0 error_message = "CurrentTime replicas must be greater than 0." } } variable "mcpgw_replicas" { description = "Number of replicas for MCPGW MCP server" type = number default = 1 validation { condition = var.mcpgw_replicas > 0 error_message = "MCPGW replicas must be greater than 0." } } variable "realserverfaketools_replicas" { description = "Number of replicas for RealServerFakeTools MCP server" type = number default = 1 validation { condition = var.realserverfaketools_replicas > 0 error_message = "RealServerFakeTools replicas must be greater than 0." } } variable "flight_booking_agent_replicas" { description = "Number of replicas for Flight Booking A2A agent" type = number default = 1 validation { condition = var.flight_booking_agent_replicas > 0 error_message = "Flight Booking agent replicas must be greater than 0." } } variable "travel_assistant_agent_replicas" { description = "Number of replicas for Travel Assistant A2A agent" type = number default = 1 validation { condition = var.travel_assistant_agent_replicas > 0 error_message = "Travel Assistant agent replicas must be greater than 0." } } # ALB Configuration variable "alb_scheme" { description = "Scheme for the ALB (internal or internet-facing)" type = string default = "internet-facing" validation { condition = contains(["internal", "internet-facing"], var.alb_scheme) error_message = "ALB scheme must be either 'internal' or 'internet-facing'." } } variable "alb_logs_bucket" { description = "S3 bucket for ALB access logs" type = string } variable "ingress_cidr_blocks" { description = "List of CIDR blocks allowed to access the ALB (main ALB + auth server + registry)" type = list(string) default = ["0.0.0.0/0"] } variable "certificate_arn" { description = "ARN of ACM certificate for HTTPS (optional)" type = string default = "" } variable "keycloak_domain" { description = "Domain name for Keycloak (e.g., kc.mycorp.click)" type = string default = "" } variable "enable_autoscaling" { description = "Whether to enable auto-scaling for ECS services" type = bool default = true } variable "autoscaling_min_capacity" { description = "Minimum number of tasks for auto-scaling" type = number default = 2 } variable "autoscaling_max_capacity" { description = "Maximum number of tasks for auto-scaling" type = number default = 4 } variable "autoscaling_target_cpu" { description = "Target CPU utilization percentage for auto-scaling" type = number default = 70 } variable "autoscaling_target_memory" { description = "Target memory utilization percentage for auto-scaling" type = number default = 80 } variable "enable_monitoring" { description = "Whether to enable CloudWatch monitoring and alarms" type = bool default = true } variable "alarm_email" { description = "Email address for CloudWatch alarm notifications" type = string default = "" } # EFS Configuration variable "efs_throughput_mode" { description = "Throughput mode for EFS (bursting or provisioned)" type = string default = "bursting" validation { condition = contains(["bursting", "provisioned"], var.efs_throughput_mode) error_message = "EFS throughput mode must be either 'bursting' or 'provisioned'." } } variable "efs_provisioned_throughput" { description = "Provisioned throughput in MiB/s for EFS (only used if throughput_mode is provisioned)" type = number default = 100 } variable "additional_tags" { description = "Additional tags to apply to all resources" type = map(string) default = {} } # Domain Configuration (Optional) variable "domain_name" { description = "Domain name for the MCP Gateway Registry (optional)" type = string default = "" } variable "create_route53_record" { description = "Whether to create Route53 DNS record for the domain" type = bool default = false } variable "route53_zone_id" { description = "Route53 hosted zone ID (required if create_route53_record is true)" type = string default = "" } # Embeddings Configuration variable "embeddings_provider" { description = "Embeddings provider: 'sentence-transformers' for local models or 'litellm' for API-based models" type = string default = "sentence-transformers" validation { condition = contains(["sentence-transformers", "litellm"], var.embeddings_provider) error_message = "Embeddings provider must be either 'sentence-transformers' or 'litellm'." } } variable "embeddings_model_name" { description = "Name of the embeddings model to use (e.g., 'all-MiniLM-L6-v2' for sentence-transformers, 'openai/text-embedding-ada-002' for litellm)" type = string default = "all-MiniLM-L6-v2" } variable "embeddings_model_dimensions" { description = "Dimension of the embeddings model (e.g., 384 for MiniLM, 1536 for OpenAI/Titan)" type = number default = 384 validation { condition = var.embeddings_model_dimensions > 0 error_message = "Embeddings model dimensions must be greater than 0." } } variable "embeddings_aws_region" { description = "AWS region for Bedrock embeddings (only used when embeddings_provider is 'litellm' with Bedrock)" type = string default = "us-east-1" } variable "embeddings_api_key" { description = "API key for embeddings provider (OpenAI, Anthropic, etc.). Only used when embeddings_provider is 'litellm'. Leave empty for Bedrock (uses IAM)." type = string default = "" sensitive = true } # Keycloak Admin Credentials (for Management API) variable "keycloak_admin_password" { description = "Keycloak admin password for Management API user/group operations" type = string sensitive = true } # ============================================================================= # SESSION COOKIE SECURITY CONFIGURATION # ============================================================================= variable "session_cookie_secure" { description = "Enable secure flag on session cookies (HTTPS-only transmission). Set to true in production with HTTPS." type = bool default = true } variable "session_cookie_domain" { description = "Domain for session cookies (e.g., '.example.com' for cross-subdomain sharing). Leave empty for single-domain deployments (cookie scoped to exact host only)." type = string default = "" } variable "oauth_store_tokens_in_session" { description = "Store OAuth provider tokens in session cookies. Set to false to avoid cookie size limits with large tokens (e.g., Entra ID). Tokens are not used functionally." type = bool default = false } # Security Scanning Configuration variable "security_scan_enabled" { description = "Enable/disable security scanning for MCP servers during registration" type = bool default = true } variable "security_scan_on_registration" { description = "Automatically scan servers when they are registered" type = bool default = true } variable "security_block_unsafe_servers" { description = "Block (disable) servers that fail security scans" type = bool default = true } variable "security_analyzers" { description = "Comma-separated list of analyzers to use for security scanning (available: yara, llm, api)" type = string default = "yara" } variable "security_scan_timeout" { description = "Security scan timeout in seconds" type = number default = 60 } variable "security_add_pending_tag" { description = "Add 'security-pending' tag to servers that fail security scan" type = bool default = true } # ============================================================================= # DOCUMENTDB CONFIGURATION (from upstream v1.0.9) # ============================================================================= variable "storage_backend" { description = "Storage backend to use: 'file' or 'documentdb'" type = string default = "file" validation { condition = contains(["file", "documentdb"], var.storage_backend) error_message = "Storage backend must be either 'file' or 'documentdb'." } } variable "documentdb_endpoint" { description = "DocumentDB cluster endpoint (required when storage_backend is 'documentdb')" type = string default = "" } variable "documentdb_database" { description = "DocumentDB database name" type = string default = "mcp_registry" } variable "documentdb_namespace" { description = "DocumentDB namespace for collections" type = string default = "default" } variable "documentdb_use_tls" { description = "Use TLS for DocumentDB connections" type = bool default = true } variable "documentdb_use_iam" { description = "Use IAM authentication for DocumentDB" type = bool default = false } variable "documentdb_credentials_secret_arn" { description = "ARN of the Secrets Manager secret containing DocumentDB credentials" type = string default = "" } # ============================================================================= # CLOUDFRONT CONFIGURATION (CloudFront HTTPS Support feature) # ============================================================================= variable "enable_cloudfront" { description = "Whether CloudFront is enabled (adds CloudFront prefix list to ALB security group)" type = bool default = false } variable "cloudfront_prefix_list_name" { description = "Name of the managed prefix list for CloudFront origin-facing IPs" type = string default = "com.amazonaws.global.cloudfront.origin-facing" } variable "additional_server_names" { description = "Additional server names for nginx (space-separated). Used in dual-mode to accept both CloudFront and custom domain requests." type = string default = "" } # HTTPS Configuration variable "enable_https" { description = "Whether to enable HTTPS listener on ALB. Set to true when certificate_arn is provided." type = bool default = false } # ============================================================================= # MICROSOFT ENTRA ID CONFIGURATION # ============================================================================= variable "entra_enabled" { description = "Enable Microsoft Entra ID as authentication provider" type = bool default = false } variable "entra_tenant_id" { description = "Azure AD Tenant ID (Directory/tenant ID from Azure Portal)" type = string default = "" } variable "entra_client_id" { description = "Entra ID Application (client) ID" type = string default = "" } variable "entra_client_secret" { description = "Entra ID Client Secret (Application secret value)" type = string default = "" sensitive = true } variable "idp_group_filter_prefix" { description = "Comma-separated list of prefixes to filter IdP groups in IAM > Groups page (e.g., 'mcp-,registry-'). Applies to all identity providers." type = string default = "" } # ============================================================================= # OKTA CONFIGURATION # ============================================================================= variable "okta_enabled" { description = "Enable Okta as authentication provider" type = bool default = false } variable "okta_domain" { description = "Okta domain (e.g., your-org.okta.com)" type = string default = "" } variable "okta_client_id" { description = "Okta Application (client) ID" type = string default = "" } variable "okta_client_secret" { description = "Okta Client Secret (Application secret value)" type = string default = "" sensitive = true } variable "okta_m2m_client_id" { description = "Okta M2M client ID for service account operations" type = string default = "" } variable "okta_m2m_client_secret" { description = "Okta M2M client secret for service account operations" type = string default = "" sensitive = true } variable "okta_api_token" { description = "Okta API token for management operations" type = string default = "" sensitive = true } variable "okta_auth_server_id" { description = "Okta Custom Authorization Server ID (for M2M tokens). Leave empty to use default Org Authorization Server." type = string default = "" } # ============================================================================= # AUTH0 CONFIGURATION # ============================================================================= variable "auth0_enabled" { description = "Enable Auth0 as authentication provider" type = bool default = false } variable "auth0_domain" { description = "Auth0 domain (e.g., your-tenant.us.auth0.com)" type = string default = "" } variable "auth0_client_id" { description = "Auth0 Application (client) ID" type = string default = "" } variable "auth0_client_secret" { description = "Auth0 Client Secret" type = string default = "" sensitive = true } variable "auth0_audience" { description = "Auth0 API audience for M2M tokens" type = string default = "" } variable "auth0_groups_claim" { description = "Custom namespaced claim for groups in Auth0 tokens" type = string default = "https://mcp-gateway/groups" } variable "auth0_m2m_client_id" { description = "Auth0 M2M client ID for IAM Management operations" type = string default = "" } variable "auth0_m2m_client_secret" { description = "Auth0 M2M client secret for IAM Management operations" type = string default = "" sensitive = true } variable "auth0_management_api_token" { description = "Auth0 Management API token (alternative to M2M credentials, expires after 24h)" type = string default = "" sensitive = true } variable "registry_static_token_auth_enabled" { description = "Enable static token auth for Registry API (IdP-independent access using REGISTRY_API_TOKEN)" type = bool default = false } variable "registry_api_token" { description = "Static API key for network-trusted mode. Must match the Bearer token value sent by clients." type = string default = "" sensitive = true } variable "registry_api_keys" { description = "JSON string configuring multiple static API keys with per-key group assignments." type = string default = "" sensitive = true } variable "max_tokens_per_user_per_hour" { description = "Maximum JWT tokens that can be vended per user per hour." type = number default = 100 } # Registration webhook (issue #742) variable "registration_webhook_url" { description = "Webhook URL to POST to on successful registration or deletion. Disabled if empty." type = string default = "" } variable "registration_webhook_auth_header" { description = "Auth header name for webhook requests." type = string default = "Authorization" } variable "registration_webhook_auth_token" { description = "Auth token for webhook requests." type = string default = "" sensitive = true } variable "registration_webhook_timeout_seconds" { description = "Timeout for webhook HTTP calls in seconds." type = number default = 10 } # Registration gate / admission control (issue #809) variable "registration_gate_enabled" { description = "Enable registration gate (admission control). Default: false." type = bool default = false } variable "registration_gate_url" { description = "URL of the registration gate endpoint." type = string default = "" } variable "registration_gate_auth_type" { description = "Auth type for gate: none, api_key, or bearer." type = string default = "none" } variable "registration_gate_auth_credential" { description = "Auth credential for the gate endpoint." type = string default = "" sensitive = true } variable "registration_gate_auth_header_name" { description = "Header name when auth_type=api_key." type = string default = "X-Api-Key" } variable "registration_gate_timeout_seconds" { description = "HTTP timeout per gate attempt in seconds." type = number default = 5 } variable "registration_gate_max_retries" { description = "Retries after first gate attempt." type = number default = 2 } variable "m2m_direct_registration_enabled" { description = "Enable the admin API at /api/iam/m2m-clients for direct M2M client registration (issue #851). Default: true." type = bool default = true } # ============================================================================= # FEDERATION CONFIGURATION (Peer-to-Peer Registry Sync) # ============================================================================= variable "registry_id" { description = "Unique identifier for this registry instance in federation." type = string default = "" } variable "federation_static_token_auth_enabled" { description = "Enable static token auth for Federation API endpoints." type = bool default = false } variable "federation_static_token" { description = "Static token for Federation API access." type = string default = "" sensitive = true } variable "federation_encryption_key" { description = "Fernet encryption key for storing federation tokens in MongoDB." type = string default = "" sensitive = true } # ============================================================================= # AWS AGENT REGISTRY FEDERATION CONFIGURATION # ============================================================================= variable "aws_registry_federation_enabled" { description = "Enable AWS Agent Registry federation." type = bool default = false } # ============================================================================= # ANS (AGENT NAMING SERVICE) CONFIGURATION # ============================================================================= variable "ans_integration_enabled" { description = "Enable ANS integration for agent identity verification." type = bool default = false } variable "ans_api_endpoint" { description = "ANS API endpoint URL." type = string default = "https://api.godaddy.com" } variable "ans_api_key" { description = "ANS API key for authentication." type = string default = "" sensitive = true } variable "ans_api_secret" { description = "ANS API secret for authentication." type = string default = "" sensitive = true } variable "ans_api_timeout_seconds" { description = "ANS API request timeout in seconds." type = number default = 30 } variable "ans_sync_interval_hours" { description = "How often to re-sync ANS verification status (in hours)." type = number default = 6 } variable "ans_verification_cache_ttl_seconds" { description = "Cache TTL for ANS verification results (in seconds)." type = number default = 3600 } # ============================================================================= # REGISTRY CARD CONFIGURATION (Federation Metadata) # ============================================================================= variable "registry_name" { description = "Human-readable registry name for federation and discovery. If not set, a random Docker-style name will be generated." type = string default = "" } variable "registry_organization_name" { description = "Organization that operates this registry. Defaults to 'ACME Inc.' if not set." type = string default = "" } variable "registry_description" { description = "Registry description for federation discovery." type = string default = "" } variable "registry_contact_email" { description = "Contact email for registry administrators. Leave empty if not publicly shared." type = string default = "" } variable "registry_contact_url" { description = "Documentation or support URL for this registry. Leave empty if not available." type = string default = "" } # ============================================================================= # AUDIT LOGGING CONFIGURATION # ============================================================================= variable "audit_log_enabled" { description = "Enable audit logging for all API and MCP requests." type = bool default = true } variable "audit_log_ttl_days" { description = "Audit log retention period in days." type = number default = 7 } # ============================================================================= # APPLICATION LOG CONFIGURATION # ============================================================================= variable "app_log_centralized_enabled" { description = "Write application logs to a centralized store for cross-pod retrieval." type = bool default = true } variable "app_log_centralized_ttl_days" { description = "Days to retain centralized application logs (TTL index)." type = number default = 1 } variable "app_log_level" { description = "Application log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)." type = string default = "INFO" } variable "app_log_excluded_loggers" { description = "Comma-separated logger names to exclude from MongoDB log writes." type = string default = "uvicorn.access,httpx,pymongo,motor" } # ============================================================================= # DEPLOYMENT MODE CONFIGURATION # ============================================================================= variable "deployment_mode" { description = "Controls how the registry integrates with the gateway/nginx. 'with-gateway' for full integration, 'registry-only' for catalog-only mode." type = string default = "with-gateway" } variable "registry_mode" { description = "Controls which features are enabled (informational - for UI feature flags). Options: 'full', 'skills-only', 'mcp-servers-only', 'agents-only'." type = string default = "full" } variable "show_servers_tab" { description = "Show the MCP Servers tab in the UI. AND-ed with registry_mode." type = bool default = true } variable "show_virtual_servers_tab" { description = "Show the Virtual MCP Servers tab in the UI." type = bool default = true } variable "show_skills_tab" { description = "Show the Skills tab in the UI. AND-ed with registry_mode." type = bool default = true } variable "show_agents_tab" { description = "Show the Agents tab in the UI. AND-ed with registry_mode." type = bool default = true } # ============================================================================= # OBSERVABILITY CONFIGURATION (Metrics Pipeline) # ============================================================================= variable "enable_observability" { description = "Enable full observability pipeline (AMP, metrics-service, ADOT collector, Grafana). When false, no observability resources are created." type = bool default = true } variable "metrics_service_image_uri" { description = "Container image URI for metrics-service. Required when enable_observability is true." type = string default = "" } variable "grafana_image_uri" { description = "Container image URI for Grafana OSS (custom image with baked-in provisioning). Required when enable_observability is true." type = string default = "" } variable "grafana_admin_password" { description = "Admin password for Grafana. Must be set when enable_observability is true." type = string sensitive = true default = "" } variable "otel_otlp_endpoint" { description = "OTLP endpoint for pushing metrics to an external platform (e.g., Datadog). Leave empty to disable." type = string default = "" } variable "otel_exporter_otlp_headers" { description = "Headers for OTLP exporter (e.g., 'dd-api-key=YOUR_KEY' for Datadog). Stored in Secrets Manager. Leave empty if not needed." type = string sensitive = true default = "" } variable "otel_otlp_export_interval_ms" { description = "OTLP export interval in milliseconds. Default 30000 (30 seconds)." type = number default = 30000 } variable "otel_exporter_otlp_metrics_temporality_preference" { description = "OTLP metrics temporality preference. Datadog requires delta. Default cumulative." type = string default = "cumulative" } # Telemetry configuration variable "mcp_telemetry_disabled" { description = "Disable anonymous startup telemetry. Set to '1' to opt out." type = string default = "" } variable "mcp_telemetry_opt_out" { description = "Disable daily heartbeat telemetry only. Set to '1' to opt out (startup ping still sent)." type = string default = "" } variable "mcp_telemetry_heartbeat_interval_minutes" { description = "Heartbeat telemetry interval in minutes. Default: 1440 (24 hours)." type = string default = "1440" } variable "telemetry_debug" { description = "Enable telemetry debug mode (logs payload instead of sending). Set to 'true' to enable." type = string default = "false" } variable "disable_ai_registry_tools_server" { description = "Disable auto-registration of the built-in airegistry-tools server on startup. Set to 'true' for GitOps/production deployments." type = string default = "false" } # ============================================================================= # GITHUB PRIVATE REPO AUTH (Issue #814) # ============================================================================= variable "github_pat" { description = "GitHub Personal Access Token for private repo SKILL.md access." type = string default = "" sensitive = true } variable "github_app_id" { description = "GitHub App ID for installation-based auth." type = string default = "" } variable "github_app_installation_id" { description = "GitHub App Installation ID." type = string default = "" } variable "github_app_private_key" { description = "GitHub App private key (PEM format)." type = string default = "" sensitive = true } variable "github_extra_hosts" { description = "Comma-separated extra GitHub hosts for enterprise instances." type = string default = "" } variable "github_api_base_url" { description = "GitHub API base URL. For GitHub Enterprise Server use https:///api/v3." type = string default = "https://api.github.com" } ================================================ FILE: terraform/aws-ecs/modules/mcp-gateway/versions.tf ================================================ terraform { required_version = ">= 1.0" required_providers { aws = { source = "hashicorp/aws" version = ">= 5.0" } random = { source = "hashicorp/random" version = ">= 3.1" } } } ================================================ FILE: terraform/aws-ecs/outputs.tf ================================================ # Root Module Outputs # VPC Outputs output "vpc_id" { description = "VPC ID" value = module.vpc.vpc_id } output "vpc_cidr" { description = "VPC CIDR block" value = module.vpc.vpc_cidr_block } output "private_subnet_ids" { description = "Private subnet IDs" value = module.vpc.private_subnets } output "public_subnet_ids" { description = "Public subnet IDs" value = module.vpc.public_subnets } # ECS Cluster Outputs output "ecs_cluster_name" { description = "ECS cluster name" value = module.ecs_cluster.name } output "ecs_cluster_arn" { description = "ECS cluster ARN" value = module.ecs_cluster.arn } # MCP Gateway Outputs output "mcp_gateway_url" { description = "MCP Gateway main URL" value = module.mcp_gateway.service_urls.registry } output "mcp_gateway_auth_url" { description = "MCP Gateway auth server URL" value = module.mcp_gateway.service_urls.auth } output "mcp_gateway_alb_dns" { description = "MCP Gateway ALB DNS name" value = module.mcp_gateway.alb_dns_name } output "mcp_gateway_https_enabled" { description = "Whether HTTPS is enabled for MCP Gateway" value = module.mcp_gateway.https_enabled } output "mcp_gateway_autoscaling_enabled" { description = "Whether auto-scaling is enabled for MCP Gateway" value = module.mcp_gateway.autoscaling_enabled } output "mcp_gateway_monitoring_enabled" { description = "Whether monitoring is enabled for MCP Gateway" value = module.mcp_gateway.monitoring_enabled } # EFS Outputs output "mcp_gateway_efs_id" { description = "MCP Gateway EFS file system ID" value = module.mcp_gateway.efs_id } output "mcp_gateway_efs_arn" { description = "MCP Gateway EFS file system ARN" value = module.mcp_gateway.efs_arn } output "mcp_gateway_efs_access_points" { description = "MCP Gateway EFS access point IDs" value = module.mcp_gateway.efs_access_points } # Monitoring Outputs output "monitoring_sns_topic" { description = "SNS topic ARN for CloudWatch alarms" value = var.enable_monitoring ? module.mcp_gateway.sns_topic_arn : null } # Summary Output output "deployment_summary" { description = "Summary of deployed components" value = { mcp_gateway_deployed = true https_enabled = var.enable_route53_dns || var.enable_cloudfront monitoring_enabled = var.enable_monitoring multi_az_nat = true autoscaling_enabled = true deployment_mode = var.enable_cloudfront && !var.enable_route53_dns ? "cloudfront" : (var.enable_route53_dns ? "custom-domain" : "development") } } # # Keycloak Outputs # output "keycloak_url" { description = "Keycloak URL" value = var.enable_route53_dns ? "https://${local.keycloak_domain}" : ( var.enable_cloudfront ? "https://${aws_cloudfront_distribution.keycloak[0].domain_name}" : "http://${aws_lb.keycloak.dns_name}" ) } output "keycloak_admin_console" { description = "Keycloak admin console URL" value = var.enable_route53_dns ? "https://${local.keycloak_domain}/admin" : ( var.enable_cloudfront ? "https://${aws_cloudfront_distribution.keycloak[0].domain_name}/admin" : "http://${aws_lb.keycloak.dns_name}/admin" ) } output "keycloak_alb_dns" { description = "Keycloak ALB DNS name" value = aws_lb.keycloak.dns_name } output "keycloak_ecr_repository" { description = "Keycloak ECR repository URL" value = aws_ecr_repository.keycloak.repository_url } # # Registry DNS and Certificate Outputs # output "registry_url" { description = "Registry URL with custom domain" value = var.enable_route53_dns ? "https://registry.${local.root_domain}" : null } output "registry_certificate_arn" { description = "ACM certificate ARN for registry subdomain" value = var.enable_route53_dns ? aws_acm_certificate.registry[0].arn : null } output "registry_dns_record" { description = "Registry DNS A record details" value = var.enable_route53_dns ? { name = aws_route53_record.registry[0].name type = aws_route53_record.registry[0].type zone_id = aws_route53_record.registry[0].zone_id } : null } # # CloudFront Outputs (when enabled) # output "cloudfront_mcp_gateway_url" { description = "CloudFront URL for MCP Gateway (when CloudFront is enabled)" value = var.enable_cloudfront ? "https://${aws_cloudfront_distribution.mcp_gateway[0].domain_name}" : null } output "cloudfront_keycloak_url" { description = "CloudFront URL for Keycloak (when CloudFront is enabled)" value = var.enable_cloudfront ? "https://${aws_cloudfront_distribution.keycloak[0].domain_name}" : null } output "deployment_mode" { description = "Current deployment mode based on configuration" value = var.enable_cloudfront && !var.enable_route53_dns ? "cloudfront" : ( var.enable_route53_dns ? "custom-domain" : "development" ) } # # Observability Outputs # output "observability_enabled" { description = "Whether the observability pipeline is enabled" value = module.mcp_gateway.observability_enabled } output "amp_workspace_id" { description = "AMP workspace ID" value = module.mcp_gateway.amp_workspace_id } output "amp_endpoint" { description = "AMP remote write endpoint" value = module.mcp_gateway.amp_endpoint } output "grafana_url" { description = "Grafana dashboard URL" value = module.mcp_gateway.grafana_url } # # DocumentDB Cluster Outputs # output "documentdb_cluster_endpoint" { description = "DocumentDB Cluster endpoint" value = aws_docdb_cluster.registry.endpoint } output "documentdb_cluster_arn" { description = "DocumentDB Cluster ARN" value = aws_docdb_cluster.registry.arn } output "documentdb_reader_endpoint" { description = "DocumentDB Cluster reader endpoint" value = aws_docdb_cluster.registry.reader_endpoint } output "documentdb_security_group_id" { description = "DocumentDB security group ID" value = aws_security_group.documentdb.id } output "documentdb_kms_key_id" { description = "KMS key ID for DocumentDB encryption" value = aws_kms_key.documentdb.id } output "documentdb_kms_key_arn" { description = "KMS key ARN for DocumentDB encryption" value = aws_kms_key.documentdb.arn } output "documentdb_secrets_manager_secret_arn" { description = "Secrets Manager secret ARN for DocumentDB credentials" value = aws_secretsmanager_secret.documentdb_credentials.arn sensitive = true } ================================================ FILE: terraform/aws-ecs/push-all-images-to-ecr.sh ================================================ #!/bin/bash set -e REGION="us-east-1" ACCOUNT_ID="128755427449" # List of images to push IMAGES=( "mcpgateway/registry:latest" "mcpgateway/currenttime:latest" "mcpgateway/mcpgw:latest" "mcpgateway/realserverfaketools:latest" "mcpgateway/flight-booking-agent:latest" "mcpgateway/travel-assistant-agent:latest" ) echo "Logging into ECR..." aws ecr get-login-password --region ${REGION} | docker login --username AWS --password-stdin ${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com for IMAGE in "${IMAGES[@]}"; do REPO_NAME=$(echo $IMAGE | cut -d'/' -f2 | cut -d':' -f1) TAG=$(echo $IMAGE | cut -d':' -f2) echo "" echo "=========================================" echo "Processing: $IMAGE" echo "=========================================" echo "Creating ECR repository: ${REPO_NAME}..." aws ecr create-repository --repository-name ${REPO_NAME} --region ${REGION} 2>/dev/null || echo "Repository already exists" echo "Pulling image (AMD64)..." docker pull --platform linux/amd64 ${IMAGE} echo "Tagging for ECR..." docker tag ${IMAGE} ${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/${REPO_NAME}:${TAG} echo "Pushing to ECR..." docker push ${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/${REPO_NAME}:${TAG} echo "✅ Done: ${REPO_NAME}:${TAG}" done echo "" echo "=========================================" echo "✅ All images pushed to ECR!" echo "=========================================" echo "" echo "Update terraform.tfvars with ECR URIs:" echo "registry_image_uri = \"${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/registry:latest\"" echo "currenttime_image_uri = \"${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/currenttime:latest\"" echo "mcpgw_image_uri = \"${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/mcpgw:latest\"" echo "realserverfaketools_image_uri = \"${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/realserverfaketools:latest\"" echo "flight_booking_agent_image_uri = \"${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/flight-booking-agent:latest\"" echo "travel_assistant_agent_image_uri = \"${ACCOUNT_ID}.dkr.ecr.${REGION}.amazonaws.com/travel-assistant-agent:latest\"" echo "" echo "Then run: terraform apply -auto-approve" ================================================ FILE: terraform/aws-ecs/registry-dns.tf ================================================ # # Registry DNS and SSL Certificate Configuration # # Provides DNS and HTTPS support for the main MCP Gateway Registry ALB # Domain: registry.mycorp.click (configured via local.root_domain) # # These resources are only created when enable_route53_dns = true # # Use existing hosted zone for the root domain data "aws_route53_zone" "registry_root" { count = var.enable_route53_dns ? 1 : 0 name = local.hosted_zone_domain private_zone = false } # Create SSL certificate for registry subdomain resource "aws_acm_certificate" "registry" { count = var.enable_route53_dns ? 1 : 0 domain_name = "registry.${local.root_domain}" validation_method = "DNS" tags = merge( local.common_tags, { Name = "registry-cert" Component = "registry" } ) lifecycle { create_before_destroy = true } } # Create DNS validation records for ACM certificate resource "aws_route53_record" "registry_certificate_validation" { for_each = var.enable_route53_dns ? { for dvo in aws_acm_certificate.registry[0].domain_validation_options : dvo.domain_name => { name = dvo.resource_record_name record = dvo.resource_record_value type = dvo.resource_record_type } } : {} allow_overwrite = true name = each.value.name records = [each.value.record] ttl = 60 type = each.value.type zone_id = data.aws_route53_zone.registry_root[0].zone_id } # Wait for certificate validation to complete resource "aws_acm_certificate_validation" "registry" { count = var.enable_route53_dns ? 1 : 0 certificate_arn = aws_acm_certificate.registry[0].arn timeouts { create = "5m" } validation_record_fqdns = [for record in aws_route53_record.registry_certificate_validation : record.fqdn] } # Create A record for registry subdomain # Points to CloudFront when both CloudFront and Route53 are enabled (Mode 3) # Points to ALB when only Route53 is enabled (Mode 2) resource "aws_route53_record" "registry" { count = var.enable_route53_dns ? 1 : 0 zone_id = data.aws_route53_zone.registry_root[0].zone_id name = "registry.${local.root_domain}" type = "A" alias { # Mode 3: Route53 → CloudFront (when both enabled) # Mode 2: Route53 → ALB (when only Route53 enabled) name = var.enable_cloudfront ? aws_cloudfront_distribution.mcp_gateway[0].domain_name : module.mcp_gateway.alb_dns_name zone_id = var.enable_cloudfront ? aws_cloudfront_distribution.mcp_gateway[0].hosted_zone_id : module.mcp_gateway.alb_zone_id evaluate_target_health = true } } ================================================ FILE: terraform/aws-ecs/scripts/README-DOCUMENTDB-CLI.md ================================================ # DocumentDB CLI Tools Command-line tools for inspecting and managing DocumentDB collections in the MCP Gateway Registry. ## Overview The DocumentDB CLI provides commands to: - List all collections in the database - Inspect collection schemas and statistics - Count documents in collections - Search and query documents - View sample documents ## Files - [`manage-documentdb.py`](../../../scripts/manage-documentdb.py) - Python script that performs DocumentDB operations - [`run-documentdb-cli.sh`](run-documentdb-cli.sh) - Shell wrapper that runs the Python script inside an ECS task with VPC access ## Usage ### Prerequisites - AWS credentials configured - DocumentDB endpoint stored in SSM Parameter Store at `/mcp-gateway/documentdb/endpoint` - DocumentDB credentials stored in Secrets Manager at `mcp-gateway/documentdb/credentials` - ECS cluster and task definition deployed ### Commands #### List All Collections ```bash ./terraform/aws-ecs/scripts/run-documentdb-cli.sh list ``` **Output:** ``` Found 6 collections in database 'mcp_registry' ================================================================================ Collection: mcp_agents_default Documents: 12 Size: 0.05 MB Collection: mcp_embeddings_1536_default Documents: 156 Size: 2.34 MB Collection: mcp_scopes_default Documents: 8 Size: 0.02 MB Collection: mcp_servers_default Documents: 24 Size: 0.15 MB ``` #### Inspect Collection Schema and Stats ```bash ./terraform/aws-ecs/scripts/run-documentdb-cli.sh inspect mcp_servers_default ``` **Output:** ``` Collection: mcp_servers_default ================================================================================ Document Count: 24 --- Collection Statistics --- Size: 0.15 MB Storage Size: 0.25 MB Total Index Size: 0.08 MB Average Object Size: 6234 bytes --- Indexes --- Index: _id_ Keys: { "_id": 1 } Index: path_1 Keys: { "path": 1 } Unique: True --- Sample Document Schema --- { "_id": "ObjectId", "path": "str", "name": "str", "enabled": "bool", "description": "str", "created_at": "datetime", "updated_at": "datetime", "metadata": "dict" } ``` #### Count Documents ```bash ./terraform/aws-ecs/scripts/run-documentdb-cli.sh count mcp_servers_default ``` **Output:** ``` Collection: mcp_servers_default Document Count: 24 ``` #### Search Documents (List) ```bash # Show first 10 documents (default) ./terraform/aws-ecs/scripts/run-documentdb-cli.sh search mcp_servers_default # Show first 20 documents ./terraform/aws-ecs/scripts/run-documentdb-cli.sh search mcp_servers_default 20 ``` **Output:** ``` Collection: mcp_servers_default Showing 10 documents (limit: 10) ================================================================================ --- Document 1 --- { "_id": "507f1f77bcf86cd799439011", "path": "/currenttime", "name": "CurrentTime Server", "enabled": true, "description": "Returns current time in various formats", "created_at": "2024-01-15T10:30:00Z", ... } --- Document 2 --- ... ``` #### Sample Document ```bash ./terraform/aws-ecs/scripts/run-documentdb-cli.sh sample mcp_servers_default ``` **Output:** ``` Collection: mcp_servers_default Sample Document: ================================================================================ { "_id": "507f1f77bcf86cd799439011", "path": "/currenttime", "name": "CurrentTime Server", "enabled": true, "description": "Returns current time in various formats", ... } ``` #### Query with Filter ```bash # Find enabled servers ./terraform/aws-ecs/scripts/run-documentdb-cli.sh query mcp_servers_default '{"enabled": true}' # Find server by path ./terraform/aws-ecs/scripts/run-documentdb-cli.sh query mcp_servers_default '{"path": "/currenttime"}' # Query with limit ./terraform/aws-ecs/scripts/run-documentdb-cli.sh query mcp_servers_default '{"enabled": true}' 5 ``` **Output:** ``` Collection: mcp_servers_default Filter: {"enabled": true} Found 18 documents (limit: 10) ================================================================================ --- Document 1 --- { "_id": "507f1f77bcf86cd799439011", "path": "/currenttime", "enabled": true, ... } ``` ## Environment Variables - `DOCUMENTDB_HOST` - Override DocumentDB endpoint (optional, read from SSM if not set) - `AWS_REGION` - AWS region (default: us-east-1) ## How It Works 1. The shell script reads DocumentDB connection details from AWS services: - Endpoint from SSM Parameter Store (`/mcp-gateway/documentdb/endpoint`) - Credentials from Secrets Manager (`mcp-gateway/documentdb/credentials`) - VPC configuration from the registry ECS service 2. It launches an ECS Fargate task using the `mcp-gateway-v2-registry` task definition 3. The task runs inside the VPC with network access to DocumentDB 4. The Python script executes the requested command and outputs results 5. Logs are retrieved from CloudWatch and displayed ## Common Collections - `mcp_servers_default` - MCP server registrations - `mcp_agents_default` - Agent registrations - `mcp_scopes_default` - Authorization scope definitions - `mcp_embeddings_1536_default` - Vector embeddings for semantic search - `mcp_groups_default` - Group definitions - `mcp_security_scans_default` - Security scan results ## Troubleshooting ### No logs found If you see "No logs found", the task may have failed to start. Check: 1. Task definition exists: `aws ecs describe-task-definition --task-definition mcp-gateway-v2-registry` 2. Network configuration is correct 3. DocumentDB credentials are valid ### Connection timeout If the task hangs or times out: 1. Verify security groups allow traffic to DocumentDB on port 27017 2. Verify task is running in the same VPC as DocumentDB 3. Check DocumentDB cluster status ### Invalid filter JSON For query commands, ensure the filter is valid JSON: ```bash # Correct ./run-documentdb-cli.sh query mcp_servers_default '{"enabled": true}' # Incorrect (missing quotes around JSON) ./run-documentdb-cli.sh query mcp_servers_default {"enabled": true} ``` ## Direct Python Usage (Local) If you have direct network access to DocumentDB (e.g., VPN, bastion host): ```bash # Set environment variables export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com export DOCUMENTDB_USERNAME=admin export DOCUMENTDB_PASSWORD=yourpassword export DOCUMENTDB_DATABASE=mcp_registry export DOCUMENTDB_USE_TLS=true export DOCUMENTDB_TLS_CA_FILE=/path/to/global-bundle.pem # Run commands directly cd scripts python manage-documentdb.py list python manage-documentdb.py inspect --collection mcp_servers_default python manage-documentdb.py search --collection mcp_servers_default --limit 5 ``` ## See Also - [OpenSearch CLI](run-aoss-cli.sh) - Similar tool for OpenSearch Serverless indexes - [DocumentDB Initialization](run-documentdb-init.sh) - Initialize DocumentDB indexes and load scopes - [View CloudWatch Logs](view-cloudwatch-logs.sh) - View ECS service logs ================================================ FILE: terraform/aws-ecs/scripts/README.md ================================================ # MCP Gateway ECS Deployment Scripts This directory contains scripts for deploying and managing the MCP Gateway services on AWS ECS. ## Registry Service Operations ### Build and Push Registry Image Build the registry Docker image and push to ECR: ```bash # From repository root make build-push IMAGE=registry ``` This command: - Builds the registry Docker image from the Dockerfile - Tags it with the ECR repository URL - Pushes to Amazon ECR - The image will be available for ECS to pull ### Force Redeploy Registry Tasks Force ECS to pull the latest image and redeploy registry tasks: ```bash aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --force-new-deployment \ --region us-east-1 ``` This command: - Triggers a new deployment without changing task definition - ECS will pull the latest image from ECR - Old tasks are gracefully drained and replaced with new ones ### Monitor Deployment Status Watch deployment progress in real-time: ```bash watch -n 5 'aws ecs describe-services \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --region us-east-1 \ --query "services[0].{Status:status,Desired:desiredCount,Running:runningCount,Pending:pendingCount,Deployments:deployments[*].{Status:status,Running:runningCount,Desired:desiredCount,RolloutState:rolloutState}}" \ --output table' ``` This command: - Refreshes every 5 seconds - Shows deployment status in table format - Displays: - Service status - Desired vs running task counts - Pending tasks - Deployment rollout state **Example Output:** ``` ---------------------------------------------------------- | DescribeServices | +----------+----------+---------+----------+--------------+ | Desired | Pending | Running | Status | | +----------+----------+---------+----------+--------------+ | 2 | 0 | 2 | ACTIVE | | +----------+----------+---------+----------+--------------+ || Deployments || |+----------+----------+---------+-------------------+ || || Desired | Running | Status | RolloutState | || |+----------+----------+---------+-------------------+ || || 2 | 2 | PRIMARY | COMPLETED | || |+----------+----------+---------+-------------------+ || ``` Press `Ctrl+C` to exit the watch command. ### Complete Deployment Workflow Full workflow to deploy registry code changes: ```bash # 1. Build and push new image make build-push IMAGE=registry # 2. Force redeploy (in separate terminal or after build completes) aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --force-new-deployment \ --region us-east-1 # 3. Monitor deployment status watch -n 5 'aws ecs describe-services \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --region us-east-1 \ --query "services[0].{Status:status,Desired:desiredCount,Running:runningCount,Pending:pendingCount,Deployments:deployments[*].{Status:status,Running:runningCount,Desired:desiredCount,RolloutState:rolloutState}}" \ --output table' ``` ## Other Services The same commands can be used for other services by replacing `registry` with the service name: - `mcp-gateway-v2-auth` - Authentication server - `mcp-gateway-v2-mcpgw` - MCP Gateway - `mcp-gateway-v2-currenttime` - Current Time MCP Server - `mcp-gateway-v2-realserverfaketools` - Test MCP Server - `mcp-gateway-v2-flight-booking-agent` - Flight Booking Agent - `mcp-gateway-v2-travel-assistant-agent` - Travel Assistant Agent ### Examples for Other Services **Auth Server:** ```bash # Build and push make build-push IMAGE=auth # Force redeploy aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-auth \ --force-new-deployment \ --region us-east-1 # Monitor watch -n 5 'aws ecs describe-services \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-auth \ --region us-east-1 \ --query "services[0].{Status:status,Desired:desiredCount,Running:runningCount,Pending:pendingCount}" \ --output table' ``` **MCP Gateway:** ```bash # Build and push make build-push IMAGE=mcpgw # Force redeploy aws ecs update-service \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-mcpgw \ --force-new-deployment \ --region us-east-1 ``` ## Deployment States Understanding deployment status: - **PENDING**: Tasks are being provisioned but not yet running - **RUNNING**: Tasks are actively running - **DRAINING**: Old tasks are being gracefully shut down - **IN_PROGRESS**: Deployment is ongoing - **COMPLETED**: Deployment finished successfully - **FAILED**: Deployment encountered errors ## Troubleshooting ### Deployment Stuck If deployment appears stuck: ```bash # Check service events aws ecs describe-services \ --cluster mcp-gateway-ecs-cluster \ --service mcp-gateway-v2-registry \ --region us-east-1 \ --query 'services[0].events[:10]' \ --output table # Check task failures aws ecs list-tasks \ --cluster mcp-gateway-ecs-cluster \ --service-name mcp-gateway-v2-registry \ --region us-east-1 \ --desired-status STOPPED \ --query 'taskArns[:5]' \ --output text | xargs -I {} aws ecs describe-tasks \ --cluster mcp-gateway-ecs-cluster \ --tasks {} \ --region us-east-1 ``` ### View Logs View CloudWatch logs for the registry service: ```bash ./view-cloudwatch-logs.sh mcp-gateway-v2-registry 50 ``` Or using AWS CLI directly: ```bash aws logs tail /ecs/mcp-gateway-v2-registry \ --follow \ --format short \ --region us-east-1 ``` ## Related Scripts - `view-cloudwatch-logs.sh` - View ECS service CloudWatch logs - `run-aoss-cli.sh` - Run OpenSearch Serverless CLI operations - `get-m2m-token.sh` - Get machine-to-machine authentication token ================================================ FILE: terraform/aws-ecs/scripts/ecs-ssh.sh ================================================ #!/bin/bash # ECS SSH Script - Dynamically finds and connects to ECS task # Usage: ./ecs-ssh.sh [service-type] [cluster-name] [region] # # Supported service types: # registry - MCP Gateway Registry # auth-server - Auth Server # keycloak - Keycloak (if available) # # Examples: # ./ecs-ssh.sh registry # ./ecs-ssh.sh auth-server # ./ecs-ssh.sh auth-server mcp-gateway-ecs-cluster us-west-2 set -e # Service type mapping: service_type -> service_name:container_name declare -A SERVICE_MAP=( [registry]="mcp-gateway-v2-registry:registry" [auth-server]="mcp-gateway-v2-auth:auth-server" [keycloak]="keycloak:keycloak" ) # Parameters SERVICE_TYPE="${1:-registry}" CLUSTER="${2:-mcp-gateway-ecs-cluster}" REGION="${3:-us-east-1}" # Get service name and container name from map if [[ -z "${SERVICE_MAP[$SERVICE_TYPE]}" ]]; then echo "Error: Unknown service type '$SERVICE_TYPE'" echo "Supported types: ${!SERVICE_MAP[@]}" exit 1 fi IFS=':' read -r SERVICE CONTAINER <<< "${SERVICE_MAP[$SERVICE_TYPE]}" echo "Connecting to ECS task..." echo " Service Type: $SERVICE_TYPE" echo " Cluster: $CLUSTER" echo " Service: $SERVICE" echo " Container: $CONTAINER" echo " Region: $REGION" echo "" # Get the first running task ARN TASK_ARN=$(aws ecs list-tasks \ --cluster "$CLUSTER" \ --service-name "$SERVICE" \ --region "$REGION" \ --query 'taskArns[0]' \ --output text) if [ -z "$TASK_ARN" ] || [ "$TASK_ARN" = "None" ]; then echo "Error: No running tasks found for service '$SERVICE' in cluster '$CLUSTER'" exit 1 fi echo "Task ARN: $TASK_ARN" echo "" # Connect to the task aws ecs execute-command \ --cluster "$CLUSTER" \ --task "$TASK_ARN" \ --container "$CONTAINER" \ --interactive \ --command "/bin/bash" \ --region "$REGION" ================================================ FILE: terraform/aws-ecs/scripts/init-documentdb.sh ================================================ #!/bin/bash # Initialize DocumentDB collections and indexes for MCP Gateway Registry # This script downloads the CA bundle (if needed) and runs the Python initialization script set -e # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PARENT_DIR="$(dirname "$SCRIPT_DIR")" # Configuration CA_BUNDLE_URL="https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem" CA_BUNDLE_FILE="${DOCUMENTDB_TLS_CA_FILE:-global-bundle.pem}" CA_BUNDLE_PATH="${PARENT_DIR}/${CA_BUNDLE_FILE}" # Colors for output GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' echo "DocumentDB Initialization Script" echo "=================================" echo "" # Check if DocumentDB host is set if [ -z "$DOCUMENTDB_HOST" ]; then echo "${RED}Error: DOCUMENTDB_HOST environment variable is not set${NC}" echo "" echo "Please set the required environment variables:" echo " export DOCUMENTDB_HOST=your-cluster.docdb.amazonaws.com" echo " export DOCUMENTDB_USERNAME=admin" echo " export DOCUMENTDB_PASSWORD=yourpassword" echo "" echo "Or use command-line arguments:" echo " $0 --host your-cluster.docdb.amazonaws.com --username admin --password yourpassword" exit 1 fi # Download CA bundle if it doesn't exist and TLS is enabled USE_TLS="${DOCUMENTDB_USE_TLS:-true}" if [ "$USE_TLS" = "true" ] && [ ! -f "$CA_BUNDLE_PATH" ]; then echo "${YELLOW}TLS is enabled but CA bundle not found${NC}" echo "Downloading AWS DocumentDB CA bundle..." echo "Source: ${CA_BUNDLE_URL}" echo "Destination: ${CA_BUNDLE_PATH}" echo "" if command -v wget &> /dev/null; then wget -O "$CA_BUNDLE_PATH" "$CA_BUNDLE_URL" elif command -v curl &> /dev/null; then curl -o "$CA_BUNDLE_PATH" "$CA_BUNDLE_URL" else echo "${RED}Error: Neither wget nor curl is available. Please install one of them.${NC}" exit 1 fi if [ -f "$CA_BUNDLE_PATH" ]; then FILE_SIZE=$(stat -f%z "$CA_BUNDLE_PATH" 2>/dev/null || stat -c%s "$CA_BUNDLE_PATH" 2>/dev/null) if [ "$FILE_SIZE" -gt 0 ]; then echo "${GREEN}Successfully downloaded CA bundle (${FILE_SIZE} bytes)${NC}" echo "" else echo "${RED}Error: Downloaded file is empty${NC}" rm -f "$CA_BUNDLE_PATH" exit 1 fi else echo "${RED}Error: Failed to download CA bundle${NC}" exit 1 fi elif [ "$USE_TLS" = "true" ]; then echo "${GREEN}CA bundle found at: ${CA_BUNDLE_PATH}${NC}" echo "" fi # Set up environment variables for the Python script export DOCUMENTDB_TLS_CA_FILE="$CA_BUNDLE_PATH" echo "Environment Configuration:" echo " DOCUMENTDB_HOST: ${DOCUMENTDB_HOST}" echo " DOCUMENTDB_PORT: ${DOCUMENTDB_PORT:-27017}" echo " DOCUMENTDB_DATABASE: ${DOCUMENTDB_DATABASE:-mcp_registry}" echo " DOCUMENTDB_NAMESPACE: ${DOCUMENTDB_NAMESPACE:-default}" echo " DOCUMENTDB_USE_TLS: ${USE_TLS}" echo " DOCUMENTDB_USE_IAM: ${DOCUMENTDB_USE_IAM:-false}" if [ -n "$DOCUMENTDB_USERNAME" ]; then echo " DOCUMENTDB_USERNAME: ${DOCUMENTDB_USERNAME}" fi echo "" echo "Running DocumentDB initialization script..." echo "" # Run the Python initialization script cd "$PARENT_DIR" if command -v uv &> /dev/null; then uv run python scripts/init-documentdb-indexes.py "$@" elif command -v python3 &> /dev/null; then python3 scripts/init-documentdb-indexes.py "$@" else echo "${RED}Error: Neither uv nor python3 is available${NC}" exit 1 fi echo "" echo "${GREEN}DocumentDB initialization complete!${NC}" ================================================ FILE: terraform/aws-ecs/scripts/init-keycloak.sh ================================================ #!/bin/bash # Initialize Keycloak with MCP Gateway configuration # This script sets up the initial realm, clients, groups, and users # # Usage: # KEYCLOAK_ADMIN_URL=https://your-keycloak-url \ # KEYCLOAK_ADMIN=admin \ # KEYCLOAK_ADMIN_PASSWORD=your-admin-password \ # AUTH_SERVER_EXTERNAL_URL=https://your-auth-server-url \ # REGISTRY_URL=https://your-registry-url \ # ./init-keycloak.sh # # Or set these in a .env file in the project root set -e # These will be set properly after loading .env in main() KEYCLOAK_URL="" # Will be overridden with KEYCLOAK_ADMIN_URL after .env is loaded REALM="mcp-gateway" KEYCLOAK_ADMIN="" KEYCLOAK_ADMIN_PASSWORD="" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color echo -e "${YELLOW}Keycloak initialization script for MCP Gateway Registry${NC}" echo "==============================================" # Function to wait for Keycloak to be ready wait_for_keycloak() { echo -n "Waiting for Keycloak to be ready..." local max_attempts=60 local attempt=0 while [ $attempt -lt $max_attempts ]; do # Try to access the admin console which indicates Keycloak is ready if curl -f -s "${KEYCLOAK_URL}/admin/" > /dev/null 2>&1; then echo -e " ${GREEN}Ready!${NC}" return 0 fi echo -n "." sleep 5 attempt=$((attempt + 1)) done echo -e " ${RED}Timeout!${NC}" echo "Keycloak did not become ready within 5 minutes" exit 1 } # Function to get admin token get_admin_token() { local response=$(curl -s -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=${KEYCLOAK_ADMIN}" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli") echo "$response" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4 } # Function to refresh admin token (call before each major operation) refresh_token() { TOKEN=$(get_admin_token) if [ -z "$TOKEN" ]; then echo -e "${RED}Error: Failed to refresh authentication token${NC}" exit 1 fi } # Function to check if realm exists realm_exists() { local token=$1 local response=$(curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}") [ "$response" = "200" ] } # Function to create realm step by step create_realm() { local token=$1 echo "Creating MCP Gateway realm..." # Check if realm already exists if realm_exists "$token"; then echo -e "${YELLOW}Realm already exists. Skipping creation...${NC}" return 0 fi # Create basic realm local realm_json='{ "realm": "mcp-gateway", "enabled": true, "registrationAllowed": false, "loginWithEmailAllowed": true, "duplicateEmailsAllowed": false, "resetPasswordAllowed": true, "editUsernameAllowed": false }' local response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$realm_json") if [ "$response" = "201" ]; then echo -e "${GREEN}Realm created successfully!${NC}" return 0 elif [ "$response" = "409" ]; then echo -e "${YELLOW}Realm already exists. Continuing...${NC}" return 0 else echo -e "${RED}Failed to create realm. HTTP status: ${response}${NC}" echo "Response body:" curl -s -X POST "${KEYCLOAK_URL}/admin/realms" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$realm_json" echo "" return 1 fi } # Function to create clients create_clients() { local token=$1 echo "Creating OAuth2 clients..." # Create web client # Build redirect URIs based on deployment mode # - Custom domain mode: use REGISTRY_URL # - CloudFront mode: use CLOUDFRONT_REGISTRY_URL # - Both modes: include both URLs local redirect_uris='"http://localhost:7860/*", "http://localhost:8888/*"' local web_origins='"http://localhost:7860", "+"' # Add custom domain URLs if available if [ -n "$REGISTRY_URL" ] && [ "$REGISTRY_URL" != "http://localhost:7860" ]; then redirect_uris="${redirect_uris}, \"${REGISTRY_URL}/oauth2/callback/keycloak\", \"${REGISTRY_URL}/*\"" web_origins="${web_origins}, \"${REGISTRY_URL}\"" echo " - Adding custom domain redirect URIs: ${REGISTRY_URL}" fi # Add CloudFront URLs if available if [ -n "$CLOUDFRONT_REGISTRY_URL" ]; then redirect_uris="${redirect_uris}, \"${CLOUDFRONT_REGISTRY_URL}/oauth2/callback/keycloak\", \"${CLOUDFRONT_REGISTRY_URL}/*\"" web_origins="${web_origins}, \"${CLOUDFRONT_REGISTRY_URL}\"" echo " - Adding CloudFront redirect URIs: ${CLOUDFRONT_REGISTRY_URL}" fi # If neither is set, use localhost as fallback if [ -z "$REGISTRY_URL" ] && [ -z "$CLOUDFRONT_REGISTRY_URL" ]; then echo " - Using localhost fallback for redirect URIs" fi local web_client_json='{ "clientId": "mcp-gateway-web", "name": "MCP Gateway Web Client", "enabled": true, "clientAuthenticatorType": "client-secret", "redirectUris": ['"${redirect_uris}"'], "webOrigins": ['"${web_origins}"'], "protocol": "openid-connect", "standardFlowEnabled": true, "implicitFlowEnabled": false, "directAccessGrantsEnabled": true, "serviceAccountsEnabled": false, "publicClient": false }' local web_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$web_client_json") if [ "$web_response" = "201" ]; then echo " - Web client created" elif [ "$web_response" = "409" ]; then echo " - Web client already exists, updating redirect URIs..." local web_client_uuid=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=mcp-gateway-web" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) if [ -n "$web_client_uuid" ] && [ "$web_client_uuid" != "null" ]; then local update_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${web_client_uuid}" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$web_client_json") if [ "$update_response" = "204" ]; then echo -e " - ${GREEN}Web client updated successfully${NC}" else echo -e " - ${RED}Failed to update web client (HTTP $update_response)${NC}" fi fi else echo -e "${RED} - Failed to create web client (HTTP $web_response)${NC}" fi # Create M2M client local m2m_client_json='{ "clientId": "mcp-gateway-m2m", "name": "MCP Gateway M2M Client", "enabled": true, "clientAuthenticatorType": "client-secret", "protocol": "openid-connect", "standardFlowEnabled": false, "implicitFlowEnabled": false, "directAccessGrantsEnabled": false, "serviceAccountsEnabled": true, "publicClient": false }' local m2m_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$m2m_client_json") if [ "$m2m_response" = "201" ]; then echo " - M2M client created" elif [ "$m2m_response" = "409" ]; then echo " - M2M client already exists" else echo -e "${RED} - Failed to create M2M client (HTTP $m2m_response)${NC}" fi echo -e "${GREEN}Clients configured successfully!${NC}" } # Function to create groups create_groups() { local token=$1 echo "Creating user groups..." local groups=( "mcp-registry-admin" "mcp-registry-user" "mcp-registry-developer" "mcp-registry-operator" "mcp-servers-unrestricted" "mcp-servers-restricted" "a2a-agent-admin" "a2a-agent-publisher" "a2a-agent-user" "registry-admins" "registry-users-lob1" "registry-users-lob2" ) for group in "${groups[@]}"; do local group_json='{ "name": "'$group'", "attributes": { "description": ["'$group' group for MCP Gateway access"] } }' curl -s -X POST "${KEYCLOAK_URL}/admin/realms/mcp-gateway/groups" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$group_json" > /dev/null done echo -e "${GREEN}Groups created successfully!${NC}" } # Function to create custom scopes create_scopes() { # Refresh token to ensure it's valid refresh_token local token=$TOKEN echo "Creating custom MCP scopes..." local scopes=("mcp-servers-unrestricted/read" "mcp-servers-unrestricted/execute" "mcp-servers-restricted/read" "mcp-servers-restricted/execute") for scope in "${scopes[@]}"; do local scope_json='{ "name": "'$scope'", "description": "MCP Gateway scope for '$scope' access", "protocol": "openid-connect" }' local response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/client-scopes" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$scope_json") if [ "$response" = "201" ]; then echo " - Created scope: $scope" elif [ "$response" = "409" ]; then echo " - Scope already exists: $scope" else echo -e "${RED} - Failed to create scope: $scope (HTTP $response)${NC}" fi done echo -e "${GREEN}Custom scopes created successfully!${NC}" } # Function to assign scopes to M2M client setup_m2m_scopes() { # Refresh token to ensure it's valid refresh_token local token=$TOKEN echo "Setting up M2M client scopes..." # Get M2M client ID local m2m_client_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=mcp-gateway-m2m" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) if [ -z "$m2m_client_id" ] || [ "$m2m_client_id" = "null" ]; then echo -e "${RED}Error: Could not find mcp-gateway-m2m client${NC}" return 1 fi # Get all available client scopes local scopes=("mcp-servers-unrestricted/read" "mcp-servers-unrestricted/execute" "mcp-servers-restricted/read" "mcp-servers-restricted/execute") for scope in "${scopes[@]}"; do # Get scope ID local scope_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/client-scopes" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="'$scope'") | .id) else empty end' 2>/dev/null) if [ ! -z "$scope_id" ] && [ "$scope_id" != "null" ]; then # Add scope as default client scope local response=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${m2m_client_id}/default-client-scopes/${scope_id}" \ -H "Authorization: Bearer ${token}") if [ "$response" = "204" ]; then echo " - Assigned scope: $scope" else echo -e "${YELLOW} - Warning: Could not assign scope $scope (HTTP $response)${NC}" fi else echo -e "${RED} - Error: Could not find scope: $scope${NC}" fi done echo -e "${GREEN}M2M client scopes configured successfully!${NC}" } # Function to create service account user for M2M client create_service_account_user() { # Refresh token to ensure it's valid refresh_token local token=$TOKEN local service_account_username="service-account-mcp-gateway-m2m" echo "Creating service account user: $service_account_username" # Check if user already exists local existing_user=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/users?username=$service_account_username" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) if [ ! -z "$existing_user" ]; then echo -e "${YELLOW}Service account user already exists with ID: $existing_user${NC}" return 0 fi # Create service account user local user_json='{ "username": "'$service_account_username'", "enabled": true, "emailVerified": true, "serviceAccountClientId": "mcp-gateway-m2m" }' local response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/users" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$user_json") if [ "$response" = "201" ]; then echo -e "${GREEN}Service account user created successfully!${NC}" # Get the newly created user ID local user_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/users?username=$service_account_username" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) echo "Created service account user with ID: $user_id" # Assign user to mcp-servers-unrestricted group local group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="mcp-servers-unrestricted") | .id) else empty end' 2>/dev/null) if [ ! -z "$group_id" ] && [ "$group_id" != "null" ]; then local group_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$user_id/groups/$group_id" \ -H "Authorization: Bearer ${token}") if [ "$group_response" = "204" ]; then echo -e "${GREEN}Service account assigned to mcp-servers-unrestricted group!${NC}" else echo -e "${YELLOW}Warning: Could not assign service account to mcp-servers-unrestricted group (HTTP $group_response)${NC}" fi else echo -e "${RED}Error: Could not find mcp-servers-unrestricted group${NC}" fi # Assign user to a2a-agent-admin group for A2A agent access local a2a_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="a2a-agent-admin") | .id) else empty end' 2>/dev/null) if [ ! -z "$a2a_group_id" ] && [ "$a2a_group_id" != "null" ]; then local a2a_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$user_id/groups/$a2a_group_id" \ -H "Authorization: Bearer ${token}") if [ "$a2a_response" = "204" ]; then echo -e "${GREEN}Service account assigned to a2a-agent-admin group!${NC}" else echo -e "${YELLOW}Warning: Could not assign service account to a2a-agent-admin group (HTTP $a2a_response)${NC}" fi else echo -e "${YELLOW}Warning: a2a-agent-admin group not found. Create it manually if A2A agent support is needed.${NC}" fi return 0 elif [ "$response" = "409" ]; then echo -e "${YELLOW}Service account user already exists. Continuing...${NC}" return 0 else echo -e "${RED}Failed to create service account user. HTTP status: ${response}${NC}" return 1 fi } # Function to create service account clients for A2A agents create_service_account_clients() { # Refresh token to ensure it's valid refresh_token local token=$TOKEN echo "Creating service account clients for A2A agents..." # Define service account clients local clients=("registry-admin-bot" "lob1-bot" "lob2-bot") local groups=("registry-admins" "registry-users-lob1" "registry-users-lob2") for i in "${!clients[@]}"; do local client_name="${clients[$i]}" local group_name="${groups[$i]}" echo " Creating client: $client_name" # Create M2M service account client local client_json='{ "clientId": "'$client_name'", "name": "'$client_name' Service Account", "description": "Service account for '$client_name' operations", "enabled": true, "serviceAccountsEnabled": true, "standardFlowEnabled": false, "implicitFlowEnabled": false, "directAccessGrantsEnabled": false, "publicClient": false, "protocol": "openid-connect" }' local response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$client_json") if [ "$response" = "201" ]; then echo " - Client created successfully" elif [ "$response" = "409" ]; then echo " - Client already exists" else echo -e "${RED} - Failed to create client (HTTP $response)${NC}" continue fi # Get the client UUID local client_uuid=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=${client_name}" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) if [ -z "$client_uuid" ] || [ "$client_uuid" = "null" ]; then echo -e "${RED} - Error: Could not find client UUID${NC}" continue fi # Get the service account user ID local service_account_user=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${client_uuid}/service-account-user" 2>/dev/null | \ jq -r '.id // empty' 2>/dev/null) if [ -z "$service_account_user" ] || [ "$service_account_user" = "null" ]; then echo -e "${RED} - Error: Could not get service account user ID${NC}" continue fi # Get the group ID local group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="'$group_name'") | .id) else empty end' 2>/dev/null) if [ -z "$group_id" ] || [ "$group_id" = "null" ]; then echo -e "${RED} - Error: Could not find group: $group_name${NC}" continue fi # Assign service account to the group local group_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/${service_account_user}/groups/${group_id}" \ -H "Authorization: Bearer ${token}") if [ "$group_response" = "204" ]; then echo " - Service account assigned to group: $group_name" else echo -e "${YELLOW} - Warning: Could not assign to group (HTTP $group_response)${NC}" fi # Add groups mapper to the client so groups appear in JWT token echo " - Adding groups mapper to client..." local groups_mapper_json='{ "name": "groups", "protocol": "openid-connect", "protocolMapper": "oidc-group-membership-mapper", "consentRequired": false, "config": { "full.path": "false", "id.token.claim": "true", "access.token.claim": "true", "claim.name": "groups", "userinfo.token.claim": "true" } }' local mapper_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${client_uuid}/protocol-mappers/models" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$groups_mapper_json") if [ "$mapper_response" = "201" ]; then echo " - Groups mapper created successfully" elif [ "$mapper_response" = "409" ]; then echo " - Groups mapper already exists" else echo -e "${YELLOW} - Warning: Could not create groups mapper (HTTP $mapper_response)${NC}" fi done echo -e "${GREEN}Service account clients created successfully!${NC}" } # Function to update user password (for existing users) update_user_password() { local token=$1 local username=$2 local password=$3 # Get user ID local user_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/users?username=${username}" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) if [ -z "$user_id" ] || [ "$user_id" = "null" ]; then return 1 fi # Reset password local password_json='{ "type": "password", "value": "'"${password}"'", "temporary": false }' local response=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/${user_id}/reset-password" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$password_json") [ "$response" = "204" ] } # Function to create test users create_users() { # Refresh token to ensure it's valid refresh_token local token=$TOKEN echo "Creating test users..." # Define usernames for consistency local admin_username="admin" local test_username="testuser" local lob1_username="lob1-user" local lob2_username="lob2-user" # Create admin user local admin_user_json='{ "username": "'$admin_username'", "email": "'$admin_username'@example.com", "enabled": true, "emailVerified": true, "firstName": "Admin", "lastName": "User", "credentials": [ { "type": "password", "value": "'${INITIAL_ADMIN_PASSWORD}'", "temporary": false } ] }' local admin_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/users" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$admin_user_json") if [ "$admin_response" = "201" ]; then echo " - Created admin user with password from Secrets Manager" elif [ "$admin_response" = "409" ]; then echo " - Admin user already exists, updating password..." if update_user_password "$token" "$admin_username" "$INITIAL_ADMIN_PASSWORD"; then echo " - Admin password updated successfully" else echo -e "${YELLOW} - Warning: Could not update admin password${NC}" fi else echo -e "${RED} - Failed to create admin user (HTTP $admin_response)${NC}" fi # Create test user local test_user_json='{ "username": "'$test_username'", "email": "'$test_username'@example.com", "enabled": true, "emailVerified": true, "firstName": "Test", "lastName": "User", "credentials": [ { "type": "password", "value": "'${INITIAL_USER_PASSWORD:-testpass}'", "temporary": false } ] }' curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/users" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$test_user_json" > /dev/null # Create lob1-user local lob1_user_json='{ "username": "'$lob1_username'", "email": "'$lob1_username'@example.com", "enabled": true, "emailVerified": true, "firstName": "LOB1", "lastName": "User", "credentials": [ { "type": "password", "value": "'${LOB1_USER_PASSWORD:-lob1pass}'", "temporary": false } ] }' curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/users" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$lob1_user_json" > /dev/null # Create lob2-user local lob2_user_json='{ "username": "'$lob2_username'", "email": "'$lob2_username'@example.com", "enabled": true, "emailVerified": true, "firstName": "LOB2", "lastName": "User", "credentials": [ { "type": "password", "value": "'${LOB2_USER_PASSWORD:-lob2pass}'", "temporary": false } ] }' curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/users" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$lob2_user_json" > /dev/null echo "Assigning users to groups..." # Get user IDs local admin_user_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/users?username=$admin_username" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) local test_user_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/users?username=$test_username" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) local lob1_user_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/users?username=$lob1_username" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) local lob2_user_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/users?username=$lob2_username" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) # Get all group IDs local admin_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="mcp-registry-admin") | .id) else empty end' 2>/dev/null) local user_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="mcp-registry-user") | .id) else empty end' 2>/dev/null) local developer_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="mcp-registry-developer") | .id) else empty end' 2>/dev/null) local operator_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="mcp-registry-operator") | .id) else empty end' 2>/dev/null) local unrestricted_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="mcp-servers-unrestricted") | .id) else empty end' 2>/dev/null) local restricted_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="mcp-servers-restricted") | .id) else empty end' 2>/dev/null) local lob1_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="registry-users-lob1") | .id) else empty end' 2>/dev/null) local lob2_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="registry-users-lob2") | .id) else empty end' 2>/dev/null) # Define usernames for consistent logging local admin_username="admin" local test_username="testuser" local lob1_username="lob1-user" local lob2_username="lob2-user" # Get registry-admins group ID for admin user local registry_admins_group_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="registry-admins") | .id) else empty end' 2>/dev/null) # Assign admin user to admin group and unrestricted servers group if [ ! -z "$admin_user_id" ] && [ ! -z "$admin_group_id" ]; then curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$admin_user_id/groups/$admin_group_id" \ -H "Authorization: Bearer ${token}" > /dev/null echo " - $admin_username assigned to mcp-registry-admin group" fi # Also assign admin to unrestricted servers group for full access if [ ! -z "$admin_user_id" ] && [ ! -z "$unrestricted_group_id" ]; then curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$admin_user_id/groups/$unrestricted_group_id" \ -H "Authorization: Bearer ${token}" > /dev/null echo " - $admin_username assigned to mcp-servers-unrestricted group" fi # Assign admin to registry-admins group for full UI permissions if [ ! -z "$admin_user_id" ] && [ ! -z "$registry_admins_group_id" ]; then curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$admin_user_id/groups/$registry_admins_group_id" \ -H "Authorization: Bearer ${token}" > /dev/null echo " - $admin_username assigned to registry-admins group" fi # Assign test user to all groups except admin if [ ! -z "$test_user_id" ]; then # Arrays of group IDs and names for loop processing local group_ids=("$user_group_id" "$developer_group_id" "$operator_group_id" "$unrestricted_group_id" "$restricted_group_id") local group_names=("mcp-registry-user" "mcp-registry-developer" "mcp-registry-operator" "mcp-servers-unrestricted" "mcp-servers-restricted") # Loop through groups and assign test user to each for i in "${!group_ids[@]}"; do local group_id="${group_ids[$i]}" local group_name="${group_names[$i]}" if [ ! -z "$group_id" ]; then curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$test_user_id/groups/$group_id" \ -H "Authorization: Bearer ${token}" > /dev/null echo " - $test_username assigned to $group_name group" fi done fi # Assign lob1-user to registry-users-lob1 group if [ ! -z "$lob1_user_id" ] && [ ! -z "$lob1_group_id" ]; then curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$lob1_user_id/groups/$lob1_group_id" \ -H "Authorization: Bearer ${token}" > /dev/null echo " - $lob1_username assigned to registry-users-lob1 group" fi # Assign lob2-user to registry-users-lob2 group if [ ! -z "$lob2_user_id" ] && [ ! -z "$lob2_group_id" ]; then curl -s -X PUT "${KEYCLOAK_URL}/admin/realms/${REALM}/users/$lob2_user_id/groups/$lob2_group_id" \ -H "Authorization: Bearer ${token}" > /dev/null echo " - $lob2_username assigned to registry-users-lob2 group" fi echo -e "${GREEN}Users created and assigned to groups successfully!${NC}" } # Function to create client secrets setup_client_secrets() { # Refresh token to ensure it's valid refresh_token local token=$TOKEN echo "Setting up client secrets..." # Get web client ID local web_client_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=mcp-gateway-web" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) # Generate secret for web client curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${web_client_id}/client-secret" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" > /dev/null local web_secret_response=$(curl -s "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${web_client_id}/client-secret" \ -H "Authorization: Bearer ${token}") web_secret=$(echo "$web_secret_response" | jq -r '.value // empty') # Get M2M client ID local m2m_client_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=mcp-gateway-m2m" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) # Generate secret for M2M client curl -s -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${m2m_client_id}/client-secret" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" > /dev/null local m2m_secret_response=$(curl -s "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${m2m_client_id}/client-secret" \ -H "Authorization: Bearer ${token}") m2m_secret=$(echo "$m2m_secret_response" | jq -r '.value // empty') echo -e "${GREEN}Client secrets generated!${NC}" # Save web client secret to AWS Secrets Manager if [ -n "$web_secret" ] && command -v aws &> /dev/null; then echo "Saving web client secret to AWS Secrets Manager..." if aws secretsmanager update-secret \ --secret-id mcp-gateway-keycloak-client-secret \ --secret-string "{\"client_id\": \"mcp-gateway-web\", \"client_secret\": \"${web_secret}\"}" \ --region "${AWS_REGION}" &>/dev/null; then echo -e "${GREEN}Web client secret saved to AWS Secrets Manager!${NC}" else echo -e "${YELLOW}Warning: Could not save web client secret to Secrets Manager${NC}" echo "You can manually update it with:" echo " aws secretsmanager update-secret --secret-id mcp-gateway-keycloak-client-secret \\" echo " --secret-string '{\"client_id\": \"mcp-gateway-web\", \"client_secret\": \"${web_secret}\"}' \\" echo " --region \${AWS_REGION}" fi fi # Save M2M client secret to AWS Secrets Manager if [ -n "$m2m_secret" ] && command -v aws &> /dev/null; then echo "Saving M2M client secret to AWS Secrets Manager..." if aws secretsmanager update-secret \ --secret-id mcp-gateway-keycloak-m2m-client-secret \ --secret-string "{\"client_id\": \"mcp-gateway-m2m\", \"client_secret\": \"${m2m_secret}\"}" \ --region "${AWS_REGION}" &>/dev/null; then echo -e "${GREEN}M2M client secret saved to AWS Secrets Manager!${NC}" else echo -e "${YELLOW}Warning: Could not save M2M client secret to Secrets Manager${NC}" echo "You can manually update it with:" echo " aws secretsmanager update-secret --secret-id mcp-gateway-keycloak-m2m-client-secret \\" echo " --secret-string '{\"client_id\": \"mcp-gateway-m2m\", \"client_secret\": \"${m2m_secret}\"}' \\" echo " --region \${AWS_REGION}" fi fi echo "" echo "==============================================" echo -e "${YELLOW}Client credentials have been created.${NC}" echo "==============================================" echo "" echo "Web Client:" echo " Client ID: mcp-gateway-web" echo " Secret: ${web_secret}" echo "" echo "M2M Client:" echo " Client ID: mcp-gateway-m2m" echo " Secret: ${m2m_secret}" echo "" echo -e "${GREEN}Note: Both client secrets have been saved to AWS Secrets Manager${NC}" echo " - mcp-gateway-keycloak-client-secret (web client)" echo " - mcp-gateway-keycloak-m2m-client-secret (M2M client)" echo "==============================================" } # Function to setup groups mapper for OAuth2 clients setup_groups_mapper() { # Refresh token to ensure it's valid refresh_token local token=$TOKEN echo "Setting up groups mapper for OAuth2 clients..." # Create groups mapper JSON local groups_mapper_json='{ "name": "groups", "protocol": "openid-connect", "protocolMapper": "oidc-group-membership-mapper", "consentRequired": false, "config": { "full.path": "false", "id.token.claim": "true", "access.token.claim": "true", "claim.name": "groups", "userinfo.token.claim": "true" } }' # Setup groups mapper for mcp-gateway-web client echo "Setting up groups mapper for mcp-gateway-web client..." local web_client_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=mcp-gateway-web" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) if [ -z "$web_client_id" ] || [ "$web_client_id" = "null" ]; then echo -e "${RED}Error: Could not find mcp-gateway-web client${NC}" return 1 fi local response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${web_client_id}/protocol-mappers/models" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$groups_mapper_json") if [ "$response" = "201" ]; then echo -e "${GREEN}Groups mapper created for mcp-gateway-web!${NC}" elif [ "$response" = "409" ]; then echo -e "${YELLOW}Groups mapper already exists for mcp-gateway-web. Continuing...${NC}" else echo -e "${RED}Failed to create groups mapper for mcp-gateway-web. HTTP status: ${response}${NC}" return 1 fi # Setup groups mapper for mcp-gateway-m2m client echo "Setting up groups mapper for mcp-gateway-m2m client..." local m2m_client_id=$(curl -s -H "Authorization: Bearer ${token}" \ "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=mcp-gateway-m2m" 2>/dev/null | \ jq -r 'if type == "array" then (.[0].id // empty) else empty end' 2>/dev/null) if [ -z "$m2m_client_id" ] || [ "$m2m_client_id" = "null" ]; then echo -e "${RED}Error: Could not find mcp-gateway-m2m client${NC}" return 1 fi local m2m_response=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${m2m_client_id}/protocol-mappers/models" \ -H "Authorization: Bearer ${token}" \ -H "Content-Type: application/json" \ -d "$groups_mapper_json") if [ "$m2m_response" = "201" ]; then echo -e "${GREEN}Groups mapper created for mcp-gateway-m2m!${NC}" elif [ "$m2m_response" = "409" ]; then echo -e "${YELLOW}Groups mapper already exists for mcp-gateway-m2m. Continuing...${NC}" else echo -e "${RED}Failed to create groups mapper for mcp-gateway-m2m. HTTP status: ${m2m_response}${NC}" return 1 fi } # Function to load values from terraform-outputs.json load_from_terraform_outputs() { local terraform_outputs="$SCRIPT_DIR/terraform-outputs.json" if [ ! -f "$terraform_outputs" ]; then echo -e "${YELLOW}Warning: terraform-outputs.json not found in $SCRIPT_DIR${NC}" return 1 fi echo "Loading values from terraform-outputs.json..." # Extract values from JSON if command -v jq &> /dev/null; then # Load KEYCLOAK_ADMIN_URL if not set if [ -z "$KEYCLOAK_ADMIN_URL" ]; then local keycloak_url=$(jq -r '.keycloak_url.value // empty' "$terraform_outputs" 2>/dev/null) if [ -n "$keycloak_url" ] && [ "$keycloak_url" != "null" ]; then KEYCLOAK_ADMIN_URL="$keycloak_url" echo " - Loaded KEYCLOAK_ADMIN_URL: $KEYCLOAK_ADMIN_URL" fi fi # Load AUTH_SERVER_EXTERNAL_URL if not set if [ -z "$AUTH_SERVER_EXTERNAL_URL" ]; then local auth_url=$(jq -r '.mcp_gateway_auth_url.value // empty' "$terraform_outputs" 2>/dev/null) if [ -n "$auth_url" ] && [ "$auth_url" != "null" ]; then AUTH_SERVER_EXTERNAL_URL="$auth_url" echo " - Loaded AUTH_SERVER_EXTERNAL_URL: $AUTH_SERVER_EXTERNAL_URL" fi fi # Load REGISTRY_URL if not set (custom domain mode) if [ -z "$REGISTRY_URL" ]; then local registry_url=$(jq -r '.registry_url.value // empty' "$terraform_outputs" 2>/dev/null) if [ -n "$registry_url" ] && [ "$registry_url" != "null" ]; then REGISTRY_URL="$registry_url" echo " - Loaded REGISTRY_URL: $REGISTRY_URL" fi fi # Load CLOUDFRONT_REGISTRY_URL if not set (CloudFront mode) if [ -z "$CLOUDFRONT_REGISTRY_URL" ]; then local cloudfront_url=$(jq -r '.cloudfront_mcp_gateway_url.value // empty' "$terraform_outputs" 2>/dev/null) if [ -n "$cloudfront_url" ] && [ "$cloudfront_url" != "null" ]; then CLOUDFRONT_REGISTRY_URL="$cloudfront_url" echo " - Loaded CLOUDFRONT_REGISTRY_URL: $CLOUDFRONT_REGISTRY_URL" fi fi # Load deployment mode to understand which URLs are active if [ -z "$DEPLOYMENT_MODE" ]; then local deployment_mode=$(jq -r '.deployment_mode.value // empty' "$terraform_outputs" 2>/dev/null) if [ -n "$deployment_mode" ] && [ "$deployment_mode" != "null" ]; then DEPLOYMENT_MODE="$deployment_mode" echo " - Loaded DEPLOYMENT_MODE: $DEPLOYMENT_MODE" fi fi # Check if we successfully loaded values if [ -n "$AUTH_SERVER_EXTERNAL_URL" ] || [ -n "$REGISTRY_URL" ] || [ -n "$KEYCLOAK_ADMIN_URL" ] || [ -n "$CLOUDFRONT_REGISTRY_URL" ]; then return 0 fi else echo -e "${YELLOW}Warning: jq not found. Skipping terraform-outputs.json parsing${NC}" return 1 fi return 1 } # Main execution main() { # Get script directory and find .env file SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PROJECT_ROOT="$( cd "$SCRIPT_DIR/../.." && pwd )" ENV_FILE="$PROJECT_ROOT/.env" # Check for AWS_REGION - required for SSM and Secrets Manager operations if [ -z "$AWS_REGION" ]; then echo -e "${RED}Error: AWS_REGION environment variable is required${NC}" echo "Please set AWS_REGION before running this script:" echo " export AWS_REGION=us-east-1" echo " # or" echo " AWS_REGION=us-east-1 $0" exit 1 fi echo "Using AWS Region: $AWS_REGION" # Load environment variables from .env file if it exists if [ -f "$ENV_FILE" ]; then echo "Loading environment variables from $ENV_FILE..." set -a # Automatically export all variables source "$ENV_FILE" set +a # Turn off automatic export echo "Environment variables loaded successfully" else echo "No .env file found at $ENV_FILE" fi # Try to load missing values from terraform-outputs.json if [ -z "$AUTH_SERVER_EXTERNAL_URL" ] || [ -z "$REGISTRY_URL" ] || [ -z "$KEYCLOAK_ADMIN_URL" ]; then echo "Attempting to load missing values from terraform-outputs.json..." load_from_terraform_outputs || true fi # Override KEYCLOAK_URL with KEYCLOAK_ADMIN_URL for API calls KEYCLOAK_URL="${KEYCLOAK_ADMIN_URL:-}" if [ -z "$KEYCLOAK_URL" ]; then echo -e "${RED}Error: KEYCLOAK_ADMIN_URL is required${NC}" echo "Please set KEYCLOAK_ADMIN_URL in your .env file or environment," echo "or ensure terraform-outputs.json contains keycloak_url." exit 1 fi KEYCLOAK_ADMIN="${KEYCLOAK_ADMIN:-admin}" echo "Using Keycloak API URL: $KEYCLOAK_URL" # Display loaded configuration echo "" echo "Configuration:" echo " - KEYCLOAK_URL: $KEYCLOAK_URL" echo " - AUTH_SERVER_EXTERNAL_URL: ${AUTH_SERVER_EXTERNAL_URL:-}" echo " - REGISTRY_URL: ${REGISTRY_URL:-}" echo "" # Try to load admin credentials from SSM Parameter Store if not set if [ -z "$KEYCLOAK_ADMIN_PASSWORD" ]; then echo "Attempting to load KEYCLOAK_ADMIN_PASSWORD from SSM Parameter Store..." if command -v aws &> /dev/null; then SSM_PASSWORD=$(aws ssm get-parameter --name "/keycloak/admin_password" --with-decryption --query 'Parameter.Value' --output text --region "${AWS_REGION}" 2>/dev/null) if [ -n "$SSM_PASSWORD" ] && [ "$SSM_PASSWORD" != "null" ]; then KEYCLOAK_ADMIN_PASSWORD="$SSM_PASSWORD" echo -e "${GREEN}Loaded KEYCLOAK_ADMIN_PASSWORD from SSM Parameter Store${NC}" fi fi fi # Check if admin password is set (from env var or SSM) if [ -z "$KEYCLOAK_ADMIN_PASSWORD" ]; then echo -e "${RED}Error: KEYCLOAK_ADMIN_PASSWORD not found${NC}" echo "Please either:" echo " 1. Export KEYCLOAK_ADMIN_PASSWORD environment variable" echo " 2. Ensure AWS credentials are configured and SSM parameter '/keycloak/admin_password' exists" exit 1 fi # Check if initial admin password is set (for realm admin user creation) if [ -z "$INITIAL_ADMIN_PASSWORD" ]; then echo -e "${RED}Error: INITIAL_ADMIN_PASSWORD environment variable is required${NC}" echo "This password will be used for the 'admin' user in the mcp-gateway realm." echo "Please export INITIAL_ADMIN_PASSWORD before running this script:" echo " export INITIAL_ADMIN_PASSWORD='YourSecurePassword123'" exit 1 fi # Wait for Keycloak to be ready wait_for_keycloak # Get admin token echo "Authenticating with Keycloak..." TOKEN=$(get_admin_token) if [ -z "$TOKEN" ]; then echo -e "${RED}Error: Failed to authenticate with Keycloak${NC}" echo "Please check your admin credentials" exit 1 fi echo -e "${GREEN}Authentication successful!${NC}" # Create realm and configure it step by step # Refresh token before each operation to prevent expiration if create_realm "$TOKEN"; then refresh_token create_clients "$TOKEN" refresh_token create_scopes "$TOKEN" refresh_token create_groups "$TOKEN" refresh_token create_users "$TOKEN" refresh_token create_service_account_user "$TOKEN" refresh_token create_service_account_clients "$TOKEN" refresh_token setup_client_secrets "$TOKEN" refresh_token setup_groups_mapper "$TOKEN" refresh_token setup_m2m_scopes "$TOKEN" else exit 1 fi echo "" echo -e "${GREEN}Keycloak initialization complete!${NC}" echo "" echo "You can now access Keycloak at: ${KEYCLOAK_URL}" echo "Admin console: ${KEYCLOAK_URL}/admin" echo "Realm: ${REALM}" echo "" echo "Users created:" echo " - admin/${INITIAL_ADMIN_PASSWORD} (realm admin - all groups including mcp-registry-admin)" echo " - testuser/${INITIAL_USER_PASSWORD:-testpass} (test user - user/developer/operator groups)" echo " - lob1-user/${LOB1_USER_PASSWORD:-lob1pass} (LOB1 user - registry-users-lob1 group)" echo " - lob2-user/${LOB2_USER_PASSWORD:-lob2pass} (LOB2 user - registry-users-lob2 group)" echo " - service-account-mcp-gateway-m2m (service account for M2M access)" echo "" echo "Service Account Clients (M2M):" echo " - registry-admin-bot (in registry-admins group)" echo " - lob1-bot (in registry-users-lob1 group)" echo " - lob2-bot (in registry-users-lob2 group)" echo "" echo "Groups created:" echo " - mcp-registry-admin, mcp-registry-user, mcp-registry-developer" echo " - mcp-registry-operator, mcp-servers-unrestricted, mcp-servers-restricted" echo " - a2a-agent-admin, a2a-agent-publisher, a2a-agent-user" echo " - registry-admins, registry-users-lob1, registry-users-lob2" echo "" echo "OAuth2 Clients:" echo " - mcp-gateway-web (for UI authentication)" echo " - mcp-gateway-m2m (for service-to-service authentication)" echo "" echo -e "${YELLOW}Remember to change the default passwords!${NC}" } # Run main function main ================================================ FILE: terraform/aws-ecs/scripts/post-deployment-setup.sh ================================================ #!/bin/bash ################################################################################ # Post-Deployment Setup Script for MCP Gateway # # This script automates the post-deployment setup process: # 1. Saves terraform outputs to JSON # 2. Validates required resources were created # 3. Waits for DNS propagation # 4. Verifies ECS services are running # 5. Initializes Keycloak (realm, clients, users, groups) # 6. Initializes MCP scopes on EFS # 7. Restarts registry and auth services # # Usage: # ./post-deployment-setup.sh [OPTIONS] # # Options: # --skip-keycloak Skip Keycloak initialization # --skip-scopes Skip scopes initialization # --skip-restart Skip service restart # --skip-dns-wait Skip DNS propagation wait # --dry-run Show what would be done without executing # --help Show this help message # # Required Environment Variables: # AWS_REGION AWS region (default: us-east-1) # KEYCLOAK_ADMIN_PASSWORD Keycloak admin password (or loaded from SSM) # INITIAL_ADMIN_PASSWORD Password for admin user in mcp-gateway realm # # Optional Environment Variables: # INITIAL_USER_PASSWORD Password for testuser (default: testpass) # LOB1_USER_PASSWORD Password for lob1-user (default: lob1pass) # LOB2_USER_PASSWORD Password for lob2-user (default: lob2pass) # ################################################################################ set -euo pipefail # Colors BLUE='\033[0;34m' GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' BOLD='\033[1m' NC='\033[0m' # Configuration SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TERRAFORM_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" OUTPUTS_FILE="$SCRIPT_DIR/terraform-outputs.json" AWS_REGION="${AWS_REGION:-us-east-1}" # Options SKIP_KEYCLOAK=false SKIP_SCOPES=false SKIP_RESTART=false SKIP_DNS_WAIT=false DRY_RUN=false # Counters for summary STEPS_TOTAL=0 STEPS_PASSED=0 STEPS_FAILED=0 STEPS_SKIPPED=0 log_info() { echo -e "${BLUE}[INFO]${NC} $*" } log_success() { echo -e "${GREEN}[SUCCESS]${NC} $*" } log_warning() { echo -e "${YELLOW}[WARNING]${NC} $*" } log_error() { echo -e "${RED}[ERROR]${NC} $*" } log_step() { echo "" echo -e "${BOLD}==========================================" echo -e "Step $1: $2" echo -e "==========================================${NC}" } show_help() { grep '^#' "$0" | tail -n +2 | head -40 | sed 's/^# //' | sed 's/^#//' exit 0 } _parse_arguments() { while [[ $# -gt 0 ]]; do case $1 in --skip-keycloak) SKIP_KEYCLOAK=true shift ;; --skip-scopes) SKIP_SCOPES=true shift ;; --skip-restart) SKIP_RESTART=true shift ;; --skip-dns-wait) SKIP_DNS_WAIT=true shift ;; --dry-run) DRY_RUN=true shift ;; --help) show_help ;; *) log_error "Unknown option: $1" show_help ;; esac done } _check_prerequisites() { log_info "Checking prerequisites..." local missing=() # Check required tools if ! command -v jq &> /dev/null; then missing+=("jq") fi if ! command -v aws &> /dev/null; then missing+=("aws-cli") fi if ! command -v terraform &> /dev/null; then missing+=("terraform") fi if ! command -v curl &> /dev/null; then missing+=("curl") fi if [[ ${#missing[@]} -gt 0 ]]; then log_error "Missing required tools: ${missing[*]}" log_error "Please install them before running this script." exit 1 fi # Check AWS credentials if ! aws sts get-caller-identity &> /dev/null; then log_error "AWS credentials not configured or invalid." exit 1 fi log_success "All prerequisites met." } _save_terraform_outputs() { log_step "1" "Saving Terraform Outputs" STEPS_TOTAL=$((STEPS_TOTAL + 1)) if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would run: $SCRIPT_DIR/save-terraform-outputs.sh" STEPS_SKIPPED=$((STEPS_SKIPPED + 1)) return 0 fi log_info "Running save-terraform-outputs.sh..." if "$SCRIPT_DIR/save-terraform-outputs.sh"; then log_success "Terraform outputs saved to $OUTPUTS_FILE" STEPS_PASSED=$((STEPS_PASSED + 1)) else log_error "Failed to save terraform outputs" STEPS_FAILED=$((STEPS_FAILED + 1)) return 1 fi } _validate_terraform_outputs() { log_step "2" "Validating Terraform Outputs" STEPS_TOTAL=$((STEPS_TOTAL + 1)) if [[ ! -f "$OUTPUTS_FILE" ]]; then log_error "Terraform outputs file not found: $OUTPUTS_FILE" STEPS_FAILED=$((STEPS_FAILED + 1)) return 1 fi log_info "Validating required resources..." # Core required outputs (always needed) local required_outputs=( "vpc_id" "ecs_cluster_name" "ecs_cluster_arn" "mcp_gateway_url" "mcp_gateway_auth_url" "keycloak_url" "mcp_gateway_efs_id" ) # Note: registry_url is only set in custom domain mode # cloudfront_mcp_gateway_url is only set in CloudFront mode # At least one of these should be available for a valid deployment local missing_outputs=() local validation_passed=true for output in "${required_outputs[@]}"; do local value value=$(jq -r ".$output.value // empty" "$OUTPUTS_FILE" 2>/dev/null) if [[ -z "$value" || "$value" == "null" ]]; then missing_outputs+=("$output") validation_passed=false log_error " Missing or empty: $output" else log_success " Found: $output = $value" fi done if [[ "$validation_passed" == "true" ]]; then log_success "All required terraform outputs validated successfully." STEPS_PASSED=$((STEPS_PASSED + 1)) # Export values for later use export KEYCLOAK_ADMIN_URL=$(jq -r '.keycloak_url.value' "$OUTPUTS_FILE") export AUTH_SERVER_EXTERNAL_URL=$(jq -r '.mcp_gateway_auth_url.value' "$OUTPUTS_FILE") export ECS_CLUSTER_NAME=$(jq -r '.ecs_cluster_name.value' "$OUTPUTS_FILE") # REGISTRY_URL: prefer custom domain, fallback to CloudFront URL local registry_url=$(jq -r '.registry_url.value // empty' "$OUTPUTS_FILE") local cloudfront_url=$(jq -r '.cloudfront_mcp_gateway_url.value // empty' "$OUTPUTS_FILE") if [[ -n "$registry_url" && "$registry_url" != "null" ]]; then export REGISTRY_URL="$registry_url" elif [[ -n "$cloudfront_url" && "$cloudfront_url" != "null" ]]; then export REGISTRY_URL="$cloudfront_url" log_info "Using CloudFront URL as REGISTRY_URL (custom domain not configured)" else export REGISTRY_URL=$(jq -r '.mcp_gateway_url.value' "$OUTPUTS_FILE") log_warning "Using ALB URL as REGISTRY_URL (no HTTPS configured)" fi # Also export CloudFront URL if available (for init-keycloak.sh) if [[ -n "$cloudfront_url" && "$cloudfront_url" != "null" ]]; then export CLOUDFRONT_REGISTRY_URL="$cloudfront_url" fi log_info "Exported configuration:" log_info " KEYCLOAK_ADMIN_URL: $KEYCLOAK_ADMIN_URL" log_info " REGISTRY_URL: $REGISTRY_URL" log_info " CLOUDFRONT_REGISTRY_URL: ${CLOUDFRONT_REGISTRY_URL:-}" log_info " AUTH_SERVER_EXTERNAL_URL: $AUTH_SERVER_EXTERNAL_URL" log_info " ECS_CLUSTER_NAME: $ECS_CLUSTER_NAME" return 0 else log_error "Missing required outputs: ${missing_outputs[*]}" log_error "Please check your terraform apply completed successfully." STEPS_FAILED=$((STEPS_FAILED + 1)) return 1 fi } _wait_for_dns_propagation() { log_step "3" "Waiting for DNS Propagation" STEPS_TOTAL=$((STEPS_TOTAL + 1)) if [[ "$SKIP_DNS_WAIT" == "true" ]]; then log_warning "Skipping DNS propagation wait (--skip-dns-wait)" STEPS_SKIPPED=$((STEPS_SKIPPED + 1)) return 0 fi if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would wait for DNS propagation" STEPS_SKIPPED=$((STEPS_SKIPPED + 1)) return 0 fi local endpoints=( "$KEYCLOAK_ADMIN_URL" "$REGISTRY_URL" ) local max_attempts=60 local wait_interval=10 local all_resolved=false log_info "Checking DNS resolution for endpoints..." log_info "This may take up to 10 minutes for new deployments." for attempt in $(seq 1 $max_attempts); do all_resolved=true for endpoint in "${endpoints[@]}"; do # Extract hostname from URL local hostname hostname=$(echo "$endpoint" | sed 's|https://||' | sed 's|http://||' | cut -d'/' -f1) if host "$hostname" &> /dev/null; then log_success " DNS resolved: $hostname" else log_warning " DNS not yet resolved: $hostname" all_resolved=false fi done if [[ "$all_resolved" == "true" ]]; then log_success "All DNS records resolved!" STEPS_PASSED=$((STEPS_PASSED + 1)) return 0 fi if [[ $attempt -lt $max_attempts ]]; then log_info "Attempt $attempt/$max_attempts - waiting ${wait_interval}s..." sleep $wait_interval fi done log_error "DNS propagation timeout. Some endpoints may not be ready." log_warning "You can retry later or use --skip-dns-wait to proceed anyway." STEPS_FAILED=$((STEPS_FAILED + 1)) return 1 } _verify_ecs_services() { log_step "4" "Verifying ECS Services" STEPS_TOTAL=$((STEPS_TOTAL + 1)) if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would verify ECS services" STEPS_SKIPPED=$((STEPS_SKIPPED + 1)) return 0 fi # Services in mcp-gateway-ecs-cluster local mcp_gateway_services=( "mcp-gateway-v2-registry" "mcp-gateway-v2-auth" "mcp-gateway-v2-mcpgw" ) # Keycloak runs in its own cluster local keycloak_cluster="keycloak" local keycloak_service="keycloak" local max_attempts=40 local wait_interval=20 log_info "Checking ECS services are running..." for attempt in $(seq 1 $max_attempts); do local all_healthy=true # Check MCP Gateway services in mcp-gateway-ecs-cluster for service in "${mcp_gateway_services[@]}"; do local status status=$(aws ecs describe-services \ --cluster "$ECS_CLUSTER_NAME" \ --services "$service" \ --region "$AWS_REGION" \ --query 'services[0].{running:runningCount,desired:desiredCount}' \ --output json 2>/dev/null || echo '{}') local running local desired running=$(echo "$status" | jq -r '.running // 0') desired=$(echo "$status" | jq -r '.desired // 0') if [[ "$running" -ge "$desired" && "$desired" -gt 0 ]]; then log_success " $service: $running/$desired running (cluster: $ECS_CLUSTER_NAME)" else log_warning " $service: $running/$desired running (waiting...)" all_healthy=false fi done # Check Keycloak in its own cluster local kc_status kc_status=$(aws ecs describe-services \ --cluster "$keycloak_cluster" \ --services "$keycloak_service" \ --region "$AWS_REGION" \ --query 'services[0].{running:runningCount,desired:desiredCount}' \ --output json 2>/dev/null || echo '{}') local kc_running local kc_desired kc_running=$(echo "$kc_status" | jq -r '.running // 0') kc_desired=$(echo "$kc_status" | jq -r '.desired // 0') if [[ "$kc_running" -ge "$kc_desired" && "$kc_desired" -gt 0 ]]; then log_success " $keycloak_service: $kc_running/$kc_desired running (cluster: $keycloak_cluster)" else log_warning " $keycloak_service: $kc_running/$kc_desired running (cluster: $keycloak_cluster, waiting...)" all_healthy=false fi if [[ "$all_healthy" == "true" ]]; then log_success "All ECS services are running!" STEPS_PASSED=$((STEPS_PASSED + 1)) return 0 fi if [[ $attempt -lt $max_attempts ]]; then log_info "Attempt $attempt/$max_attempts - waiting ${wait_interval}s for services..." sleep $wait_interval fi done log_error "ECS services did not reach healthy state in time." log_warning "Check CloudWatch logs for errors." STEPS_FAILED=$((STEPS_FAILED + 1)) return 1 } _initialize_keycloak() { log_step "5" "Initializing Keycloak" STEPS_TOTAL=$((STEPS_TOTAL + 1)) if [[ "$SKIP_KEYCLOAK" == "true" ]]; then log_warning "Skipping Keycloak initialization (--skip-keycloak)" STEPS_SKIPPED=$((STEPS_SKIPPED + 1)) return 0 fi if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would run: $SCRIPT_DIR/init-keycloak.sh" STEPS_SKIPPED=$((STEPS_SKIPPED + 1)) return 0 fi # Try to load INITIAL_ADMIN_PASSWORD from Secrets Manager if not set if [[ -z "${INITIAL_ADMIN_PASSWORD:-}" ]]; then log_info "INITIAL_ADMIN_PASSWORD not set, attempting to load from Secrets Manager..." # Find the admin password secret by name pattern (mcp-gateway-v2-admin-password-*) local secret_name secret_name=$(aws secretsmanager list-secrets \ --region "$AWS_REGION" \ --filter Key=name,Values=mcp-gateway-v2-admin-password \ --query 'SecretList[0].Name' \ --output text 2>/dev/null) if [[ -n "$secret_name" && "$secret_name" != "None" ]]; then INITIAL_ADMIN_PASSWORD=$(aws secretsmanager get-secret-value \ --secret-id "$secret_name" \ --region "$AWS_REGION" \ --query 'SecretString' \ --output text 2>/dev/null) if [[ -n "$INITIAL_ADMIN_PASSWORD" ]]; then export INITIAL_ADMIN_PASSWORD log_success "Loaded INITIAL_ADMIN_PASSWORD from Secrets Manager ($secret_name)" fi fi fi # Final check - if still not set, error out if [[ -z "${INITIAL_ADMIN_PASSWORD:-}" ]]; then log_error "INITIAL_ADMIN_PASSWORD could not be loaded from Secrets Manager." log_error "Either set it manually or ensure the secret exists:" log_error " export INITIAL_ADMIN_PASSWORD='YourSecurePassword123'" STEPS_FAILED=$((STEPS_FAILED + 1)) return 1 fi log_info "Running init-keycloak.sh..." log_info "Using KEYCLOAK_ADMIN_URL: $KEYCLOAK_ADMIN_URL" # Export variables for init-keycloak.sh export KEYCLOAK_ADMIN_URL export REGISTRY_URL export AUTH_SERVER_EXTERNAL_URL export AWS_REGION if "$SCRIPT_DIR/init-keycloak.sh"; then log_success "Keycloak initialized successfully!" STEPS_PASSED=$((STEPS_PASSED + 1)) else log_error "Keycloak initialization failed." log_warning "Check the error messages above and try running init-keycloak.sh manually." STEPS_FAILED=$((STEPS_FAILED + 1)) return 1 fi } _initialize_scopes() { log_step "6" "Initializing MCP Scopes" STEPS_TOTAL=$((STEPS_TOTAL + 1)) if [[ "$SKIP_SCOPES" == "true" ]]; then log_warning "Skipping scopes initialization (--skip-scopes)" STEPS_SKIPPED=$((STEPS_SKIPPED + 1)) return 0 fi # Detect storage backend from terraform outputs local documentdb_endpoint documentdb_endpoint=$(jq -r '.documentdb_cluster_endpoint.value // empty' "$OUTPUTS_FILE" 2>/dev/null) if [[ -n "$documentdb_endpoint" && "$documentdb_endpoint" != "null" ]]; then # DocumentDB mode log_info "Detected DocumentDB storage backend" log_info "DocumentDB endpoint: $documentdb_endpoint" if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would run: $SCRIPT_DIR/run-documentdb-init.sh" STEPS_SKIPPED=$((STEPS_SKIPPED + 1)) return 0 fi log_info "Running DocumentDB initialization (indexes + scopes)..." if "$SCRIPT_DIR/run-documentdb-init.sh"; then log_success "DocumentDB initialized with indexes and scopes!" STEPS_PASSED=$((STEPS_PASSED + 1)) else log_error "DocumentDB initialization failed." STEPS_FAILED=$((STEPS_FAILED + 1)) return 1 fi else # EFS mode (default) log_info "Using EFS storage backend" if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would run: $SCRIPT_DIR/run-scopes-init-task.sh --skip-build" STEPS_SKIPPED=$((STEPS_SKIPPED + 1)) return 0 fi log_info "Running scopes initialization task on EFS..." if "$SCRIPT_DIR/run-scopes-init-task.sh" --skip-build; then log_success "MCP scopes initialized on EFS!" STEPS_PASSED=$((STEPS_PASSED + 1)) else log_error "Scopes initialization failed." STEPS_FAILED=$((STEPS_FAILED + 1)) return 1 fi fi } _restart_services() { log_step "7" "Restarting Registry and Auth Services" STEPS_TOTAL=$((STEPS_TOTAL + 1)) if [[ "$SKIP_RESTART" == "true" ]]; then log_warning "Skipping service restart (--skip-restart)" STEPS_SKIPPED=$((STEPS_SKIPPED + 1)) return 0 fi if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would restart ECS services" STEPS_SKIPPED=$((STEPS_SKIPPED + 1)) return 0 fi local services_to_restart=( "mcp-gateway-v2-registry" "mcp-gateway-v2-auth" ) log_info "Forcing new deployments for services to pick up new configuration..." for service in "${services_to_restart[@]}"; do log_info " Restarting: $service" if aws ecs update-service \ --cluster "$ECS_CLUSTER_NAME" \ --service "$service" \ --force-new-deployment \ --region "$AWS_REGION" &> /dev/null; then log_success " Restart initiated: $service" else log_error " Failed to restart: $service" fi done log_info "Waiting for services to stabilize..." local max_attempts=40 local wait_interval=10 for attempt in $(seq 1 $max_attempts); do local all_stable=true for service in "${services_to_restart[@]}"; do local status status=$(aws ecs describe-services \ --cluster "$ECS_CLUSTER_NAME" \ --services "$service" \ --region "$AWS_REGION" \ --query 'services[0].deployments | length(@)' \ --output text 2>/dev/null || echo "0") if [[ "$status" == "1" ]]; then log_success " $service: deployment complete" else log_warning " $service: deployment in progress ($status active)" all_stable=false fi done if [[ "$all_stable" == "true" ]]; then log_success "All services restarted successfully!" STEPS_PASSED=$((STEPS_PASSED + 1)) return 0 fi if [[ $attempt -lt $max_attempts ]]; then log_info "Attempt $attempt/$max_attempts - waiting ${wait_interval}s..." sleep $wait_interval fi done log_warning "Services are still deploying. They should complete shortly." STEPS_PASSED=$((STEPS_PASSED + 1)) } _verify_endpoints() { log_step "8" "Verifying Application Endpoints" STEPS_TOTAL=$((STEPS_TOTAL + 1)) if [[ "$DRY_RUN" == "true" ]]; then log_info "[DRY RUN] Would verify application endpoints" STEPS_SKIPPED=$((STEPS_SKIPPED + 1)) return 0 fi log_info "Testing endpoint health..." local endpoints=( "$REGISTRY_URL/health|Registry Health" "$KEYCLOAK_ADMIN_URL/admin/|Keycloak Admin" ) local all_healthy=true for endpoint_info in "${endpoints[@]}"; do local url="${endpoint_info%|*}" local name="${endpoint_info#*|}" local http_code http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url" 2>/dev/null || echo "000") if [[ "$http_code" =~ ^(200|301|302)$ ]]; then log_success " $name: HTTP $http_code" else log_warning " $name: HTTP $http_code (may still be starting)" all_healthy=false fi done if [[ "$all_healthy" == "true" ]]; then log_success "All endpoints responding!" STEPS_PASSED=$((STEPS_PASSED + 1)) else log_warning "Some endpoints not yet responding. They may need more time to start." STEPS_PASSED=$((STEPS_PASSED + 1)) fi } _print_summary() { echo "" echo -e "${BOLD}==========================================" echo -e "Post-Deployment Setup Summary" echo -e "==========================================${NC}" echo "" echo -e "Total Steps: $STEPS_TOTAL" echo -e "${GREEN}Passed: $STEPS_PASSED${NC}" echo -e "${RED}Failed: $STEPS_FAILED${NC}" echo -e "${YELLOW}Skipped: $STEPS_SKIPPED${NC}" echo "" if [[ "$STEPS_FAILED" -eq 0 ]]; then echo -e "${GREEN}${BOLD}Post-deployment setup completed successfully!${NC}" echo "" echo "Next steps:" echo " 1. Access Keycloak Admin: $KEYCLOAK_ADMIN_URL/admin" echo " 2. Access Registry: $REGISTRY_URL" echo " 3. Test authentication flow" echo "" else echo -e "${RED}${BOLD}Post-deployment setup completed with errors.${NC}" echo "" echo "Please review the error messages above and:" echo " 1. Check CloudWatch logs for service errors" echo " 2. Verify terraform apply completed successfully" echo " 3. Re-run this script with appropriate --skip-* flags" echo "" fi } main() { _parse_arguments "$@" echo -e "${BOLD}==========================================" echo -e "MCP Gateway Post-Deployment Setup" echo -e "==========================================${NC}" echo "" echo "AWS Region: $AWS_REGION" echo "Terraform Dir: $TERRAFORM_DIR" echo "Dry Run: $DRY_RUN" echo "" _check_prerequisites # Step 1: Save terraform outputs _save_terraform_outputs || true # Step 2: Validate outputs if ! _validate_terraform_outputs; then log_error "Cannot proceed without valid terraform outputs." _print_summary exit 1 fi # Step 3: Wait for DNS _wait_for_dns_propagation || true # Step 4: Verify ECS services _verify_ecs_services || true # Step 5: Initialize Keycloak _initialize_keycloak || true # Step 6: Initialize scopes _initialize_scopes || true # Step 7: Restart services _restart_services || true # Step 8: Verify endpoints _verify_endpoints || true # Print summary _print_summary # Exit with error if any steps failed if [[ "$STEPS_FAILED" -gt 0 ]]; then exit 1 fi } # Run main function main "$@" ================================================ FILE: terraform/aws-ecs/scripts/pre-destroy-cleanup.sh ================================================ #!/bin/bash # # Pre-Destroy Cleanup Script # Run this before 'terraform destroy' to clean up resources that may block deletion # set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color AWS_REGION="${AWS_REGION:-us-east-1}" echo "============================================" echo "MCP Gateway Pre-Destroy Cleanup" echo "Region: $AWS_REGION" echo "============================================" echo "" # Function to log messages log_info() { echo -e "${GREEN}[INFO]${NC} $1" } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" } log_error() { echo -e "${RED}[ERROR]${NC} $1" } # Step 1: Scale down and delete ECS services echo "" echo "Step 1: Cleaning up ECS Services" echo "--------------------------------" # MCP Gateway ECS Cluster services MCP_CLUSTER="mcp-gateway-ecs-cluster" SERVICES=$(aws ecs list-services --cluster "$MCP_CLUSTER" --region "$AWS_REGION" --query 'serviceArns[*]' --output text 2>/dev/null || echo "") if [[ -n "$SERVICES" ]]; then for service_arn in $SERVICES; do service_name=$(echo "$service_arn" | awk -F'/' '{print $NF}') log_info "Scaling down and deleting service: $service_name" aws ecs update-service --cluster "$MCP_CLUSTER" --service "$service_name" --desired-count 0 --region "$AWS_REGION" --output text --query 'service.serviceName' 2>/dev/null || true aws ecs delete-service --cluster "$MCP_CLUSTER" --service "$service_name" --force --region "$AWS_REGION" --output text --query 'service.serviceName' 2>/dev/null || true done else log_info "No services found in $MCP_CLUSTER" fi # Keycloak cluster services KC_CLUSTER="keycloak" KC_SERVICES=$(aws ecs list-services --cluster "$KC_CLUSTER" --region "$AWS_REGION" --query 'serviceArns[*]' --output text 2>/dev/null || echo "") if [[ -n "$KC_SERVICES" ]]; then for service_arn in $KC_SERVICES; do service_name=$(echo "$service_arn" | awk -F'/' '{print $NF}') log_info "Scaling down and deleting service: $service_name (keycloak cluster)" aws ecs update-service --cluster "$KC_CLUSTER" --service "$service_name" --desired-count 0 --region "$AWS_REGION" --output text --query 'service.serviceName' 2>/dev/null || true aws ecs delete-service --cluster "$KC_CLUSTER" --service "$service_name" --force --region "$AWS_REGION" --output text --query 'service.serviceName' 2>/dev/null || true done else log_info "No services found in $KC_CLUSTER" fi # Step 2: Wait for tasks to stop echo "" echo "Step 2: Waiting for ECS tasks to stop" echo "--------------------------------------" sleep 10 for cluster in "$MCP_CLUSTER" "$KC_CLUSTER"; do TASKS=$(aws ecs list-tasks --cluster "$cluster" --region "$AWS_REGION" --query 'taskArns[*]' --output text 2>/dev/null || echo "") if [[ -n "$TASKS" ]]; then log_info "Waiting for tasks in $cluster to stop..." for i in {1..12}; do TASKS=$(aws ecs list-tasks --cluster "$cluster" --region "$AWS_REGION" --query 'taskArns[*]' --output text 2>/dev/null || echo "") if [[ -z "$TASKS" ]]; then log_info "All tasks in $cluster stopped" break fi log_info "Still waiting... ($i/12)" sleep 10 done else log_info "No running tasks in $cluster" fi done # Step 3: Clean up Service Discovery namespaces echo "" echo "Step 3: Cleaning up Service Discovery Namespaces" echo "-------------------------------------------------" NAMESPACES=$(aws servicediscovery list-namespaces --region "$AWS_REGION" --query 'Namespaces[?contains(Name, `mcp-gateway`)].{Id:Id,Name:Name}' --output json 2>/dev/null || echo "[]") if [[ "$NAMESPACES" != "[]" ]]; then echo "$NAMESPACES" | jq -r '.[] | "\(.Id) \(.Name)"' | while read -r ns_id ns_name; do log_info "Processing namespace: $ns_name ($ns_id)" # Delete services in the namespace first NS_SERVICES=$(aws servicediscovery list-services --filters Name=NAMESPACE_ID,Values="$ns_id" --region "$AWS_REGION" --query 'Services[*].Id' --output text 2>/dev/null || echo "") if [[ -n "$NS_SERVICES" ]]; then for svc_id in $NS_SERVICES; do log_info " Deleting service: $svc_id" aws servicediscovery delete-service --id "$svc_id" --region "$AWS_REGION" 2>/dev/null || log_warn " Failed to delete service $svc_id" done fi # Now delete the namespace log_info " Deleting namespace: $ns_name" aws servicediscovery delete-namespace --id "$ns_id" --region "$AWS_REGION" 2>/dev/null || log_warn " Failed to delete namespace $ns_name (may require additional IAM permissions)" done else log_info "No MCP Gateway service discovery namespaces found" fi # Step 4: ECR Repositories - PRESERVED (not deleted) echo "" echo "Step 4: ECR Repositories" echo "------------------------" echo "" log_warn "============================================================" log_warn "ECR REPOSITORIES ARE NOT DELETED BY THIS SCRIPT" log_warn "============================================================" log_warn "" log_warn "Container images are preserved to avoid expensive rebuilds." log_warn "Images can be reused after terraform apply without rebuilding." log_warn "" log_warn "If you want to delete ECR repositories manually, run:" log_warn "" log_warn " aws ecr delete-repository --repository-name keycloak --force --region $AWS_REGION" log_warn " aws ecr delete-repository --repository-name mcp-gateway-registry --force --region $AWS_REGION" log_warn " aws ecr delete-repository --repository-name mcp-gateway-auth-server --force --region $AWS_REGION" log_warn " aws ecr delete-repository --repository-name mcp-gateway-currenttime --force --region $AWS_REGION" log_warn " aws ecr delete-repository --repository-name mcp-gateway-mcpgw --force --region $AWS_REGION" log_warn " aws ecr delete-repository --repository-name mcp-gateway-realserverfaketools --force --region $AWS_REGION" log_warn " aws ecr delete-repository --repository-name mcp-gateway-flight-booking-agent --force --region $AWS_REGION" log_warn " aws ecr delete-repository --repository-name mcp-gateway-travel-assistant-agent --force --region $AWS_REGION" log_warn "" log_warn "============================================================" echo "" # Step 5: Force delete Secrets Manager secrets echo "" echo "Step 5: Cleaning up Secrets Manager Secrets" echo "--------------------------------------------" SECRETS=( "keycloak/database" "mcp-gateway-keycloak-client-secret" "mcp-gateway-keycloak-m2m-client-secret" ) for secret in "${SECRETS[@]}"; do if aws secretsmanager describe-secret --secret-id "$secret" --region "$AWS_REGION" &>/dev/null; then log_info "Force deleting secret: $secret" aws secretsmanager delete-secret --secret-id "$secret" --force-delete-without-recovery --region "$AWS_REGION" 2>/dev/null || log_warn "Failed to delete $secret" else log_info "Secret not found (already deleted): $secret" fi done # Step 6: Clean up any orphaned load balancers echo "" echo "Step 6: Checking for orphaned resources" echo "----------------------------------------" # Check for target groups that might block ALB deletion TGS=$(aws elbv2 describe-target-groups --region "$AWS_REGION" --query 'TargetGroups[?contains(TargetGroupName, `keycloak`) || contains(TargetGroupName, `mcp-gateway`)].TargetGroupArn' --output text 2>/dev/null || echo "") if [[ -n "$TGS" ]]; then log_warn "Found target groups that may need manual cleanup:" for tg in $TGS; do echo " - $tg" done fi echo "" echo "============================================" echo "Pre-Destroy Cleanup Complete" echo "============================================" echo "" echo "You can now run: terraform destroy" echo "" ================================================ FILE: terraform/aws-ecs/scripts/requirements.txt ================================================ pydantic>=2.0.0 requests>=2.31.0 ================================================ FILE: terraform/aws-ecs/scripts/rotate-keycloak-web-client-secret.sh ================================================ #!/bin/bash # Rotate and sync mcp-gateway-web client secret between Keycloak and AWS Secrets Manager # # PREREQUISITES: # - Keycloak must be fully initialized (run init-keycloak.sh first) # - mcp-gateway-web client must exist in Keycloak # - Keycloak admin credentials must be configured in terraform.tfvars or .env # - AWS Secrets Manager must have mcp-gateway-keycloak-client-secret # # This script: # 1. Connects to Keycloak admin console # 2. Generates a NEW client secret in Keycloak (Keycloak is source of truth) # 3. Updates AWS Secrets Manager with the new Keycloak-generated secret # # Use this for: # - Secret rotation (security best practice) # - Syncing Keycloak and AWS Secrets Manager when out of sync # - After manual client modifications in Keycloak admin console set -e # Color codes RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' print_success() { echo -e "${GREEN}✓${NC} $1"; } print_error() { echo -e "${RED}✗${NC} $1"; } print_info() { echo -e "${YELLOW}ℹ${NC} $1"; } # Get script directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TERRAFORM_DIR="$(dirname "$SCRIPT_DIR")" PROJECT_ROOT="$(dirname "$TERRAFORM_DIR")" print_info "Rotating Keycloak client secret for mcp-gateway-web" # Try to load from .env file first (same as init-keycloak.sh) if [ -f "$PROJECT_ROOT/.env" ]; then set -a source "$PROJECT_ROOT/.env" set +a print_info "Loaded configuration from .env file" fi # Fall back to terraform.tfvars if .env doesn't have the values if [ -z "$KEYCLOAK_ADMIN_URL" ]; then if [ -f "$TERRAFORM_DIR/terraform.tfvars" ]; then KEYCLOAK_ADMIN_URL=$(grep "^keycloak_domain" "$TERRAFORM_DIR/terraform.tfvars" | cut -d'"' -f2) if [ -n "$KEYCLOAK_ADMIN_URL" ]; then KEYCLOAK_ADMIN_URL="https://${KEYCLOAK_ADMIN_URL}" fi fi fi if [ -z "$KEYCLOAK_ADMIN" ] && [ -f "$TERRAFORM_DIR/terraform.tfvars" ]; then KEYCLOAK_ADMIN=$(grep "^keycloak_admin" "$TERRAFORM_DIR/terraform.tfvars" | cut -d'"' -f2) fi if [ -z "$KEYCLOAK_ADMIN_PASSWORD" ] && [ -f "$TERRAFORM_DIR/terraform.tfvars" ]; then KEYCLOAK_ADMIN_PASSWORD=$(grep "^keycloak_admin_password" "$TERRAFORM_DIR/terraform.tfvars" | cut -d'"' -f2) fi # Use KEYCLOAK_ADMIN_URL as the base URL KEYCLOAK_URL="${KEYCLOAK_ADMIN_URL:-}" if [ -z "$KEYCLOAK_URL" ]; then print_error "KEYCLOAK_ADMIN_URL is required" echo "Please set KEYCLOAK_ADMIN_URL in your .env file or environment," echo "or ensure terraform-outputs.json contains keycloak_url." exit 1 fi REALM="mcp-gateway" CLIENT_ID="mcp-gateway-web" AWS_REGION="${AWS_REGION:-us-west-2}" print_info "Keycloak URL: $KEYCLOAK_URL" print_info "Realm: $REALM" print_info "Client ID: $CLIENT_ID" # Get the client secret from AWS Secrets Manager print_info "Retrieving client secret from AWS Secrets Manager..." SECRET_JSON=$(aws secretsmanager get-secret-value \ --secret-id mcp-gateway-keycloak-client-secret \ --region "$AWS_REGION" \ --query 'SecretString' \ --output text) CLIENT_SECRET=$(echo "$SECRET_JSON" | jq -r '.client_secret // empty') if [ -z "$CLIENT_SECRET" ]; then print_error "Could not retrieve client secret from Secrets Manager" exit 1 fi print_success "Client secret retrieved" # Get admin access token print_info "Getting Keycloak admin token..." TOKEN_RESPONSE=$(curl -s -k -X POST "${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=${KEYCLOAK_ADMIN}" \ -d "password=${KEYCLOAK_ADMIN_PASSWORD}" \ -d "grant_type=password" \ -d "client_id=admin-cli") ADMIN_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r '.access_token // empty') if [ -z "$ADMIN_TOKEN" ]; then print_error "Failed to get admin token" echo "Response:" echo "$TOKEN_RESPONSE" exit 1 fi print_success "Admin token obtained" # Get all clients in the realm print_info "Fetching clients in realm $REALM..." CLIENTS_RESPONSE=$(curl -s -k -X GET "${KEYCLOAK_URL}/admin/realms/${REALM}/clients" \ -H "Authorization: Bearer ${ADMIN_TOKEN}" \ -H "Content-Type: application/json") # Find the client UUID CLIENT_UUID=$(echo "$CLIENTS_RESPONSE" | jq -r ".[] | select(.clientId == \"${CLIENT_ID}\") | .id" | head -1) if [ -z "$CLIENT_UUID" ]; then print_error "Client $CLIENT_ID not found in realm $REALM" print_info "Available clients:" echo "$CLIENTS_RESPONSE" | jq -r '.[].clientId' exit 1 fi print_success "Found client UUID: $CLIENT_UUID" # Generate a new client secret in Keycloak print_info "Generating new client secret in Keycloak..." SECRET_RESPONSE=$(curl -s -k -X POST "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${CLIENT_UUID}/client-secret" \ -H "Authorization: Bearer ${ADMIN_TOKEN}" \ -H "Content-Type: application/json" \ -d '{}') GENERATED_SECRET=$(echo "$SECRET_RESPONSE" | jq -r '.value // empty') if [ -z "$GENERATED_SECRET" ]; then print_error "Failed to generate client secret" echo "Response: $SECRET_RESPONSE" | jq '.' exit 1 fi print_success "New client secret generated in Keycloak" # Update the secret in AWS Secrets Manager with the Keycloak-generated secret print_info "Updating AWS Secrets Manager with Keycloak-generated secret..." aws secretsmanager update-secret \ --secret-id mcp-gateway-keycloak-client-secret \ --secret-string "{\"client_id\": \"${CLIENT_ID}\", \"client_secret\": \"${GENERATED_SECRET}\"}" \ --region "$AWS_REGION" > /dev/null print_success "Secrets Manager updated" # Verify the client is configured correctly print_info "Verifying client configuration..." CLIENT_CONFIG=$(curl -s -k -X GET "${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${CLIENT_UUID}" \ -H "Authorization: Bearer ${ADMIN_TOKEN}" \ -H "Content-Type: application/json") print_success "Client configuration verified" echo "" echo "==================================================" echo "Keycloak Client Secret Rotation Complete!" echo "==================================================" echo "" echo "Client Details:" echo " Client ID: $CLIENT_ID" echo " Realm: $REALM" echo " Client UUID: $CLIENT_UUID" echo "" echo "Configuration:" echo " Enabled: $(echo "$CLIENT_CONFIG" | jq -r '.enabled')" echo " Auth Type: $(echo "$CLIENT_CONFIG" | jq -r '.clientAuthenticatorType')" echo " Public Client: $(echo "$CLIENT_CONFIG" | jq -r '.publicClient')" echo "" echo "Secret Sync Status:" echo " ✓ New secret generated in Keycloak" echo " ✓ AWS Secrets Manager updated" echo "" echo "Next Steps:" echo " 1. Restart registry ECS tasks to pick up new secret from Secrets Manager" echo " 2. Verify login functionality at your registry URL" echo "" ================================================ FILE: terraform/aws-ecs/scripts/run-documentdb-cli.sh ================================================ #!/bin/bash # Run DocumentDB management commands via ECS task # # This script runs the manage-documentdb.py script inside an ECS task # with proper network access to the DocumentDB cluster in the VPC. # # Usage: # ./terraform/aws-ecs/scripts/run-documentdb-cli.sh list # ./terraform/aws-ecs/scripts/run-documentdb-cli.sh inspect mcp_servers_default # ./terraform/aws-ecs/scripts/run-documentdb-cli.sh count mcp_servers_default # ./terraform/aws-ecs/scripts/run-documentdb-cli.sh search mcp_servers_default 5 # ./terraform/aws-ecs/scripts/run-documentdb-cli.sh sample mcp_servers_default # ./terraform/aws-ecs/scripts/run-documentdb-cli.sh query mcp_servers_default '{"enabled": true}' set -e # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" TERRAFORM_DIR="$(dirname "$SCRIPT_DIR")" PROJECT_ROOT="$(dirname "$(dirname "$TERRAFORM_DIR")")" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # Show help function show_help() { cat << EOF DocumentDB Management CLI Usage: $0 [options] Commands: list List all collections in the database inspect Inspect collection schema and stats count Count documents in a collection search [limit] Search documents in a collection (default limit: 10) sample Show a sample document from collection query Query documents with MongoDB filter JSON Options: -h, --help Show this help message Examples: $0 list $0 inspect mcp_servers_default $0 count mcp_scopes_default $0 search mcp_servers_default 20 $0 sample mcp_servers_default $0 query mcp_servers_default '{"enabled": true}' $0 query mcp_servers_default '{"path": "/currenttime"}' Environment Variables: DOCUMENTDB_HOST Override DocumentDB endpoint (optional) AWS_REGION AWS region (default: us-east-1) The script automatically reads the DocumentDB endpoint from SSM Parameter Store if available, otherwise falls back to DOCUMENTDB_HOST environment variable. EOF exit 0 } # Check for help flag if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then show_help fi # Parse command COMMAND=${1:-list} shift || true # Build command arguments case "$COMMAND" in list) PYTHON_ARGS="list" ;; inspect|count|sample) COLLECTION_NAME=${1:-} if [ -z "$COLLECTION_NAME" ]; then echo -e "${RED}Error: Collection name required for $COMMAND command${NC}" echo "Usage: $0 $COMMAND " echo "Run '$0 --help' for more information" exit 1 fi shift || true PYTHON_ARGS="$COMMAND --collection $COLLECTION_NAME" ;; search) COLLECTION_NAME=${1:-} LIMIT=${2:-10} if [ -z "$COLLECTION_NAME" ]; then echo -e "${RED}Error: Collection name required for search command${NC}" echo "Usage: $0 search [limit]" echo "Run '$0 --help' for more information" exit 1 fi PYTHON_ARGS="search --collection $COLLECTION_NAME --limit $LIMIT" ;; query) COLLECTION_NAME=${1:-} FILTER_JSON=${2:-} LIMIT=${3:-10} if [ -z "$COLLECTION_NAME" ] || [ -z "$FILTER_JSON" ]; then echo -e "${RED}Error: Collection name and filter required for query command${NC}" echo "Usage: $0 query '' [limit]" echo "Example: $0 query mcp_servers_default '{\"enabled\": true}'" echo "Run '$0 --help' for more information" exit 1 fi PYTHON_ARGS="query --collection $COLLECTION_NAME --filter '$FILTER_JSON' --limit $LIMIT" ;; *) echo -e "${RED}Unknown command: $COMMAND${NC}" echo "" echo "Available commands:" echo " list - List all collections" echo " inspect - Inspect collection schema and stats" echo " count - Count documents" echo " search [limit] - Search documents (default limit: 10)" echo " sample - Show sample document" echo " query - Query with MongoDB filter" echo "" echo "Run '$0 --help' for detailed usage information" exit 1 ;; esac # Get AWS account and region AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) AWS_REGION="${AWS_REGION:-us-east-1}" # ECS configuration CLUSTER_NAME="mcp-gateway-ecs-cluster" TASK_FAMILY="mcp-gateway-v2-registry" CONTAINER_NAME="registry" # Get DocumentDB host from SSM Parameter Store if [ -z "$DOCUMENTDB_HOST" ]; then echo -e "${YELLOW}Fetching DocumentDB endpoint from SSM Parameter Store...${NC}" DOCUMENTDB_HOST=$(aws ssm get-parameter \ --name "/mcp-gateway/documentdb/endpoint" \ --query 'Parameter.Value' \ --output text \ --region "$AWS_REGION" 2>/dev/null || echo "") if [ -n "$DOCUMENTDB_HOST" ]; then echo -e "${GREEN}Found DocumentDB endpoint in SSM${NC}" fi fi # Validate DocumentDB host if [ -z "$DOCUMENTDB_HOST" ]; then echo -e "${RED}Error: DocumentDB endpoint not found${NC}" echo "" echo "Set DOCUMENTDB_HOST environment variable or ensure SSM parameter exists:" echo " /mcp-gateway/documentdb/endpoint" exit 1 fi # Get credentials from Secrets Manager echo -e "${YELLOW}Fetching DocumentDB credentials from Secrets Manager...${NC}" SECRET_ARN=$(aws secretsmanager list-secrets \ --filters Key=name,Values=mcp-gateway/documentdb/credentials \ --query 'SecretList[0].ARN' \ --output text \ --region "$AWS_REGION" 2>/dev/null || echo "") DOCUMENTDB_USERNAME="" DOCUMENTDB_PASSWORD="" if [ -n "$SECRET_ARN" ] && [ "$SECRET_ARN" != "None" ]; then SECRET_JSON=$(aws secretsmanager get-secret-value \ --secret-id "$SECRET_ARN" \ --query 'SecretString' \ --output text \ --region "$AWS_REGION" 2>/dev/null || echo "") if [ -n "$SECRET_JSON" ]; then DOCUMENTDB_USERNAME=$(echo "$SECRET_JSON" | jq -r '.username // ""') DOCUMENTDB_PASSWORD=$(echo "$SECRET_JSON" | jq -r '.password // ""') echo -e "${GREEN}Found credentials in Secrets Manager${NC}" fi fi # Get VPC configuration from registry service echo -e "${YELLOW}Getting VPC configuration from registry service...${NC}" VPC_CONFIG=$(aws ecs describe-services \ --cluster "$CLUSTER_NAME" \ --services mcp-gateway-v2-registry \ --region "$AWS_REGION" \ --query 'services[0].networkConfiguration.awsvpcConfiguration' \ --output json) SUBNETS=$(echo "$VPC_CONFIG" | jq -r '.subnets | join(",")') SECURITY_GROUPS=$(echo "$VPC_CONFIG" | jq -r '.securityGroups | join(",")') echo -e "${BLUE}Configuration:${NC}" echo " Cluster: $CLUSTER_NAME" echo " Task: $TASK_FAMILY" echo " DocumentDB Host: $DOCUMENTDB_HOST" echo " DocumentDB Username: ${DOCUMENTDB_USERNAME:-}" echo " Command: $COMMAND" echo "" # Check if task definition exists TASK_DEF_ARN=$(aws ecs describe-task-definition \ --task-definition "$TASK_FAMILY" \ --region "$AWS_REGION" \ --query 'taskDefinition.taskDefinitionArn' \ --output text 2>/dev/null || echo "") if [ -z "$TASK_DEF_ARN" ] || [ "$TASK_DEF_ARN" = "None" ]; then echo -e "${RED}Error: Task definition '$TASK_FAMILY' not found${NC}" echo "" echo "You need to create the task definition first." echo "Run: cd terraform/aws-ecs && terraform apply" exit 1 fi echo -e "${GREEN}Task definition found: $TASK_DEF_ARN${NC}" echo "" # Create command to run Python script DOCKER_COMMAND="source /app/.venv/bin/activate && cd /app/scripts && python manage-documentdb.py $PYTHON_ARGS" # Run the ECS task echo -e "${YELLOW}Starting ECS task...${NC}" TASK_ARN=$(aws ecs run-task \ --cluster "$CLUSTER_NAME" \ --task-definition "$TASK_FAMILY" \ --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SECURITY_GROUPS],assignPublicIp=DISABLED}" \ --overrides "$(jq -n \ --arg container "$CONTAINER_NAME" \ --arg cmd "$DOCKER_COMMAND" \ --arg host "$DOCUMENTDB_HOST" \ --arg user "$DOCUMENTDB_USERNAME" \ --arg pass "$DOCUMENTDB_PASSWORD" \ '{ "containerOverrides": [{ "name": $container, "command": ["/bin/bash", "-c", $cmd], "environment": [ {"name": "RUN_INIT_SCRIPTS", "value": "true"}, {"name": "DOCUMENTDB_HOST", "value": $host}, {"name": "DOCUMENTDB_PORT", "value": "27017"}, {"name": "DOCUMENTDB_USERNAME", "value": $user}, {"name": "DOCUMENTDB_PASSWORD", "value": $pass}, {"name": "DOCUMENTDB_DATABASE", "value": "mcp_registry"}, {"name": "DOCUMENTDB_USE_TLS", "value": "true"}, {"name": "DOCUMENTDB_USE_IAM", "value": "false"}, {"name": "DOCUMENTDB_TLS_CA_FILE", "value": "/app/certs/global-bundle.pem"} ] }] }')" \ --region "$AWS_REGION" \ --query 'tasks[0].taskArn' \ --output text) if [ -z "$TASK_ARN" ] || [ "$TASK_ARN" = "None" ]; then echo -e "${RED}Failed to start ECS task${NC}" exit 1 fi TASK_ID=$(basename "$TASK_ARN") echo -e "${GREEN}Task started: $TASK_ID${NC}" echo "" # Wait for task to complete echo -e "${YELLOW}Waiting for task to complete...${NC}" for i in {1..60}; do sleep 2 STATUS=$(aws ecs describe-tasks \ --cluster "$CLUSTER_NAME" \ --tasks "$TASK_ARN" \ --region "$AWS_REGION" \ --query 'tasks[0].lastStatus' \ --output text) if [ "$STATUS" = "STOPPED" ]; then echo -e "${GREEN}Task completed${NC}" break fi echo " [$i] Status: $STATUS" done # Get exit code EXIT_CODE=$(aws ecs describe-tasks \ --cluster "$CLUSTER_NAME" \ --tasks "$TASK_ARN" \ --region "$AWS_REGION" \ --query 'tasks[0].containers[0].exitCode' \ --output text) echo "" echo -e "${BLUE}Task exit code: $EXIT_CODE${NC}" # Get logs (wait a bit for logs to be available) echo "" echo -e "${YELLOW}Retrieving task logs...${NC}" sleep 3 # Get the actual log stream name LOG_STREAM_NAME="ecs/registry/$TASK_ID" echo "" printf '=%.0s' {1..100} echo "" # Try to get logs LOGS=$(aws logs get-log-events \ --log-group-name "/ecs/mcp-gateway-v2-registry" \ --log-stream-name "$LOG_STREAM_NAME" \ --region "$AWS_REGION" \ --query 'events[*].message' \ --output json 2>/dev/null) if [ $? -eq 0 ] && [ -n "$LOGS" ] && [ "$LOGS" != "[]" ]; then # Parse JSON array and print each message on a new line echo "$LOGS" | jq -r '.[]' 2>/dev/null || echo "$LOGS" else echo "No logs found in stream: $LOG_STREAM_NAME" echo "" echo "Available log streams:" aws logs describe-log-streams \ --log-group-name "/ecs/mcp-gateway-v2-registry" \ --order-by LastEventTime \ --descending \ --max-items 5 \ --region "$AWS_REGION" \ --query 'logStreams[*].logStreamName' \ --output text 2>/dev/null || echo "Could not retrieve log streams" fi echo "" printf '=%.0s' {1..100} echo "" # Exit with same code as task if [ "$EXIT_CODE" = "0" ]; then echo -e "${GREEN}SUCCESS: Command completed${NC}" else echo -e "${RED}ERROR: Command failed${NC}" fi exit "${EXIT_CODE:-1}" ================================================ FILE: terraform/aws-ecs/scripts/run-documentdb-init.sh ================================================ #!/bin/bash # Run DocumentDB initialization via ECS task # # This script runs the init-documentdb-indexes.py script inside an ECS task # with proper network access to the DocumentDB cluster in the VPC. # # Usage: # ./terraform/aws-ecs/scripts/run-documentdb-init.sh # ./terraform/aws-ecs/scripts/run-documentdb-init.sh --entra-group-id "your-guid" set -e # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" TERRAFORM_DIR="$(dirname "$SCRIPT_DIR")" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Default values ENTRA_GROUP_ID="" # Show help function show_help() { cat << EOF DocumentDB Initialization Script Usage: $0 [options] This script runs the DocumentDB index initialization inside an ECS task with proper network access to the DocumentDB cluster. Options: -h, --help Show this help message --entra-group-id Entra ID Group Object ID for admin group (required when entra_enabled=true in terraform.tfvars) Environment Variables: DOCUMENTDB_HOST Override DocumentDB endpoint (optional) AWS_REGION AWS region (default: us-west-2) ENTRA_ADMIN_GROUP_ID Entra ID Group Object ID (alternative to --entra-group-id) The script automatically reads the DocumentDB endpoint from SSM Parameter Store if available, otherwise falls back to DOCUMENTDB_HOST environment variable. For Microsoft Entra ID deployments: 1. Create a "registry-admins" group in Azure Portal 2. Get the Group Object ID: Azure Portal -> Groups -> [group name] -> Object Id 3. Pass it via --entra-group-id or ENTRA_ADMIN_GROUP_ID env var EOF exit 0 } # Parse command line arguments while [[ $# -gt 0 ]]; do case $1 in -h|--help) show_help ;; --entra-group-id) ENTRA_GROUP_ID="$2" shift 2 ;; *) echo -e "${RED}Unknown option: $1${NC}" show_help ;; esac done # Check for env var if not provided via CLI if [ -z "$ENTRA_GROUP_ID" ] && [ -n "$ENTRA_ADMIN_GROUP_ID" ]; then ENTRA_GROUP_ID="$ENTRA_ADMIN_GROUP_ID" fi # Get AWS account and region AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) AWS_REGION="${AWS_REGION:-us-west-2}" # Check if Entra ID is enabled in terraform.tfvars TFVARS_FILE="$TERRAFORM_DIR/terraform.tfvars" ENTRA_ENABLED="false" if [ -f "$TFVARS_FILE" ]; then # Extract entra_enabled value from terraform.tfvars ENTRA_ENABLED=$(grep -E "^entra_enabled\s*=" "$TFVARS_FILE" | sed 's/.*=\s*//' | tr -d ' "' || echo "false") fi # If Entra is enabled, require the group ID if [ "$ENTRA_ENABLED" = "true" ]; then if [ -z "$ENTRA_GROUP_ID" ]; then echo -e "${RED}Error: Microsoft Entra ID is enabled (entra_enabled=true in terraform.tfvars)${NC}" echo "" echo "You must provide the Entra ID Group Object ID for the admin group:" echo "" echo " $0 --entra-group-id \"your-group-object-id\"" echo "" echo "To get the Group Object ID:" echo " 1. Go to Azure Portal -> Microsoft Entra ID -> Groups" echo " 2. Find or create your 'registry-admins' group" echo " 3. Copy the 'Object Id' value" echo "" exit 1 fi echo -e "${GREEN}Entra ID enabled - using Group Object ID: $ENTRA_GROUP_ID${NC}" else echo -e "${BLUE}Keycloak mode - no Entra Group ID required${NC}" fi # ECS configuration CLUSTER_NAME="mcp-gateway-ecs-cluster" TASK_FAMILY="mcp-gateway-v2-registry" CONTAINER_NAME="registry" # Terraform outputs file location OUTPUTS_FILE="$SCRIPT_DIR/terraform-outputs.json" # Get DocumentDB host - check sources in order of priority: # 1. Environment variable (explicit override) # 2. Terraform outputs file # 3. SSM Parameter Store if [ -z "$DOCUMENTDB_HOST" ]; then # Try terraform outputs first if [ -f "$OUTPUTS_FILE" ]; then echo -e "${YELLOW}Checking terraform outputs for DocumentDB endpoint...${NC}" DOCUMENTDB_HOST=$(jq -r '.documentdb_cluster_endpoint.value // empty' "$OUTPUTS_FILE" 2>/dev/null || echo "") if [ -n "$DOCUMENTDB_HOST" ] && [ "$DOCUMENTDB_HOST" != "null" ]; then echo -e "${GREEN}Found DocumentDB endpoint in terraform outputs${NC}" else DOCUMENTDB_HOST="" fi fi # Fall back to SSM Parameter Store if [ -z "$DOCUMENTDB_HOST" ]; then echo -e "${YELLOW}Fetching DocumentDB endpoint from SSM Parameter Store...${NC}" DOCUMENTDB_HOST=$(aws ssm get-parameter \ --name "/mcp-gateway/documentdb/endpoint" \ --query 'Parameter.Value' \ --output text \ --region "$AWS_REGION" 2>/dev/null || echo "") if [ -n "$DOCUMENTDB_HOST" ] && [ "$DOCUMENTDB_HOST" != "None" ]; then echo -e "${GREEN}Found DocumentDB endpoint in SSM${NC}" else DOCUMENTDB_HOST="" fi fi fi # Validate DocumentDB host if [ -z "$DOCUMENTDB_HOST" ]; then echo -e "${RED}Error: DocumentDB endpoint not found${NC}" echo "" echo "Checked the following sources:" echo " 1. DOCUMENTDB_HOST environment variable" echo " 2. Terraform outputs file: $OUTPUTS_FILE" echo " 3. SSM Parameter Store: /mcp-gateway/documentdb/endpoint" echo "" echo "Make sure you have run 'terraform apply' and saved outputs," echo "or set DOCUMENTDB_HOST environment variable." exit 1 fi # Get credentials from Secrets Manager echo -e "${YELLOW}Fetching DocumentDB credentials from Secrets Manager...${NC}" SECRET_ARN=$(aws secretsmanager list-secrets \ --filters Key=name,Values=mcp-gateway/documentdb/credentials \ --query 'SecretList[0].ARN' \ --output text \ --region "$AWS_REGION" 2>/dev/null || echo "") DOCUMENTDB_USERNAME="" DOCUMENTDB_PASSWORD="" if [ -n "$SECRET_ARN" ] && [ "$SECRET_ARN" != "None" ]; then SECRET_JSON=$(aws secretsmanager get-secret-value \ --secret-id "$SECRET_ARN" \ --query 'SecretString' \ --output text \ --region "$AWS_REGION" 2>/dev/null || echo "") if [ -n "$SECRET_JSON" ]; then DOCUMENTDB_USERNAME=$(echo "$SECRET_JSON" | jq -r '.username // ""') DOCUMENTDB_PASSWORD=$(echo "$SECRET_JSON" | jq -r '.password // ""') echo -e "${GREEN}Found credentials in Secrets Manager${NC}" fi fi # Get VPC configuration from registry service echo -e "${YELLOW}Getting VPC configuration from registry service...${NC}" VPC_CONFIG=$(aws ecs describe-services \ --cluster "$CLUSTER_NAME" \ --services mcp-gateway-v2-registry \ --region "$AWS_REGION" \ --query 'services[0].networkConfiguration.awsvpcConfiguration' \ --output json) SUBNETS=$(echo "$VPC_CONFIG" | jq -r '.subnets | join(",")') SECURITY_GROUPS=$(echo "$VPC_CONFIG" | jq -r '.securityGroups | join(",")') echo -e "${BLUE}Configuration:${NC}" echo " Cluster: $CLUSTER_NAME" echo " Task: $TASK_FAMILY" echo " DocumentDB Host: $DOCUMENTDB_HOST" echo " DocumentDB Username: ${DOCUMENTDB_USERNAME:-}" echo " Entra Enabled: $ENTRA_ENABLED" echo " Entra Group ID: ${ENTRA_GROUP_ID:-}" echo "" # Create simple command to run Python initialization # NOTE: init-documentdb-indexes.py now loads the admin scope from registry-admins.json # which is sufficient to bootstrap the system. All subsequent groups and users are # created via the registry API. The load-scopes.py call is commented out and may be # removed in a future version. echo -e "${YELLOW}Preparing initialization command...${NC}" # Build the init command, adding --entra-group-id if provided if [ -n "$ENTRA_GROUP_ID" ]; then INIT_COMMAND="source /app/.venv/bin/activate && cd /app/scripts && python init-documentdb-indexes.py --entra-group-id '$ENTRA_GROUP_ID'" else INIT_COMMAND="source /app/.venv/bin/activate && cd /app/scripts && python init-documentdb-indexes.py" fi # INIT_COMMAND="source /app/.venv/bin/activate && cd /app/scripts && python init-documentdb-indexes.py && python load-scopes.py --scopes-file /app/config/scopes.yml" # Check if task definition exists TASK_DEF_ARN=$(aws ecs describe-task-definition \ --task-definition "$TASK_FAMILY" \ --region "$AWS_REGION" \ --query 'taskDefinition.taskDefinitionArn' \ --output text 2>/dev/null || echo "") if [ -z "$TASK_DEF_ARN" ] || [ "$TASK_DEF_ARN" = "None" ]; then echo -e "${RED}Error: Task definition '$TASK_FAMILY' not found${NC}" echo "" echo "You need to create the task definition first." echo "Run: cd terraform/aws-ecs && terraform apply" exit 1 fi echo -e "${GREEN}Task definition found: $TASK_DEF_ARN${NC}" echo "" # Run the ECS task echo -e "${YELLOW}Starting ECS task...${NC}" TASK_ARN=$(aws ecs run-task \ --cluster "$CLUSTER_NAME" \ --task-definition "$TASK_FAMILY" \ --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={subnets=[$SUBNETS],securityGroups=[$SECURITY_GROUPS],assignPublicIp=DISABLED}" \ --overrides "$(jq -n \ --arg container "$CONTAINER_NAME" \ --arg cmd "$INIT_COMMAND" \ --arg host "$DOCUMENTDB_HOST" \ --arg user "$DOCUMENTDB_USERNAME" \ --arg pass "$DOCUMENTDB_PASSWORD" \ '{ "containerOverrides": [{ "name": $container, "command": ["/bin/bash", "-c", $cmd], "environment": [ {"name": "RUN_INIT_SCRIPTS", "value": "true"}, {"name": "DOCUMENTDB_HOST", "value": $host}, {"name": "DOCUMENTDB_PORT", "value": "27017"}, {"name": "DOCUMENTDB_USERNAME", "value": $user}, {"name": "DOCUMENTDB_PASSWORD", "value": $pass}, {"name": "DOCUMENTDB_DATABASE", "value": "mcp_registry"}, {"name": "DOCUMENTDB_NAMESPACE", "value": "default"}, {"name": "DOCUMENTDB_USE_TLS", "value": "true"}, {"name": "DOCUMENTDB_USE_IAM", "value": "false"}, {"name": "DOCUMENTDB_TLS_CA_FILE", "value": "/app/certs/global-bundle.pem"} ] }] }')" \ --region "$AWS_REGION" \ --query 'tasks[0].taskArn' \ --output text) if [ -z "$TASK_ARN" ] || [ "$TASK_ARN" = "None" ]; then echo -e "${RED}Failed to start ECS task${NC}" exit 1 fi TASK_ID=$(basename "$TASK_ARN") echo -e "${GREEN}Task started: $TASK_ID${NC}" echo "" # Wait for task to complete echo -e "${YELLOW}Waiting for task to complete (this may take 2-3 minutes)...${NC}" for i in {1..90}; do sleep 2 STATUS=$(aws ecs describe-tasks \ --cluster "$CLUSTER_NAME" \ --tasks "$TASK_ARN" \ --region "$AWS_REGION" \ --query 'tasks[0].lastStatus' \ --output text) if [ "$STATUS" = "STOPPED" ]; then echo -e "${GREEN}Task completed${NC}" break fi echo " [$i] Status: $STATUS" done # Get exit code EXIT_CODE=$(aws ecs describe-tasks \ --cluster "$CLUSTER_NAME" \ --tasks "$TASK_ARN" \ --region "$AWS_REGION" \ --query 'tasks[0].containers[0].exitCode' \ --output text) echo "" echo -e "${BLUE}Task exit code: $EXIT_CODE${NC}" # Get logs (wait a bit for logs to be available) echo "" echo -e "${YELLOW}Retrieving task logs...${NC}" sleep 3 # Get the actual log stream name LOG_STREAM_NAME="ecs/registry/$TASK_ID" echo "" printf '=%.0s' {1..100} echo "" # Try to get logs LOGS=$(aws logs get-log-events \ --log-group-name "/ecs/mcp-gateway-v2-registry" \ --log-stream-name "$LOG_STREAM_NAME" \ --region "$AWS_REGION" \ --query 'events[*].message' \ --output json 2>/dev/null) if [ $? -eq 0 ] && [ -n "$LOGS" ] && [ "$LOGS" != "[]" ]; then # Parse JSON array and print each message on a new line echo "$LOGS" | jq -r '.[]' 2>/dev/null || echo "$LOGS" else echo "No logs found in stream: $LOG_STREAM_NAME" echo "" echo "Available log streams:" aws logs describe-log-streams \ --log-group-name "/ecs/mcp-gateway-v2-registry" \ --order-by LastEventTime \ --descending \ --max-items 5 \ --region "$AWS_REGION" \ --query 'logStreams[*].logStreamName' \ --output text 2>/dev/null || echo "Could not retrieve log streams" fi echo "" printf '=%.0s' {1..100} echo "" # Exit with same code as task if [ "$EXIT_CODE" = "0" ]; then echo -e "${GREEN}SUCCESS: DocumentDB initialization completed${NC}" else echo -e "${RED}ERROR: DocumentDB initialization failed${NC}" fi exit "${EXIT_CODE:-1}" ================================================ FILE: terraform/aws-ecs/scripts/run-scopes-init-task.sh ================================================ #!/bin/bash ################################################################################ # Initialize Scopes on EFS # # This script: # 1. Builds and pushes the scopes-init Docker image to ECR # 2. Reads terraform outputs from terraform-outputs.json # 3. Creates an ECS task definition for scopes-init container # 4. Runs the task on the ECS cluster # 5. Waits for task completion # 6. Displays logs from CloudWatch # # Usage: # ./scripts/run-scopes-init-task.sh [OPTIONS] # # Options: # --skip-build Skip building and pushing Docker image # --aws-region REGION AWS region (default: us-west-2) # --aws-profile PROFILE AWS profile to use (default: default) # --wait-timeout SECONDS Timeout waiting for task (default: 300) # --help Show this help message # # Examples: # # Build image and run task (default) # ./scripts/run-scopes-init-task.sh # # # Skip build and run task only # ./scripts/run-scopes-init-task.sh --skip-build # # # With custom timeout # ./scripts/run-scopes-init-task.sh --wait-timeout 600 # ################################################################################ set -euo pipefail # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Configuration with defaults AWS_REGION="${AWS_REGION:-us-west-2}" AWS_PROFILE="${AWS_PROFILE:-default}" WAIT_TIMEOUT=300 SKIP_BUILD=false SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" TERRAFORM_DIR="$REPO_ROOT/terraform/aws-ecs" OUTPUTS_FILE="$SCRIPT_DIR/terraform-outputs.json" BUILD_SCRIPT="$SCRIPT_DIR/build-and-push-scopes-init.sh" # Functions log_info() { echo -e "${BLUE}[INFO]${NC} $*" } log_success() { echo -e "${GREEN}[SUCCESS]${NC} $*" } log_warning() { echo -e "${YELLOW}[WARNING]${NC} $*" } log_error() { echo -e "${RED}[ERROR]${NC} $*" } show_help() { grep '^#' "$0" | tail -n +2 | sed 's/^# //' | sed 's/^#//' exit 0 } # Parse arguments while [[ $# -gt 0 ]]; do case $1 in --skip-build) SKIP_BUILD=true shift ;; --aws-region) AWS_REGION="$2" shift 2 ;; --aws-profile) AWS_PROFILE="$2" shift 2 ;; --wait-timeout) WAIT_TIMEOUT="$2" shift 2 ;; --help) show_help ;; *) log_error "Unknown option: $1" show_help ;; esac done log_info "==========================================" log_info "Scopes Init ECS Task Runner" log_info "==========================================" log_info "AWS Region: $AWS_REGION" log_info "AWS Profile: $AWS_PROFILE" log_info "Skip Build: $SKIP_BUILD" log_info "Wait Timeout: $WAIT_TIMEOUT seconds" log_info "==========================================" # Step 0: Build and push Docker image (optional) if [[ "$SKIP_BUILD" == "false" ]]; then log_info "Step 0/7: Building and pushing scopes-init Docker image..." if [[ ! -f "$BUILD_SCRIPT" ]]; then log_error "Build script not found: $BUILD_SCRIPT" exit 1 fi if AWS_REGION="$AWS_REGION" bash "$BUILD_SCRIPT"; then log_success "Docker image built and pushed successfully" # Extract image URI from the build output by getting the latest image IMAGE_URI="$(aws ecr describe-images \ --repository-name mcp-gateway-scopes-init \ --region "$AWS_REGION" \ --query 'sort_by(imageDetails, &imagePushedAt)[-1].imageTags[0]' \ --output text 2>/dev/null)" ACCOUNT_ID="$(aws sts get-caller-identity --region "$AWS_REGION" --query Account --output text 2>/dev/null)" IMAGE_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/mcp-gateway-scopes-init:${IMAGE_URI}" log_success "Image URI: $IMAGE_URI" else log_error "Failed to build Docker image" exit 1 fi else log_info "Skipping Docker image build as requested" # Get the latest image from ECR IMAGE_TAG="$(aws ecr describe-images \ --repository-name mcp-gateway-scopes-init \ --region "$AWS_REGION" \ --query 'sort_by(imageDetails, &imagePushedAt)[-1].imageTags[0]' \ --output text 2>/dev/null)" ACCOUNT_ID="$(aws sts get-caller-identity --region "$AWS_REGION" --query Account --output text 2>/dev/null)" IMAGE_URI="${ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/mcp-gateway-scopes-init:${IMAGE_TAG}" log_success "Using existing image: $IMAGE_URI" fi # Step 1: Check terraform outputs file log_info "Step 1/6: Validating terraform outputs..." if [[ ! -f "$OUTPUTS_FILE" ]]; then log_error "terraform-outputs.json not found at $OUTPUTS_FILE" log_info "Run this command first to generate outputs:" log_info " cd $TERRAFORM_DIR" log_info " terraform output -json > $OUTPUTS_FILE" exit 1 fi log_success "Found terraform outputs file" # Step 2: Extract parameters from terraform outputs log_info "Step 2/6: Extracting parameters from terraform outputs..." CLUSTER_NAME=$(jq -r '.ecs_cluster_name.value // empty' "$OUTPUTS_FILE" 2>/dev/null) if [[ -z "$CLUSTER_NAME" ]]; then log_error "Could not extract ecs_cluster_name from terraform outputs" exit 1 fi log_success "Cluster: $CLUSTER_NAME" EFS_ID=$(jq -r '.mcp_gateway_efs_id.value // empty' "$OUTPUTS_FILE" 2>/dev/null) if [[ -z "$EFS_ID" ]]; then log_error "Could not extract mcp_gateway_efs_id from terraform outputs" log_info "Make sure terraform outputs are up to date by running:" log_info " cd $TERRAFORM_DIR && terraform output -json > $OUTPUTS_FILE" exit 1 fi log_success "EFS ID: $EFS_ID" ACCESS_POINT_ID=$(jq -r '.mcp_gateway_efs_access_points.value.auth_config // empty' "$OUTPUTS_FILE" 2>/dev/null) if [[ -z "$ACCESS_POINT_ID" ]]; then log_error "Could not extract mcp_gateway_efs_access_points.auth_config from terraform outputs" exit 1 fi log_success "Access Point ID: $ACCESS_POINT_ID" # Get VPC configuration from registry service log_info "Step 3/6: Fetching VPC configuration from registry service..." SUBNET_IDS=$(aws ecs describe-services \ --cluster "$CLUSTER_NAME" \ --services "mcp-gateway-v2-registry" \ --region "$AWS_REGION" \ --query 'services[0].networkConfiguration.awsvpcConfiguration.subnets[*]' \ --output text 2>/dev/null) if [[ -z "$SUBNET_IDS" ]]; then log_error "Could not get subnet IDs from registry service" exit 1 fi log_success "Subnets: $SUBNET_IDS" SECURITY_GROUP_IDS=$(aws ecs describe-services \ --cluster "$CLUSTER_NAME" \ --services "mcp-gateway-v2-registry" \ --region "$AWS_REGION" \ --query 'services[0].networkConfiguration.awsvpcConfiguration.securityGroups[*]' \ --output text 2>/dev/null) if [[ -z "$SECURITY_GROUP_IDS" ]]; then log_error "Could not get security group IDs from registry service" exit 1 fi log_success "Security Groups: $SECURITY_GROUP_IDS" # Get AWS account ID if [[ -z "$AWS_PROFILE" || "$AWS_PROFILE" == "default" ]]; then AWS_ACCOUNT=$(aws sts get-caller-identity \ --region "$AWS_REGION" \ --query Account \ --output text 2>/dev/null) else AWS_ACCOUNT=$(aws sts get-caller-identity \ --region "$AWS_REGION" \ --profile "$AWS_PROFILE" \ --query Account \ --output text 2>/dev/null) fi if [[ -z "$AWS_ACCOUNT" ]]; then log_error "Could not get AWS account ID" exit 1 fi log_success "AWS Account: $AWS_ACCOUNT" # Get execution role from existing auth-server task EXECUTION_ROLE=$(aws ecs describe-task-definition \ --task-definition mcp-gateway-v2-auth \ --region "$AWS_REGION" \ --query 'taskDefinition.executionRoleArn' \ --output text 2>/dev/null) if [[ -z "$EXECUTION_ROLE" ]]; then log_error "Could not get execution role from auth-server task" exit 1 fi log_success "Execution Role: $EXECUTION_ROLE" # Step 4: Create task definition log_info "Step 4/6: Registering ECS task definition..." TASK_DEF=$(cat < "$TASK_DEF_FILE" if [[ -z "$AWS_PROFILE" || "$AWS_PROFILE" == "default" ]]; then TASK_DEF_ARN=$(aws ecs register-task-definition \ --cli-input-json "file://$TASK_DEF_FILE" \ --region "$AWS_REGION" \ --query 'taskDefinition.taskDefinitionArn' \ --output text 2>/dev/null) else TASK_DEF_ARN=$(aws ecs register-task-definition \ --cli-input-json "file://$TASK_DEF_FILE" \ --region "$AWS_REGION" \ --profile "$AWS_PROFILE" \ --query 'taskDefinition.taskDefinitionArn' \ --output text 2>/dev/null) fi # Clean up temp file rm -f "$TASK_DEF_FILE" if [[ -z "$TASK_DEF_ARN" ]]; then log_error "Failed to register task definition" exit 1 fi log_success "Task definition registered: $TASK_DEF_ARN" # Step 5: Create CloudWatch log group if needed log_info "Step 5/6: Checking CloudWatch log group..." LOG_CHECK_CMD="aws logs describe-log-groups --log-group-name-prefix /ecs/mcp-gateway-scopes-init --region $AWS_REGION" if [[ -n "$AWS_PROFILE" && "$AWS_PROFILE" != "default" ]]; then LOG_CHECK_CMD="$LOG_CHECK_CMD --profile $AWS_PROFILE" fi if ! $LOG_CHECK_CMD --query 'logGroups[0].logGroupName' 2>/dev/null | grep -q "mcp-gateway-scopes-init"; then log_info "Creating CloudWatch log group..." if [[ -z "$AWS_PROFILE" || "$AWS_PROFILE" == "default" ]]; then aws logs create-log-group \ --log-group-name "/ecs/mcp-gateway-scopes-init" \ --region "$AWS_REGION" 2>/dev/null || true else aws logs create-log-group \ --log-group-name "/ecs/mcp-gateway-scopes-init" \ --region "$AWS_REGION" \ --profile "$AWS_PROFILE" 2>/dev/null || true fi log_success "Log group created" else log_success "Log group already exists" fi # Step 6: Run the task log_info "Step 6/6: Running ECS task..." # Convert space-separated values to JSON arrays SUBNET_JSON=$(echo "$SUBNET_IDS" | awk '{for(i=1;i<=NF;i++) print "\""$i"\""}' | paste -sd ',' -) SG_JSON=$(echo "$SECURITY_GROUP_IDS" | awk '{for(i=1;i<=NF;i++) print "\""$i"\""}' | paste -sd ',' -) if [[ -z "$AWS_PROFILE" || "$AWS_PROFILE" == "default" ]]; then TASK_ARN=$(aws ecs run-task \ --cluster "$CLUSTER_NAME" \ --task-definition "mcp-gateway-scopes-init" \ --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={subnets=[$SUBNET_JSON],securityGroups=[$SG_JSON],assignPublicIp=DISABLED}" \ --region "$AWS_REGION" \ --query 'tasks[0].taskArn' \ --output text 2>/dev/null) else TASK_ARN=$(aws ecs run-task \ --cluster "$CLUSTER_NAME" \ --task-definition "mcp-gateway-scopes-init" \ --launch-type FARGATE \ --network-configuration "awsvpcConfiguration={subnets=[$SUBNET_JSON],securityGroups=[$SG_JSON],assignPublicIp=DISABLED}" \ --region "$AWS_REGION" \ --profile "$AWS_PROFILE" \ --query 'tasks[0].taskArn' \ --output text 2>/dev/null) fi if [[ -z "$TASK_ARN" ]]; then log_error "Failed to run task" exit 1 fi TASK_ID=$(echo "$TASK_ARN" | awk -F'/' '{print $NF}') log_success "Task started: $TASK_ARN" # Wait for task completion log_info "Waiting for task to complete (timeout: $WAIT_TIMEOUT seconds)..." ELAPSED=0 INTERVAL=5 while [[ $ELAPSED -lt $WAIT_TIMEOUT ]]; do if [[ -z "$AWS_PROFILE" || "$AWS_PROFILE" == "default" ]]; then TASK_STATUS=$(aws ecs describe-tasks \ --cluster "$CLUSTER_NAME" \ --tasks "$TASK_ARN" \ --region "$AWS_REGION" \ --query 'tasks[0].{lastStatus:lastStatus,exitCode:containers[0].exitCode}' \ --output json 2>/dev/null) else TASK_STATUS=$(aws ecs describe-tasks \ --cluster "$CLUSTER_NAME" \ --tasks "$TASK_ARN" \ --region "$AWS_REGION" \ --profile "$AWS_PROFILE" \ --query 'tasks[0].{lastStatus:lastStatus,exitCode:containers[0].exitCode}' \ --output json 2>/dev/null) fi LAST_STATUS=$(echo "$TASK_STATUS" | jq -r '.lastStatus // "UNKNOWN"') EXIT_CODE=$(echo "$TASK_STATUS" | jq -r '.exitCode // "null"') log_info "Task status: $LAST_STATUS (elapsed: ${ELAPSED}s)" if [[ "$LAST_STATUS" == "STOPPED" ]]; then if [[ "$EXIT_CODE" == "0" ]]; then log_success "Task completed successfully!" break else log_error "Task failed with exit code: $EXIT_CODE" break fi fi sleep $INTERVAL ELAPSED=$((ELAPSED + INTERVAL)) done if [[ $ELAPSED -ge $WAIT_TIMEOUT ]]; then log_warning "Task did not complete within timeout period" fi # Display task logs log_info "Retrieving task logs from CloudWatch..." echo "" LOG_STREAM="ecs/scopes-init/$TASK_ID" # Wait a moment for logs to appear sleep 2 if [[ -z "$AWS_PROFILE" || "$AWS_PROFILE" == "default" ]]; then LOGS=$(aws logs get-log-events \ --log-group-name "/ecs/mcp-gateway-scopes-init" \ --log-stream-name "$LOG_STREAM" \ --region "$AWS_REGION" \ --query 'events[*].message' \ --output text 2>/dev/null || echo "") else LOGS=$(aws logs get-log-events \ --log-group-name "/ecs/mcp-gateway-scopes-init" \ --log-stream-name "$LOG_STREAM" \ --region "$AWS_REGION" \ --profile "$AWS_PROFILE" \ --query 'events[*].message' \ --output text 2>/dev/null || echo "") fi if [[ -n "$LOGS" ]]; then log_info "CloudWatch Logs:" echo "$LOGS" | while read -r line; do echo " $line" done else log_warning "No logs found (they may take a moment to appear)" fi echo "" log_success "==========================================" log_success "Scopes Init Task Complete!" log_success "==========================================" log_info "Task ARN: $TASK_ARN" log_info "Exit Code: $EXIT_CODE" log_info "" log_info "The scopes.yml file should now be available on the EFS mount" log_info "at /auth_config/scopes.yml for registry and auth-server containers." log_info "" if [[ "$EXIT_CODE" != "0" ]]; then log_error "Task failed. Check the logs above for details." exit 1 fi exit 0 ================================================ FILE: terraform/aws-ecs/scripts/save-terraform-outputs.sh ================================================ #!/bin/bash ################################################################################ # Save Terraform Outputs to JSON File # # This script: # 1. Runs terraform output to get all deployed resource information # 2. Saves output as JSON to terraform-outputs.json in the scripts directory # 3. Creates a backup of previous outputs in terraform/.terraform/ directory # # Usage: # ./save-terraform-outputs.sh [OPTIONS] # # Options: # --output-file FILE Output file name (default: terraform-outputs.json) # --terraform-dir DIR Terraform directory (default: aws-ecs) # --no-backup Don't create backup of previous output # --help Show this help message # # Examples: # # Save outputs with default filename (to scripts directory) # ./save-terraform-outputs.sh # # # Save to custom filename (to scripts directory) # ./save-terraform-outputs.sh --output-file my-outputs.json # # # Disable backups # ./save-terraform-outputs.sh --no-backup # # Note: Backups are stored in terraform/aws-ecs/.terraform/ which is gitignored # ################################################################################ set -euo pipefail # Colors BLUE='\033[0;34m' GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' # Configuration OUTPUT_FILE="terraform-outputs.json" TERRAFORM_DIR="terraform/aws-ecs" CREATE_BACKUP=true SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" TIMESTAMP=$(date +%Y%m%d_%H%M%S) OUTPUT_DIR="$SCRIPT_DIR" # Save outputs to scripts directory BACKUP_DIR="" # Will be set to .terraform directory after validation log_info() { echo -e "${BLUE}[INFO]${NC} $*" } log_success() { echo -e "${GREEN}[SUCCESS]${NC} $*" } log_warning() { echo -e "${YELLOW}[WARNING]${NC} $*" } log_error() { echo -e "${RED}[ERROR]${NC} $*" } show_help() { grep '^#' "$0" | tail -n +2 | sed 's/^# //' | sed 's/^#//'\ exit 0 } # Parse arguments while [[ $# -gt 0 ]]; do case $1 in --output-file) OUTPUT_FILE="$2" shift 2 ;; --terraform-dir) TERRAFORM_DIR="$2" shift 2 ;; --no-backup) CREATE_BACKUP=false shift ;; --help) show_help ;; *) log_error "Unknown option: $1" show_help ;; esac done # Validate terraform directory TERRAFORM_PATH="$REPO_ROOT/$TERRAFORM_DIR" if [[ ! -d "$TERRAFORM_PATH" ]]; then log_error "Terraform directory not found: $TERRAFORM_PATH" exit 1 fi # Set backup directory to .terraform within terraform directory BACKUP_DIR="$TERRAFORM_PATH/.terraform" # Create .terraform directory if it doesn't exist if [[ ! -d "$BACKUP_DIR" ]]; then log_info "Creating .terraform directory for backups: $BACKUP_DIR" mkdir -p "$BACKUP_DIR" fi # Get absolute output path if [[ "$OUTPUT_FILE" != /* ]]; then OUTPUT_FILE="$OUTPUT_DIR/$OUTPUT_FILE" fi log_info "==========================================" log_info "Terraform Outputs Export Script" log_info "==========================================" log_info "Terraform Directory: $TERRAFORM_PATH" log_info "Output File: $OUTPUT_FILE" log_info "Backup Directory: $BACKUP_DIR" log_info "Create Backup: $CREATE_BACKUP" log_info "==========================================" # Create backup if file exists if [[ -f "$OUTPUT_FILE" && "$CREATE_BACKUP" == "true" ]]; then BACKUP_FILE="$BACKUP_DIR/terraform-outputs.json.backup-${TIMESTAMP}" log_info "Creating backup of previous outputs..." cp "$OUTPUT_FILE" "$BACKUP_FILE" log_success "Backup created: $BACKUP_FILE" fi # Run terraform output log_info "Running terraform output..." cd "$TERRAFORM_PATH" log_info "Exporting as JSON..." if terraform output -json > "$OUTPUT_FILE" 2>/dev/null; then log_success "JSON outputs exported successfully" else log_error "Failed to export JSON outputs" exit 1 fi # Verify file was created if [[ -f "$OUTPUT_FILE" ]]; then FILE_SIZE=$(du -h "$OUTPUT_FILE" | cut -f1) log_success "Output file created successfully" log_info "File: $OUTPUT_FILE" log_info "Size: $FILE_SIZE" echo "" log_success "==========================================" log_success "Terraform outputs saved to:" log_success "$OUTPUT_FILE" log_success "==========================================" else log_error "Failed to create output file" exit 1 fi exit 0 ================================================ FILE: terraform/aws-ecs/scripts/service_mgmt.sh ================================================ #!/bin/bash # Service Management Script for MCP Gateway Registry # Usage: ./cli/service_mgmt.sh {add|delete|monitor|test|add-to-groups|remove-from-groups|create-group|delete-group|list-groups} [args...] set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # Unicode symbols CHECK_MARK="✓" CROSS_MARK="✗" # Get script directory and project root SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" TERRAFORM_OUTPUTS="$SCRIPT_DIR/terraform-outputs.json" # Load environment variables from .env file if it exists if [ -f "$PROJECT_ROOT/.env" ]; then set -a # automatically export all variables source "$PROJECT_ROOT/.env" set +a fi # Load configuration from environment variables or terraform outputs load_config() { # GATEWAY_URL - try env var, then terraform outputs if [ -z "$GATEWAY_URL" ]; then if [ -f "$TERRAFORM_OUTPUTS" ] && command -v jq &> /dev/null; then GATEWAY_URL=$(jq -r '.registry_url.value // empty' "$TERRAFORM_OUTPUTS" 2>/dev/null) fi fi GATEWAY_URL="${GATEWAY_URL:-http://localhost}" } # Load configuration load_config # Default service name DEFAULT_SERVICE="example-server" print_success() { echo -e "${GREEN}${CHECK_MARK} $1${NC}" } print_error() { echo -e "${RED}${CROSS_MARK} $1${NC}" } print_info() { echo -e "${YELLOW}ℹ $1${NC}" } check_prerequisites() { print_info "Checking prerequisites..." # Get M2M token from get-m2m-token.sh # All informational messages go to stderr, only token comes to stdout local token_output if ! token_output=$("$SCRIPT_DIR/get-m2m-token.sh" 2>&1 >/dev/null); then # Capture stderr for error messages print_error "Failed to get M2M token" echo "$token_output" exit 1 fi # Get the actual token (last line of stdout) OAUTH_TOKEN=$("$SCRIPT_DIR/get-m2m-token.sh") if [ -z "$OAUTH_TOKEN" ]; then print_error "Failed to obtain OAuth token" exit 1 fi # Export token for use by subprocesses export OAUTH_TOKEN print_success "OAuth token obtained" } run_mcp_command() { local tool="$1" local args="$2" local description="$3" print_info "$description" # Print the exact command being executed echo "🔍 Executing: uv run cli/mcp_client.py --url ${GATEWAY_URL}/mcpgw/mcp call --tool $tool --args '$args'" if output=$(cd "$PROJECT_ROOT" && uv run cli/mcp_client.py --url "${GATEWAY_URL}/mcpgw/mcp" call --tool "$tool" --args "$args" 2>&1); then print_success "$description completed" echo "$output" return 0 else print_error "$description failed" echo "$output" return 1 fi } verify_server_in_list() { local service_name="$1" local should_exist="$2" # "true" or "false" print_info "Checking server in service list..." if output=$(cd "$PROJECT_ROOT" && uv run cli/mcp_client.py --url "${GATEWAY_URL}/mcpgw/mcp" call --tool list_services --args '{}' 2>&1); then if echo "$output" | grep -q "$service_name"; then if [ "$should_exist" = "true" ]; then print_success "Server found in service list" echo "$output" | grep -A2 -B2 "$service_name" return 0 else print_error "Server still exists in service list (should be removed)" return 1 fi else if [ "$should_exist" = "false" ]; then print_success "Server not found in service list (expected)" return 0 else print_error "Server not found in service list" return 1 fi fi else print_error "Failed to check service list" echo "$output" return 1 fi } verify_scopes_yml() { local service_name="$1" local should_exist="$2" # "true" or "false" print_info "Checking scopes.yml files..." # Check container scopes.yml local container_count container_count=$(docker exec mcp-gateway-registry-auth-server-1 grep -c "$service_name" /app/scopes.yml 2>/dev/null || echo "0") # Ensure we only get the last line if multiple lines are returned container_count=$(echo "$container_count" | tail -1) if [ "$should_exist" = "true" ] && [ "$container_count" -gt "0" ]; then print_success "Server found in container scopes.yml ($container_count occurrences)" elif [ "$should_exist" = "false" ] && [ "$container_count" -eq "0" ]; then print_success "Server not found in container scopes.yml (expected)" else if [ "$should_exist" = "true" ]; then print_error "Server not found in container scopes.yml" else print_error "Server still exists in container scopes.yml ($container_count occurrences)" fi return 1 fi # Check host scopes.yml local host_count host_count=$(grep -c "$service_name" "${HOME}/mcp-gateway/auth_server/scopes.yml" 2>/dev/null || echo "0") # Ensure we only get the last line if multiple lines are returned host_count=$(echo "$host_count" | tail -1) if [ "$should_exist" = "true" ] && [ "$host_count" -gt "0" ]; then print_success "Server found in host scopes.yml ($host_count occurrences)" elif [ "$should_exist" = "false" ] && [ "$host_count" -eq "0" ]; then print_success "Server not found in host scopes.yml (expected)" else if [ "$should_exist" = "true" ]; then print_error "Server not found in host scopes.yml" else print_error "Server still exists in host scopes.yml ($host_count occurrences)" fi return 1 fi } verify_faiss_metadata() { local service_name="$1" local should_exist="$2" # "true" or "false" print_info "Checking FAISS index metadata..." local metadata_count metadata_count=$(docker exec mcp-gateway-registry-registry-1 grep -c "$service_name" /app/registry/servers/service_index_metadata.json 2>/dev/null || echo "0") # Ensure we only get the last line if multiple lines are returned metadata_count=$(echo "$metadata_count" | tail -1) if [ "$should_exist" = "true" ] && [ "$metadata_count" -gt "0" ]; then print_success "Server found in FAISS metadata ($metadata_count occurrences)" elif [ "$should_exist" = "false" ] && [ "$metadata_count" -eq "0" ]; then print_success "Server not found in FAISS metadata (expected)" else if [ "$should_exist" = "true" ]; then print_error "Server not found in FAISS metadata" else print_error "Server still exists in FAISS metadata ($metadata_count occurrences)" fi return 1 fi } parse_health_output() { local json_output="$1" local service_filter="$2" # Write output to temp file to avoid shell escaping issues local temp_file=$(mktemp) echo "$json_output" > "$temp_file" # Use Python to parse JSON and format output python3 -c " import json import sys from datetime import datetime, timezone import re try: # Read from temp file with open('$temp_file', 'r') as f: output = f.read() # Look for the main JSON response (starts after authentication message) json_start = output.find('{') if json_start == -1: print('No JSON found in output') sys.exit(1) # Find the matching closing brace brace_count = 0 json_end = json_start for i, char in enumerate(output[json_start:], json_start): if char == '{': brace_count += 1 elif char == '}': brace_count -= 1 if brace_count == 0: json_end = i + 1 break json_text = output[json_start:json_end] data = json.loads(json_text) # Extract health data from structuredContent if available, otherwise from top level if 'structuredContent' in data: health_data = data['structuredContent'] else: # Fallback to top level if no structuredContent health_data = data current_time = datetime.now(timezone.utc) print('Health Check Results:') print('=' * 50) for service_path, info in health_data.items(): # Skip if filtering for specific service and this doesn't match if '$service_filter' and '$service_filter' not in service_path: continue status = info.get('status', 'unknown') last_checked = info.get('last_checked_iso', '') num_tools = info.get('num_tools', 0) # Calculate time difference if last_checked: try: check_time = datetime.fromisoformat(last_checked.replace('Z', '+00:00')) time_diff = current_time - check_time seconds_ago = int(time_diff.total_seconds()) time_str = f'{seconds_ago} seconds ago' except: time_str = 'unknown time' else: time_str = 'never checked' # Format status with color indicators if status == 'healthy': status_display = '✓ healthy' elif status == 'unhealthy': status_display = '✗ unhealthy' elif 'auth-expired' in status: status_display = '⚠ healthy-auth-expired' else: status_display = f'? {status}' print(f'Service: {service_path}') print(f' Status: {status_display}') print(f' Last checked: {time_str}') print(f' Tools available: {num_tools}') print() except json.JSONDecodeError as e: print(f'Error parsing JSON: {e}') with open('$temp_file', 'r') as f: print('Raw output:') print(f.read()) sys.exit(1) except Exception as e: print(f'Error processing health check: {e}') sys.exit(1) " # Clean up temp file rm -f "$temp_file" } run_health_check() { local service_name="$1" print_info "Running health check..." if output=$(cd "$PROJECT_ROOT" && uv run cli/mcp_client.py --url "${GATEWAY_URL}/mcpgw/mcp" call --tool healthcheck --args '{}' 2>&1); then print_success "Health check completed" echo "" # Parse and display formatted output if ! parse_health_output "$output" "$service_name"; then print_error "Failed to parse health check output" echo "Raw output:" echo "$output" return 1 fi return 0 else print_error "Health check failed" echo "$output" return 1 fi } validate_config() { local config_json="$1" # Use Python to validate fields according to register_service tool spec python3 -c " import json import sys try: config = json.loads('''$config_json''') # Required fields (based on register_service tool spec) required_fields = ['server_name', 'path', 'proxy_pass_url'] missing_fields = [] for field in required_fields: if field not in config or not config[field]: missing_fields.append(field) if missing_fields: print(f'ERROR: Missing required fields in config: {missing_fields}') sys.exit(1) # Handle bedrock-agentcore specific URL formatting auth_provider = config.get('auth_provider', '') if auth_provider == 'bedrock-agentcore': # Ensure path begins and ends with '/' path = config['path'] if not path.startswith('/'): path = '/' + path if not path.endswith('/'): path = path + '/' config['path'] = path # Ensure proxy_pass_url ends with '/' and does not have '/mcp' or '/mcp/' at the end proxy_url = config['proxy_pass_url'] # Remove trailing '/mcp/' or '/mcp' if proxy_url.endswith('/mcp/'): proxy_url = proxy_url[:-5] # Remove '/mcp/' elif proxy_url.endswith('/mcp'): proxy_url = proxy_url[:-4] # Remove '/mcp' # Ensure it ends with '/' if not proxy_url.endswith('/'): proxy_url = proxy_url + '/' config['proxy_pass_url'] = proxy_url # Validate field types and constraints errors = [] # server_name: must be string and non-empty if not isinstance(config['server_name'], str) or not config['server_name'].strip(): errors.append('server_name must be a non-empty string') # path: must be string, start with '/', and be unique URL path prefix if not isinstance(config['path'], str): errors.append('path must be a string') elif not config['path'].startswith('/'): errors.append('path must start with \"/\"') elif len(config['path']) < 2: errors.append('path must be more than just \"/\"') # proxy_pass_url: must be string and valid URL format if not isinstance(config['proxy_pass_url'], str): errors.append('proxy_pass_url must be a string') elif not (config['proxy_pass_url'].startswith('http://') or config['proxy_pass_url'].startswith('https://')): errors.append('proxy_pass_url must start with http:// or https://') # Check for unknown fields (not part of tool spec) allowed_fields = {'server_name', 'path', 'proxy_pass_url', 'description', 'tags', 'num_tools', 'license', 'auth_provider', 'auth_scheme', 'supported_transports', 'headers', 'tool_list', 'repository_url', 'website_url', 'package_npm'} unknown_fields = set(config.keys()) - allowed_fields if unknown_fields: errors.append(f'Unknown fields not allowed by register_service tool spec: {sorted(unknown_fields)}') # Optional field validations if 'description' in config and config['description'] is not None: if not isinstance(config['description'], str): errors.append('description must be a string') if 'tags' in config and config['tags'] is not None: if not isinstance(config['tags'], list): errors.append('tags must be a list') elif not all(isinstance(tag, str) for tag in config['tags']): errors.append('all tags must be strings') if 'num_tools' in config and config['num_tools'] is not None: if not isinstance(config['num_tools'], int) or config['num_tools'] < 0: errors.append('num_tools must be a non-negative integer') if 'license' in config and config['license'] is not None: if not isinstance(config['license'], str): errors.append('license must be a string') if errors: print('ERROR: Config validation failed:') for error in errors: print(f' - {error}') sys.exit(1) # Extract service name from path for validation service_name = config['path'].lstrip('/').rstrip('/') # Output both the modified config and service name # First line: modified config as JSON # Second line: service name print(json.dumps(config)) print(service_name) except json.JSONDecodeError as e: print(f'ERROR: Invalid JSON in config: {e}') sys.exit(1) except Exception as e: print(f'ERROR: Config validation failed: {e}') sys.exit(1) " } add_service() { local config_file="${1}" local analyzers="${2:-yara}" if [ -z "$config_file" ]; then print_error "Usage: $0 add [analyzers]" print_error "Example: $0 add cli/examples/example-server-config.json" print_error "Example: $0 add cli/examples/example-server-config.json yara,llm" exit 1 fi if [ ! -f "$config_file" ]; then print_error "Config file not found: $config_file" print_error "Full path searched: $(pwd)/$config_file" exit 1 fi print_info "Loading config from: $config_file" local config_json config_json="$(cat "$config_file")" # Validate config and extract service name local validation_output service_name modified_config if ! validation_output=$(validate_config "$config_json"); then print_error "Config validation failed" echo "$validation_output" # This contains error message exit 1 fi # Parse the two-line output: first line is modified config, second is service name modified_config=$(echo "$validation_output" | head -n 1) service_name=$(echo "$validation_output" | tail -n 1) # Use the modified config for registration config_json="$modified_config" # Extract service_path from config for later use local service_path service_path=$(python3 -c " import json config = json.loads('''$config_json''') print(config.get('path', '')) ") echo "=== Adding Service: $service_name ===" # Check prerequisites check_prerequisites # Extract proxy_pass_url for security scanning local proxy_pass_url proxy_pass_url=$(python3 -c " import json config = json.loads('''$config_json''') print(config.get('proxy_pass_url', '')) ") # Extract headers from config if present local headers_json headers_json=$(python3 -c " import json config = json.loads('''$config_json''') headers = config.get('headers', {}) if headers: print(json.dumps(headers)) else: print('') ") # Check if LLM analyzer is requested and API key is available if [[ "$analyzers" == *"llm"* ]]; then if [ -z "$MCP_SCANNER_LLM_API_KEY" ] || [[ "$MCP_SCANNER_LLM_API_KEY" == *"your_"* ]] || [[ "$MCP_SCANNER_LLM_API_KEY" == *"placeholder"* ]]; then echo "" print_error "LLM analyzer requested but MCP_SCANNER_LLM_API_KEY is not configured" print_info "Current value: ${MCP_SCANNER_LLM_API_KEY:-}" print_info "" print_info "Options:" print_info " 1. Add real API key to .env file: MCP_SCANNER_LLM_API_KEY=sk-..." print_info " 2. Set environment variable: export MCP_SCANNER_LLM_API_KEY=sk-..." print_info " 3. Use only YARA analyzer: $0 add $config_file yara" exit 1 fi fi # Run security scan echo "" echo "=== Security Scan ===" print_info "Scanning server for security vulnerabilities..." print_info "Using analyzers: $analyzers" local is_safe="true" local scan_output="" # Prepare scan URL - append /mcp if not already present local scan_url="$proxy_pass_url" if [[ ! "$scan_url" =~ /mcp/?$ ]] && [[ ! "$scan_url" =~ /sse/?$ ]]; then # Remove trailing slash if present, then add /mcp scan_url="${scan_url%/}/mcp" print_info "Appending /mcp to scan URL: $scan_url" fi # Run scan using Python CLI and capture JSON output # Note: Scanner exits with code 1 when unsafe, so we need to capture both success and "failure" cases local scan_exit_code=0 local scan_cmd="cd \"$PROJECT_ROOT\" && uv run cli/mcp_security_scanner.py --server-url \"$scan_url\" --analyzers \"$analyzers\" --json" # Add headers if present in config if [ -n "$headers_json" ]; then print_info "Using custom headers from config for security scan" scan_cmd="$scan_cmd --headers '$headers_json'" fi scan_output=$(eval "$scan_cmd" 2>&1) || scan_exit_code=$? print_info "scan_exit_code - $scan_exit_code" # Exit code 0 = safe, exit code 1 = unsafe, exit code 2 = error if [ $scan_exit_code -eq 0 ]; then print_success "Security scan passed - Server is SAFE" elif [ $scan_exit_code -eq 1 ]; then print_error "Security scan failed - Server has critical or high severity issues" print_info "Server will be registered but marked as UNHEALTHY with security-pending status" # Add security-pending tag to config_json BEFORE registration echo "" echo "====Adding security-pending tag to configuration====" print_info "Adding 'security-pending' tag to server configuration before registration..." config_json=$(python3 -c " import json import sys try: config = json.loads('''$config_json''') # Add security-pending tag if not already present tags = config.get('tags', []) if 'security-pending' not in tags: tags.append('security-pending') config['tags'] = tags print(json.dumps(config)) sys.exit(0) except Exception as e: print(f'Failed to add tag: {e}', file=sys.stderr) sys.exit(1) ") if [ $? -eq 0 ]; then print_success "Added 'security-pending' tag to configuration" else print_error "Failed to add 'security-pending' tag to configuration" exit 1 fi else print_error "Security scan encountered an error (exit code: $scan_exit_code)" print_info "Server will be registered but marked as UNHEALTHY with security-pending status" fi echo "" # Register the service if ! run_mcp_command "register_service" "$config_json" "Registering service"; then exit 1 fi # Verify registration echo "" echo "=== Verifying Registration ===" if ! verify_server_in_list "$service_path" "true"; then exit 1 fi if ! verify_scopes_yml "$service_name" "true"; then exit 1 fi if ! verify_faiss_metadata "$service_name" "true"; then exit 1 fi if [ $scan_exit_code -eq 1 ]; then #Disabling the server echo "" echo "====Disabling the server====" # Generate JWT token for internal auth using shared SECRET_KEY if [ -z "$SECRET_KEY" ]; then print_error "SECRET_KEY not set in environment - cannot disable server" else local auth_token auth_token=$(python3 -c " from registry.auth.internal import generate_internal_token print(generate_internal_token(subject='cli-service-mgmt', purpose='toggle-service')) " 2>/dev/null) if [ -z "$auth_token" ]; then print_error "Failed to generate auth token - cannot disable server" else # Call the internal toggle endpoint to set service to disabled (false) # Since the server was just auto-enabled during registration, we need to toggle it OFF print_info "Calling toggle endpoint with: ${GATEWAY_URL}/api/internal/toggle" print_info "Service path: $service_path" output=$(curl -s -w "\nHTTP_STATUS:%{http_code}" -X POST "${GATEWAY_URL}/api/internal/toggle" \ -H "Authorization: Bearer $auth_token" \ --data-urlencode "service_path=$service_path" 2>&1) # Extract HTTP status code from response http_status=$(echo "$output" | grep "HTTP_STATUS:" | cut -d':' -f2) response_body=$(echo "$output" | sed '/HTTP_STATUS:/d') print_info "Toggle API HTTP Status: $http_status" print_info "Toggle API Response: $response_body" if [ "$http_status" = "200" ]; then print_success "Server disabled due to failed security scan" else print_error "Failed to disable server - HTTP Status: $http_status" print_error "Response: $response_body" fi print_info "Review the security scan report before enabling this server" fi fi fi # Run health check echo "" echo "=== Health Check ===" if ! run_health_check "$service_name"; then exit 1 fi echo "" print_success "Service $service_name successfully added and verified!" } delete_service() { local service_path="${1}" local service_name="${2}" if [ -z "$service_path" ] || [ -z "$service_name" ]; then print_error "Usage: $0 delete " print_error "Example: $0 delete /example-server example-server" exit 1 fi echo "=== Deleting Service: $service_name (path: $service_path) ===" # Check prerequisites check_prerequisites # Remove the service if ! run_mcp_command "remove_service" "{\"service_path\": \"$service_path\"}" "Removing service"; then exit 1 fi # Verify deletion echo "" echo "=== Verifying Deletion ===" if ! verify_server_in_list "$service_path" "false"; then exit 1 fi if ! verify_scopes_yml "$service_name" "false"; then exit 1 fi if ! verify_faiss_metadata "$service_name" "false"; then exit 1 fi echo "" print_success "Service $service_name successfully deleted and verified!" } test_service() { local config_file="${1}" if [ -z "$config_file" ]; then print_error "Usage: $0 test " print_error "Example: $0 test cli/examples/example-server-config.json" exit 1 fi if [ ! -f "$config_file" ]; then print_error "Config file not found: $config_file" print_error "Full path searched: $(pwd)/$config_file" exit 1 fi print_info "Loading config from: $config_file" local config_json config_json="$(cat "$config_file")" # Validate config and extract service info local validation_output service_name modified_config if ! validation_output=$(validate_config "$config_json"); then print_error "Config validation failed" echo "$validation_output" # This contains error message exit 1 fi # Parse the two-line output: first line is modified config, second is service name modified_config=$(echo "$validation_output" | head -n 1) service_name=$(echo "$validation_output" | tail -n 1) # Use the modified config config_json="$modified_config" # Extract description and tags for testing local description tags_json description=$(python3 -c " import json config = json.loads('''$config_json''') print(config.get('description', '')) ") tags_json=$(python3 -c " import json config = json.loads('''$config_json''') tags = config.get('tags', []) print(json.dumps(tags)) ") echo "=== Testing Service: $service_name ===" # Check prerequisites check_prerequisites # Test intelligent tool finder with description if [ -n "$description" ]; then print_info "Testing search with description: \"$description\"" if ! run_mcp_command "intelligent_tool_finder" "{\"natural_language_query\": \"$description\"}" "Searching with description"; then print_error "Failed to search with description" else print_success "Search with description completed" fi echo "" fi # Test intelligent tool finder with tags only if [ "$tags_json" != "[]" ]; then print_info "Testing search with tags: $tags_json" if ! run_mcp_command "intelligent_tool_finder" "{\"tags\": $tags_json}" "Searching with tags"; then print_error "Failed to search with tags" else print_success "Search with tags completed" fi echo "" fi # Test combined search if [ -n "$description" ] && [ "$tags_json" != "[]" ]; then print_info "Testing combined search with description and tags" if ! run_mcp_command "intelligent_tool_finder" "{\"natural_language_query\": \"$description\", \"tags\": $tags_json}" "Combined search"; then print_error "Failed combined search" else print_success "Combined search completed" fi echo "" fi echo "" print_success "Service testing completed!" } monitor_services() { local config_file="${1}" local service_name="" if [ -n "$config_file" ]; then if [ ! -f "$config_file" ]; then print_error "Config file not found: $config_file" exit 1 fi print_info "Loading config from: $config_file" local config_json config_json="$(cat "$config_file")" # Validate config and extract service name local validation_output modified_config if ! validation_output=$(validate_config "$config_json"); then print_error "Config validation failed" echo "$validation_output" # This contains error message exit 1 fi # Parse the two-line output: first line is modified config, second is service name modified_config=$(echo "$validation_output" | head -n 1) service_name=$(echo "$validation_output" | tail -n 1) echo "=== Monitoring Service: $service_name ===" else echo "=== Monitoring All Services ===" fi # Check prerequisites check_prerequisites # Run health check if ! run_health_check "$service_name"; then exit 1 fi echo "" print_success "Monitoring completed!" } scan_server_security() { local server_url="$1" local analyzers="${2:-yara}" local api_key="${3:-}" local headers="${4:-}" if [ -z "$server_url" ]; then print_error "Usage: $0 scan [analyzers] [api-key] [headers]" print_error "Example: $0 scan https://mcp.deepwki.com/mcp" print_error "Example: $0 scan https://mcp.deepwki.com/mcp yara,llm" print_error "Example: $0 scan https://mcp.deepwki.com/mcp yara,llm \$MCP_SCANNER_LLM_API_KEY" print_error "Example: $0 scan https://mcp.deepwki.com/mcp yara '' '{\"X-Authorization\": \"token123\"}'" print_error "" print_error "Note: For LLM analyzer, set MCP_SCANNER_LLM_API_KEY environment variable" print_error " or pass API key as third argument" print_error "Note: For custom headers, pass JSON string as fourth argument" exit 1 fi echo "=== Security Scan: $server_url ===" # Check if LLM analyzer is requested and API key is available if [[ "$analyzers" == *"llm"* ]]; then # Check both environment variable and CLI argument local key_to_check="${api_key:-$MCP_SCANNER_LLM_API_KEY}" if [ -z "$key_to_check" ] || [[ "$key_to_check" == *"your_"* ]] || [[ "$key_to_check" == *"placeholder"* ]]; then echo "" print_error "LLM analyzer requested but MCP_SCANNER_LLM_API_KEY is not configured" print_info "Current value: ${MCP_SCANNER_LLM_API_KEY:-}" print_info "" print_info "Options:" print_info " 1. Add real API key to .env file: MCP_SCANNER_LLM_API_KEY=sk-..." print_info " 2. Set environment variable: export MCP_SCANNER_LLM_API_KEY=sk-..." print_info " 3. Pass API key as argument: $0 scan $server_url $analyzers sk-your-key" print_info " 4. Use only YARA analyzer: $0 scan $server_url yara" return 1 fi fi # Build command local cmd="cd \"$PROJECT_ROOT\" && uv run cli/mcp_security_scanner.py --server-url \"$server_url\" --analyzers \"$analyzers\"" # Add API key if provided if [ -n "$api_key" ]; then cmd="$cmd --api-key \"$api_key\"" fi # Add headers if provided if [ -n "$headers" ]; then cmd="$cmd --headers '$headers'" fi print_info "Running security scan..." print_info "Analyzers: $analyzers" # Run scan and capture exit code if eval "$cmd"; then print_success "Security scan completed - Server is SAFE" return 0 else local exit_code=$? if [ $exit_code -eq 1 ]; then print_error "Security scan completed - Server is UNSAFE (has critical or high severity issues)" else print_error "Security scan failed with error code $exit_code" fi return $exit_code fi } show_usage() { echo "Usage: $0 {add|delete|monitor|test|scan|add-to-groups|remove-from-groups|create-group|delete-group|list-groups} [args...]" echo "" echo "Service Commands:" echo " add [analyzers] - Add a service using JSON config and verify registration" echo " analyzers: yara (default), llm, or yara,llm" echo " delete - Delete a service by path and name" echo " monitor [config-file] - Run health check (all services or specific service from config)" echo " test - Test service searchability using intelligent_tool_finder" echo " scan [analyzers] [api-key] - Run security scan on MCP server" echo " analyzers: yara (default), llm, or yara,llm" echo "" echo "Server-to-Group Commands:" echo " add-to-groups - Add server to specific scopes groups (comma-separated)" echo " remove-from-groups - Remove server from specific scopes groups (comma-separated)" echo "" echo "Group Management Commands:" echo " create-group [description] - Create a new group in Keycloak and scopes.yml" echo " delete-group - Delete a group from Keycloak and scopes.yml" echo " list-groups - List all groups with synchronization status" echo "" echo "Config File Requirements:" echo " Required fields: server_name, path, proxy_pass_url" echo " Optional fields: description, tags, num_tools, license," echo " auth_provider, auth_scheme, supported_transports, headers, tool_list" echo " Constraints:" echo " - path must start with '/' and be more than just '/'" echo " - proxy_pass_url must start with http:// or https://" echo " - server_name must be non-empty string" echo " - tags must be array of strings" echo " - num_tools must be a non-negative integer" echo " - supported_transports must be array of strings" echo " - headers must be array of objects" echo " - tool_list must be array of objects" echo "" echo "Examples:" echo " # Service operations" echo " $0 add cli/examples/example-server-config.json # Add with default YARA analyzer" echo " export MCP_SCANNER_LLM_API_KEY=sk-..." echo " $0 add cli/examples/example-server-config.json yara,llm # Add with both analyzers" echo " $0 add cli/examples/example-server-config.json llm # Add with only LLM analyzer" echo " $0 delete /example-server example-server" echo " $0 monitor # All services" echo " $0 monitor cli/examples/example-server-config.json # Specific service" echo " $0 test cli/examples/example-server-config.json # Test searchability" echo "" echo " # Security scanning" echo " $0 scan https://mcp.deepwki.com/mcp # Security scan with default YARA" echo " export MCP_SCANNER_LLM_API_KEY=sk-..." echo " $0 scan https://mcp.deepwki.com/mcp yara,llm # Scan with both analyzers (uses env var)" echo " $0 scan https://mcp.deepwki.com/mcp llm sk-... # Scan with only LLM (pass API key directly)" echo " $0 scan https://mcp.deepwki.com/mcp yara '' '{\"X-Authorization\": \"token\"}' # Scan with custom headers" echo "" echo " # Server-to-group operations" echo " $0 add-to-groups example-server 'mcp-servers-restricted/read,mcp-servers-restricted/execute'" echo " $0 remove-from-groups example-server 'mcp-servers-restricted/read,mcp-servers-restricted/execute'" echo "" echo " # Group management operations" echo " $0 create-group mcp-servers-finance/read 'Finance team read access'" echo " $0 delete-group mcp-servers-finance/read" echo " $0 list-groups" } add_to_groups() { local server_name="$1" local groups="$2" if [ -z "$server_name" ] || [ -z "$groups" ]; then print_error "Usage: $0 add-to-groups " print_error "Example: $0 add-to-groups example-server 'mcp-servers-restricted/read,mcp-servers-restricted/execute'" exit 1 fi echo "=== Adding Server to Scopes Groups: $server_name ===" # Check prerequisites check_prerequisites # Convert comma-separated groups to JSON array format local groups_json groups_json=$(echo "$groups" | sed 's/,/","/g' | sed 's/^/"/' | sed 's/$/"/') groups_json="[$groups_json]" print_info "Adding server '$server_name' to groups: $groups" # Call the MCP tool local response if response=$(run_mcp_command "add_server_to_scopes_groups" "{\"server_name\": \"$server_name\", \"group_names\": $groups_json}"); then # Check if the response indicates success if echo "$response" | grep -q '"success": true'; then print_success "Server successfully added to groups" # Extract and display details local server_path server_path=$(echo "$response" | grep -o '"server_path": "[^"]*"' | cut -d'"' -f4) if [ -n "$server_path" ]; then print_info "Server path: $server_path" fi print_info "Groups: $groups" print_success "Scopes groups updated and auth server reloaded" else # Extract error message if available local error_msg error_msg=$(echo "$response" | grep -o '"error": "[^"]*"' | cut -d'"' -f4) if [ -n "$error_msg" ]; then print_error "Failed to add server to groups: $error_msg" else print_error "Failed to add server to groups (unknown error)" echo "Response: $response" fi exit 1 fi else print_error "Failed to call add_server_to_scopes_groups tool" exit 1 fi echo "" print_success "Add to groups operation completed!" } remove_from_groups() { local server_name="$1" local groups="$2" if [ -z "$server_name" ] || [ -z "$groups" ]; then print_error "Usage: $0 remove-from-groups " print_error "Example: $0 remove-from-groups example-server 'mcp-servers-restricted/read,mcp-servers-restricted/execute'" exit 1 fi echo "=== Removing Server from Scopes Groups: $server_name ===" # Check prerequisites check_prerequisites # Convert comma-separated groups to JSON array format local groups_json groups_json=$(echo "$groups" | sed 's/,/","/g' | sed 's/^/"/' | sed 's/$/"/') groups_json="[$groups_json]" print_info "Removing server '$server_name' from groups: $groups" # Call the MCP tool local response if response=$(run_mcp_command "remove_server_from_scopes_groups" "{\"server_name\": \"$server_name\", \"group_names\": $groups_json}"); then # Check if the response indicates success if echo "$response" | grep -q '"success": true'; then print_success "Server successfully removed from groups" # Extract and display details local server_path server_path=$(echo "$response" | grep -o '"server_path": "[^"]*"' | cut -d'"' -f4) if [ -n "$server_path" ]; then print_info "Server path: $server_path" fi print_info "Groups: $groups" print_success "Scopes groups updated and auth server reloaded" else # Extract error message if available local error_msg error_msg=$(echo "$response" | grep -o '"error": "[^"]*"' | cut -d'"' -f4) if [ -n "$error_msg" ]; then print_error "Failed to remove server from groups: $error_msg" else print_error "Failed to remove server from groups (unknown error)" echo "Response: $response" fi exit 1 fi else print_error "Failed to call remove_server_from_scopes_groups tool" exit 1 fi echo "" print_success "Remove from groups operation completed!" } create_group() { local group_name="$1" local description="${2:-}" if [ -z "$group_name" ]; then print_error "Group name is required" echo "Usage: $0 create-group [description]" exit 1 fi echo "=== Creating Group: $group_name ===" # Check prerequisites check_prerequisites # Prepare arguments for create_group MCP tool local args="{\"group_name\": \"$group_name\"" if [ -n "$description" ]; then # Escape description for JSON local escaped_desc=$(echo "$description" | sed 's/"/\\"/g') args="$args, \"description\": \"$escaped_desc\"" fi args="$args}" # Call create_group MCP tool if ! run_mcp_command "create_group" "$args" "Creating group '$group_name'"; then print_error "Failed to create group" exit 1 fi # Verify in scopes.yml (container) print_info "Verifying group in container scopes.yml..." if docker exec mcp-gateway-registry-auth-server-1 cat /app/scopes.yml | grep -q "^$group_name:"; then print_success "Group found in container scopes.yml" else print_error "Group NOT found in container scopes.yml" fi # Verify in scopes.yml (host) local host_scopes_file="$HOME/mcp-gateway/auth_server/scopes.yml" if [ -f "$host_scopes_file" ]; then print_info "Verifying group in host scopes.yml..." if grep -q "^$group_name:" "$host_scopes_file"; then print_success "Group found in host scopes.yml" else print_error "Group NOT found in host scopes.yml" fi fi echo "" print_success "Create group operation completed!" } delete_group() { local group_name="$1" if [ -z "$group_name" ]; then print_error "Group name is required" echo "Usage: $0 delete-group " exit 1 fi echo "=== Deleting Group: $group_name ===" # Check prerequisites check_prerequisites # Prepare arguments for delete_group MCP tool local args="{\"group_name\": \"$group_name\"}" # Call delete_group MCP tool if ! run_mcp_command "delete_group" "$args" "Deleting group '$group_name'"; then print_error "Failed to delete group" exit 1 fi # Verify removal from scopes.yml (container) print_info "Verifying group removal from container scopes.yml..." if docker exec mcp-gateway-registry-auth-server-1 cat /app/scopes.yml | grep -q "^$group_name:"; then print_error "Group still found in container scopes.yml" else print_success "Group removed from container scopes.yml" fi # Verify removal from scopes.yml (host) local host_scopes_file="$HOME/mcp-gateway/auth_server/scopes.yml" if [ -f "$host_scopes_file" ]; then print_info "Verifying group removal from host scopes.yml..." if grep -q "^$group_name:" "$host_scopes_file"; then print_error "Group still found in host scopes.yml" else print_success "Group removed from host scopes.yml" fi fi echo "" print_success "Delete group operation completed!" } list_groups() { echo "=== Listing All Groups ===" # Check prerequisites check_prerequisites # Call list_groups MCP tool local args="{}" print_info "Fetching groups from Keycloak and scopes.yml..." if output=$(cd "$PROJECT_ROOT" && uv run cli/mcp_client.py --url "${GATEWAY_URL}/mcpgw/mcp" call --tool list_groups --args "$args" 2>&1); then print_success "Groups retrieved successfully" echo "" echo "$output" else print_error "Failed to list groups" echo "$output" exit 1 fi echo "" print_success "List groups operation completed!" } # Main script logic case "${1:-}" in add) add_service "$2" "$3" ;; delete) delete_service "$2" "$3" ;; monitor) monitor_services "$2" ;; test) test_service "$2" ;; scan) scan_server_security "$2" "$3" "$4" "$5" ;; add-to-groups) add_to_groups "$2" "$3" ;; remove-from-groups) remove_from_groups "$2" "$3" ;; create-group) create_group "$2" "$3" ;; delete-group) delete_group "$2" ;; list-groups) list_groups ;; *) show_usage exit 1 ;; esac ================================================ FILE: terraform/aws-ecs/scripts/user_mgmt.sh ================================================ #!/bin/bash # User Management Script for MCP Gateway Registry # This script manages both M2M (machine-to-machine) service accounts and human users set -e # Configuration SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TERRAFORM_OUTPUTS="$SCRIPT_DIR/terraform-outputs.json" # Load configuration from environment variables or terraform outputs load_config() { # Try environment variables first, then terraform outputs # ADMIN_URL / KEYCLOAK_URL if [ -z "$ADMIN_URL" ]; then if [ -f "$TERRAFORM_OUTPUTS" ] && command -v jq &> /dev/null; then ADMIN_URL=$(jq -r '.keycloak_url.value // empty' "$TERRAFORM_OUTPUTS" 2>/dev/null) fi fi ADMIN_URL="${ADMIN_URL:-http://localhost:8080}" # REALM REALM="${REALM:-mcp-gateway}" # ADMIN_USER ADMIN_USER="${ADMIN_USER:-admin}" # ADMIN_PASSWORD - try env var, then SSM if AWS CLI available ADMIN_PASS="${KEYCLOAK_ADMIN_PASSWORD}" if [ -z "$ADMIN_PASS" ] && command -v aws &> /dev/null; then ADMIN_PASS=$(aws ssm get-parameter --name "/keycloak/admin_password" --with-decryption --query 'Parameter.Value' --output text --region "${AWS_REGION:-us-west-2}" 2>/dev/null || echo "") fi # OAuth tokens directory OAUTH_TOKENS_DIR="${OAUTH_TOKENS_DIR:-$SCRIPT_DIR/../.oauth-tokens}" CLIENT_SECRETS_FILE="$OAUTH_TOKENS_DIR/keycloak-client-secrets.txt" } # Load configuration on script start load_config # Colors for output GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # Usage function usage() { echo "Usage: $0 {create-m2m|create-human|delete-user|list-users|list-groups} [OPTIONS]" echo "" echo "Commands:" echo " create-m2m - Create M2M service account for machine-to-machine authentication" echo " create-human - Create human user with Keycloak login capabilities" echo " delete-user - Delete a user (M2M or human)" echo " list-users - List all users in the realm" echo " list-groups - List all available groups" echo "" echo "M2M Service Account Options:" echo " -n, --name NAME - Service account name (required)" echo " -g, --groups GROUPS - Comma-separated list of groups (required)" echo " -d, --description DESC - Description of the service account" echo "" echo "Human User Options:" echo " -u, --username USERNAME - Username (required)" echo " -e, --email EMAIL - Email address (required)" echo " -f, --firstname NAME - First name (required)" echo " -l, --lastname NAME - Last name (required)" echo " -g, --groups GROUPS - Comma-separated list of groups (required)" echo " -p, --password PASS - Initial password (optional, will prompt if not provided)" echo "" echo "Delete User Options:" echo " -u, --username USERNAME - Username to delete (required)" echo "" echo "Examples:" echo " # Create M2M service account" echo " $0 create-m2m --name agent-finance-bot --groups 'mcp-servers-finance/read,mcp-servers-finance/execute'" echo "" echo " # Create human user" echo " $0 create-human --username jdoe --email jdoe@example.com --firstname John --lastname Doe --groups 'mcp-servers-restricted/read'" echo "" echo " # Delete user" echo " $0 delete-user --username agent-finance-bot" echo "" echo " # List all users" echo " $0 list-users" echo "" echo " # List all groups" echo " $0 list-groups" } # Function to get admin token get_admin_token() { if [ -z "$ADMIN_PASS" ]; then echo -e "${RED}Error: KEYCLOAK_ADMIN_PASSWORD environment variable is required${NC}" echo "Please set it before running this script:" echo "export KEYCLOAK_ADMIN_PASSWORD=\"your-secure-password\"" exit 1 fi TOKEN=$(curl -s -X POST "$ADMIN_URL/realms/master/protocol/openid-connect/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "username=$ADMIN_USER" \ -d "password=$ADMIN_PASS" \ -d "grant_type=password" \ -d "client_id=admin-cli" | jq -r '.access_token // empty') if [ -z "$TOKEN" ]; then echo -e "${RED}Failed to get admin token${NC}" exit 1 fi } # Function to list all groups list_groups() { echo -e "${BLUE}Listing all groups in realm '$REALM'${NC}" echo "==============================================" get_admin_token GROUPS=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/groups" 2>/dev/null) echo "$GROUPS" | jq -r 'if type == "array" then (.[] | "\(.name) (ID: \(.id))") else empty end' 2>/dev/null echo "" echo -e "${GREEN}Total groups: $(echo "$GROUPS" | jq 'if type == "array" then (. | length) else 0 end' 2>/dev/null)${NC}" } # Function to list all users list_users() { echo -e "${BLUE}Listing all users in realm '$REALM'${NC}" echo "==============================================" get_admin_token USERS=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/users") echo "$USERS" | jq -r '.[] | "Username: \(.username), Email: \(.email // "N/A"), Enabled: \(.enabled), ID: \(.id)"' echo "" echo -e "${GREEN}Total users: $(echo "$USERS" | jq '. | length')${NC}" } # Function to check if group exists check_group_exists() { local group_name="$1" GROUP_ID=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/groups" 2>/dev/null | \ jq -r "if type == \"array\" then (.[] | select(.name==\"$group_name\") | .id) else empty end" 2>/dev/null) if [ -z "$GROUP_ID" ] || [ "$GROUP_ID" = "null" ]; then return 1 fi return 0 } # Function to validate groups validate_groups() { local groups_input="$1" IFS=',' read -ra GROUPS_ARRAY <<< "$groups_input" local invalid_groups=() for group in "${GROUPS_ARRAY[@]}"; do group=$(echo "$group" | xargs) # trim whitespace if ! check_group_exists "$group"; then invalid_groups+=("$group") fi done if [ ${#invalid_groups[@]} -gt 0 ]; then echo -e "${RED}Error: The following groups do not exist:${NC}" for group in "${invalid_groups[@]}"; do echo " - $group" done echo "" echo -e "${YELLOW}Available groups:${NC}" curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/groups" 2>/dev/null | \ jq -r 'if type == "array" then (.[].name) else empty end' 2>/dev/null | sed 's/^/ - /' return 1 fi return 0 } # Function to create M2M client create_m2m_client() { local client_id="$1" local description="$2" echo "Creating M2M client: $client_id" # Check if client already exists EXISTING_CLIENT=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/clients?clientId=$client_id" | \ jq -r '.[0].id // empty') if [ -n "$EXISTING_CLIENT" ]; then echo -e "${YELLOW}Client '$client_id' already exists, using existing client${NC}" CLIENT_UUID="$EXISTING_CLIENT" return 0 fi # Create the client CLIENT_JSON="{ \"clientId\": \"$client_id\", \"name\": \"$client_id\", \"description\": \"$description\", \"enabled\": true, \"clientAuthenticatorType\": \"client-secret\", \"serviceAccountsEnabled\": true, \"standardFlowEnabled\": false, \"directAccessGrantsEnabled\": false, \"publicClient\": false, \"protocol\": \"openid-connect\" }" RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$ADMIN_URL/admin/realms/$REALM/clients" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "$CLIENT_JSON") if [ "$RESPONSE" = "201" ]; then echo -e "${GREEN}✓ M2M client created successfully${NC}" # Get the client UUID CLIENT_UUID=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/clients?clientId=$client_id" | \ jq -r '.[0].id') echo "Client UUID: $CLIENT_UUID" else echo -e "${RED}Failed to create M2M client. HTTP: $RESPONSE${NC}" exit 1 fi } # Function to get client secret get_client_secret() { local client_uuid="$1" CLIENT_SECRET=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/clients/$client_uuid/client-secret" | \ jq -r '.value') if [ -z "$CLIENT_SECRET" ] || [ "$CLIENT_SECRET" = "null" ]; then echo -e "${RED}Failed to retrieve client secret${NC}" exit 1 fi } # Function to add groups mapper to client add_groups_mapper() { local client_uuid="$1" echo "Adding groups mapper to client..." # Check if groups mapper already exists EXISTING_MAPPER=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/clients/$client_uuid/protocol-mappers/models" 2>/dev/null | \ jq -r 'if type == "array" then (.[] | select(.name=="groups") | .id) else empty end' 2>/dev/null) if [ -n "$EXISTING_MAPPER" ] && [ "$EXISTING_MAPPER" != "null" ]; then echo -e "${GREEN}✓ Groups mapper already exists${NC}" return 0 fi GROUPS_MAPPER='{ "name": "groups", "protocol": "openid-connect", "protocolMapper": "oidc-group-membership-mapper", "consentRequired": false, "config": { "full.path": "false", "id.token.claim": "true", "access.token.claim": "true", "claim.name": "groups", "userinfo.token.claim": "true" } }' RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$ADMIN_URL/admin/realms/$REALM/clients/$client_uuid/protocol-mappers/models" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "$GROUPS_MAPPER") if [ "$RESPONSE" = "201" ] || [ "$RESPONSE" = "409" ]; then echo -e "${GREEN}✓ Groups mapper configured${NC}" else echo -e "${RED}Failed to add groups mapper. HTTP: $RESPONSE${NC}" exit 1 fi } # Function to get service account user ID get_service_account_user() { local client_uuid="$1" SERVICE_ACCOUNT_USER=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/clients/$client_uuid/service-account-user" | \ jq -r '.id') if [ -z "$SERVICE_ACCOUNT_USER" ] || [ "$SERVICE_ACCOUNT_USER" = "null" ]; then echo -e "${RED}Failed to retrieve service account user${NC}" exit 1 fi } # Function to assign user to groups assign_user_to_groups() { local user_id="$1" local groups_input="$2" IFS=',' read -ra GROUPS_ARRAY <<< "$groups_input" for group in "${GROUPS_ARRAY[@]}"; do group=$(echo "$group" | xargs) # trim whitespace # Get group ID GROUP_ID=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/groups" | \ jq -r ".[] | select(.name==\"$group\") | .id") if [ -z "$GROUP_ID" ] || [ "$GROUP_ID" = "null" ]; then echo -e "${RED}Group '$group' not found${NC}" continue fi # Assign to group RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ -X PUT "$ADMIN_URL/admin/realms/$REALM/users/$user_id/groups/$GROUP_ID" \ -H "Authorization: Bearer $TOKEN") if [ "$RESPONSE" = "204" ]; then echo -e "${GREEN}✓ Assigned to group: $group${NC}" else echo -e "${RED}Failed to assign to group '$group'. HTTP: $RESPONSE${NC}" fi done } # Function to refresh all credentials using get-all-client-credentials.sh refresh_all_credentials() { echo "Refreshing all client credentials..." # Try to find the script in multiple locations local script_locations=( "$SCRIPT_DIR/../../../keycloak/setup/get-all-client-credentials.sh" "$SCRIPT_DIR/../../keycloak/setup/get-all-client-credentials.sh" "$SCRIPT_DIR/../keycloak/setup/get-all-client-credentials.sh" ) local script_found=false for script_path in "${script_locations[@]}"; do if [ -f "$script_path" ]; then local script_dir=$(dirname "$script_path") # Export environment variables for the subshell export KEYCLOAK_ADMIN_URL="$ADMIN_URL" export KEYCLOAK_REALM="$REALM" export KEYCLOAK_ADMIN="$ADMIN_USER" export KEYCLOAK_ADMIN_PASSWORD="$ADMIN_PASS" (cd "$script_dir" && ./get-all-client-credentials.sh) echo -e "${GREEN}✓ All credentials refreshed${NC}" script_found=true break fi done if [ "$script_found" = false ]; then echo -e "${YELLOW}Warning: get-all-client-credentials.sh not found, skipping credential refresh${NC}" echo "Credentials will need to be manually retrieved from Keycloak" fi } # Function to generate access token for M2M client generate_access_token() { local client_id="$1" echo "Generating access token for: $client_id" # Try to find the script in multiple locations local script_locations=( "$SCRIPT_DIR/../../../keycloak/setup/generate-agent-token.sh" "$SCRIPT_DIR/../../keycloak/setup/generate-agent-token.sh" "$SCRIPT_DIR/../keycloak/setup/generate-agent-token.sh" ) local script_found=false for script_path in "${script_locations[@]}"; do if [ -f "$script_path" ]; then local script_dir=$(dirname "$script_path") # Export environment variables for the subshell export KEYCLOAK_ADMIN_URL="$ADMIN_URL" export KEYCLOAK_REALM="$REALM" export KEYCLOAK_ADMIN="$ADMIN_USER" export KEYCLOAK_ADMIN_PASSWORD="$ADMIN_PASS" (cd "$script_dir" && ./generate-agent-token.sh "$client_id") echo -e "${GREEN}✓ Access token generated${NC}" script_found=true break fi done if [ "$script_found" = false ]; then echo -e "${YELLOW}Warning: generate-agent-token.sh not found, skipping token generation${NC}" echo "Token will need to be manually generated" fi } # Function to create M2M service account create_m2m_account() { local name="" local groups="" local description="" # Parse arguments while [[ $# -gt 0 ]]; do case $1 in -n|--name) name="$2" shift 2 ;; -g|--groups) groups="$2" shift 2 ;; -d|--description) description="$2" shift 2 ;; *) echo -e "${RED}Unknown option: $1${NC}" usage exit 1 ;; esac done # Validate required parameters if [ -z "$name" ]; then echo -e "${RED}Error: Service account name is required${NC}" usage exit 1 fi if [ -z "$groups" ]; then echo -e "${RED}Error: Groups are required${NC}" usage exit 1 fi if [ -z "$description" ]; then description="M2M service account for $name" fi CLIENT_ID="$name" echo -e "${BLUE}Creating M2M Service Account${NC}" echo "==============================================" echo "Name: $name" echo "Groups: $groups" echo "Description: $description" echo "" # Get admin token get_admin_token # Validate groups if ! validate_groups "$groups"; then exit 1 fi # Create M2M client create_m2m_client "$CLIENT_ID" "$description" # Add groups mapper add_groups_mapper "$CLIENT_UUID" # Get service account user get_service_account_user "$CLIENT_UUID" # Assign to groups assign_user_to_groups "$SERVICE_ACCOUNT_USER" "$groups" # Get client secret get_client_secret "$CLIENT_UUID" # Refresh all credentials using the existing script echo "" refresh_all_credentials # Generate access token and .env file echo "" generate_access_token "$CLIENT_ID" echo "" echo -e "${GREEN}SUCCESS! M2M service account created${NC}" echo "==============================================" echo "Client ID: $CLIENT_ID" echo "Client Secret: $CLIENT_SECRET" echo "Groups: $groups" echo "" echo -e "${YELLOW}Credentials saved to:${NC}" echo " $OAUTH_TOKENS_DIR/${CLIENT_ID}.json (client credentials)" echo " $OAUTH_TOKENS_DIR/${CLIENT_ID}-token.json (access token)" echo " $OAUTH_TOKENS_DIR/${CLIENT_ID}.env (environment variables)" echo " $OAUTH_TOKENS_DIR/keycloak-client-secrets.txt (all client secrets)" echo "" echo -e "${YELLOW}Test the account:${NC}" echo "curl -X POST '$ADMIN_URL/realms/$REALM/protocol/openid-connect/token' \\" echo " -H 'Content-Type: application/x-www-form-urlencoded' \\" echo " -d 'grant_type=client_credentials' \\" echo " -d 'client_id=$CLIENT_ID' \\" echo " -d 'client_secret=$CLIENT_SECRET'" } # Function to create human user create_human_user() { local username="" local email="" local firstname="" local lastname="" local groups="" local password="" # Parse arguments while [[ $# -gt 0 ]]; do case $1 in -u|--username) username="$2" shift 2 ;; -e|--email) email="$2" shift 2 ;; -f|--firstname) firstname="$2" shift 2 ;; -l|--lastname) lastname="$2" shift 2 ;; -g|--groups) groups="$2" shift 2 ;; -p|--password) password="$2" shift 2 ;; *) echo -e "${RED}Unknown option: $1${NC}" usage exit 1 ;; esac done # Validate required parameters if [ -z "$username" ]; then echo -e "${RED}Error: Username is required${NC}" usage exit 1 fi if [ -z "$email" ]; then echo -e "${RED}Error: Email is required${NC}" usage exit 1 fi if [ -z "$firstname" ]; then echo -e "${RED}Error: First name is required${NC}" usage exit 1 fi if [ -z "$lastname" ]; then echo -e "${RED}Error: Last name is required${NC}" usage exit 1 fi if [ -z "$groups" ]; then echo -e "${RED}Error: Groups are required${NC}" usage exit 1 fi # Prompt for password if not provided if [ -z "$password" ]; then echo -n "Enter password for user: " read -s password echo "" echo -n "Confirm password: " read -s password_confirm echo "" if [ "$password" != "$password_confirm" ]; then echo -e "${RED}Error: Passwords do not match${NC}" exit 1 fi fi echo -e "${BLUE}Creating Human User${NC}" echo "==============================================" echo "Username: $username" echo "Email: $email" echo "Name: $firstname $lastname" echo "Groups: $groups" echo "" # Get admin token get_admin_token # Validate groups if ! validate_groups "$groups"; then exit 1 fi # Check if user already exists EXISTING_USER=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/users?username=$username" | \ jq -r '.[0].id // empty') if [ -n "$EXISTING_USER" ]; then echo -e "${RED}Error: User '$username' already exists${NC}" exit 1 fi # Create user USER_JSON="{ \"username\": \"$username\", \"email\": \"$email\", \"firstName\": \"$firstname\", \"lastName\": \"$lastname\", \"enabled\": true, \"emailVerified\": true, \"credentials\": [{ \"type\": \"password\", \"value\": \"$password\", \"temporary\": false }] }" RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$ADMIN_URL/admin/realms/$REALM/users" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "$USER_JSON") if [ "$RESPONSE" = "201" ]; then echo -e "${GREEN}✓ User created successfully${NC}" # Get the user ID USER_ID=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/users?username=$username" | \ jq -r '.[0].id') echo "User ID: $USER_ID" # Assign to groups assign_user_to_groups "$USER_ID" "$groups" echo "" echo -e "${GREEN}SUCCESS! Human user created${NC}" echo "==============================================" echo "Username: $username" echo "Email: $email" echo "Groups: $groups" echo "" echo -e "${YELLOW}User can login to Keycloak at:${NC}" echo "$ADMIN_URL/realms/$REALM/account" echo "" echo -e "${YELLOW}Or authenticate via API:${NC}" echo "curl -X POST '$ADMIN_URL/realms/$REALM/protocol/openid-connect/token' \\" echo " -H 'Content-Type: application/x-www-form-urlencoded' \\" echo " -d 'grant_type=password' \\" echo " -d 'client_id=mcp-gateway-m2m' \\" echo " -d 'username=$username' \\" echo " -d 'password=YOUR_PASSWORD'" else echo -e "${RED}Failed to create user. HTTP: $RESPONSE${NC}" exit 1 fi } # Function to delete user delete_user() { local username="" # Parse arguments while [[ $# -gt 0 ]]; do case $1 in -u|--username) username="$2" shift 2 ;; *) echo -e "${RED}Unknown option: $1${NC}" usage exit 1 ;; esac done # Validate required parameters if [ -z "$username" ]; then echo -e "${RED}Error: Username is required${NC}" usage exit 1 fi echo -e "${BLUE}Deleting User${NC}" echo "==============================================" echo "Username: $username" echo "" # Get admin token get_admin_token # Find user USER_ID=$(curl -s -H "Authorization: Bearer $TOKEN" \ "$ADMIN_URL/admin/realms/$REALM/users?username=$username" | \ jq -r '.[0].id // empty') if [ -z "$USER_ID" ]; then echo -e "${RED}Error: User '$username' not found${NC}" exit 1 fi # Delete user RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \ -X DELETE "$ADMIN_URL/admin/realms/$REALM/users/$USER_ID" \ -H "Authorization: Bearer $TOKEN") if [ "$RESPONSE" = "204" ]; then echo -e "${GREEN}✓ User deleted successfully${NC}" # Refresh all credentials to update files echo "" refresh_all_credentials echo "" echo -e "${GREEN}✓ Credential files updated${NC}" else echo -e "${RED}Failed to delete user. HTTP: $RESPONSE${NC}" exit 1 fi } # Main execution main() { if [ $# -eq 0 ]; then usage exit 1 fi COMMAND=$1 shift case $COMMAND in create-m2m) create_m2m_account "$@" ;; create-human) create_human_user "$@" ;; delete-user) delete_user "$@" ;; list-users) list_users ;; list-groups) list_groups ;; -h|--help|help) usage exit 0 ;; *) echo -e "${RED}Unknown command: $COMMAND${NC}" usage exit 1 ;; esac } # Run main function main "$@" ================================================ FILE: terraform/aws-ecs/scripts/view-cloudwatch-logs.sh ================================================ #!/bin/bash ################################################################################ # View CloudWatch Logs for ECS Tasks # # This script: # 1. Reads Terraform outputs to find ECS log groups # 2. Displays logs from the last N minutes # 3. Supports live tailing with --follow flag # 4. Supports filtering by component (keycloak, registry, auth-server, alb) # # Usage: # ./scripts/view-cloudwatch-logs.sh [OPTIONS] # # Options: # --minutes N Number of minutes to look back (default: 30) # --follow Follow logs in real-time (like tail -f) # --component COMP View logs for specific component: # keycloak, registry, auth-server, all (default: all) # --start-time TIME Start time (format: 2024-01-15T10:00:00Z) # --end-time TIME End time (format: 2024-01-15T10:30:00Z) # --filter PATTERN Filter logs by pattern (regex) # --help Show this help message # # Examples: # # View logs from last 30 minutes for all components # ./scripts/view-cloudwatch-logs.sh # # # Follow Keycloak logs in real-time # ./scripts/view-cloudwatch-logs.sh --component keycloak --follow # # # View registry logs from last 5 minutes # ./scripts/view-cloudwatch-logs.sh --component registry --minutes 5 # # # View logs with pattern filter # ./scripts/view-cloudwatch-logs.sh --filter "ERROR" # # # View auth-server logs excluding health checks (default) # ./scripts/view-cloudwatch-logs.sh --component auth-server # # # View auth-server logs including health check logs # ./scripts/view-cloudwatch-logs.sh --component auth-server --include-health # ################################################################################ set -euo pipefail # Colors BLUE='\033[0;34m' GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' CYAN='\033[0;36m' NC='\033[0m' # Configuration MINUTES=30 FOLLOW=false COMPONENT="all" FILTER_PATTERN="" EXCLUDE_HEALTH_CHECKS=true START_TIME="" END_TIME="" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" TERRAFORM_DIR="$REPO_ROOT/terraform/aws-ecs" OUTPUTS_FILE="$SCRIPT_DIR/terraform-outputs.json" # Log groups mapping - will be populated dynamically declare -A LOG_GROUPS=() log_info() { echo -e "${BLUE}[INFO]${NC} $*" } log_success() { echo -e "${GREEN}[SUCCESS]${NC} $*" } log_warning() { echo -e "${YELLOW}[WARNING]${NC} $*" } log_error() { echo -e "${RED}[ERROR]${NC} $*" } log_component() { echo -e "${CYAN}[$1]${NC} $2" } show_help() { grep '^#' "$0" | tail -n +2 | sed 's/^# //' | sed 's/^#//' exit 0 } _discover_ecs_log_groups() { log_info "Discovering ECS services and log groups..." # Get all log groups matching ECS patterns local ecs_logs=$(aws logs describe-log-groups \ --log-group-name-prefix "/ecs/" \ --region "$AWS_REGION" \ --query 'logGroups[*].logGroupName' \ --output text 2>/dev/null || true) if [[ -z "$ecs_logs" ]]; then ecs_logs=$(aws logs describe-log-groups \ --log-group-name-prefix "/aws/ecs/" \ --region "$AWS_REGION" \ --query 'logGroups[*].logGroupName' \ --output text 2>/dev/null || true) fi # Also add ALB logs local alb_logs=$(aws logs describe-log-groups \ --log-group-name-prefix "/aws/alb" \ --region "$AWS_REGION" \ --query 'logGroups[*].logGroupName' \ --output text 2>/dev/null || true) # Combine all logs local all_logs="$ecs_logs $alb_logs" # Populate the LOG_GROUPS array for log_group in $all_logs; do # Extract service name from log group local service_name=$(basename "$log_group") # Clean up common prefixes service_name=$(echo "$service_name" | sed 's/^mcp-gateway-v2-//' | sed 's/^mcp-gateway-//') service_name=$(echo "$service_name" | sed 's/-server$//' | sed 's/-init$//') # Use full name if empty after cleanup if [[ -z "$service_name" ]]; then service_name=$(basename "$log_group") fi LOG_GROUPS[$service_name]="$log_group" done if [[ ${#LOG_GROUPS[@]} -eq 0 ]]; then log_warning "No ECS log groups found in region $AWS_REGION" return 1 fi log_success "Found ${#LOG_GROUPS[@]} log groups" # Debug: Show discovered components log_info "Available components: ${!LOG_GROUPS[@]}" } _validate_outputs_file() { if [[ ! -f "$OUTPUTS_FILE" ]]; then log_warning "Terraform outputs file not found: $OUTPUTS_FILE" log_info "Discovering services from AWS instead..." return 1 fi return 0 } _get_log_groups() { local comp="$1" if [[ "$comp" == "all" ]]; then echo "${!LOG_GROUPS[@]}" else if [[ -z "${LOG_GROUPS[$comp]:-}" ]]; then log_error "Unknown component: $comp" >&2 log_info "Available components: ${!LOG_GROUPS[@]}" >&2 return 1 fi echo "$comp" fi } _calculate_start_time() { if [[ -n "$START_TIME" ]]; then echo "$START_TIME" else # Calculate timestamp from N minutes ago if command -v date &> /dev/null; then if [[ "$OSTYPE" == "darwin"* ]]; then # macOS date -u -v-${MINUTES}M +%s000 else # Linux date -u -d "$MINUTES minutes ago" +%s000 fi fi fi } _calculate_end_time() { if [[ -n "$END_TIME" ]]; then echo "$END_TIME" else # Current timestamp in milliseconds if command -v date &> /dev/null; then date -u +%s000 fi fi } _check_log_group_exists() { local log_group="$1" if aws logs describe-log-groups \ --log-group-name-prefix "$log_group" \ --region "$AWS_REGION" \ &>/dev/null; then return 0 else return 1 fi } _should_exclude_log() { local message="$1" # Exclude health check logs if [[ "$EXCLUDE_HEALTH_CHECKS" == "true" ]]; then # Health check patterns to exclude if [[ "$message" =~ GET\ /health\ HTTP ]]; then return 0 # Should exclude fi fi return 1 # Don't exclude } _tail_logs() { local log_group="$1" local follow="${2:-false}" log_component "$log_group" "Fetching logs..." # Check if log group exists if ! _check_log_group_exists "$log_group"; then log_warning "Log group not found: $log_group" return 1 fi if [[ "$follow" == "true" ]]; then # Real-time tailing aws logs tail "$log_group" \ --follow \ --since "${MINUTES}m" \ --region "$AWS_REGION" \ $(if [[ -n "$FILTER_PATTERN" ]]; then echo "--filter-pattern $FILTER_PATTERN"; fi) \ 2>&1 | while read -r message; do if ! _should_exclude_log "$message"; then echo "$message" fi done else # Display logs from the past N minutes local start_time=$(_calculate_start_time) local end_time=$(_calculate_end_time) aws logs filter-log-events \ --log-group-name "$log_group" \ --start-time "$start_time" \ --end-time "$end_time" \ --region "$AWS_REGION" \ $(if [[ -n "$FILTER_PATTERN" ]]; then echo "--filter-pattern $FILTER_PATTERN"; fi) \ --query 'events[*].[timestamp, message]' \ --output text \ 2>/dev/null | while read -r timestamp message; do if [[ -n "$timestamp" && -n "$message" ]]; then # Skip health check logs if enabled if _should_exclude_log "$message"; then continue fi # Convert timestamp from milliseconds to readable format if command -v date &> /dev/null; then if [[ "$OSTYPE" == "darwin"* ]]; then formatted_time=$(date -u -r $((timestamp / 1000)) +"%Y-%m-%d %H:%M:%S") else formatted_time=$(date -u -d @$((timestamp / 1000)) +"%Y-%m-%d %H:%M:%S") fi else formatted_time=$(echo "scale=0; $timestamp / 1000" | bc) fi echo "[${formatted_time}] $message" fi done || true fi } _view_all_logs() { local follow="${1:-false}" local components # Get components and check for errors if ! components=$(_get_log_groups "$COMPONENT"); then exit 1 fi echo "" log_info "==========================================" log_info "CloudWatch Logs Viewer" log_info "==========================================" log_info "Components: $COMPONENT" log_info "Minutes back: $MINUTES" log_info "Follow mode: $follow" if [[ -n "$FILTER_PATTERN" ]]; then log_info "Filter pattern: $FILTER_PATTERN" fi log_info "==========================================" echo "" # If following, tail all logs concurrently if [[ "$follow" == "true" ]]; then # For follow mode, we'll tail each log group for comp in $components; do log_group="${LOG_GROUPS[$comp]}" echo "" echo "---[ $comp logs (live) ]---" _tail_logs "$log_group" "true" & done wait else # For non-follow mode, display logs sequentially for comp in $components; do log_group="${LOG_GROUPS[$comp]}" echo "" echo "---[ $comp logs ]---" _tail_logs "$log_group" "false" done fi echo "" log_success "==========================================" log_success "Log viewing complete" log_success "==========================================" } # Parse arguments while [[ $# -gt 0 ]]; do case $1 in --minutes) MINUTES="$2" shift 2 ;; --follow) FOLLOW=true shift ;; --component) COMPONENT="$2" shift 2 ;; --start-time) START_TIME="$2" shift 2 ;; --end-time) END_TIME="$2" shift 2 ;; --filter) FILTER_PATTERN="$2" shift 2 ;; --include-health) EXCLUDE_HEALTH_CHECKS=false shift ;; --help) show_help ;; *) log_error "Unknown option: $1" show_help ;; esac done # Validate inputs if ! [[ "$MINUTES" =~ ^[0-9]+$ ]]; then log_error "Minutes must be a number" exit 1 fi # Verify AWS CLI is available if ! command -v aws &> /dev/null; then log_error "AWS CLI is not installed or not in PATH" exit 1 fi # Check AWS_REGION is set if [[ -z "${AWS_REGION:-}" ]]; then log_error "AWS_REGION environment variable is not set" log_info "Please set AWS_REGION before running this script:" log_info " export AWS_REGION=us-east-1" exit 1 fi log_info "Using AWS region: $AWS_REGION" # Main execution # Always try discovery first (more reliable than outputs file) _discover_ecs_log_groups || { log_warning "Discovery from AWS failed, attempting to use Terraform outputs..." _validate_outputs_file || { log_error "Failed to discover ECS log groups and outputs file not found" exit 1 } } _view_all_logs "$FOLLOW" exit 0 ================================================ FILE: terraform/aws-ecs/scripts/view-logs.sh ================================================ #!/bin/bash # View CloudWatch logs for MCP Gateway services # Usage: ./scripts/view-logs.sh [service-name] [minutes] # # Examples: # ./scripts/view-logs.sh auth 5 # ./scripts/view-logs.sh registry 10 # ./scripts/view-logs.sh keycloak 15 # ./scripts/view-logs.sh opensearch 5 set -e SERVICE=${1:-registry} MINUTES=${2:-5} case "$SERVICE" in auth|auth-server) LOG_GROUP="/ecs/mcp-gateway-v2-auth-server" ;; registry) LOG_GROUP="/ecs/mcp-gateway-v2-registry" ;; keycloak|kc) LOG_GROUP="/ecs/keycloak" ;; opensearch|os) LOG_GROUP="/ecs/opensearch-cluster" ;; mcpgw|gateway) LOG_GROUP="/ecs/mcp-gateway-v2-mcpgw" ;; currenttime|ct) LOG_GROUP="/ecs/mcp-gateway-v2-currenttime" ;; realserver|rs|realserverfaketools) LOG_GROUP="/ecs/mcp-gateway-v2-realserverfaketools" ;; flight|flight-booking) LOG_GROUP="/ecs/mcp-gateway-v2-flight-booking-agent" ;; travel|travel-assistant) LOG_GROUP="/ecs/mcp-gateway-v2-travel-assistant-agent" ;; *) echo "Unknown service: $SERVICE" echo "" echo "Available services:" echo " auth, registry, keycloak, opensearch" echo " mcpgw, currenttime, realserver, flight, travel" exit 1 ;; esac echo "Viewing logs for $SERVICE (last $MINUTES minutes)..." echo "Log group: $LOG_GROUP" echo "" aws logs tail "$LOG_GROUP" --since "${MINUTES}m" --format short --follow ================================================ FILE: terraform/aws-ecs/secret-rotation-config.tf ================================================ # # Secret Rotation Configuration # # This file adds automatic rotation to existing secrets defined in: # - documentdb.tf: aws_secretsmanager_secret.documentdb_credentials # - keycloak-database.tf: aws_secretsmanager_secret.keycloak_db_secret # # Secrets are rotated every 30 days automatically by Lambda functions. # # # Enable Rotation for DocumentDB Credentials # resource "aws_secretsmanager_secret_rotation" "documentdb_credentials" { secret_id = aws_secretsmanager_secret.documentdb_credentials.id rotation_lambda_arn = aws_lambda_function.documentdb_rotation.arn rotation_rules { automatically_after_days = 30 } depends_on = [ aws_lambda_permission.documentdb_rotation, aws_secretsmanager_secret_version.documentdb_credentials ] } # # Enable Rotation for Keycloak Database Credentials # resource "aws_secretsmanager_secret_rotation" "keycloak_db_secret" { secret_id = aws_secretsmanager_secret.keycloak_db_secret.id rotation_lambda_arn = aws_lambda_function.rds_rotation.arn rotation_rules { automatically_after_days = 30 } depends_on = [ aws_lambda_permission.rds_rotation, aws_secretsmanager_secret_version.keycloak_db_secret ] } ================================================ FILE: terraform/aws-ecs/secret-rotation.tf ================================================ # # AWS Secrets Manager Rotation with Lambda Functions # # This configuration implements automatic password rotation for DocumentDB and RDS # using AWS Lambda functions. Secrets are rotated every 30 days automatically. # # Architecture: # - Lambda functions are deployed in VPC private subnets for database access # - IAM roles grant permissions to read/update secrets and modify databases # - CloudWatch Logs capture rotation execution logs for troubleshooting # - Lambda functions implement the 4-step AWS rotation process: # 1. createSecret: Generate new random password # 2. setSecret: Update database with new password # 3. testSecret: Verify new password works # 4. finishSecret: Promote new version to AWSCURRENT # # # IAM Role for Lambda Rotation Functions # resource "aws_iam_role" "rotation_lambda" { name = "${var.name}-secret-rotation-lambda" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } }] }) tags = local.common_tags } # # IAM Policy for Lambda to Rotate Secrets # #checkov:skip=CKV_AWS_290:GetRandomPassword and EC2 network interface actions require wildcard resource per AWS API design #checkov:skip=CKV_AWS_355:GetRandomPassword and EC2 network interface actions require wildcard resource per AWS API design resource "aws_iam_role_policy" "rotation_lambda" { name = "${var.name}-secret-rotation-policy" role = aws_iam_role.rotation_lambda.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "SecretsManagerAccess" Effect = "Allow" Action = [ "secretsmanager:DescribeSecret", "secretsmanager:GetSecretValue", "secretsmanager:PutSecretValue", "secretsmanager:UpdateSecretVersionStage" ] Resource = [ aws_secretsmanager_secret.documentdb_credentials.arn, aws_secretsmanager_secret.keycloak_db_secret.arn ] }, { Sid = "GenerateRandomPassword" Effect = "Allow" Action = [ "secretsmanager:GetRandomPassword" ] Resource = "*" }, { Sid = "KMSAccess" Effect = "Allow" Action = [ "kms:Decrypt", "kms:DescribeKey", "kms:GenerateDataKey" ] Resource = [ aws_kms_key.documentdb.arn, aws_kms_key.rds.arn ] }, { Sid = "RDSAccess" Effect = "Allow" Action = [ "rds:DescribeDBInstances", "rds:DescribeDBClusters", "rds:ModifyDBCluster" ] Resource = aws_rds_cluster.keycloak.arn }, { Sid = "DocumentDBAccess" Effect = "Allow" Action = [ "docdb:DescribeDBClusters", "docdb:ModifyDBCluster" ] Resource = aws_docdb_cluster.registry.arn }, { Sid = "VPCNetworkInterface" Effect = "Allow" Action = [ "ec2:CreateNetworkInterface", "ec2:DescribeNetworkInterfaces", "ec2:DeleteNetworkInterface", "ec2:AssignPrivateIpAddresses", "ec2:UnassignPrivateIpAddresses" ] Resource = "*" } ] }) } # # Attach Lambda VPC Execution Policy # resource "aws_iam_role_policy_attachment" "lambda_vpc_execution" { role = aws_iam_role.rotation_lambda.name policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" } # # Security Group for Lambda Functions # resource "aws_security_group" "rotation_lambda" { name = "${var.name}-rotation-lambda-sg" description = "Security group for secret rotation Lambda functions" vpc_id = module.vpc.vpc_id tags = merge( local.common_tags, { Name = "${var.name}-rotation-lambda-sg" Component = "secrets-rotation" } ) } # # Lambda -> DocumentDB # resource "aws_vpc_security_group_egress_rule" "lambda_to_documentdb" { security_group_id = aws_security_group.rotation_lambda.id referenced_security_group_id = aws_security_group.documentdb.id from_port = 27017 to_port = 27017 ip_protocol = "tcp" description = "Allow Lambda to connect to DocumentDB for rotation" tags = merge( local.common_tags, { Name = "lambda-to-documentdb" } ) } # # DocumentDB <- Lambda # resource "aws_vpc_security_group_ingress_rule" "documentdb_from_lambda" { security_group_id = aws_security_group.documentdb.id referenced_security_group_id = aws_security_group.rotation_lambda.id from_port = 27017 to_port = 27017 ip_protocol = "tcp" description = "Allow Lambda rotation function to connect to DocumentDB" tags = merge( local.common_tags, { Name = "documentdb-from-lambda" } ) } # # Lambda -> RDS # resource "aws_vpc_security_group_egress_rule" "lambda_to_rds" { security_group_id = aws_security_group.rotation_lambda.id referenced_security_group_id = aws_security_group.keycloak_db.id from_port = 3306 to_port = 3306 ip_protocol = "tcp" description = "Allow Lambda to connect to RDS for rotation" tags = merge( local.common_tags, { Name = "lambda-to-rds" } ) } # # RDS <- Lambda # resource "aws_vpc_security_group_ingress_rule" "rds_from_lambda" { security_group_id = aws_security_group.keycloak_db.id referenced_security_group_id = aws_security_group.rotation_lambda.id from_port = 3306 to_port = 3306 ip_protocol = "tcp" description = "Allow Lambda rotation function to connect to RDS" tags = merge( local.common_tags, { Name = "rds-from-lambda" } ) } # # Lambda -> HTTPS (for Secrets Manager API) # resource "aws_vpc_security_group_egress_rule" "lambda_to_https" { security_group_id = aws_security_group.rotation_lambda.id cidr_ipv4 = "0.0.0.0/0" from_port = 443 to_port = 443 ip_protocol = "tcp" description = "Allow Lambda to call AWS APIs (Secrets Manager, KMS)" tags = merge( local.common_tags, { Name = "lambda-to-https" } ) } # # CloudWatch Log Groups for Lambda Functions # #checkov:skip=CKV_AWS_158:KMS encryption for CloudWatch logs not required in this deployment resource "aws_cloudwatch_log_group" "documentdb_rotation" { name = "/aws/lambda/${var.name}-rotate-documentdb" retention_in_days = 30 tags = merge( local.common_tags, { Component = "secrets-rotation" } ) } #checkov:skip=CKV_AWS_158:KMS encryption for CloudWatch logs not required in this deployment resource "aws_cloudwatch_log_group" "rds_rotation" { name = "/aws/lambda/${var.name}-rotate-rds" retention_in_days = 30 tags = merge( local.common_tags, { Component = "secrets-rotation" } ) } # # Lambda Function Package - DocumentDB Rotation # data "archive_file" "documentdb_rotation" { type = "zip" source_dir = "${path.module}/lambda/rotate-documentdb" output_path = "${path.module}/.terraform/lambda/rotate-documentdb.zip" } # # Lambda Function Package - RDS Rotation # data "archive_file" "rds_rotation" { type = "zip" source_dir = "${path.module}/lambda/rotate-rds" output_path = "${path.module}/.terraform/lambda/rotate-rds.zip" } # # Lambda Function - DocumentDB Rotation # #checkov:skip=CKV_AWS_115:Reserved concurrency not needed for secret rotation Lambda #checkov:skip=CKV_AWS_116:DLQ not needed for synchronous secret rotation Lambda #checkov:skip=CKV_AWS_173:Lambda environment variables use default encryption #checkov:skip=CKV_AWS_272:Code signing not configured for internal rotation Lambdas resource "aws_lambda_function" "documentdb_rotation" { filename = data.archive_file.documentdb_rotation.output_path function_name = "${var.name}-rotate-documentdb" role = aws_iam_role.rotation_lambda.arn handler = "index.lambda_handler" source_code_hash = data.archive_file.documentdb_rotation.output_base64sha256 runtime = "python3.13" timeout = 300 memory_size = 256 vpc_config { subnet_ids = module.vpc.private_subnets security_group_ids = [aws_security_group.rotation_lambda.id] } environment { variables = { SECRETS_MANAGER_ENDPOINT = "https://secretsmanager.${var.aws_region}.amazonaws.com" EXCLUDE_CHARACTERS = "/@\"'\\" } } tracing_config { mode = "Active" } tags = merge( local.common_tags, { Component = "secrets-rotation" } ) depends_on = [ aws_cloudwatch_log_group.documentdb_rotation, aws_iam_role_policy.rotation_lambda, aws_iam_role_policy_attachment.lambda_vpc_execution ] } # # Lambda Function - RDS Rotation # #checkov:skip=CKV_AWS_115:Reserved concurrency not needed for secret rotation Lambda #checkov:skip=CKV_AWS_116:DLQ not needed for synchronous secret rotation Lambda #checkov:skip=CKV_AWS_173:Lambda environment variables use default encryption #checkov:skip=CKV_AWS_272:Code signing not configured for internal rotation Lambdas resource "aws_lambda_function" "rds_rotation" { filename = data.archive_file.rds_rotation.output_path function_name = "${var.name}-rotate-rds" role = aws_iam_role.rotation_lambda.arn handler = "index.lambda_handler" source_code_hash = data.archive_file.rds_rotation.output_base64sha256 runtime = "python3.13" timeout = 300 memory_size = 256 vpc_config { subnet_ids = module.vpc.private_subnets security_group_ids = [aws_security_group.rotation_lambda.id] } environment { variables = { SECRETS_MANAGER_ENDPOINT = "https://secretsmanager.${var.aws_region}.amazonaws.com" EXCLUDE_CHARACTERS = "/@\"'\\" } } tracing_config { mode = "Active" } tags = merge( local.common_tags, { Component = "secrets-rotation" } ) depends_on = [ aws_cloudwatch_log_group.rds_rotation, aws_iam_role_policy.rotation_lambda, aws_iam_role_policy_attachment.lambda_vpc_execution ] } # # Lambda Permission for Secrets Manager - DocumentDB # #checkov:skip=CKV_AWS_364:Lambda resource-based policy does not use IAM policy document version field resource "aws_lambda_permission" "documentdb_rotation" { statement_id = "AllowExecutionFromSecretsManager" action = "lambda:InvokeFunction" function_name = aws_lambda_function.documentdb_rotation.function_name principal = "secretsmanager.amazonaws.com" } # # Lambda Permission for Secrets Manager - RDS # #checkov:skip=CKV_AWS_364:Lambda resource-based policy does not use IAM policy document version field resource "aws_lambda_permission" "rds_rotation" { statement_id = "AllowExecutionFromSecretsManager" action = "lambda:InvokeFunction" function_name = aws_lambda_function.rds_rotation.function_name principal = "secretsmanager.amazonaws.com" } ================================================ FILE: terraform/aws-ecs/setup-documentdb-env.sh ================================================ #!/bin/bash # # Setup environment variables for DocumentDB Terraform deployment # # Usage: # source ./setup-documentdb-env.sh # # This script sets Terraform variables as environment variables for security. # Credentials are not stored in terraform.tfvars files. # # Exit on error set -e echo "Setting up DocumentDB Terraform environment variables..." # DocumentDB Admin Credentials # IMPORTANT: Change these to your actual credentials! export TF_VAR_documentdb_admin_username="docdbadmin" export TF_VAR_documentdb_admin_password="CHANGE-ME-YourSecurePassword123!" # Optional: Override default capacity settings # Uncomment and modify as needed: # export TF_VAR_documentdb_shard_capacity=2 # Options: 2, 4, 8, 16, 32, 64 # export TF_VAR_documentdb_shard_count=1 # 1-32 shards echo "" echo "✅ Environment variables set:" echo " TF_VAR_documentdb_admin_username = $TF_VAR_documentdb_admin_username" echo " TF_VAR_documentdb_admin_password = ******** (hidden)" echo "" echo "⚠️ IMPORTANT: Change the password before deploying to production!" echo "" echo "Next steps:" echo " 1. Edit this file and set a secure password" echo " 2. Source this file: source ./setup-documentdb-env.sh" echo " 3. Deploy: terraform plan && terraform apply" echo "" ================================================ FILE: terraform/aws-ecs/terraform.tfvars.example ================================================ # Terraform Variables Example Template # # This is an EXAMPLE file showing all configurable variables. # # TO USE: # 1. Copy this file: cp terraform.tfvars.example terraform.tfvars # 2. Edit terraform.tfvars with YOUR values # 3. Run: terraform apply # # NOTE: terraform.tfvars is in .gitignore and will NOT be committed to git # ============================================================================ # NETWORK CONFIGURATION - IP ADDRESSES # ============================================================================ # Main ALB (Registry, Auth Server, Gradio) - Allowed IP addresses # These IPs can access: # - http://mcp-gateway-alb-*.us-east-1.elb.amazonaws.com (ports 80, 443) # - http://mcp-gateway-alb-*.us-east-1.elb.amazonaws.com:7860 (Registry) # - http://mcp-gateway-alb-*.us-east-1.elb.amazonaws.com:8888 (Auth Server) # Options: # - Specific IP: "YOUR.IP.ADDRESS/32" (most secure) # - Public access: "0.0.0.0/0" (use with caution) # - Enterprise CIDR: "10.0.0.0/8" (your organization's network) ingress_cidr_blocks = [ "YOUR.LAPTOP.IP.ADDR/32", # Your laptop IP (find with: curl ifconfig.me) "YOUR.EC2.INSTANCE.IP/32", # Your EC2 instance or NAT Gateway IP ] # ============================================================================ # DOMAIN CONFIGURATION # ============================================================================ # Option 1: Use regional domains (RECOMMENDED for multi-region deployments) # This automatically creates domains based on the region: # - Keycloak: kc.{region}.mycorp.click (e.g., kc.us-west-2.mycorp.click) # - Registry: registry.{region}.mycorp.click (e.g., registry.us-west-2.mycorp.click) use_regional_domains = true base_domain = "mycorp.click" # Option 2: Use custom static domains (set use_regional_domains = false) # Uncomment these if you want fixed domains regardless of region: # use_regional_domains = false # keycloak_domain = "kc.example.com" # root_domain = "example.com" # ============================================================================ # DEPLOYMENT MODE - CloudFront vs Custom Domain # ============================================================================ # Mode 1: Custom Domain (Route53/ACM) - enable_cloudfront=false, enable_route53_dns=true # - Creates Route53 DNS records and ACM certificates # - Access via custom domain (e.g., registry.us-east-1.mycorp.click) # - HTTPS with ACM certificate # # Mode 2: CloudFront Only - enable_cloudfront=true, enable_route53_dns=false # - Creates CloudFront distributions with default certificates # - Access via CloudFront URLs (*.cloudfront.net) # - No Route53 DNS or ACM certificates created # - Recommended for testing or when you don't have a custom domain # # Mode 3: Development (HTTP) - enable_cloudfront=false, enable_route53_dns=false # - No HTTPS, no custom domain, no CloudFront # - Access directly via ALB DNS name # - For local/dev testing only # # Mode 4: Dual Ingress - enable_cloudfront=true, enable_route53_dns=true # - Both CloudFront AND custom domain access # - More complex setup, useful for migration scenarios # Enable CloudFront distributions (Mode 2 or Mode 4) # Set to true to create CloudFront distributions in front of ALB # Default: false (use custom domain with Route53/ACM) # enable_cloudfront = false # Enable Route53 DNS and ACM certificates (Mode 1 or Mode 4) # Set to false when using CloudFront-only deployment (Mode 2) # Default: true (create custom domain setup) # enable_route53_dns = true # CloudFront Prefix List (optional, for Mode 2 or Mode 4) # Restricts ALB ingress to CloudFront origin-facing IPs for enhanced security # Leave commented out to allow direct ALB access # Set to "com.amazonaws.global.cloudfront.origin-facing" for CloudFront-only access # cloudfront_prefix_list_name = "com.amazonaws.global.cloudfront.origin-facing" # ============================================================================ # KEYCLOAK CREDENTIALS (used when entra_enabled = false) # ============================================================================ # Keycloak admin credentials keycloak_admin = "admin" keycloak_admin_password = "CHANGE-ME-SECURE-PASSWORD" # Keycloak database credentials keycloak_database_username = "keycloak" keycloak_database_password = "CHANGE-ME-DB-PASSWORD" # ============================================================================ # CONTAINER IMAGE URIS (REQUIRED) # ============================================================================ # Registry image from ECR registry_image_uri = "YOUR_ACCOUNT_ID.dkr.ecr.YOUR_AWS_REGION.amazonaws.com/mcp-gateway-registry:latest" # Auth server image from ECR auth_server_image_uri = "YOUR_ACCOUNT_ID.dkr.ecr.YOUR_AWS_REGION.amazonaws.com/mcp-gateway-auth-server:latest" # MCP Server images from ECR currenttime_image_uri = "YOUR_ACCOUNT_ID.dkr.ecr.YOUR_AWS_REGION.amazonaws.com/mcp-gateway-currenttime:latest" mcpgw_image_uri = "YOUR_ACCOUNT_ID.dkr.ecr.YOUR_AWS_REGION.amazonaws.com/mcp-gateway-mcpgw:latest" realserverfaketools_image_uri = "YOUR_ACCOUNT_ID.dkr.ecr.YOUR_AWS_REGION.amazonaws.com/mcp-gateway-realserverfaketools:latest" # A2A Agent images from ECR flight_booking_agent_image_uri = "YOUR_ACCOUNT_ID.dkr.ecr.YOUR_AWS_REGION.amazonaws.com/mcp-gateway-flight-booking-agent:latest" travel_assistant_agent_image_uri = "YOUR_ACCOUNT_ID.dkr.ecr.YOUR_AWS_REGION.amazonaws.com/mcp-gateway-travel-assistant-agent:latest" # ============================================================================ # OPTIONAL: ADDITIONAL CONFIGURATION # ============================================================================ # AWS Region # Can also be set via TF_VAR_aws_region environment variable # Default: "us-east-1" # aws_region = "us-west-2" # Deployment name prefix # Default: "mcp-gateway" # name = "mcp-gateway" # VPC CIDR block # Default: "10.0.0.0/16" # vpc_cidr = "10.0.0.0/16" # Enable CloudWatch monitoring # Default: true # enable_monitoring = true # Email for alarm notifications (optional) # alarm_email = "" # ============================================================================ # OPTIONAL: KEYCLOAK DATABASE SCALING # ============================================================================ # Database Aurora Serverless capacity (default values shown) # keycloak_database_min_acu = 0.5 # keycloak_database_max_acu = 2 # ============================================================================ # OPTIONAL: KEYCLOAK LOGGING # ============================================================================ # Keycloak log level # Default: "INFO" # keycloak_log_level = "INFO" # ============================================================================ # OPTIONAL: SERVICE REPLICA COUNTS # ============================================================================ # Service replica counts (when autoscaling is disabled) # Uncomment to override defaults (all default to 1) # currenttime_replicas = 1 # mcpgw_replicas = 1 # realserverfaketools_replicas = 1 # flight_booking_agent_replicas = 1 # travel_assistant_agent_replicas = 1 # ============================================================================ # SESSION COOKIE CONFIGURATION # ============================================================================ # Session cookie secure flag (HTTPS-only transmission) # MUST be set to true in production deployments with HTTPS # Set to false only for local development over HTTP # Default: true session_cookie_secure = true # Session cookie domain (for cross-subdomain authentication) # Leave unset or empty for single-domain deployments (RECOMMENDED for most cases) # Set to domain with leading dot for cross-subdomain sharing # # Examples: # Single domain deployment (registry.example.com): Leave empty or unset # session_cookie_domain = "" # # CloudFront-only mode (Mode 2): Leave empty for CloudFront URLs # session_cookie_domain = "" # # Cross-subdomain (registry.mycorp.click + kc.mycorp.click): # session_cookie_domain = ".mycorp.click" # # Multi-region with regional subdomains (registry.us-east-1.mycorp.click): # session_cookie_domain = ".mycorp.click" # # Multi-level organizational domains (registry.region-1.corp.company.internal): # session_cookie_domain = ".corp.company.internal" # # Default: Empty (cookie scoped to exact host only - safest option) session_cookie_domain = "" # Control whether OAuth provider tokens are stored in session cookies # When false: OAuth tokens NOT stored in session cookies, reduces cookie size # When true: tokens stored (may cause cookie size issues with large tokens from Entra ID) # The tokens stored in the session are not used functionally, so false is recommended # Default: false oauth_store_tokens_in_session = false # ============================================================================ # EMBEDDINGS CONFIGURATION # ============================================================================ # Embeddings provider and model configuration for semantic search # # Option 1: Use local sentence-transformers (DEFAULT - no API costs) # Default values (uncomment to use): # embeddings_provider = "sentence-transformers" # embeddings_model_name = "all-MiniLM-L6-v2" # embeddings_model_dimensions = 384 # Option 2: Use OpenAI embeddings (better quality, requires API key) # Uncomment and set your API key: # embeddings_provider = "litellm" # embeddings_model_name = "openai/text-embedding-ada-002" # embeddings_model_dimensions = 1536 # embeddings_api_key = "sk-proj-YOUR-OPENAI-API-KEY" # Option 3: Use Amazon Bedrock Titan embeddings (uses IAM, no API key needed) # Uncomment to use Bedrock: # embeddings_provider = "litellm" # embeddings_model_name = "bedrock/amazon.titan-embed-text-v1" # embeddings_model_dimensions = 1536 # embeddings_aws_region = "us-east-1" # embeddings_api_key = "" # Empty for Bedrock (uses IAM) # ============================================================================ # DOCUMENTDB ELASTIC CLUSTER CONFIGURATION # ============================================================================ # DocumentDB Elastic Cluster credentials (REQUIRED) # RECOMMENDED: Set via environment variables instead of tfvars for security: # export TF_VAR_documentdb_admin_username="docdbadmin" # export TF_VAR_documentdb_admin_password="YourSecurePassword123!" # # Alternatively, uncomment below (less secure - credentials in file): # documentdb_admin_username = "docdbadmin" # documentdb_admin_password = "CHANGE-ME-DocumentDB-Password123!" # DocumentDB Elastic Cluster capacity configuration (OPTIONAL) # Uncomment to override defaults # vCPU capacity per shard # Options: 2, 4, 8, 16, 32, 64 # Default: 2 (recommended for small-medium workloads) # For production with high load, use 4 or 8 # documentdb_shard_capacity = 2 # Number of shards (1-32) # Default: 1 (recommended for most deployments) # Increase only when scaling beyond single shard capacity # documentdb_shard_count = 1 # ============================================================================ # REGISTRY STATIC TOKEN AUTH (IdP-independent API access) # ============================================================================ # Enable static token auth for Registry API endpoints (/api/*, /v0.1/*) # MCP Gateway endpoints still require full IdP authentication # Default: false registry_static_token_auth_enabled = false # Static API key for Registry API. Clients send: Authorization: Bearer # Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))" # Can also be set via environment variable: # export TF_VAR_registry_api_token="your-generated-token" registry_api_token = "m3zT65wREARMVDToKosg_DgNkKqS_434hNxy3sslGPY" # Multi-key static tokens with per-key group assignments (JSON string) # Each key maps a label to a key/groups pair. Clients send: Authorization: Bearer # Groups must exist in scopes.yml group_mappings for scope resolution. # Can also be set via environment variable: # export TF_VAR_registry_api_keys='{"monitoring":{"key":"","groups":["public-mcp-users"]}}' # # Example with three keys at different privilege levels: # registry_api_keys = "{\"monitoring-script\":{\"key\":\"<64-char-token>\",\"groups\":[\"public-mcp-users\"]},\"deploy-pipeline\":{\"key\":\"<64-char-token>\",\"groups\":[\"mcp-registry-admin\"]},\"koda-integration\":{\"key\":\"<64-char-token>\",\"groups\":[\"registry-users-lob1\"]}}" # Maximum JWT tokens that can be vended per user per hour # Default: 100 # max_tokens_per_user_per_hour = 100 # ============================================================================ # REGISTRATION WEBHOOK (Issue #742) # ============================================================================ # Fire an async POST to this URL when a server, agent, or skill is registered # or deleted. Disabled when empty (default). # registration_webhook_url = "https://your-endpoint.example.com/webhook" # Auth header name. If "Authorization", the token is auto-prefixed with "Bearer ". # For custom headers (e.g. X-API-Key), the token is sent as-is. # Default: "Authorization" # registration_webhook_auth_header = "Authorization" # Auth token for webhook requests. Leave empty for unauthenticated webhooks. # registration_webhook_auth_token = "" # Timeout for webhook HTTP calls in seconds. Default: 10 # registration_webhook_timeout_seconds = 10 # ============================================================================ # REGISTRATION GATE / ADMISSION CONTROL (Issue #809) # ============================================================================ # Enable registration gate (admission control). When enabled, an external # endpoint must approve registrations and updates before they are persisted. # Fail-closed: if the gate is unreachable after retries, registration is blocked. # Default: false # registration_gate_enabled = false # URL of the registration gate endpoint. Must be set when gate is enabled. # registration_gate_url = "https://your-endpoint.example.com/gate" # Auth type: none, api_key, or bearer. Default: none # registration_gate_auth_type = "none" # Auth credential for api_key or bearer auth types. # registration_gate_auth_credential = "" # Header name when auth_type=api_key. Default: X-Api-Key # registration_gate_auth_header_name = "X-Api-Key" # HTTP timeout per gate request attempt in seconds. Default: 5 # registration_gate_timeout_seconds = 5 # Retries after the first gate attempt (exponential backoff). Default: 2 # registration_gate_max_retries = 2 # ============================================================================ # M2M DIRECT CLIENT REGISTRATION (Issue #851) # ============================================================================ # Enable the admin API at /api/iam/m2m-clients that writes M2M client_ids # and their group mappings directly to the idp_m2m_clients collection, # without requiring an IdP Admin API token (e.g. OKTA_API_TOKEN). # Records created via this API are tagged provider="manual". # Default: true # m2m_direct_registration_enabled = true # ============================================================================ # REGISTRY CARD CONFIGURATION (Federation Metadata) # ============================================================================ # Registry identity and metadata for federation and discovery # These values populate the registry card shown in federated environments # Human-readable registry name (display name for your registry) # If not set, a random Docker-style name will be generated (e.g., "brave-falcon-registry") # Displayed in federated registry listings and UI headers # registry_name = "AI Gateway Registry" # Organization that operates this registry # If not set, defaults to "ACME Inc." # registry_organization_name = "ACME Inc." # Registry description for federation # registry_description = "Central registry for all your AI assets" # Contact email for registry administrators (leave empty if not publicly shared) # registry_contact_email = "" # Documentation or support URL for this registry (leave empty if not available) # registry_contact_url = "" # ============================================================================ # ANS (AGENT NAMING SERVICE) CONFIGURATION # ============================================================================ # Enable ANS integration for agent identity verification # When enabled, agents can be linked to ANS records for verified identity # Default: false # ans_integration_enabled = true # ANS API endpoint URL # Default: "https://api.godaddy.com" # ans_api_endpoint = "https://api.godaddy.com" # ANS API credentials (required when ans_integration_enabled = true) # RECOMMENDED: Set via environment variables for security: # export TF_VAR_ans_api_key="your-ans-api-key" # export TF_VAR_ans_api_secret="your-ans-api-secret" # ans_api_key = "your-ans-api-key" # ans_api_secret = "your-ans-api-secret" # ANS API request timeout in seconds (default: 30) # ans_api_timeout_seconds = 30 # How often to re-sync ANS verification status in hours (default: 6) # ans_sync_interval_hours = 6 # Cache TTL for ANS verification results in seconds (default: 3600) # ans_verification_cache_ttl_seconds = 3600 # ============================================================================ # FEDERATION CONFIGURATION (Peer-to-Peer Registry Sync) # ============================================================================ # Unique identifier for this registry instance in federation. # Used to identify the source of synced items (e.g., "my-registry", "prod-us-east-1"). # registry_id = "my-registry" # Enable static token auth for Federation API endpoints (/api/federation/*, /api/peers/*). # When enabled, peer registries can authenticate using FEDERATION_STATIC_TOKEN. # Default: false # federation_static_token_auth_enabled = true # Static token for Federation API access. Peer registries use this as Bearer token. # Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))" # Can also be set via environment variable: # export TF_VAR_federation_static_token="your-generated-token" # federation_static_token = "your-federation-token-here" # Fernet encryption key for storing federation tokens in MongoDB. # Required on importing registry (the one that syncs FROM peer registries). # Generate with: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # Can also be set via environment variable: # export TF_VAR_federation_encryption_key="your-fernet-key-here" # federation_encryption_key = "your-fernet-encryption-key-here" # ============================================================================ # AUDIT LOGGING CONFIGURATION # ============================================================================ # Enable/disable audit logging for compliance monitoring # When enabled, all API and MCP requests are logged to DocumentDB # Default: true # audit_log_enabled = true # Audit log retention period in days # Logs older than this are automatically deleted via DocumentDB TTL index # Common values: 7 (dev), 30 (standard), 90 (compliance) # Default: 7 # audit_log_ttl_days = 7 # ============================================================================ # APPLICATION LOG CONFIGURATION # ============================================================================ # # Centralized application logging to MongoDB for cross-pod log retrieval. # When enabled, application logs from all pods are written to a shared # MongoDB collection accessible via the admin API. # # Enable writing application logs to centralized store # Default: true # app_log_centralized_enabled = true # Application log retention period in days (TTL index) # Default: 1 # app_log_centralized_ttl_days = 1 # Application log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) # Default: "INFO" # app_log_level = "INFO" # Comma-separated logger names to exclude from MongoDB writes # Default: "uvicorn.access,httpx,pymongo,motor" # app_log_excluded_loggers = "uvicorn.access,httpx,pymongo,motor" # ============================================================================ # AUTHENTICATION PROVIDER CONFIGURATION # ============================================================================ # # IMPORTANT: Only ONE authentication provider should be enabled at a time! # # Three authentication provider options: # 1. Keycloak (default) - Leave both okta_enabled and entra_enabled as false/unset # 2. Microsoft Entra ID - Set entra_enabled = true # 3. Okta - Set okta_enabled = true # # If multiple providers are enabled simultaneously, priority order is: # okta_enabled > entra_enabled > keycloak (default) # # ============================================================================ # Microsoft Entra ID Configuration (Alternative to Keycloak) # Set entra_enabled = true to use Microsoft Entra ID instead of Keycloak # When enabled, AUTH_PROVIDER is automatically set to "entra" # IMPORTANT: Set okta_enabled = false (or omit) when using Entra # Enable Microsoft Entra ID authentication (default: false, uses Keycloak) # entra_enabled = true # Entra ID Tenant ID (Directory ID from Azure Portal) # entra_tenant_id = "your-tenant-id-guid" # Entra ID Application (client) ID # entra_client_id = "your-client-id-guid" # Entra ID Client Secret # entra_client_secret = "your-client-secret" # Entra ID Login Base URL (optional - defaults to Azure Public Cloud) # Change only if using a sovereign cloud: # - Azure Public Cloud (default): https://login.microsoftonline.com # - Azure Government: https://login.microsoftonline.us # - Azure China: https://login.chinacloudapi.cn # - Azure Germany: https://login.microsoftonline.de # entra_login_base_url = "https://login.microsoftonline.com" # IdP Group Filter Prefix (optional, comma-separated, applies to all identity providers) # Only groups whose name starts with any of these prefixes are shown in IAM > Groups # Example with single prefix: # idp_group_filter_prefix = "mcp-" # Example with multiple prefixes: # idp_group_filter_prefix = "mcp-,registry-,ai-" # ============================================================================ # OKTA CONFIGURATION (Alternative to Keycloak) # ============================================================================ # Set okta_enabled = true to use Okta instead of Keycloak # When enabled, AUTH_PROVIDER is automatically set to "okta" # IMPORTANT: Set entra_enabled = false (or omit) when using Okta # Enable Okta authentication (default: false, uses Keycloak) # okta_enabled = true # Okta domain (e.g., your-org.okta.com) # Get from: Okta Admin Console # okta_domain = "your-org.okta.com" # Okta Application (client) ID # Get from: Okta Admin Console → Applications → Your App → General # okta_client_id = "your-client-id" # Okta Client Secret # Get from: Okta Admin Console → Applications → Your App → General → Client Credentials # okta_client_secret = "your-client-secret" # Okta M2M Client ID (for service account operations) # Create a separate OAuth 2.0 client for M2M operations # okta_m2m_client_id = "your-m2m-client-id" # Okta M2M Client Secret # okta_m2m_client_secret = "your-m2m-client-secret" # Okta API Token (for IAM management operations) # Get from: Okta Admin Console → Security → API → Tokens # okta_api_token = "your-api-token" # Okta Custom Authorization Server ID (optional) # Get from: Okta Admin Console → Security → API → Authorization Servers # If using custom authorization server for M2M, specify the ID here (e.g., aus1108sx6pwGzb8T698) # If not set, uses the default Org Authorization Server # okta_auth_server_id = "your-auth-server-id" # ============================================================================ # AUTH0 CONFIGURATION (Alternative to Keycloak) # ============================================================================ # Set auth0_enabled = true to use Auth0 instead of Keycloak # When enabled, AUTH_PROVIDER is automatically set to "auth0" # IMPORTANT: Set entra_enabled and okta_enabled = false (or omit) when using Auth0 # Enable Auth0 authentication (default: false, uses Keycloak) # auth0_enabled = true # Auth0 domain (e.g., your-tenant.us.auth0.com) # Get from: Auth0 Dashboard → Applications → Your App → Settings # auth0_domain = "your-tenant.us.auth0.com" # Auth0 Application (client) ID # Get from: Auth0 Dashboard → Applications → Your App → Settings # auth0_client_id = "your-client-id" # Auth0 Client Secret # Get from: Auth0 Dashboard → Applications → Your App → Settings # auth0_client_secret = "your-client-secret" # Auth0 API Audience (optional - for API access tokens) # This is the API Identifier from Auth0 Dashboard → APIs # auth0_audience = "https://your-api-identifier" # Auth0 Groups Claim (custom claim for group memberships) # Must match the namespace used in your Auth0 Action # Default: https://mcp-gateway/groups # auth0_groups_claim = "https://mcp-gateway/groups" # Auth0 M2M Client ID (REQUIRED for IAM Management - user/role administration) # Create an M2M application in Auth0 with Auth0 Management API permissions # Get from: Auth0 Dashboard → Applications → Create Application → Machine to Machine # auth0_m2m_client_id = "your-m2m-client-id" # Auth0 M2M Client Secret (REQUIRED for IAM Management) # auth0_m2m_client_secret = "your-m2m-client-secret" # Auth0 Management API Token (alternative to M2M credentials) # You can use a static Management API token instead of M2M client credentials # Generate in Auth0 Dashboard → Applications → APIs → Auth0 Management API → API Explorer # WARNING: Static tokens expire after 24 hours - M2M credentials recommended for production # auth0_management_api_token = "your-management-api-token" # ============================================================================ # DEPLOYMENT MODE CONFIGURATION # ============================================================================ # DEPLOYMENT_MODE controls how the registry integrates with the gateway/nginx # Options: # - "with-gateway" (default): Full integration with nginx reverse proxy # - Nginx config is regenerated when servers are registered/deleted # - Frontend shows gateway authentication instructions # - "registry-only": Registry operates as catalog/discovery service only # - Nginx config is NOT updated on server changes # - Frontend shows direct connection mode (proxy_pass_url) # - Use when registry is separate from gateway infrastructure # Default: "with-gateway" (uncomment to change) deployment_mode = "registry-only" # REGISTRY_MODE controls which features are enabled (informational - for UI feature flags) # This setting affects the /api/config response which the frontend can use # to show/hide navigation elements. Currently informational only - all APIs remain active. # Options: # - "full" (default): All features enabled (mcp_servers, agents, skills, federation) # - "skills-only": Only skills feature flag enabled # - "mcp-servers-only": Only MCP server feature flag enabled # - "agents-only": Only A2A agent feature flag enabled # Note: with-gateway + skills-only is invalid and auto-corrects to registry-only + skills-only # Default: "full" (uncomment to change) # registry_mode = "full" # Tab visibility overrides (AND-ed with registry_mode) # Controls which tabs are shown in the UI without affecting backend APIs. # All default to true (backward compatible). Set to false to hide a tab. # show_servers_tab = true # show_virtual_servers_tab = true # show_skills_tab = true # show_agents_tab = true # ============================================================================ # OBSERVABILITY CONFIGURATION (Metrics Pipeline) # ============================================================================ # Enable full observability pipeline (AMP, metrics-service, ADOT collector, Grafana) # When false, no observability resources are created # Default: true # enable_observability = true # Container image URIs for observability services (required when enable_observability = true) # metrics_service_image_uri = "YOUR_ACCOUNT_ID.dkr.ecr.YOUR_AWS_REGION.amazonaws.com/mcp-gateway-metrics-service:latest" # grafana_image_uri = "YOUR_ACCOUNT_ID.dkr.ecr.YOUR_AWS_REGION.amazonaws.com/mcp-gateway-grafana:latest" # Grafana admin password (REQUIRED when enable_observability = true) # IMPORTANT: You MUST set a strong, random password. Do NOT use "admin" or any weak default. # Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(24))" # Can also be set via environment variable: # export TF_VAR_grafana_admin_password="your-secure-password" grafana_admin_password = "CHANGE-ME-SET-STRONG-PASSWORD" # OTLP exporter for pushing metrics to external platforms (Datadog, New Relic, etc.) # Leave empty/commented to disable (only Prometheus/AMP pipeline will be active) # The headers value is stored in AWS Secrets Manager (not in the ECS task definition) # # Datadog example: # otel_otlp_endpoint = "https://otlp.datadoghq.com" # otel_exporter_otlp_headers = "dd-api-key=YOUR_DATADOG_API_KEY" # # New Relic example: # otel_otlp_endpoint = "https://otlp.nr-data.net" # otel_exporter_otlp_headers = "api-key=YOUR_NEW_RELIC_LICENSE_KEY" # # RECOMMENDED: Set the headers via environment variable for security: # export TF_VAR_otel_exporter_otlp_headers="dd-api-key=YOUR_KEY" # # Export interval in milliseconds (default: 30000 = 30 seconds) # otel_otlp_export_interval_ms = 30000 # otel_otlp_endpoint = "" # otel_exporter_otlp_headers = "" # otel_exporter_otlp_metrics_temporality_preference = "cumulative" # ============================================================================ # TELEMETRY CONFIGURATION (Anonymous Usage Telemetry) # ============================================================================ # Anonymous startup telemetry is ON by default. It sends a single event at # registry startup with version, OS, architecture, cloud provider, compute # platform, storage backend, and aggregate search query counts. No PII is # collected. All requests are HMAC-signed to prevent endpoint abuse. # # See docs/TELEMETRY.md for full details on what is collected. # Disable anonymous startup telemetry entirely # Set to "1" to opt out # mcp_telemetry_disabled = "" # Disable daily heartbeat telemetry only (startup ping still sent, default: heartbeat ON) # Set to "1" to opt out of heartbeat # mcp_telemetry_opt_out = "" # Heartbeat telemetry interval in minutes (default: 1440 = 24 hours) # mcp_telemetry_heartbeat_interval_minutes = "1440" # Debug mode: log telemetry payload to stdout instead of sending # Useful for verifying what data would be sent # telemetry_debug = "false" # ============================================================================ # AWS REGISTRY FEDERATION (optional) # ============================================================================ # Registry IDs and sync details are managed via /api/federation/config API. # These are global flags only. # Enable AWS Registry federation (default: false) # Registry IDs, region, sync settings are managed via /api/federation/config API. aws_registry_federation_enabled = true # ============================================================================ # DEMO SERVER CONFIGURATION (Issue #764) # ============================================================================ # Disable built-in airegistry-tools server auto-registration. # Set to "true" for production/GitOps deployments that control all server # registrations through a pipeline. Default: "false" (demo server registers on startup). # Note: this flag prevents new registrations only; it does not remove the server # if it was already registered in a previous run. # disable_ai_registry_tools_server = "false" # ============================================================================ # GITHUB PRIVATE REPO AUTH (Issue #814) # ============================================================================ # Authentication for fetching SKILL.md from private GitHub repositories. # Choose Option 1 (PAT) or Option 2 (GitHub App). Leave all blank to disable. # Option 1: Personal Access Token # Generate at https://github.com/settings/tokens with 'repo' scope # github_pat = "ghp_your_token_here" # Option 2: GitHub App authentication # Create at https://github.com/settings/apps with Contents (read-only) permission # github_app_id = "123456" # github_app_installation_id = "78901234" # github_app_private_key = "-----BEGIN RSA PRIVATE KEY-----\\n...\\n-----END RSA PRIVATE KEY-----" # GitHub Enterprise Server support # github_extra_hosts = "github.mycompany.com,raw.github.mycompany.com" # github_api_base_url = "https://github.mycompany.com/api/v3" ================================================ FILE: terraform/aws-ecs/variables.tf ================================================ variable "name" { description = "Name of the deployment" type = string default = "mcp-gateway" } variable "aws_region" { description = "AWS region for deployment. Can be set via TF_VAR_aws_region environment variable or terraform.tfvars" type = string default = "us-west-2" } variable "vpc_cidr" { description = "CIDR block for VPC" type = string default = "10.0.0.0/16" } variable "ingress_cidr_blocks" { description = "List of CIDR blocks allowed to access the ALB (main ALB + auth server + registry)" type = list(string) default = ["0.0.0.0/0"] } variable "enable_monitoring" { description = "Whether to enable CloudWatch monitoring and alarms" type = bool default = true } variable "alarm_email" { description = "Email address for CloudWatch alarm notifications" type = string default = "" } variable "alarm_sns_topic_arn" { description = "SNS topic ARN for CloudWatch alarm notifications. Leave empty to disable SNS notifications." type = string default = "" } # # Keycloak Configuration Variables # variable "use_regional_domains" { description = "Use region-based domains (e.g., kc.us-west-2.mycorp.click). If false, uses keycloak_domain and root_domain directly" type = bool default = true } variable "base_domain" { description = "Base domain for regional domains (e.g., mycorp.click). Used when use_regional_domains is true" type = string default = "mycorp.click" } variable "certificate_arn" { description = "ARN of ACM certificate for HTTPS. Leave empty to disable HTTPS" type = string default = "" } variable "keycloak_domain" { description = "Full domain for Keycloak (e.g., kc.example.com). Used when use_regional_domains is false" type = string default = "" } variable "root_domain" { description = "Root domain with Route53 hosted zone. Used when use_regional_domains is false" type = string default = "" } variable "keycloak_admin" { description = "Keycloak admin username" type = string sensitive = true default = "admin" } variable "keycloak_admin_password" { description = "Keycloak admin password" type = string sensitive = true } variable "keycloak_database_username" { description = "Keycloak database username" type = string sensitive = true default = "keycloak" } variable "keycloak_database_password" { description = "Keycloak database password" type = string sensitive = true } variable "keycloak_database_min_acu" { description = "Minimum Aurora Capacity Units" type = number default = 0.5 } variable "keycloak_database_max_acu" { description = "Maximum Aurora Capacity Units" type = number default = 2 } variable "keycloak_log_level" { description = "Keycloak log level" type = string default = "INFO" } # # MCP Gateway Services - Container Images # variable "registry_image_uri" { description = "Container image URI for registry service" type = string default = "" } variable "auth_server_image_uri" { description = "Container image URI for auth server service" type = string default = "mcpgateway/auth-server:latest" } variable "currenttime_image_uri" { description = "Container image URI for currenttime MCP server" type = string default = "" } variable "mcpgw_image_uri" { description = "Container image URI for mcpgw MCP server" type = string default = "" } variable "realserverfaketools_image_uri" { description = "Container image URI for realserverfaketools MCP server" type = string default = "" } variable "flight_booking_agent_image_uri" { description = "Container image URI for flight booking A2A agent" type = string default = "" } variable "travel_assistant_agent_image_uri" { description = "Container image URI for travel assistant A2A agent" type = string default = "" } # # MCP Gateway Services - Replica Counts # variable "currenttime_replicas" { description = "Number of replicas for CurrentTime MCP server" type = number default = 1 } variable "mcpgw_replicas" { description = "Number of replicas for MCPGW MCP server" type = number default = 1 } variable "realserverfaketools_replicas" { description = "Number of replicas for RealServerFakeTools MCP server" type = number default = 1 } variable "flight_booking_agent_replicas" { description = "Number of replicas for Flight Booking A2A agent" type = number default = 1 } variable "travel_assistant_agent_replicas" { description = "Number of replicas for Travel Assistant A2A agent" type = number default = 1 } # # Embeddings Configuration # variable "embeddings_provider" { description = "Embeddings provider: 'sentence-transformers' for local models or 'litellm' for API-based models" type = string default = "sentence-transformers" } variable "embeddings_model_name" { description = "Name of the embeddings model to use (e.g., 'all-MiniLM-L6-v2' for sentence-transformers, 'openai/text-embedding-ada-002' for litellm)" type = string default = "all-MiniLM-L6-v2" } variable "embeddings_model_dimensions" { description = "Dimension of the embeddings model (e.g., 384 for MiniLM, 1536 for OpenAI/Titan)" type = number default = 384 } variable "embeddings_aws_region" { description = "AWS region for Bedrock embeddings (only used when embeddings_provider is 'litellm' with Bedrock)" type = string default = "us-east-1" } variable "embeddings_api_key" { description = "API key for embeddings provider (OpenAI, Anthropic, etc.). Only used when embeddings_provider is 'litellm'. Leave empty for Bedrock (uses IAM)." type = string default = "" sensitive = true } # ============================================================================= # SESSION COOKIE SECURITY CONFIGURATION # ============================================================================= variable "session_cookie_secure" { description = "Enable secure flag on session cookies (HTTPS-only transmission). Set to true in production with HTTPS." type = bool default = true } variable "session_cookie_domain" { description = "Domain for session cookies (e.g., '.example.com' for cross-subdomain sharing). Leave empty for single-domain deployments (cookie scoped to exact host only)." type = string default = "" } # ============================================================================= # DOCUMENTDB CONFIGURATION (from upstream v1.0.9) # ============================================================================= variable "documentdb_admin_username" { description = "DocumentDB Elastic Cluster admin username" type = string sensitive = true default = "docdbadmin" } variable "documentdb_admin_password" { description = "DocumentDB Elastic Cluster admin password (minimum 8 characters). Only required when storage_backend is 'documentdb'." type = string sensitive = true default = "" # Not required when using file storage backend } variable "documentdb_shard_capacity" { description = "vCPU capacity per shard (2, 4, 8, 16, 32, or 64)" type = number default = 2 validation { condition = contains([2, 4, 8, 16, 32, 64], var.documentdb_shard_capacity) error_message = "Shard capacity must be one of: 2, 4, 8, 16, 32, 64" } } variable "documentdb_shard_count" { description = "Number of shards (1-32). Start with 1, scale as needed." type = number default = 1 validation { condition = var.documentdb_shard_count >= 1 && var.documentdb_shard_count <= 32 error_message = "Shard count must be between 1 and 32" } } variable "documentdb_instance_class" { description = "Instance class for DocumentDB cluster instances (e.g., db.t3.medium, db.r5.large)" type = string default = "db.t3.medium" validation { condition = can(regex("^db\\.(t3|t4g|r5|r6g)\\.(medium|large|xlarge|2xlarge|4xlarge|8xlarge|12xlarge|16xlarge)$", var.documentdb_instance_class)) error_message = "Instance class must be a valid DocumentDB instance type (e.g., db.t3.medium, db.r5.large)" } } variable "documentdb_replica_count" { description = "Number of read replica instances (0-15). Start with 0, add replicas for HA." type = number default = 0 validation { condition = var.documentdb_replica_count >= 0 && var.documentdb_replica_count <= 15 error_message = "Replica count must be between 0 and 15" } } # Storage Backend Configuration variable "storage_backend" { description = "Storage backend to use: 'file' or 'documentdb'" type = string default = "file" validation { condition = contains(["file", "documentdb"], var.storage_backend) error_message = "Storage backend must be either 'file' or 'documentdb'." } } variable "documentdb_database" { description = "DocumentDB database name" type = string default = "mcp_registry" } variable "documentdb_namespace" { description = "DocumentDB namespace for collections" type = string default = "default" } variable "documentdb_use_tls" { description = "Use TLS for DocumentDB connections" type = bool default = true } variable "documentdb_use_iam" { description = "Use IAM authentication for DocumentDB" type = bool default = false } # ============================================================================= # CLOUDFRONT CONFIGURATION (CloudFront HTTPS Support feature) # ============================================================================= variable "enable_cloudfront" { description = "Enable CloudFront distributions for HTTPS without custom domain. Uses default *.cloudfront.net certificates." type = bool default = false } variable "cloudfront_prefix_list_name" { description = "Name of the managed prefix list for ALB ingress (e.g., CloudFront origin-facing IPs). Leave empty to disable prefix list rule. Default is AWS CloudFront prefix list." type = string default = "" # Set to "com.amazonaws.global.cloudfront.origin-facing" when enable_cloudfront=true } variable "enable_route53_dns" { description = "Enable Route53 DNS records and ACM certificates for custom domain. Set to false when using CloudFront-only deployment." type = bool default = true } # ============================================================================= # SECURITY SCANNING CONFIGURATION # ============================================================================= variable "security_scan_enabled" { description = "Enable security scanning for MCP servers" type = bool default = false } variable "security_scan_on_registration" { description = "Automatically scan servers when they are registered" type = bool default = false } variable "security_block_unsafe_servers" { description = "Block (disable) servers that fail security scans" type = bool default = false } variable "security_analyzers" { description = "Analyzers to use for security scanning (comma-separated: yara, llm, api)" type = string default = "yara" } variable "security_scan_timeout" { description = "Security scan timeout in seconds" type = number default = 60 } variable "security_add_pending_tag" { description = "Add 'security-pending' tag to servers that fail security scan" type = bool default = false } # ============================================================================= # MICROSOFT ENTRA ID CONFIGURATION # ============================================================================= variable "entra_enabled" { description = "Enable Microsoft Entra ID as authentication provider" type = bool default = false } variable "entra_tenant_id" { description = "Azure AD Tenant ID (Directory/tenant ID from Azure Portal)" type = string default = "" } variable "entra_client_id" { description = "Entra ID Application (client) ID" type = string default = "" } variable "entra_client_secret" { description = "Entra ID Client Secret (Application secret value)" type = string default = "" sensitive = true } variable "idp_group_filter_prefix" { description = "Comma-separated list of prefixes to filter IdP groups in IAM > Groups page (e.g., 'mcp-,registry-'). Applies to all identity providers." type = string default = "" } # ============================================================================= # OKTA CONFIGURATION # ============================================================================= variable "okta_enabled" { description = "Enable Okta as authentication provider" type = bool default = false } variable "okta_domain" { description = "Okta domain (e.g., dev-12345678.okta.com or your-org.okta.com)" type = string default = "" } variable "okta_client_id" { description = "Okta Web Application (client) ID" type = string default = "" } variable "okta_client_secret" { description = "Okta Client Secret" type = string default = "" sensitive = true } variable "okta_m2m_client_id" { description = "Okta M2M Client ID (for service account operations)" type = string default = "" } variable "okta_m2m_client_secret" { description = "Okta M2M Client Secret" type = string default = "" sensitive = true } variable "okta_api_token" { description = "Okta API Token (for IAM management operations)" type = string default = "" sensitive = true } variable "okta_auth_server_id" { description = "Okta Custom Authorization Server ID (optional - for M2M tokens)" type = string default = "" } # ============================================================================= # AUTH0 CONFIGURATION # ============================================================================= variable "auth0_enabled" { description = "Enable Auth0 as authentication provider" type = bool default = false } variable "auth0_domain" { description = "Auth0 domain (e.g., your-tenant.us.auth0.com)" type = string default = "" } variable "auth0_client_id" { description = "Auth0 Web Application (client) ID" type = string default = "" } variable "auth0_client_secret" { description = "Auth0 Client Secret" type = string default = "" sensitive = true } variable "auth0_audience" { description = "Auth0 API Audience (optional - for API access tokens)" type = string default = "" } variable "auth0_groups_claim" { description = "Auth0 custom claim for group memberships (must be namespaced URI)" type = string default = "https://mcp-gateway/groups" } variable "auth0_m2m_client_id" { description = "Auth0 M2M Client ID (for IAM Management - user/role administration)" type = string default = "" } variable "auth0_m2m_client_secret" { description = "Auth0 M2M Client Secret" type = string default = "" sensitive = true } variable "auth0_management_api_token" { description = "Auth0 Management API Token (alternative to M2M credentials)" type = string default = "" sensitive = true } # ============================================================================= # OAUTH TOKEN STORAGE CONFIGURATION # ============================================================================= variable "oauth_store_tokens_in_session" { description = "Store OAuth provider tokens in session cookies. Set to false to avoid cookie size limits with large tokens (e.g., Entra ID). Tokens are not used functionally." type = bool default = false } # ============================================================================= # REGISTRY STATIC TOKEN AUTH (IdP-independent API access) # ============================================================================= variable "registry_static_token_auth_enabled" { description = "Enable static token auth for Registry API endpoints (/api/*, /v0.1/*). MCP Gateway endpoints still require full IdP authentication." type = bool default = false } variable "registry_api_token" { description = "Static API key for Registry API. Clients send: Authorization: Bearer . Generate with: python3 -c \"import secrets; print(secrets.token_urlsafe(32))\"" type = string default = "" sensitive = true } variable "registry_api_keys" { description = "JSON string configuring multiple static API keys with per-key group assignments. Example: '{\"monitoring\":{\"key\":\"\",\"groups\":[\"mcp-readonly\"]}}'" type = string default = "" sensitive = true } variable "max_tokens_per_user_per_hour" { description = "Maximum JWT tokens that can be vended per user per hour." type = number default = 100 } # ============================================================================= # REGISTRATION WEBHOOK (Issue #742) # ============================================================================= variable "registration_webhook_url" { description = "Webhook URL to POST to on successful registration or deletion. Disabled if empty." type = string default = "" } variable "registration_webhook_auth_header" { description = "Auth header name for webhook requests (e.g. Authorization, X-API-Key). If Authorization, Bearer is auto-prepended." type = string default = "Authorization" } variable "registration_webhook_auth_token" { description = "Auth token for webhook requests. Leave empty for unauthenticated webhooks." type = string default = "" sensitive = true } variable "registration_webhook_timeout_seconds" { description = "Timeout for webhook HTTP calls in seconds." type = number default = 10 } # ============================================================================= # REGISTRATION GATE / ADMISSION CONTROL (Issue #809) # ============================================================================= variable "registration_gate_enabled" { description = "Enable the registration gate (admission control). When enabled, an external endpoint must approve registrations and updates before they are persisted. Default: false." type = bool default = false } variable "registration_gate_url" { description = "URL of the registration gate endpoint. Must be set when gate is enabled." type = string default = "" } variable "registration_gate_auth_type" { description = "Auth type for the gate endpoint: none, api_key, or bearer. Default: none." type = string default = "none" } variable "registration_gate_auth_credential" { description = "Auth credential for the gate endpoint (used with api_key or bearer auth types)." type = string default = "" sensitive = true } variable "registration_gate_auth_header_name" { description = "Header name when auth_type=api_key. Default: X-Api-Key." type = string default = "X-Api-Key" } variable "registration_gate_timeout_seconds" { description = "HTTP timeout per gate request attempt in seconds. Default: 5." type = number default = 5 } variable "registration_gate_max_retries" { description = "Number of retries after the first gate attempt. Uses exponential backoff. Default: 2." type = number default = 2 } # ============================================================================= # M2M DIRECT CLIENT REGISTRATION (Issue #851) # ============================================================================= variable "m2m_direct_registration_enabled" { description = "Enable the admin API at /api/iam/m2m-clients that writes M2M client_ids and groups directly to the idp_m2m_clients collection without an IdP Admin API token. Default: true." type = bool default = true } # ============================================================================= # FEDERATION CONFIGURATION (Peer-to-Peer Registry Sync) # ============================================================================= variable "registry_id" { description = "Unique identifier for this registry instance in federation. Used to identify the source of synced items." type = string default = "" } variable "federation_static_token_auth_enabled" { description = "Enable static token auth for Federation API endpoints (/api/federation/*, /api/peers/*). When enabled, peer registries can authenticate using FEDERATION_STATIC_TOKEN." type = bool default = false } variable "federation_static_token" { description = "Static token for Federation API access. Peer registries use this as Bearer token. Generate with: python3 -c \"import secrets; print(secrets.token_urlsafe(32))\"" type = string default = "" sensitive = true } variable "federation_encryption_key" { description = "Fernet encryption key for storing federation tokens in MongoDB. Required on importing registry. Generate with: python3 -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" type = string default = "" sensitive = true } # ============================================================================= # AWS AGENT REGISTRY FEDERATION CONFIGURATION # ============================================================================= variable "aws_registry_federation_enabled" { description = "Enable AWS Agent Registry federation." type = bool default = false } # ============================================================================= # ANS (AGENT NAMING SERVICE) CONFIGURATION # ============================================================================= variable "ans_integration_enabled" { description = "Enable ANS integration for agent identity verification." type = bool default = false } variable "ans_api_endpoint" { description = "ANS API endpoint URL." type = string default = "https://api.godaddy.com" } variable "ans_api_key" { description = "ANS API key for authentication." type = string default = "" sensitive = true } variable "ans_api_secret" { description = "ANS API secret for authentication." type = string default = "" sensitive = true } variable "ans_api_timeout_seconds" { description = "ANS API request timeout in seconds." type = number default = 30 } variable "ans_sync_interval_hours" { description = "How often to re-sync ANS verification status (in hours)." type = number default = 6 } variable "ans_verification_cache_ttl_seconds" { description = "Cache TTL for ANS verification results (in seconds)." type = number default = 3600 } # ============================================================================= # AUDIT LOGGING CONFIGURATION # ============================================================================= variable "audit_log_enabled" { description = "Enable audit logging for all API and MCP requests. Logs are stored in DocumentDB with automatic TTL-based retention." type = bool default = true } variable "audit_log_ttl_days" { description = "Audit log retention period in days. Logs older than this are automatically deleted via DocumentDB TTL index. Common values: 7 (dev), 30 (standard), 90 (compliance)." type = number default = 7 validation { condition = var.audit_log_ttl_days >= 1 && var.audit_log_ttl_days <= 365 error_message = "Audit log TTL must be between 1 and 365 days" } } # ============================================================================= # APPLICATION LOG CONFIGURATION # ============================================================================= variable "app_log_centralized_enabled" { description = "Write application logs to a centralized store for cross-pod retrieval." type = bool default = true } variable "app_log_centralized_ttl_days" { description = "Days to retain centralized application logs (TTL index). Common values: 1 (dev), 3 (staging), 7 (production)." type = number default = 1 validation { condition = var.app_log_centralized_ttl_days >= 1 && var.app_log_centralized_ttl_days <= 365 error_message = "Application log TTL must be between 1 and 365 days" } } variable "app_log_level" { description = "Application log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)." type = string default = "INFO" } variable "app_log_excluded_loggers" { description = "Comma-separated logger names to exclude from MongoDB log writes." type = string default = "uvicorn.access,httpx,pymongo,motor" } # ============================================================================= # REGISTRY CARD CONFIGURATION (Federation Metadata) # ============================================================================= variable "registry_name" { description = "Human-readable registry name for federation and discovery. If not set, a random Docker-style name will be generated." type = string default = "" } variable "registry_organization_name" { description = "Organization that operates this registry. Defaults to 'ACME Inc.' if not set." type = string default = "" } variable "registry_description" { description = "Registry description for federation discovery." type = string default = "" } variable "registry_contact_email" { description = "Contact email for registry administrators. Leave empty if not publicly shared." type = string default = "" } variable "registry_contact_url" { description = "Documentation or support URL for this registry. Leave empty if not available." type = string default = "" } # ============================================================================= # DEPLOYMENT MODE CONFIGURATION # ============================================================================= variable "deployment_mode" { description = <<-EOT Controls how the registry integrates with the gateway/nginx. - "with-gateway" (default): Full integration with nginx reverse proxy. Nginx config is regenerated when servers are registered/deleted. Frontend shows gateway authentication instructions. - "registry-only": Registry operates as catalog/discovery service only. Nginx config is NOT updated on server changes. Frontend shows direct connection mode (proxy_pass_url). Use when registry is separate from gateway infrastructure. EOT type = string default = "with-gateway" validation { condition = contains(["with-gateway", "registry-only"], var.deployment_mode) error_message = "deployment_mode must be either 'with-gateway' or 'registry-only'" } } variable "registry_mode" { description = <<-EOT Controls which features are enabled (informational - for UI feature flags). This setting affects the /api/config response which the frontend can use to show/hide navigation elements. Currently informational only - all APIs remain active. - "full" (default): All features enabled (mcp_servers, agents, skills, federation) - "skills-only": Only skills feature flag enabled - "mcp-servers-only": Only MCP server feature flag enabled - "agents-only": Only A2A agent feature flag enabled Note: with-gateway + skills-only is invalid and auto-corrects to registry-only + skills-only EOT type = string default = "full" validation { condition = contains(["full", "skills-only", "mcp-servers-only", "agents-only"], var.registry_mode) error_message = "registry_mode must be one of: 'full', 'skills-only', 'mcp-servers-only', 'agents-only'" } } variable "show_servers_tab" { description = "Show the MCP Servers tab in the UI. AND-ed with registry_mode." type = bool default = true } variable "show_virtual_servers_tab" { description = "Show the Virtual MCP Servers tab in the UI." type = bool default = true } variable "show_skills_tab" { description = "Show the Skills tab in the UI. AND-ed with registry_mode." type = bool default = true } variable "show_agents_tab" { description = "Show the Agents tab in the UI. AND-ed with registry_mode." type = bool default = true } # ============================================================================= # OBSERVABILITY CONFIGURATION (Metrics Pipeline) # ============================================================================= variable "enable_observability" { description = "Enable full observability pipeline (AMP, metrics-service, ADOT collector, Grafana). When false, no observability resources are created." type = bool default = true } variable "metrics_service_image_uri" { description = "Container image URI for metrics-service. Required when enable_observability is true." type = string default = "" } variable "grafana_image_uri" { description = "Container image URI for Grafana OSS (custom image with baked-in provisioning). Required when enable_observability is true." type = string default = "" } variable "grafana_admin_password" { description = "Admin password for Grafana. Must be set when enable_observability is true." type = string sensitive = true default = "" } variable "otel_otlp_endpoint" { description = "OTLP endpoint for pushing metrics to an external platform (e.g., Datadog). Leave empty to disable." type = string default = "" } variable "otel_exporter_otlp_headers" { description = "Headers for OTLP exporter (e.g., 'dd-api-key=YOUR_KEY' for Datadog). Stored in Secrets Manager. Leave empty if not needed." type = string sensitive = true default = "" } variable "otel_otlp_export_interval_ms" { description = "OTLP export interval in milliseconds. Default 30000 (30 seconds)." type = number default = 30000 } variable "otel_exporter_otlp_metrics_temporality_preference" { description = "OTLP metrics temporality preference. Datadog requires delta. Default cumulative." type = string default = "cumulative" } # ============================================================================= # TELEMETRY CONFIGURATION (Issue #559) # ============================================================================= variable "mcp_telemetry_disabled" { description = "Disable anonymous startup telemetry. Set to '1' to opt out." type = string default = "" } variable "mcp_telemetry_opt_out" { description = "Disable daily heartbeat telemetry only. Set to '1' to opt out (startup ping still sent)." type = string default = "" } variable "mcp_telemetry_heartbeat_interval_minutes" { description = "Heartbeat telemetry interval in minutes. Default: 1440 (24 hours)." type = string default = "1440" } variable "telemetry_debug" { description = "Enable telemetry debug mode (logs payload instead of sending). Set to 'true' to enable." type = string default = "false" } variable "disable_ai_registry_tools_server" { description = "Disable auto-registration of the built-in airegistry-tools server on startup. Set to 'true' for GitOps/production deployments." type = string default = "false" } # ============================================================================= # GITHUB PRIVATE REPO AUTH (Issue #814) # ============================================================================= variable "github_pat" { description = "GitHub Personal Access Token for private repo SKILL.md access. Generate at https://github.com/settings/tokens with 'repo' scope." type = string default = "" sensitive = true } variable "github_app_id" { description = "GitHub App ID for installation-based auth." type = string default = "" } variable "github_app_installation_id" { description = "GitHub App Installation ID." type = string default = "" } variable "github_app_private_key" { description = "GitHub App private key (PEM format). Newlines should be encoded as literal \\n." type = string default = "" sensitive = true } variable "github_extra_hosts" { description = "Comma-separated extra GitHub hosts for enterprise instances (e.g. github.mycompany.com,raw.github.mycompany.com)." type = string default = "" } variable "github_api_base_url" { description = "GitHub API base URL. For GitHub Enterprise Server use https:///api/v3." type = string default = "https://api.github.com" } # ============================================================================= # WAF CONFIGURATION (Issue #603 Security Hardening) # ============================================================================= variable "enable_waf" { description = "Enable WAFv2 Web ACLs for ALBs. Requires wafv2:* IAM permissions. Set to false if IAM permissions are not available." type = bool default = false } ================================================ FILE: terraform/aws-ecs/vpc.tf ================================================ data "aws_availability_zones" "available" { state = "available" } locals { azs = slice(data.aws_availability_zones.available.names, 0, 3) # VPC endpoint service name prefix varies by partition and endpoint type # Gateway endpoints (S3, DynamoDB): com.amazonaws.{region}.{service} (same in all regions) # Interface endpoints (STS, etc): # - Standard AWS: com.amazonaws.{region}.{service} # - China regions: cn.com.amazonaws.{region}.{service} interface_endpoint_prefix = data.aws_partition.current.partition == "aws-cn" ? "cn.com.amazonaws" : "com.amazonaws" gateway_endpoint_prefix = "com.amazonaws" } #checkov:skip=CKV_TF_1:Module version is pinned via version constraint module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 6.0" name = "${var.name}-vpc" cidr = var.vpc_cidr azs = local.azs private_subnets = [for k, v in local.azs : cidrsubnet(var.vpc_cidr, 4, k)] public_subnets = [for k, v in local.azs : cidrsubnet(var.vpc_cidr, 8, k + 48)] enable_nat_gateway = true single_nat_gateway = false one_nat_gateway_per_az = true enable_dns_hostnames = true enable_dns_support = true # VPC Flow Logs enable_flow_log = false # Tags for ECS and ALB usage private_subnet_tags = { "subnet-type" = "private" } public_subnet_tags = { "subnet-type" = "public" } } # VPC Endpoints for AWS services resource "aws_vpc_endpoint" "sts" { vpc_id = module.vpc.vpc_id service_name = "${local.interface_endpoint_prefix}.${data.aws_region.current.region}.sts" vpc_endpoint_type = "Interface" subnet_ids = module.vpc.private_subnets security_group_ids = [aws_security_group.vpc_endpoints.id] private_dns_enabled = true } resource "aws_vpc_endpoint" "s3" { vpc_id = module.vpc.vpc_id service_name = "${local.gateway_endpoint_prefix}.${data.aws_region.current.region}.s3" vpc_endpoint_type = "Gateway" route_table_ids = module.vpc.private_route_table_ids } # Security group for VPC endpoints resource "aws_security_group" "vpc_endpoints" { name = "${var.name}-vpc-endpoints" description = "Security group for VPC interface endpoints allowing HTTPS from within VPC" vpc_id = module.vpc.vpc_id ingress { description = "Allow HTTPS from VPC CIDR for AWS service endpoints" from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = [module.vpc.vpc_cidr_block] } tags = merge( local.common_tags, { Name = "${var.name}-vpc-endpoints" } ) } ================================================ FILE: terraform/aws-ecs/waf.tf ================================================ # # WAFv2 Web ACL Configuration for MCP Gateway and Keycloak ALBs # Set enable_waf = false in terraform.tfvars if you don't have wafv2:* IAM permissions # # WAFv2 Web ACL for MCP Gateway ALB resource "aws_wafv2_web_acl" "mcp_gateway" { count = var.enable_waf ? 1 : 0 name = "${var.name}-mcp-gateway-waf" scope = "REGIONAL" default_action { allow {} } # AWS Managed Rules - Common Rule Set rule { name = "AWSManagedRulesCommonRuleSet" priority = 1 override_action { none {} } statement { managed_rule_group_statement { name = "AWSManagedRulesCommonRuleSet" vendor_name = "AWS" } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "AWSManagedRulesCommonRuleSetMetric" sampled_requests_enabled = true } } # AWS Managed Rules - Known Bad Inputs rule { name = "AWSManagedRulesKnownBadInputsRuleSet" priority = 2 override_action { none {} } statement { managed_rule_group_statement { name = "AWSManagedRulesKnownBadInputsRuleSet" vendor_name = "AWS" } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "AWSManagedRulesKnownBadInputsRuleSetMetric" sampled_requests_enabled = true } } # IP-based rate limiting rule (100 req/5min per IP) rule { name = "IPRateLimitRule" priority = 3 action { block {} } statement { rate_based_statement { limit = 100 aggregate_key_type = "IP" } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "IPRateLimitRuleMetric" sampled_requests_enabled = true } } # Global rate limiting rule (2000 req/5min globally) rule { name = "GlobalRateLimitRule" priority = 4 action { block {} } statement { rate_based_statement { limit = 2000 aggregate_key_type = "CONSTANT" } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "GlobalRateLimitRuleMetric" sampled_requests_enabled = true } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "${var.name}-mcp-gateway-waf" sampled_requests_enabled = true } tags = merge( local.common_tags, { Purpose = "WAF protection for MCP Gateway ALB" Component = "security" } ) } # Associate WAF with MCP Gateway ALB resource "aws_wafv2_web_acl_association" "mcp_gateway" { count = var.enable_waf ? 1 : 0 resource_arn = module.mcp_gateway.alb_arn web_acl_arn = aws_wafv2_web_acl.mcp_gateway[0].arn } # CloudWatch Log Group for WAF logs resource "aws_cloudwatch_log_group" "waf_mcp_gateway" { count = var.enable_waf ? 1 : 0 name = "/aws/wafv2/${var.name}-mcp-gateway" retention_in_days = 30 tags = merge( local.common_tags, { Purpose = "WAF logs for MCP Gateway" Component = "security" } ) } # WAF Logging Configuration resource "aws_wafv2_web_acl_logging_configuration" "mcp_gateway" { count = var.enable_waf ? 1 : 0 resource_arn = aws_wafv2_web_acl.mcp_gateway[0].arn log_destination_configs = [aws_cloudwatch_log_group.waf_mcp_gateway[0].arn] # Redact sensitive headers from logs redacted_fields { single_header { name = "authorization" } } } # WAFv2 Web ACL for Keycloak ALB resource "aws_wafv2_web_acl" "keycloak" { count = var.enable_waf ? 1 : 0 name = "${var.name}-keycloak-waf" scope = "REGIONAL" default_action { allow {} } # AWS Managed Rules - Common Rule Set rule { name = "AWSManagedRulesCommonRuleSet" priority = 1 override_action { none {} } statement { managed_rule_group_statement { name = "AWSManagedRulesCommonRuleSet" vendor_name = "AWS" } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "AWSManagedRulesCommonRuleSetMetric" sampled_requests_enabled = true } } # AWS Managed Rules - Known Bad Inputs rule { name = "AWSManagedRulesKnownBadInputsRuleSet" priority = 2 override_action { none {} } statement { managed_rule_group_statement { name = "AWSManagedRulesKnownBadInputsRuleSet" vendor_name = "AWS" } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "AWSManagedRulesKnownBadInputsRuleSetMetric" sampled_requests_enabled = true } } # IP-based rate limiting rule (100 req/5min per IP) rule { name = "IPRateLimitRule" priority = 3 action { block {} } statement { rate_based_statement { limit = 100 aggregate_key_type = "IP" } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "IPRateLimitRuleMetric" sampled_requests_enabled = true } } # Global rate limiting rule (2000 req/5min globally) rule { name = "GlobalRateLimitRule" priority = 4 action { block {} } statement { rate_based_statement { limit = 2000 aggregate_key_type = "CONSTANT" } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "GlobalRateLimitRuleMetric" sampled_requests_enabled = true } } visibility_config { cloudwatch_metrics_enabled = true metric_name = "${var.name}-keycloak-waf" sampled_requests_enabled = true } tags = merge( local.common_tags, { Purpose = "WAF protection for Keycloak ALB" Component = "security" } ) } # Associate WAF with Keycloak ALB resource "aws_wafv2_web_acl_association" "keycloak" { count = var.enable_waf ? 1 : 0 resource_arn = aws_lb.keycloak.arn web_acl_arn = aws_wafv2_web_acl.keycloak[0].arn } # CloudWatch Log Group for Keycloak WAF logs resource "aws_cloudwatch_log_group" "waf_keycloak" { count = var.enable_waf ? 1 : 0 name = "/aws/wafv2/${var.name}-keycloak" retention_in_days = 30 tags = merge( local.common_tags, { Purpose = "WAF logs for Keycloak" Component = "security" } ) } # WAF Logging Configuration for Keycloak resource "aws_wafv2_web_acl_logging_configuration" "keycloak" { count = var.enable_waf ? 1 : 0 resource_arn = aws_wafv2_web_acl.keycloak[0].arn log_destination_configs = [aws_cloudwatch_log_group.waf_keycloak[0].arn] # Redact sensitive headers from logs redacted_fields { single_header { name = "authorization" } } } # Outputs output "mcp_gateway_waf_arn" { description = "ARN of WAF Web ACL for MCP Gateway" value = var.enable_waf ? aws_wafv2_web_acl.mcp_gateway[0].arn : "" } output "keycloak_waf_arn" { description = "ARN of WAF Web ACL for Keycloak" value = var.enable_waf ? aws_wafv2_web_acl.keycloak[0].arn : "" } ================================================ FILE: terraform/telemetry-collector/README.md ================================================ # Telemetry Collector Infrastructure Server-side telemetry collector for MCP Gateway Registry (Issue #559). ## Overview Privacy-first serverless telemetry collector that receives anonymous usage data from registry instances worldwide. **Architecture:** - **API Gateway HTTP API** - HTTPS endpoint for telemetry events - **Lambda Function** - VPC-enabled, validates and stores events - **DynamoDB** - Privacy-preserving rate limiting (IP hashing) - **DocumentDB** - MongoDB-compatible storage with 365-day TTL - **Secrets Manager** - Secure credential storage **Key Features:** - Always returns 204 (no information leakage) - Hash-based rate limiting (no IP storage) - VPC-secured DocumentDB - Fail-silent design (never blocks clients) - TLS encryption everywhere ## Architecture ``` Registry Instances (worldwide deployments) | | HTTPS POST /v1/collect | (startup + heartbeat events) v +----------------------------+ | API Gateway HTTP API | | (throttle: 50 req/s burst) | | (CORS: restricted origins) | +----------------------------+ | | AWS_PROXY integration v +----------------------------------------------------------------+ | AWS VPC (10.0.0.0/16) | | | | +------------------+ +------------------+ | | | Public Subnet | | Public Subnet | (2 AZs) | | | (10.0.0.0/24) | | (10.0.1.0/24) | | | | | | | | | | +- NAT Gateway --+ +-- NAT Gateway -+ | | | +--|---------------+ +----------------|--+ | | | | | | v v | | +------------------+ +------------------+ | | | Private Subnet | | Private Subnet | (2 AZs) | | | (10.0.10.0/24) | | (10.0.11.0/24) | | | | | | | | | | +------------+ | | +------------+ | | | | | Lambda | | | | DocumentDB | | | | | | Function |--+-----+->| Cluster | | | | | +------------+ | | | (TLS only) | | | | | | | | +------------+ | | | +-------|----------+ +------------------+ | | | | +----------|-----------------------------------------------------+ | | (via NAT Gateway) v +---------------------+ +---------------------+ | DynamoDB | | Secrets Manager | | (rate limiting) | | (DocumentDB creds) | | | | | | ip_hash -> counter | | username / password | | TTL auto-cleanup | | database name | +---------------------+ +---------------------+ Request Flow: --------------------------------------------------------------- 1. Registry sends HTTPS POST to API Gateway 2. API Gateway invokes Lambda (AWS_PROXY) 3. Lambda hashes source IP (SHA-256, never stored) 4. Lambda checks DynamoDB rate limit (10 req/min per IP) 5. Lambda validates payload (Pydantic schema) 6. Lambda fetches DocumentDB creds from Secrets Manager 7. Lambda stores event in DocumentDB (TLS connection) 8. Lambda returns 204 (always, regardless of outcome) Optional Bastion Host: --------------------------------------------------------------- When bastion_enabled = true, a t2.micro EC2 instance is created in a public subnet with SSH access for direct DocumentDB queries via mongosh. ``` ## Prerequisites - AWS CLI v2 configured with credentials - Terraform >= 1.0 - Python 3.14+ and pip (for Lambda packaging) - mongosh (optional, for DocumentDB index setup) ```bash # Verify prerequisites aws sts get-caller-identity terraform version python3 --version ``` ## Quick Start (Automated) The `deploy.sh` script handles everything end-to-end: ```bash cd terraform/telemetry-collector ./deploy.sh testing # ~$85-90/month # or ./deploy.sh production # ~$195-200/month ``` **What it does:** 1. Checks prerequisites (AWS CLI, Terraform) 2. Creates `terraform.tfvars` from template 3. Builds Lambda deployment package 4. Deploys all infrastructure (~15-20 minutes) 5. Configures DocumentDB indexes automatically 6. Tests with curl 7. Saves deployment info to `deployment-info.txt` After deployment, integrate with the registry: ```bash export MCP_TELEMETRY_ENDPOINT=https://[your-id].execute-api.us-east-1.amazonaws.com/v1/collect cd ../.. uv run python -m registry ``` ## Manual Deployment (Step by Step) ### Step 1: Configure Variables ```bash cd terraform/telemetry-collector # Copy example configuration and edit cp terraform.tfvars.example terraform.tfvars vi terraform.tfvars ``` **Required variables:** ```hcl aws_region = "us-east-1" deployment_stage = "testing" # or "production" documentdb_instance_class = "db.t3.medium" # or "db.r5.large" ``` **Optional variables (production):** ```hcl cors_allowed_origins = ["https://mcpgateway.io", "https://app.mcpgateway.io"] custom_domain = "telemetry.mcpgateway.io" route53_zone_id = "Z1234567890ABC" alarm_email = "alerts@example.com" ``` **Bastion host setup (optional, for direct DocumentDB access):** To enable the bastion host, you need an SSH key pair and your public IP: ```bash # Generate an SSH key pair (if you don't have one) ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "bastion-telemetry" # Get your public key cat ~/.ssh/id_ed25519.pub # Get your public IP curl -s ifconfig.me ``` Then set these in `terraform.tfvars`: ```hcl bastion_enabled = true bastion_public_key = "ssh-ed25519 AAAA... your-key-here" bastion_allowed_cidrs = ["YOUR_PUBLIC_IP/32"] # e.g. ["203.0.113.42/32"] ``` After deployment, set up the bastion helper scripts: ```bash # Run the setup script (copies connect.sh, query.sh, and config to bastion) ./bastion-scripts/setup-bastion.sh # Or do it manually with SCP: BASTION_IP=$(terraform output -raw bastion_public_ip) DOCDB_ENDPOINT=$(terraform output -raw documentdb_endpoint) SECRET_ARN=$(terraform output -raw documentdb_secret_arn) # Create config file with terraform output values cat > /tmp/bastion.env < Invocations` - **Lambda Errors**: `AWS/Lambda > Errors` - **API Gateway Requests**: `AWS/ApiGateway > Count` - **DynamoDB Operations**: `AWS/DynamoDB > ConsumedReadCapacityUnits` ### CloudWatch Alarms (Production Only) Alarms are automatically created when `deployment_stage = "production"` and `alarm_email` is set: - Lambda errors (> 10 in 5 minutes) - Lambda throttles (> 5 in 5 minutes) - Lambda duration (> 10 seconds average) - API Gateway 5xx errors (> 10 in 5 minutes) ### Query Telemetry Data ```bash DOCDB_ENDPOINT=$(terraform output -raw documentdb_endpoint) # Get password aws secretsmanager get-secret-value \ --secret-id telemetry-collector-docdb \ --query SecretString --output text | jq -r '.password' # Connect and query mongosh --host $DOCDB_ENDPOINT \ --username telemetry_admin \ --tls \ --tlsCAFile global-bundle.pem use telemetry; db.startup_events.find().count(); db.startup_events.find({"v": "1.0.16"}); db.heartbeat_events.find({"search_backend": "documentdb"}); ``` ## Production Deployment ### Custom Domain Setup 1. **Update variables:** ```hcl custom_domain = "telemetry.mcpgateway.io" route53_zone_id = "Z1234567890ABC" ``` 2. **Deploy:** ```bash terraform apply ``` 3. **Wait for certificate validation** (~5-10 minutes) 4. **Verify DNS:** ```bash dig telemetry.mcpgateway.io curl -X POST https://telemetry.mcpgateway.io/v1/collect -d '{}' ``` ### Enable Alarms ```hcl alarm_email = "alerts@example.com" deployment_stage = "production" ``` **Note:** You'll receive an SNS subscription confirmation email. Click the link to activate alarms. ## Updating the Collector ### Update Lambda Function Code When you change files in `lambda/collector/`, you must rebuild the zip, run terraform apply, AND force Lambda to pick up the new code. Terraform may not detect zip content changes if the file path and size are similar. ```bash cd terraform/telemetry-collector # Step 1: Rebuild the zip package (see Step 2 in Deployment above) cd lambda/collector && pip install -r requirements.txt -t . && cd ../.. zip -r lambda_function.zip lambda/collector/ # Step 2: Apply terraform (updates infrastructure and zip hash) terraform apply -auto-approve # Step 3: Force Lambda to use the new code # Terraform may cache the old zip hash — this ensures the update takes effect aws lambda update-function-code \ --function-name telemetry-collector \ --zip-file fileb://lambda_function.zip \ --region $(terraform output -raw aws_region) # Step 4: Verify the update aws logs tail /aws/lambda/telemetry-collector --since 1m --region $(terraform output -raw aws_region) ``` **Why Step 3 is needed:** Terraform tracks the zip file by its `filebase64sha256` hash. If the hash in the state file matches the new zip (e.g., due to caching), Terraform skips the Lambda update. The AWS CLI command forces the code update regardless. ### Update Infrastructure ```bash # Edit Terraform files (.tf) terraform apply ``` ## Troubleshooting ### Lambda cannot connect to DocumentDB **Symptoms:** CloudWatch logs show "Failed to connect to DocumentDB" or timeout errors. **Solution:** 1. Verify Lambda is in correct VPC and subnets: ```bash aws lambda get-function-configuration --function-name telemetry-collector | jq '.VpcConfig' ``` 2. Verify security groups allow traffic: ```bash aws ec2 describe-security-groups --filters Name=group-name,Values=telemetry-collector-* ``` 3. Verify DocumentDB is running: ```bash aws docdb describe-db-clusters --db-cluster-identifier telemetry-collector ``` ### Rate limiting not working **Symptoms:** More than 10 requests per minute from same IP are accepted. **Solution:** 1. Check DynamoDB table exists: ```bash aws dynamodb describe-table --table-name telemetry-collector-rate-limit ``` 2. Check TTL is enabled: ```bash aws dynamodb describe-time-to-live --table-name telemetry-collector-rate-limit ``` ### Always returns 204 even for valid events **This is expected behavior.** The collector always returns 204 for privacy (no information leakage). To verify events are being stored: 1. Check CloudWatch logs for "Stored startup event" 2. Query DocumentDB directly to verify documents are inserted ### Script fails at prerequisites check - Install AWS CLI: `brew install awscli` (macOS) or `sudo apt-get install awscli` (Linux) - Configure AWS: `aws configure` - Install Terraform: `brew install terraform` (macOS) or see https://developer.hashicorp.com/terraform/install ### High costs DocumentDB is the largest cost item. To minimize: - Use smallest instance (`db.t3.medium`) for testing - Destroy when not actively using: `./destroy.sh` - Consider MongoDB Atlas M0 (free) as an alternative for non-production use ## Files Reference **Source files:** - `lambda/collector/index.py` - Lambda handler code - `lambda/collector/schemas.py` - Pydantic validation schemas - `lambda/collector/requirements.txt` - Python dependencies **Terraform files:** - `*.tf` - Infrastructure definitions - `variables.tf` - All configurable variables - `terraform.tfvars.example` - Example configuration (copy to `terraform.tfvars`) **Generated files (not committed):** - `lambda_function.zip` - Lambda deployment package - `terraform.tfvars` - Your deployment configuration - `terraform.tfstate` - Terraform state (DO NOT DELETE) - `deployment-info.txt` - Collector URL, endpoints, test commands - `global-bundle.pem` - DocumentDB CA certificate ## Cleanup ```bash cd terraform/telemetry-collector ./destroy.sh ``` **Warning:** This deletes ALL telemetry data. Cannot be undone. Production deployments retain a final DocumentDB snapshot. ## Security Considerations 1. **No IP Logging:** Source IPs are hashed (SHA-256) for rate limiting only 2. **VPC Isolation:** DocumentDB is not internet-accessible 3. **TLS Everywhere:** All connections use TLS encryption 4. **Secrets Manager:** Credentials are encrypted at rest 5. **IAM Least Privilege:** Lambda has minimal required permissions 6. **Always 204:** No error messages leak system information 7. **CORS Restricted:** Only configured origins can submit telemetry via browser ## Support - **GitHub Issues:** https://github.com/agentic-community/mcp-gateway-registry/issues - **Client Code:** Issue #558 (client-side telemetry) - **Server Code:** Issue #559 (this infrastructure) ## License Same as parent repository (MCP Gateway Registry). ================================================ FILE: terraform/telemetry-collector/bastion-scripts/connect.sh ================================================ #!/bin/bash # Fetch credentials from Secrets Manager and connect to DocumentDB interactively set -e # Configuration (set by setup.sh) source ~/bastion.env SECRET=$(aws secretsmanager get-secret-value \ --secret-id "$SECRET_ARN" \ --region "$AWS_REGION" \ --query SecretString --output text) USERNAME=$(echo "$SECRET" | jq -r .username) PASSWORD=$(echo "$SECRET" | jq -r .password) DATABASE=$(echo "$SECRET" | jq -r .database) echo "Connecting to DocumentDB as $USERNAME..." export MONGOSH_PASSWORD="$PASSWORD" mongosh "mongodb://$USERNAME@$DOCDB_ENDPOINT:27017/$DATABASE" \ --tls \ --tlsCAFile ~/global-bundle.pem \ --retryWrites false \ --authenticationMechanism SCRAM-SHA-1 \ --password "$MONGOSH_PASSWORD" unset MONGOSH_PASSWORD ================================================ FILE: terraform/telemetry-collector/bastion-scripts/query.sh ================================================ #!/bin/bash # Run a quick summary query against telemetry collections set -e # Configuration (set by setup.sh) source ~/bastion.env SECRET=$(aws secretsmanager get-secret-value \ --secret-id "$SECRET_ARN" \ --region "$AWS_REGION" \ --query SecretString --output text) USERNAME=$(echo "$SECRET" | jq -r .username) PASSWORD=$(echo "$SECRET" | jq -r .password) DATABASE=$(echo "$SECRET" | jq -r .database) export MONGOSH_PASSWORD="$PASSWORD" mongosh "mongodb://$USERNAME@$DOCDB_ENDPOINT:27017/$DATABASE" \ --tls \ --tlsCAFile ~/global-bundle.pem \ --retryWrites false \ --authenticationMechanism SCRAM-SHA-1 \ --password "$MONGOSH_PASSWORD" \ --quiet \ --eval ' print("=== Startup Events ==="); print("Total:", db.startup_events.countDocuments()); print("Last 5:"); db.startup_events.find({}, {_id:0}) .sort({_id:-1}).limit(5).forEach(printjson); print("\n=== Heartbeat Events ==="); print("Total:", db.heartbeat_events.countDocuments()); print("Last 5:"); db.heartbeat_events.find({}, {_id:0}) .sort({_id:-1}).limit(5).forEach(printjson); print("\n=== Storage Backend Breakdown ==="); db.startup_events.aggregate([ {$group: {_id: "$storage", count: {$sum: 1}}}, {$sort: {count: -1}} ]).forEach(printjson); ' unset MONGOSH_PASSWORD ================================================ FILE: terraform/telemetry-collector/bastion-scripts/setup-bastion.sh ================================================ #!/bin/bash # Post-deploy script: installs tools and copies helper scripts to bastion host # Usage: ./bastion-scripts/setup-bastion.sh # Run from terraform/telemetry-collector/ after terraform apply set -e SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" TF_DIR="$(dirname "$SCRIPT_DIR")" SSH_KEY="${SSH_KEY:-~/.ssh/id_ed25519}" cd "$TF_DIR" # Get values from terraform outputs BASTION_IP=$(terraform output -raw bastion_public_ip 2>/dev/null) DOCDB_ENDPOINT=$(terraform output -raw documentdb_endpoint 2>/dev/null) SECRET_ARN=$(terraform output -raw documentdb_secret_arn 2>/dev/null) AWS_REGION=$(terraform output -raw aws_region 2>/dev/null || echo "us-east-1") if [ -z "$BASTION_IP" ] || [ "$BASTION_IP" = "Bastion not enabled" ]; then echo "Error: Could not get bastion IP. Is bastion_enabled = true?" exit 1 fi echo "Setting up bastion host at $BASTION_IP..." # Step 1: Install mongosh, jq, and download CA bundle on bastion echo "Installing mongosh and dependencies..." ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" ec2-user@"$BASTION_IP" 'bash -s' <<'REMOTE' sudo bash -c ' cat > /etc/yum.repos.d/mongodb-org-7.repo << EOF [mongodb-org-7] name=MongoDB Repository baseurl=https://repo.mongodb.org/yum/amazon/2023/mongodb-org/7.0/x86_64/ gpgcheck=1 enabled=1 gpgkey=https://pgp.mongodb.com/server-7.0.asc EOF dnf install -y mongodb-mongosh aws-cli jq ' [ -f ~/global-bundle.pem ] || curl -sS https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem -o ~/global-bundle.pem REMOTE # Step 2: Create bastion.env with terraform output values echo "Copying configuration and scripts..." cat > /tmp/bastion.env < dict[str, str]: """Load connection variables from ~/bastion.env. Returns: Dict with DOCDB_ENDPOINT, SECRET_ARN, AWS_REGION. Raises: SystemExit: If bastion.env is missing or incomplete. """ if not os.path.exists(BASTION_ENV_PATH): logger.error(f"Bastion env file not found: {BASTION_ENV_PATH}") logger.error("Run setup-bastion.sh first to configure the bastion host.") sys.exit(1) env = {} with open(BASTION_ENV_PATH) as f: for line in f: line = line.strip() if "=" in line and not line.startswith("#"): key, _, value = line.partition("=") env[key.strip()] = value.strip().strip('"') required_keys = ["DOCDB_ENDPOINT", "SECRET_ARN", "AWS_REGION"] for key in required_keys: if key not in env: logger.error(f"Missing {key} in {BASTION_ENV_PATH}") sys.exit(1) return env def _get_credentials( secret_arn: str, aws_region: str, ) -> dict[str, str]: """Fetch DocumentDB credentials from AWS Secrets Manager. Args: secret_arn: ARN of the secret in Secrets Manager. aws_region: AWS region for the Secrets Manager call. Returns: Dict with username, password, database. Raises: SystemExit: If credentials cannot be retrieved. """ try: result = subprocess.run( # nosec B603 B607 - hardcoded command [ "aws", "secretsmanager", "get-secret-value", "--secret-id", secret_arn, "--region", aws_region, "--query", "SecretString", "--output", "text", ], capture_output=True, text=True, check=True, timeout=30, ) # Parse secret and extract only needed fields — never log raw output parsed = json.loads(result.stdout.strip()) username = parsed["username"] password = parsed["password"] database = parsed.get("database", "telemetry") # Clear raw secret from memory del parsed return { "username": username, "password": password, "database": database, } except subprocess.CalledProcessError: logger.error("Failed to get secret from Secrets Manager (check ARN and permissions)") sys.exit(1) except (json.JSONDecodeError, KeyError): logger.error("Failed to parse secret (unexpected format)") sys.exit(1) def _run_mongosh( endpoint: str, username: str, password: str, database: str, eval_script: str, timeout: int = 120, ) -> str | None: """Run a mongosh eval command and return stdout. Args: endpoint: DocumentDB cluster endpoint. username: Database username. password: Database password. database: Database name. eval_script: JavaScript to evaluate. timeout: Command timeout in seconds. Returns: Stdout string on success, None on failure. """ conn_string = f"mongodb://{username}@{endpoint}:27017/{database}" try: result = subprocess.run( # nosec B603 B607 - hardcoded command [ "mongosh", conn_string, "--tls", "--tlsCAFile", CA_BUNDLE_PATH, "--retryWrites", "false", "--authenticationMechanism", "SCRAM-SHA-1", "--password", password, "--quiet", "--eval", eval_script, ], capture_output=True, text=True, check=True, timeout=timeout, ) return result.stdout.strip() except subprocess.CalledProcessError: logger.error("mongosh command failed (check connection and credentials)") return None except subprocess.TimeoutExpired: logger.error("mongosh command timed out") return None def _get_collection_count( endpoint: str, username: str, password: str, database: str, collection: str, ) -> int: """Get document count for a collection. Args: endpoint: DocumentDB cluster endpoint. username: Database username. password: Database password. database: Database name. collection: Collection name to count. Returns: Number of documents in the collection. """ eval_script = f"print(db.{collection}.countDocuments({{}}));" output = _run_mongosh(endpoint, username, password, database, eval_script, timeout=30) if output is None: logger.error(f"Failed to count documents in {collection}") return 0 try: return int(output) except ValueError: logger.error(f"Unexpected count output for {collection}: {output[:80]}") return 0 def _fetch_documents( endpoint: str, username: str, password: str, database: str, collection: str, ) -> list[dict]: """Fetch all documents from a DocumentDB collection. Args: endpoint: DocumentDB cluster endpoint. username: Database username. password: Database password. database: Database name. collection: Collection name to query. Returns: List of document dicts. """ eval_script = ( f"db.{collection}.find({{}}, {{_id:0}})" f".sort({{ts:1}}).forEach(d => print(JSON.stringify(d)));" ) output = _run_mongosh(endpoint, username, password, database, eval_script) if output is None: logger.error(f"Failed to fetch documents from {collection}") return [] documents = [] for line in output.split("\n"): line = line.strip() if not line: continue try: documents.append(json.loads(line)) except json.JSONDecodeError: logger.debug(f"Skipping non-JSON line: {line[:80]}") return documents def _delete_documents( endpoint: str, username: str, password: str, database: str, collection: str, ) -> int: """Delete all documents from a DocumentDB collection. Args: endpoint: DocumentDB cluster endpoint. username: Database username. password: Database password. database: Database name. collection: Collection name to purge. Returns: Number of documents deleted. """ eval_script = ( f"var r = db.{collection}.deleteMany({{}});" f"print(JSON.stringify({{deletedCount: r.deletedCount}}));" ) output = _run_mongosh(endpoint, username, password, database, eval_script) if output is None: logger.error(f"Failed to delete documents from {collection}") return 0 try: parsed = json.loads(output) return parsed.get("deletedCount", 0) except json.JSONDecodeError: logger.error(f"Failed to parse delete result for {collection}") return 0 def _write_csv( documents: list[dict], columns: list[str], output_path: str, ) -> int: """Write documents to a CSV file. Args: documents: List of document dicts. columns: Column names for the CSV header. output_path: Output file path. Returns: Number of rows written. """ with open(output_path, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=columns, extrasaction="ignore") writer.writeheader() for doc in documents: # Flatten nested $date objects from BSON extended JSON for key in ("stored_at", "ts"): val = doc.get(key) if isinstance(val, dict) and "$date" in val: doc[key] = val["$date"] writer.writerow(doc) return len(documents) def _resolve_collections( collection_arg: str, ) -> list[str]: """Resolve the --collection argument to a list of collection names. Args: collection_arg: "all", "startup_events", or "heartbeat_events". Returns: List of collection name strings. """ if collection_arg == "all": return list(COLLECTIONS) return [collection_arg] def _print_summary(documents: list[dict]) -> None: """Print a formatted summary of telemetry data. Args: documents: List of all documents (startup + heartbeat events). """ if not documents: return # Separate by event type startup_events = [d for d in documents if d.get("event") == "startup"] heartbeat_events = [d for d in documents if d.get("event") == "heartbeat"] # Get unique registry IDs startup_ids: set[str] = {d.get("registry_id") for d in startup_events if d.get("registry_id")} heartbeat_ids: set[str] = { d.get("registry_id") for d in heartbeat_events if d.get("registry_id") } all_ids = startup_ids | heartbeat_ids print("\n" + "=" * 80) print("TELEMETRY DATA SUMMARY") print("=" * 80) print(f"\nTotal Events: {len(documents)}") print(f" - Startup Events: {len(startup_events):4d}") print(f" - Heartbeat Events: {len(heartbeat_events):4d}") print(f"\nUnique Registry Instances: {len(all_ids)}") print(f" - Sent Startup: {len(startup_ids):4d}") print(f" - Sent Heartbeat: {len(heartbeat_ids):4d}") # Aggregate field summaries for startup events if startup_events: print("\n" + "-" * 80) print("STARTUP EVENTS - Field Distribution") print("-" * 80) # Version distribution versions = Counter(d.get("v") for d in startup_events if d.get("v")) print(f"\nRegistry Versions ({len(versions)} unique):") for version, count in versions.most_common(10): print(f" {version:20s} : {count:4d} ({count / len(startup_events) * 100:5.1f}%)") # Python version distribution py_versions = Counter(d.get("py") for d in startup_events if d.get("py")) print(f"\nPython Versions ({len(py_versions)} unique):") for py_ver, count in py_versions.most_common(): print(f" Python {py_ver:15s} : {count:4d} ({count / len(startup_events) * 100:5.1f}%)") # OS distribution os_dist = Counter(d.get("os") for d in startup_events if d.get("os")) print(f"\nOperating Systems ({len(os_dist)} unique):") for os_name, count in os_dist.most_common(): print(f" {os_name:20s} : {count:4d} ({count / len(startup_events) * 100:5.1f}%)") # Cloud provider distribution cloud_dist = Counter(d.get("cloud") for d in startup_events if d.get("cloud")) print(f"\nCloud Providers ({len(cloud_dist)} unique):") for cloud, count in cloud_dist.most_common(): print(f" {cloud:20s} : {count:4d} ({count / len(startup_events) * 100:5.1f}%)") # Compute platform distribution compute_dist = Counter(d.get("compute") for d in startup_events if d.get("compute")) print(f"\nCompute Platforms ({len(compute_dist)} unique):") for compute, count in compute_dist.most_common(): print(f" {compute:20s} : {count:4d} ({count / len(startup_events) * 100:5.1f}%)") # Storage backend distribution storage_dist = Counter(d.get("storage") for d in startup_events if d.get("storage")) print(f"\nStorage Backends ({len(storage_dist)} unique):") for storage, count in storage_dist.most_common(): print(f" {storage:20s} : {count:4d} ({count / len(startup_events) * 100:5.1f}%)") # Auth provider distribution auth_dist = Counter(d.get("auth") for d in startup_events if d.get("auth")) print(f"\nAuth Providers ({len(auth_dist)} unique):") for auth, count in auth_dist.most_common(): print(f" {auth:20s} : {count:4d} ({count / len(startup_events) * 100:5.1f}%)") # Federation enabled federation_count = sum(1 for d in startup_events if d.get("federation") is True) print( f"\nFederation Enabled: {federation_count:4d} ({federation_count / len(startup_events) * 100:5.1f}%)" ) # Deployment mode mode_dist = Counter(d.get("mode") for d in startup_events if d.get("mode")) print(f"\nDeployment Modes ({len(mode_dist)} unique):") for mode, count in mode_dist.most_common(): print(f" {mode:20s} : {count:4d} ({count / len(startup_events) * 100:5.1f}%)") # Aggregate field summaries for heartbeat events if heartbeat_events: print("\n" + "-" * 80) print("HEARTBEAT EVENTS - Field Distribution") print("-" * 80) # Server count statistics server_counts = [ d.get("servers_count", 0) for d in heartbeat_events if d.get("servers_count") is not None ] if server_counts: print("\nRegistered MCP Servers:") print(f" Average: {sum(server_counts) / len(server_counts):.1f}") print(f" Min: {min(server_counts)}") print(f" Max: {max(server_counts)}") print(f" Total: {sum(server_counts)}") # Agent count statistics agent_counts = [ d.get("agents_count", 0) for d in heartbeat_events if d.get("agents_count") is not None ] if agent_counts: print("\nRegistered Agents:") print(f" Average: {sum(agent_counts) / len(agent_counts):.1f}") print(f" Min: {min(agent_counts)}") print(f" Max: {max(agent_counts)}") print(f" Total: {sum(agent_counts)}") # Skills count statistics skills_counts = [ d.get("skills_count", 0) for d in heartbeat_events if d.get("skills_count") is not None ] if skills_counts: print("\nRegistered Skills:") print(f" Average: {sum(skills_counts) / len(skills_counts):.1f}") print(f" Min: {min(skills_counts)}") print(f" Max: {max(skills_counts)}") print(f" Total: {sum(skills_counts)}") # Peers count statistics peers_counts = [ d.get("peers_count", 0) for d in heartbeat_events if d.get("peers_count") is not None ] if peers_counts: print("\nFederation Peers:") print(f" Average: {sum(peers_counts) / len(peers_counts):.1f}") print(f" Min: {min(peers_counts)}") print(f" Max: {max(peers_counts)}") print(f" Total: {sum(peers_counts)}") # Search backend distribution search_backend_dist = Counter( d.get("search_backend") for d in heartbeat_events if d.get("search_backend") ) print(f"\nSearch Backends ({len(search_backend_dist)} unique):") for backend, count in search_backend_dist.most_common(): print(f" {backend:20s} : {count:4d} ({count / len(heartbeat_events) * 100:5.1f}%)") # Embeddings provider distribution embeddings_dist = Counter( d.get("embeddings_provider") for d in heartbeat_events if d.get("embeddings_provider") ) print(f"\nEmbeddings Providers ({len(embeddings_dist)} unique):") for provider, count in embeddings_dist.most_common(): print(f" {provider:20s} : {count:4d} ({count / len(heartbeat_events) * 100:5.1f}%)") # Uptime statistics uptime_hours = [ d.get("uptime_hours", 0) for d in heartbeat_events if d.get("uptime_hours") is not None ] if uptime_hours: print("\nUptime (hours):") print(f" Average: {sum(uptime_hours) / len(uptime_hours):.1f}") print(f" Min: {min(uptime_hours):.1f}") print(f" Max: {max(uptime_hours):.1f}") # Search query statistics (common to both) # search_queries_total is a lifetime cumulative counter per instance, # so we deduplicate by taking max per registry_id before summing. print("\n" + "-" * 80) print("SEARCH QUERY STATISTICS") print("-" * 80) instance_max_total: dict[str, int] = {} instance_max_24h: dict[str, int] = {} instance_max_1h: dict[str, int] = {} for d in documents: rid = d.get("registry_id") or f"{d.get('cloud')}/{d.get('compute')}" sq_total = d.get("search_queries_total") sq_24h = d.get("search_queries_24h") sq_1h = d.get("search_queries_1h") if sq_total is not None: instance_max_total[rid] = max(instance_max_total.get(rid, 0), sq_total) if sq_24h is not None: instance_max_24h[rid] = max(instance_max_24h.get(rid, 0), sq_24h) if sq_1h is not None: instance_max_1h[rid] = max(instance_max_1h.get(rid, 0), sq_1h) if instance_max_total: fleet_total = sum(instance_max_total.values()) instances_with_search = sum(1 for v in instance_max_total.values() if v > 0) print("\nTotal Search Queries (lifetime, deduplicated per instance):") print(f" Fleet Total: {fleet_total:,}") print(f" Instances with search activity: {instances_with_search}") print(f" Max from single instance: {max(instance_max_total.values()):,}") if instance_max_24h: fleet_24h = sum(instance_max_24h.values()) print("\nSearch Queries (max 24h window per instance):") print(f" Fleet Total: {fleet_24h:,}") print(f" Max from single instance: {max(instance_max_24h.values()):,}") if instance_max_1h: fleet_1h = sum(instance_max_1h.values()) print("\nSearch Queries (max 1h window per instance):") print(f" Fleet Total: {fleet_1h:,}") print(f" Max from single instance: {max(instance_max_1h.values()):,}") print("\n" + "=" * 80 + "\n") def _connect(args: argparse.Namespace) -> tuple[dict[str, str], dict[str, str]]: """Load bastion env and fetch credentials. Args: args: Parsed CLI arguments (uses args.debug). Returns: Tuple of (env_dict, credentials_dict). """ env = _load_bastion_env() logger.info(f"DocumentDB endpoint: {env['DOCDB_ENDPOINT']}") creds = _get_credentials(env["SECRET_ARN"], env["AWS_REGION"]) logger.info("Using configured database for telemetry DocumentDB connection") return env, creds # --------------------------------------------------------------------------- # Public subcommand handlers # --------------------------------------------------------------------------- def cmd_export(args: argparse.Namespace) -> None: """Handle the 'export' subcommand — dump telemetry data to CSV. Args: args: Parsed CLI arguments. """ env, creds = _connect(args) target_collections = _resolve_collections(args.collection) start_time = time.time() all_documents = [] for collection in target_collections: logger.info(f"Fetching {collection}...") docs = _fetch_documents( endpoint=env["DOCDB_ENDPOINT"], username=creds["username"], password=creds["password"], database=creds["database"], collection=collection, ) logger.info(f" Found {len(docs)} documents") all_documents.extend(docs) if not all_documents: logger.warning("No documents found. CSV not created.") return # Print summary statistics _print_summary(all_documents) # Determine columns based on collection if args.collection == "startup_events": columns = STARTUP_COLUMNS elif args.collection == "heartbeat_events": columns = HEARTBEAT_COLUMNS else: columns = ALL_COLUMNS rows_written = _write_csv(all_documents, columns, args.output) elapsed = time.time() - start_time logger.info(f"Exported {rows_written} rows to {args.output} in {elapsed:.1f}s") def cmd_purge(args: argparse.Namespace) -> None: """Handle the 'purge' subcommand — delete telemetry data from DocumentDB. Args: args: Parsed CLI arguments. """ env, creds = _connect(args) target_collections = _resolve_collections(args.collection) # Show counts before deletion total_count = 0 for collection in target_collections: count = _get_collection_count( endpoint=env["DOCDB_ENDPOINT"], username=creds["username"], password=creds["password"], database=creds["database"], collection=collection, ) logger.info(f" {collection}: {count} documents") total_count += count if total_count == 0: logger.info("No documents to delete.") return # Confirm deletion if not args.confirm: answer = input( f"\nDelete {total_count} documents from {', '.join(target_collections)}? [y/N] " ) if answer.lower() != "y": logger.info("Aborted.") return # Delete documents start_time = time.time() total_deleted = 0 for collection in target_collections: logger.info(f"Purging {collection}...") deleted = _delete_documents( endpoint=env["DOCDB_ENDPOINT"], username=creds["username"], password=creds["password"], database=creds["database"], collection=collection, ) logger.info(f" Deleted {deleted} documents from {collection}") total_deleted += deleted elapsed = time.time() - start_time logger.info(f"Purged {total_deleted} total documents in {elapsed:.1f}s") def main(): """Parse arguments and dispatch to the appropriate subcommand.""" parser = argparse.ArgumentParser( description="Manage telemetry data in DocumentDB", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python3 telemetry_db.py export python3 telemetry_db.py export --output /tmp/metrics.csv python3 telemetry_db.py export --collection startup_events python3 telemetry_db.py purge python3 telemetry_db.py purge --collection heartbeat_events python3 telemetry_db.py purge --confirm """, ) parser.add_argument( "--debug", action="store_true", help="Enable debug logging", ) subparsers = parser.add_subparsers(dest="command", required=True) # --- export subcommand --- export_parser = subparsers.add_parser( "export", help="Export telemetry data to CSV", ) export_parser.add_argument( "--output", default=DEFAULT_OUTPUT, help=f"Output CSV file path (default: {DEFAULT_OUTPUT})", ) export_parser.add_argument( "--collection", choices=["all", "startup_events", "heartbeat_events"], default="all", help="Which collection to export (default: all)", ) # --- purge subcommand --- purge_parser = subparsers.add_parser( "purge", help="Delete all telemetry data from DocumentDB", ) purge_parser.add_argument( "--collection", choices=["all", "startup_events", "heartbeat_events"], default="all", help="Which collection to purge (default: all)", ) purge_parser.add_argument( "--confirm", action="store_true", help="Skip interactive confirmation prompt", ) args = parser.parse_args() if args.debug: logging.getLogger().setLevel(logging.DEBUG) if args.command == "export": cmd_export(args) elif args.command == "purge": cmd_purge(args) if __name__ == "__main__": main() ================================================ FILE: terraform/telemetry-collector/bastion.tf ================================================ # Bastion host for DocumentDB access # Free tier: t2.micro, Amazon Linux 2023, in public subnet # IAM role for bastion to read Secrets Manager resource "aws_iam_role" "bastion" { count = var.bastion_enabled ? 1 : 0 name = "telemetry-collector-bastion-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "ec2.amazonaws.com" } }] }) } resource "aws_iam_role_policy" "bastion_secrets" { count = var.bastion_enabled ? 1 : 0 name = "bastion-read-secrets" role = aws_iam_role.bastion[0].id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = "secretsmanager:GetSecretValue" Resource = aws_secretsmanager_secret.documentdb_credentials.arn }] }) } resource "aws_iam_instance_profile" "bastion" { count = var.bastion_enabled ? 1 : 0 name = "telemetry-collector-bastion-profile" role = aws_iam_role.bastion[0].name } # Key pair for SSH access resource "aws_key_pair" "bastion" { count = var.bastion_enabled ? 1 : 0 key_name = "telemetry-collector-bastion" public_key = var.bastion_public_key tags = { Name = "telemetry-collector-bastion" } } # Security group for bastion resource "aws_security_group" "bastion" { count = var.bastion_enabled ? 1 : 0 name = "telemetry-collector-bastion-sg" description = "Bastion host for DocumentDB access" vpc_id = aws_vpc.telemetry.id ingress { description = "SSH" from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = var.bastion_allowed_cidrs } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "telemetry-collector-bastion-sg" } } # Allow bastion to reach DocumentDB resource "aws_security_group_rule" "docdb_from_bastion" { count = var.bastion_enabled ? 1 : 0 type = "ingress" from_port = 27017 to_port = 27017 protocol = "tcp" security_group_id = aws_security_group.documentdb.id source_security_group_id = aws_security_group.bastion[0].id description = "MongoDB from bastion" } # Latest Amazon Linux 2023 AMI data "aws_ami" "amazon_linux_2023" { count = var.bastion_enabled ? 1 : 0 most_recent = true owners = ["amazon"] filter { name = "name" values = ["al2023-ami-*-x86_64"] } filter { name = "virtualization-type" values = ["hvm"] } } # Bastion EC2 instance (t2.micro — free tier eligible) resource "aws_instance" "bastion" { count = var.bastion_enabled ? 1 : 0 ami = data.aws_ami.amazon_linux_2023[0].id instance_type = "t2.micro" subnet_id = aws_subnet.public[0].id vpc_security_group_ids = [aws_security_group.bastion[0].id] key_name = aws_key_pair.bastion[0].key_name associate_public_ip_address = true iam_instance_profile = aws_iam_instance_profile.bastion[0].name tags = { Name = "telemetry-collector-bastion" } } ================================================ FILE: terraform/telemetry-collector/check-status.sh ================================================ #!/bin/bash # Telemetry Collector Status Check Script # Run this every 6 hours during the 24-hour monitoring period set -e echo "=== Telemetry Collector Status Check ===" echo "Time: $(date -u)" echo "" echo "1. Lambda Errors (last 6 hours):" ERROR_COUNT=$(aws logs filter-log-events \ --log-group-name /aws/lambda/telemetry-collector \ --start-time $(($(date +%s) * 1000 - 21600000)) \ --filter-pattern "ERROR" \ --query 'length(events)' \ --output text 2>/dev/null || echo "0") echo " Errors: $ERROR_COUNT" if [ "$ERROR_COUNT" != "0" ] && [ "$ERROR_COUNT" != "None" ]; then echo " ⚠️ WARNING: Errors detected!" fi echo "" echo "2. Lambda Invocations (last 24 hours):" INVOCATIONS=$(aws cloudwatch get-metric-statistics \ --namespace AWS/Lambda \ --metric-name Invocations \ --dimensions Name=FunctionName,Value=telemetry-collector \ --start-time $(date -u -v-24H +%Y-%m-%dT%H:%M:%S 2>/dev/null || date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%S) \ --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \ --period 86400 \ --statistics Sum \ --query 'Datapoints[0].Sum' \ --output text 2>/dev/null || echo "N/A") echo " Total: $INVOCATIONS" echo "" echo "3. Average Duration (last 24 hours):" AVG_DURATION=$(aws cloudwatch get-metric-statistics \ --namespace AWS/Lambda \ --metric-name Duration \ --dimensions Name=FunctionName,Value=telemetry-collector \ --start-time $(date -u -v-24H +%Y-%m-%dT%H:%M:%S 2>/dev/null || date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%S) \ --end-time $(date -u +%Y-%m-%dT%H:%M:%S) \ --period 86400 \ --statistics Average \ --query 'Datapoints[0].Average' \ --output text 2>/dev/null || echo "N/A") if [ "$AVG_DURATION" != "N/A" ] && [ "$AVG_DURATION" != "None" ]; then echo " Duration: ${AVG_DURATION}ms" # Check if duration is too high (>1000ms average) DURATION_INT=$(echo $AVG_DURATION | cut -d. -f1) if [ "$DURATION_INT" -gt 1000 ]; then echo " ⚠️ WARNING: Duration higher than expected!" fi else echo " Duration: No data yet" fi echo "" echo "4. Recent Events (last hour):" RECENT_EVENTS=$(aws logs filter-log-events \ --log-group-name /aws/lambda/telemetry-collector \ --start-time $(($(date +%s) * 1000 - 3600000)) \ --query 'events[*].message' \ --output text 2>/dev/null | grep -E "(Stored|Validated)" | tail -5) if [ -n "$RECENT_EVENTS" ]; then echo "$RECENT_EVENTS" else echo " No events in last hour" fi echo "" echo "5. Rate Limit Table Status:" RATE_LIMIT_COUNT=$(aws dynamodb scan \ --table-name telemetry-collector-rate-limit \ --select COUNT \ --query 'Count' \ --output text 2>/dev/null || echo "0") echo " Tracked IPs: $RATE_LIMIT_COUNT" echo "" echo "=== Status Check Complete ===" echo "" echo "Next check: $(date -u -v+6H +%Y-%m-%d\ %H:%M:%S\ UTC 2>/dev/null || date -u -d '6 hours' +%Y-%m-%d\ %H:%M:%S\ UTC)" ================================================ FILE: terraform/telemetry-collector/cloudwatch.tf ================================================ # CloudWatch log group for Lambda function resource "aws_cloudwatch_log_group" "telemetry_collector" { name = "/aws/lambda/telemetry-collector" retention_in_days = var.log_retention_days tags = { Name = "telemetry-collector-logs" } } # CloudWatch log group for API Gateway resource "aws_cloudwatch_log_group" "api_gateway" { name = "/aws/apigateway/telemetry-collector" retention_in_days = var.log_retention_days tags = { Name = "telemetry-collector-api-logs" } } # SNS topic for alarms (if email provided) resource "aws_sns_topic" "alarms" { count = var.alarm_email != "" ? 1 : 0 name = "telemetry-collector-alarms" tags = { Name = "telemetry-collector-alarms" } } # SNS topic subscription resource "aws_sns_topic_subscription" "alarm_email" { count = var.alarm_email != "" ? 1 : 0 topic_arn = aws_sns_topic.alarms[0].arn protocol = "email" endpoint = var.alarm_email } # CloudWatch alarm for Lambda errors resource "aws_cloudwatch_metric_alarm" "lambda_errors" { count = var.deployment_stage == "production" && var.alarm_email != "" ? 1 : 0 alarm_name = "telemetry-collector-lambda-errors" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "Errors" namespace = "AWS/Lambda" period = 300 statistic = "Sum" threshold = 10 alarm_description = "This metric monitors Lambda function errors" alarm_actions = [aws_sns_topic.alarms[0].arn] dimensions = { FunctionName = aws_lambda_function.telemetry_collector.function_name } } # CloudWatch alarm for Lambda throttles resource "aws_cloudwatch_metric_alarm" "lambda_throttles" { count = var.deployment_stage == "production" && var.alarm_email != "" ? 1 : 0 alarm_name = "telemetry-collector-lambda-throttles" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "Throttles" namespace = "AWS/Lambda" period = 300 statistic = "Sum" threshold = 5 alarm_description = "This metric monitors Lambda function throttles" alarm_actions = [aws_sns_topic.alarms[0].arn] dimensions = { FunctionName = aws_lambda_function.telemetry_collector.function_name } } # CloudWatch alarm for Lambda duration (high latency) resource "aws_cloudwatch_metric_alarm" "lambda_duration" { count = var.deployment_stage == "production" && var.alarm_email != "" ? 1 : 0 alarm_name = "telemetry-collector-lambda-duration" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "Duration" namespace = "AWS/Lambda" period = 300 statistic = "Average" threshold = 10000 # 10 seconds alarm_description = "This metric monitors Lambda function execution time" alarm_actions = [aws_sns_topic.alarms[0].arn] dimensions = { FunctionName = aws_lambda_function.telemetry_collector.function_name } } # CloudWatch alarm for API Gateway 5xx errors resource "aws_cloudwatch_metric_alarm" "api_gateway_5xx" { count = var.deployment_stage == "production" && var.alarm_email != "" ? 1 : 0 alarm_name = "telemetry-collector-api-5xx-errors" comparison_operator = "GreaterThanThreshold" evaluation_periods = 2 metric_name = "5XXError" namespace = "AWS/ApiGateway" period = 300 statistic = "Sum" threshold = 10 alarm_description = "This metric monitors API Gateway 5xx errors" alarm_actions = [aws_sns_topic.alarms[0].arn] dimensions = { ApiId = aws_apigatewayv2_api.telemetry.id } } ================================================ FILE: terraform/telemetry-collector/create-indexes.js ================================================ use telemetry; // TTL indexes (auto-delete after 365 days) db.startup_events.createIndex( { "received_at": 1 }, { expireAfterSeconds: 31536000 } ); db.heartbeat_events.createIndex( { "received_at": 1 }, { expireAfterSeconds: 31536000 } ); // Query indexes db.startup_events.createIndex({ "instance_id": 1 }); db.startup_events.createIndex({ "v": 1, "received_at": -1 }); db.heartbeat_events.createIndex({ "instance_id": 1 }); // Verify indexes print("\n=== Startup Events Indexes ==="); printjson(db.startup_events.getIndexes()); print("\n=== Heartbeat Events Indexes ==="); printjson(db.heartbeat_events.getIndexes()); // Show stored events print("\n=== Stored Startup Events ==="); print("Count: " + db.startup_events.count()); db.startup_events.find().forEach(printjson); ================================================ FILE: terraform/telemetry-collector/deploy.sh ================================================ #!/bin/bash # Deployment script for telemetry collector infrastructure # Usage: ./deploy.sh [testing|production] set -e # Exit on error # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Get script directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # Default to testing if no argument provided DEPLOYMENT_STAGE="${1:-testing}" if [[ "$DEPLOYMENT_STAGE" != "testing" && "$DEPLOYMENT_STAGE" != "production" ]]; then echo -e "${RED}Error: Deployment stage must be 'testing' or 'production'${NC}" echo "Usage: ./deploy.sh [testing|production]" exit 1 fi echo -e "${BLUE}========================================${NC}" echo -e "${BLUE}Telemetry Collector Deployment Script${NC}" echo -e "${BLUE}Stage: $DEPLOYMENT_STAGE${NC}" echo -e "${BLUE}========================================${NC}" echo "" # Function to check prerequisites check_prerequisites() { echo -e "${YELLOW}Checking prerequisites...${NC}" # Check AWS CLI if ! command -v aws &> /dev/null; then echo -e "${RED}Error: AWS CLI not found. Please install it first.${NC}" exit 1 fi # Check AWS credentials if ! aws sts get-caller-identity &> /dev/null; then echo -e "${RED}Error: AWS credentials not configured. Run 'aws configure' first.${NC}" exit 1 fi AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) echo -e "${GREEN}✓ AWS CLI configured (Account: $AWS_ACCOUNT_ID)${NC}" # Check Terraform if ! command -v terraform &> /dev/null; then echo -e "${RED}Error: Terraform not found. Please install it first.${NC}" exit 1 fi TERRAFORM_VERSION=$(terraform version -json | grep -o '"terraform_version":"[^"]*' | cut -d'"' -f4) echo -e "${GREEN}✓ Terraform installed (Version: $TERRAFORM_VERSION)${NC}" # Check if mongosh is available (for post-deployment index setup) if command -v mongosh &> /dev/null; then echo -e "${GREEN}✓ mongosh installed${NC}" else echo -e "${YELLOW}⚠ mongosh not found (needed for DocumentDB index setup)${NC}" echo -e "${YELLOW} Install: brew install mongosh (macOS) or download from MongoDB${NC}" fi echo "" } # Function to configure terraform.tfvars configure_variables() { echo -e "${YELLOW}Configuring deployment variables...${NC}" if [[ ! -f "$SCRIPT_DIR/terraform.tfvars" ]]; then echo -e "${BLUE}Creating terraform.tfvars from template...${NC}" cp "$SCRIPT_DIR/terraform.tfvars.example" "$SCRIPT_DIR/terraform.tfvars" # Update deployment_stage if [[ "$DEPLOYMENT_STAGE" == "testing" ]]; then sed -i.bak 's/deployment_stage = "testing"/deployment_stage = "testing"/' "$SCRIPT_DIR/terraform.tfvars" sed -i.bak 's/documentdb_instance_class = "db.t3.medium"/documentdb_instance_class = "db.t3.medium"/' "$SCRIPT_DIR/terraform.tfvars" else sed -i.bak 's/deployment_stage = "testing"/deployment_stage = "production"/' "$SCRIPT_DIR/terraform.tfvars" sed -i.bak 's/documentdb_instance_class = "db.t3.medium"/documentdb_instance_class = "db.r5.large"/' "$SCRIPT_DIR/terraform.tfvars" fi rm "$SCRIPT_DIR/terraform.tfvars.bak" echo -e "${GREEN}✓ Created terraform.tfvars${NC}" echo -e "${YELLOW} Please review and edit if needed: $SCRIPT_DIR/terraform.tfvars${NC}" echo "" # Ask user if they want to continue read -p "Continue with deployment? (y/n): " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then echo -e "${YELLOW}Deployment cancelled. Edit terraform.tfvars and run again.${NC}" exit 0 fi else echo -e "${GREEN}✓ Using existing terraform.tfvars${NC}" fi echo "" } # Function to deploy infrastructure deploy_infrastructure() { echo -e "${YELLOW}Deploying infrastructure with Terraform...${NC}" cd "$SCRIPT_DIR" # Initialize Terraform echo -e "${BLUE}Running terraform init...${NC}" terraform init echo "" # Plan deployment echo -e "${BLUE}Running terraform plan...${NC}" terraform plan -out=tfplan echo "" # Estimate cost if [[ "$DEPLOYMENT_STAGE" == "testing" ]]; then ESTIMATED_COST="~\$85-90/month" else ESTIMATED_COST="~\$195-200/month" fi echo -e "${YELLOW}========================================${NC}" echo -e "${YELLOW}Estimated monthly cost: $ESTIMATED_COST${NC}" echo -e "${YELLOW}Resources to create:${NC}" echo -e "${YELLOW} - VPC with NAT Gateways (2 AZs)${NC}" echo -e "${YELLOW} - DocumentDB cluster (1 instance)${NC}" echo -e "${YELLOW} - Lambda function${NC}" echo -e "${YELLOW} - API Gateway HTTP API${NC}" echo -e "${YELLOW} - DynamoDB table${NC}" echo -e "${YELLOW} - Secrets Manager secret${NC}" echo -e "${YELLOW} - CloudWatch log groups${NC}" echo -e "${YELLOW}========================================${NC}" echo "" read -p "Apply Terraform plan? This will create AWS resources. (y/n): " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then echo -e "${YELLOW}Deployment cancelled.${NC}" exit 0 fi # Apply deployment echo -e "${BLUE}Running terraform apply (this takes ~15-20 minutes)...${NC}" terraform apply tfplan echo -e "${GREEN}✓ Infrastructure deployed successfully!${NC}" echo "" } # Function to save outputs save_outputs() { echo -e "${YELLOW}Saving deployment outputs...${NC}" cd "$SCRIPT_DIR" COLLECTOR_URL=$(terraform output -raw collector_url) DOCDB_ENDPOINT=$(terraform output -raw documentdb_endpoint) SECRET_ARN=$(terraform output -raw documentdb_secret_arn) # Save to file cat > "$SCRIPT_DIR/deployment-info.txt" < /dev/null; then echo -e "${YELLOW}⚠ mongosh not installed. Skipping automatic index setup.${NC}" echo -e "${YELLOW} Please install mongosh and run index setup manually.${NC}" echo -e "${YELLOW} Instructions saved in: $SCRIPT_DIR/deployment-info.txt${NC}" return fi cd "$SCRIPT_DIR" DOCDB_ENDPOINT=$(terraform output -raw documentdb_endpoint) echo -e "${BLUE}Retrieving DocumentDB password from Secrets Manager...${NC}" DOCDB_PASSWORD=$(aws secretsmanager get-secret-value \ --secret-id telemetry-collector-docdb \ --query SecretString \ --output text | jq -r '.password') if [[ -z "$DOCDB_PASSWORD" ]]; then echo -e "${RED}Error: Failed to retrieve DocumentDB password${NC}" echo -e "${YELLOW} Run index setup manually using instructions in deployment-info.txt${NC}" return fi echo -e "${BLUE}Downloading DocumentDB CA bundle...${NC}" if [[ ! -f "$SCRIPT_DIR/global-bundle.pem" ]]; then wget -q https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem -O "$SCRIPT_DIR/global-bundle.pem" fi echo -e "${BLUE}Creating DocumentDB indexes...${NC}" # Create index commands cat > "$SCRIPT_DIR/create-indexes.js" <<'EOF' use telemetry; // TTL indexes (auto-delete after 365 days) db.startup_events.createIndex( { "received_at": 1 }, { expireAfterSeconds: 31536000 } ); db.heartbeat_events.createIndex( { "received_at": 1 }, { expireAfterSeconds: 31536000 } ); // Query indexes db.startup_events.createIndex({ "instance_id": 1 }); db.startup_events.createIndex({ "v": 1, "received_at": -1 }); db.heartbeat_events.createIndex({ "instance_id": 1 }); // Verify indexes print("Startup Events Indexes:"); printjson(db.startup_events.getIndexes()); print("\nHeartbeat Events Indexes:"); printjson(db.heartbeat_events.getIndexes()); EOF # Run index creation mongosh "mongodb://telemetry_admin:$DOCDB_PASSWORD@$DOCDB_ENDPOINT/telemetry?authSource=admin&tls=true&tlsCAFile=$SCRIPT_DIR/global-bundle.pem&retryWrites=false" \ --file "$SCRIPT_DIR/create-indexes.js" if [[ $? -eq 0 ]]; then echo -e "${GREEN}✓ DocumentDB indexes created successfully!${NC}" else echo -e "${YELLOW}⚠ Index creation failed. Run manually using instructions in deployment-info.txt${NC}" fi # Cleanup rm -f "$SCRIPT_DIR/create-indexes.js" echo "" } # Function to test deployment test_deployment() { echo -e "${YELLOW}========================================${NC}" echo -e "${YELLOW}Testing Deployment${NC}" echo -e "${YELLOW}========================================${NC}" echo "" cd "$SCRIPT_DIR" COLLECTOR_URL=$(terraform output -raw collector_url) echo -e "${BLUE}Sending test startup event...${NC}" RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$COLLECTOR_URL" \ -H "Content-Type: application/json" \ -d "{ \"event\": \"startup\", \"schema_version\": \"1\", \"instance_id\": \"00000000-0000-0000-0000-000000000001\", \"v\": \"1.0.16\", \"py\": \"3.12\", \"os\": \"linux\", \"arch\": \"x86_64\", \"mode\": \"with-gateway\", \"registry_mode\": \"full\", \"storage\": \"file\", \"auth\": \"keycloak\", \"federation\": false, \"ts\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" }") HTTP_CODE=$(echo "$RESPONSE" | tail -n 1) if [[ "$HTTP_CODE" == "204" ]]; then echo -e "${GREEN}✓ Test successful! Received HTTP 204${NC}" echo "" echo -e "${BLUE}Checking CloudWatch logs...${NC}" sleep 3 # Wait for logs to appear aws logs tail /aws/lambda/telemetry-collector --since 1m | grep "Stored startup event" || true else echo -e "${RED}✗ Test failed. Expected HTTP 204, got: $HTTP_CODE${NC}" echo -e "${YELLOW} Check Lambda logs: aws logs tail /aws/lambda/telemetry-collector --follow${NC}" fi echo "" } # Main execution main() { check_prerequisites configure_variables deploy_infrastructure save_outputs setup_documentdb_indexes test_deployment echo -e "${GREEN}========================================${NC}" echo -e "${GREEN}Deployment Complete!${NC}" echo -e "${GREEN}========================================${NC}" echo "" echo -e "${BLUE}Next Steps:${NC}" echo -e " 1. Review deployment info: ${YELLOW}$SCRIPT_DIR/deployment-info.txt${NC}" echo -e " 2. Monitor logs: ${YELLOW}aws logs tail /aws/lambda/telemetry-collector --follow${NC}" echo -e " 3. Integrate with registry: ${YELLOW}export MCP_TELEMETRY_ENDPOINT=$COLLECTOR_URL${NC}" echo "" echo -e "${BLUE}To destroy infrastructure later:${NC}" echo -e " ${YELLOW}cd $SCRIPT_DIR && terraform destroy${NC}" echo "" } # Run main function main ================================================ FILE: terraform/telemetry-collector/destroy.sh ================================================ #!/bin/bash # Cleanup script for telemetry collector infrastructure # Usage: ./destroy.sh set -e # Exit on error # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Get script directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" echo -e "${RED}========================================${NC}" echo -e "${RED}Telemetry Collector Cleanup Script${NC}" echo -e "${RED}========================================${NC}" echo "" # Warning banner echo -e "${YELLOW}⚠⚠⚠ WARNING ⚠⚠⚠${NC}" echo -e "${YELLOW}This will DESTROY all telemetry collector infrastructure:${NC}" echo -e "${YELLOW} - VPC and NAT Gateways${NC}" echo -e "${YELLOW} - DocumentDB cluster (including all data)${NC}" echo -e "${YELLOW} - Lambda function${NC}" echo -e "${YELLOW} - API Gateway${NC}" echo -e "${YELLOW} - DynamoDB table${NC}" echo -e "${YELLOW} - Secrets Manager secrets${NC}" echo -e "${YELLOW} - CloudWatch logs${NC}" echo "" echo -e "${RED}THIS ACTION CANNOT BE UNDONE!${NC}" echo "" # Check if deployment exists cd "$SCRIPT_DIR" if [[ ! -f "terraform.tfstate" ]]; then echo -e "${YELLOW}No terraform state found. Nothing to destroy.${NC}" exit 0 fi # Show current deployment info if command -v terraform &> /dev/null; then echo -e "${BLUE}Current deployment:${NC}" terraform show -json | jq -r '.values.root_module.resources[] | select(.type == "aws_apigatewayv2_api") | .values.name' 2>/dev/null || echo " telemetry-collector-api" echo "" fi # Confirmation 1 read -p "Type 'yes' to confirm destruction: " CONFIRM1 if [[ "$CONFIRM1" != "yes" ]]; then echo -e "${GREEN}Destruction cancelled.${NC}" exit 0 fi # Confirmation 2 (double check) echo "" echo -e "${RED}Final confirmation: This will delete ALL telemetry data.${NC}" read -p "Type 'DESTROY' in all caps to proceed: " CONFIRM2 if [[ "$CONFIRM2" != "DESTROY" ]]; then echo -e "${GREEN}Destruction cancelled.${NC}" exit 0 fi echo "" echo -e "${BLUE}Destroying infrastructure...${NC}" echo "" # Run terraform destroy terraform destroy -auto-approve if [[ $? -eq 0 ]]; then echo "" echo -e "${GREEN}========================================${NC}" echo -e "${GREEN}Infrastructure Destroyed Successfully${NC}" echo -e "${GREEN}========================================${NC}" echo "" # Cleanup local files echo -e "${BLUE}Cleaning up local files...${NC}" rm -f "$SCRIPT_DIR/deployment-info.txt" rm -f "$SCRIPT_DIR/global-bundle.pem" rm -f "$SCRIPT_DIR/tfplan" rm -f "$SCRIPT_DIR/.terraform.lock.hcl" rm -rf "$SCRIPT_DIR/.terraform" echo -e "${GREEN}✓ Cleanup complete${NC}" else echo "" echo -e "${RED}Error during destruction. Check terraform state.${NC}" exit 1 fi ================================================ FILE: terraform/telemetry-collector/documentdb.tf ================================================ # DocumentDB subnet group (requires at least 2 subnets in different AZs) resource "aws_docdb_subnet_group" "telemetry" { name = "telemetry-collector-docdb-subnet-group" subnet_ids = aws_subnet.private[*].id tags = { Name = "telemetry-collector-docdb-subnet-group" } } # DocumentDB cluster parameter group (customize settings) resource "aws_docdb_cluster_parameter_group" "telemetry" { family = "docdb5.0" name = "telemetry-collector-docdb-params" description = "Custom parameter group for telemetry collector DocumentDB cluster" parameter { name = "tls" value = "enabled" } parameter { name = "ttl_monitor" value = "enabled" } tags = { Name = "telemetry-collector-docdb-params" } } # DocumentDB cluster resource "aws_docdb_cluster" "telemetry" { cluster_identifier = "telemetry-collector" engine = "docdb" master_username = var.documentdb_master_username master_password = random_password.documentdb_master.result backup_retention_period = 7 preferred_backup_window = "03:00-04:00" # 3-4 AM UTC preferred_maintenance_window = "sun:04:00-sun:05:00" # Sunday 4-5 AM UTC db_subnet_group_name = aws_docdb_subnet_group.telemetry.name db_cluster_parameter_group_name = aws_docdb_cluster_parameter_group.telemetry.name vpc_security_group_ids = [aws_security_group.documentdb.id] skip_final_snapshot = var.deployment_stage == "testing" final_snapshot_identifier = var.deployment_stage == "production" ? "telemetry-collector-final-snapshot-${formatdate("YYYY-MM-DD-hhmm", timestamp())}" : null enabled_cloudwatch_logs_exports = ["audit", "profiler"] storage_encrypted = true tags = { Name = "telemetry-collector" } } # DocumentDB cluster instance (single instance for testing, can add more for production) resource "aws_docdb_cluster_instance" "telemetry" { identifier = "telemetry-collector-instance-1" cluster_identifier = aws_docdb_cluster.telemetry.id instance_class = var.documentdb_instance_class tags = { Name = "telemetry-collector-instance-1" } } # Random password for DocumentDB master user resource "random_password" "documentdb_master" { length = 32 special = true # Exclude problematic characters for connection strings override_special = "!#$%&*()-_=+[]{}<>:?" } ================================================ FILE: terraform/telemetry-collector/domain.tf ================================================ # ACM certificate for custom domain (production only) resource "aws_acm_certificate" "telemetry" { count = var.custom_domain != "" ? 1 : 0 domain_name = var.custom_domain validation_method = "DNS" lifecycle { create_before_destroy = true } tags = { Name = "telemetry-collector-cert" } } # Route53 record for ACM certificate validation resource "aws_route53_record" "cert_validation" { count = var.custom_domain != "" && var.route53_zone_id != "" ? 1 : 0 zone_id = var.route53_zone_id name = tolist(aws_acm_certificate.telemetry[0].domain_validation_options)[0].resource_record_name type = tolist(aws_acm_certificate.telemetry[0].domain_validation_options)[0].resource_record_type records = [tolist(aws_acm_certificate.telemetry[0].domain_validation_options)[0].resource_record_value] ttl = 60 } # ACM certificate validation resource "aws_acm_certificate_validation" "telemetry" { count = var.custom_domain != "" && var.route53_zone_id != "" ? 1 : 0 certificate_arn = aws_acm_certificate.telemetry[0].arn validation_record_fqdns = [aws_route53_record.cert_validation[0].fqdn] } # API Gateway custom domain name resource "aws_apigatewayv2_domain_name" "telemetry" { count = var.custom_domain != "" ? 1 : 0 domain_name = var.custom_domain domain_name_configuration { certificate_arn = aws_acm_certificate.telemetry[0].arn endpoint_type = "REGIONAL" security_policy = "TLS_1_2" } depends_on = [aws_acm_certificate_validation.telemetry] } # API Gateway domain mapping resource "aws_apigatewayv2_api_mapping" "telemetry" { count = var.custom_domain != "" ? 1 : 0 api_id = aws_apigatewayv2_api.telemetry.id domain_name = aws_apigatewayv2_domain_name.telemetry[0].id stage = aws_apigatewayv2_stage.telemetry.id } # Route53 A record for custom domain resource "aws_route53_record" "telemetry" { count = var.custom_domain != "" && var.route53_zone_id != "" ? 1 : 0 zone_id = var.route53_zone_id name = var.custom_domain type = "A" alias { name = aws_apigatewayv2_domain_name.telemetry[0].domain_name_configuration[0].target_domain_name zone_id = aws_apigatewayv2_domain_name.telemetry[0].domain_name_configuration[0].hosted_zone_id evaluate_target_health = false } } ================================================ FILE: terraform/telemetry-collector/dynamodb.tf ================================================ # DynamoDB table for rate limiting resource "aws_dynamodb_table" "rate_limit" { name = "telemetry-collector-rate-limit" billing_mode = "PAY_PER_REQUEST" # On-demand pricing hash_key = "ip_hash" attribute { name = "ip_hash" type = "S" } ttl { attribute_name = "expiry_time" enabled = true } point_in_time_recovery { enabled = var.deployment_stage == "production" } tags = { Name = "telemetry-collector-rate-limit" } } ================================================ FILE: terraform/telemetry-collector/iam.tf ================================================ # IAM role for Lambda function resource "aws_iam_role" "lambda_execution" { name = "telemetry-collector-lambda-role" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "lambda.amazonaws.com" } } ] }) tags = { Name = "telemetry-collector-lambda-role" } } # CloudWatch Logs policy resource "aws_iam_role_policy" "lambda_cloudwatch" { name = "telemetry-collector-cloudwatch-policy" role = aws_iam_role.lambda_execution.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents" ] Resource = "${aws_cloudwatch_log_group.telemetry_collector.arn}:*" } ] }) } # VPC network interface policy (required for VPC-enabled Lambda) resource "aws_iam_role_policy" "lambda_vpc" { name = "telemetry-collector-vpc-policy" role = aws_iam_role.lambda_execution.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "ec2:CreateNetworkInterface", "ec2:DescribeNetworkInterfaces", "ec2:DeleteNetworkInterface", "ec2:AssignPrivateIpAddresses", "ec2:UnassignPrivateIpAddresses" ] # AWS requires Resource = "*" for EC2 network interface operations # (CreateNetworkInterface, DescribeNetworkInterfaces, etc.) Resource = "*" } ] }) } # DynamoDB policy (rate limiting table) resource "aws_iam_role_policy" "lambda_dynamodb" { name = "telemetry-collector-dynamodb-policy" role = aws_iam_role.lambda_execution.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem" ] Resource = aws_dynamodb_table.rate_limit.arn } ] }) } # Secrets Manager policy (DocumentDB credentials) resource "aws_iam_role_policy" "lambda_secrets" { name = "telemetry-collector-secrets-policy" role = aws_iam_role.lambda_execution.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = [ "secretsmanager:GetSecretValue" ] Resource = aws_secretsmanager_secret.documentdb_credentials.arn } ] }) } ================================================ FILE: terraform/telemetry-collector/lambda/collector/index.py ================================================ """ AWS Lambda handler for telemetry collector. Privacy-first design: - Always returns 204 (no information leakage) - Hashes source IP for rate limiting (no storage) - Fail-silent: all errors caught and logged - TLS-only DocumentDB connection Architecture: - API Gateway HTTP API → Lambda → DynamoDB (rate limiting) → DocumentDB (storage) """ import hashlib import hmac import json import logging import os from datetime import UTC, datetime from urllib.parse import quote_plus import boto3 import pymongo from botocore.exceptions import ClientError from pydantic import ValidationError from schemas import HeartbeatEvent, StartupEvent # Configure logging logger = logging.getLogger() logger.setLevel(logging.INFO) # HMAC signing key — must match the key in registry/core/telemetry.py. # This is NOT a secret. It prevents casual abuse (random curl requests) # by requiring callers to compute a valid HMAC over the request body. TELEMETRY_SIGNING_KEY = "mcp-registry-telemetry-v1-a7f3b9c2e1d4" # AWS clients (lazy-init for testability without credentials) dynamodb = None secretsmanager = None def _init_aws_clients(): global dynamodb, secretsmanager if dynamodb is None: dynamodb = boto3.resource("dynamodb") secretsmanager = boto3.client("secretsmanager") # Environment variables (required — Lambda will fail fast if misconfigured) RATE_LIMIT_TABLE = os.environ["RATE_LIMIT_TABLE"] DOCUMENTDB_SECRET_ARN = os.environ["DOCUMENTDB_SECRET_ARN"] DOCUMENTDB_ENDPOINT = os.environ["DOCUMENTDB_ENDPOINT"] # Rate limiting constants RATE_LIMIT_WINDOW_SECONDS = 60 RATE_LIMIT_MAX_REQUESTS = 10 # Globals for connection pooling (reused across warm Lambda invocations) _mongo_client: pymongo.MongoClient | None = None _mongo_database = None # pymongo Database instance _credentials: dict | None = None def _get_credentials() -> dict: """Get DocumentDB credentials from Secrets Manager (cached).""" global _credentials _init_aws_clients() if _credentials is not None: return _credentials try: response = secretsmanager.get_secret_value(SecretId=DOCUMENTDB_SECRET_ARN) _credentials = json.loads(response["SecretString"]) logger.info("Retrieved DocumentDB credentials from Secrets Manager") return _credentials except ClientError as e: logger.error(f"Failed to retrieve DocumentDB credentials: {e}") raise def _get_database(): """Get DocumentDB database client (singleton, reused across invocations).""" global _mongo_client, _mongo_database if _mongo_database is not None: return _mongo_database credentials = _get_credentials() username = quote_plus(credentials["username"]) password = quote_plus(credentials["password"]) db_name = credentials.get("database", "telemetry") connection_string = ( f"mongodb://{username}:{password}@" f"{DOCUMENTDB_ENDPOINT}/{db_name}?" f"authMechanism=SCRAM-SHA-1&authSource=admin" f"&tls=true&tlsAllowInvalidCertificates=true&retryWrites=false" f"&directConnection=true" f"&connectTimeoutMS=10000&serverSelectionTimeoutMS=10000" ) logger.info(f"Connecting to DocumentDB at {DOCUMENTDB_ENDPOINT}") _mongo_client = pymongo.MongoClient(connection_string) _mongo_database = _mongo_client[db_name] # Verify connection _mongo_client.server_info() logger.info("Connected to DocumentDB") return _mongo_database def _verify_signature(body: str, signature: str) -> bool: """Verify HMAC-SHA256 signature of the request body. Args: body: The raw request body string. signature: The hex-encoded HMAC signature from the header. Returns: True if the signature is valid, False otherwise. """ if not signature: return False expected = hmac.new( TELEMETRY_SIGNING_KEY.encode(), body.encode(), hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, signature) def _hash_ip(ip: str) -> str: """Hash IP address (SHA-256) for privacy-preserving rate limiting.""" return hashlib.sha256(ip.encode()).hexdigest() def _check_rate_limit(ip_hash: str) -> bool: """Check rate limit using DynamoDB atomic counter. Returns True if allowed.""" _init_aws_clients() try: table = dynamodb.Table(RATE_LIMIT_TABLE) now = int(datetime.now(UTC).timestamp()) window_start = now - RATE_LIMIT_WINDOW_SECONDS # First, try to reset expired entries and set count to 1 try: table.update_item( Key={"ip_hash": ip_hash}, UpdateExpression="SET request_count = :one, expiry_time = :expiry, last_request = :now", ExpressionAttributeValues={ ":one": 1, ":expiry": now + RATE_LIMIT_WINDOW_SECONDS, ":now": now, ":window_start": window_start, }, ConditionExpression="attribute_not_exists(last_request) OR last_request < :window_start", ) return True # Window expired or new entry — allowed except ClientError as e: if e.response["Error"]["Code"] != "ConditionalCheckFailedException": raise # Item exists and window hasn't expired — increment # Increment within active window table.update_item( Key={"ip_hash": ip_hash}, UpdateExpression="ADD request_count :inc SET last_request = :now", ExpressionAttributeValues={ ":inc": 1, ":now": now, ":max": RATE_LIMIT_MAX_REQUESTS, }, ConditionExpression="request_count < :max", ) return True except ClientError as e: if e.response["Error"]["Code"] == "ConditionalCheckFailedException": logger.warning(f"Rate limit exceeded for IP hash: {ip_hash[:8]}...") return False logger.error(f"Rate limit check failed: {e}") return True # Fail-open for telemetry def _store_event(event_type: str, payload: dict) -> None: """Store validated telemetry event in DocumentDB.""" db = _get_database() collection = db[f"{event_type}_events"] # Convert ts string to BSON datetime for consistent querying if "ts" in payload and isinstance(payload["ts"], str): try: payload["ts"] = datetime.fromisoformat(payload["ts"].replace("Z", "+00:00")) except (ValueError, TypeError): pass # Keep as string if parsing fails document = { **payload, "received_at": datetime.now(UTC), } result = collection.insert_one(document) logger.info( f"Stored {event_type} event: registry_id={payload.get('registry_id', 'unknown')} " f"doc_id={result.inserted_id}" ) def lambda_handler(event: dict, context: dict) -> dict: """ Lambda handler for telemetry collector. Always returns 204 No Content (privacy-first: no information leakage). """ try: # Rate limit by hashed IP source_ip = event.get("requestContext", {}).get("http", {}).get("sourceIp", "unknown") if not _check_rate_limit(_hash_ip(source_ip)): return {"statusCode": 204} # Verify HMAC signature (reject unsigned or forged requests) headers = event.get("headers", {}) signature = headers.get("x-telemetry-signature", "") raw_body = event.get("body", "") if not _verify_signature(raw_body, signature): logger.warning(f"Invalid or missing signature from {_hash_ip(source_ip)[:8]}...") return {"statusCode": 204} # Parse body body = event.get("body", "{}") if isinstance(body, str): try: payload = json.loads(body) except json.JSONDecodeError as e: logger.error(f"Invalid JSON: {e}") return {"statusCode": 204} else: payload = body # Validate by event type event_type = payload.get("event") if event_type == "startup": try: validated = StartupEvent(**payload) logger.info(f"Validated startup event: v={validated.v} storage={validated.storage}") except ValidationError as e: logger.error(f"Startup validation failed: {e}") return {"statusCode": 204} elif event_type == "heartbeat": try: validated = HeartbeatEvent(**payload) logger.info( f"Validated heartbeat event: v={validated.v} servers={validated.servers_count}" ) except ValidationError as e: logger.error(f"Heartbeat validation failed: {e}") return {"statusCode": 204} else: logger.error(f"Unknown event type: {event_type}") return {"statusCode": 204} # Store in DocumentDB try: _store_event(event_type, validated.model_dump()) except Exception as e: logger.error(f"Failed to store event: {e}") return {"statusCode": 204} except Exception as e: logger.exception(f"Unexpected error in lambda_handler: {e}") return {"statusCode": 204} ================================================ FILE: terraform/telemetry-collector/lambda/collector/requirements.txt ================================================ # AWS Lambda dependencies for telemetry collector # Sync MongoDB driver pymongo>=4.6.0,<4.8.0 # Data validation pydantic>=2.5.0 # AWS SDK (available in Lambda runtime, pinned for local testing) boto3>=1.34.0 ================================================ FILE: terraform/telemetry-collector/lambda/collector/schemas.py ================================================ """ Pydantic validation schemas for telemetry events. Matches schemas from registry/core/telemetry.py (issue #558 client implementation). """ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field, field_validator class StartupEvent(BaseModel): """ Startup telemetry event (Tier 1 - opt-out, default ON). Sent once at registry startup to track: - Version distribution - Python version compatibility - OS and architecture - Deployment configurations - Auth provider usage """ event: str = Field(..., pattern="^startup$") registry_id: str | None = Field(default=None, max_length=36, description="Registry card UUID") v: str = Field(..., min_length=1, max_length=200, description="Registry version") py: str = Field(..., pattern=r"^\d+\.\d+$", description="Python version (major.minor)") os: str = Field(..., pattern="^(linux|darwin|windows)$", description="Operating system") arch: str = Field(..., min_length=1, max_length=20, description="CPU architecture") cloud: str = Field( default="unknown", pattern="^(aws|gcp|azure|unknown)$", description="Cloud provider", ) compute: str = Field( default="unknown", pattern="^(ecs|eks|kubernetes|docker|ec2|vm|unknown)$", description="Compute platform", ) mode: str = Field( ..., pattern="^(with-gateway|registry-only)$", description="Deployment mode", ) registry_mode: str = Field( ..., pattern="^(full|skills-only|mcp-servers-only|agents-only)$", description="Registry operating mode", ) storage: str = Field( ..., pattern="^(file|documentdb|mongodb-ce)$", description="Storage backend", ) auth: str = Field(..., min_length=1, max_length=50, description="Auth provider") federation: bool = Field(..., description="Federation enabled") search_queries_total: int = Field( default=0, ge=0, description="Lifetime semantic search query count" ) search_queries_24h: int = Field(default=0, ge=0, description="Search queries in last 24 hours") search_queries_1h: int = Field(default=0, ge=0, description="Search queries in last hour") ts: str = Field(..., description="ISO 8601 timestamp") @field_validator("ts") @classmethod def validate_timestamp(cls, v: str) -> str: """Validate ISO 8601 timestamp format.""" try: datetime.fromisoformat(v.replace("Z", "+00:00")) except ValueError as e: raise ValueError(f"Invalid ISO 8601 timestamp: {e}") from e return v model_config = ConfigDict( json_schema_extra={ "example": { "event": "startup", "registry_id": "c546a650-8af9-4721-9efb-7df221b2a0d9", "v": "1.0.16", "py": "3.12", "os": "linux", "arch": "x86_64", "cloud": "aws", "compute": "ecs", "mode": "with-gateway", "registry_mode": "full", "storage": "documentdb", "auth": "keycloak", "federation": True, "search_queries_total": 150, "search_queries_24h": 12, "search_queries_1h": 3, "ts": "2026-03-18T00:00:00Z", } } ) class HeartbeatEvent(BaseModel): """ Heartbeat telemetry event (Tier 2 - opt-in, default OFF). Sent every 24 hours when opted in to track: - Aggregate counts (servers, agents, skills, peers) - Search backend usage - Embeddings provider - Instance uptime """ event: str = Field(..., pattern="^heartbeat$") registry_id: str | None = Field(default=None, max_length=36, description="Registry card UUID") v: str = Field(..., min_length=1, max_length=200, description="Registry version") cloud: str = Field( default="unknown", pattern="^(aws|gcp|azure|unknown)$", description="Cloud provider", ) compute: str = Field( default="unknown", pattern="^(ecs|eks|kubernetes|docker|ec2|vm|unknown)$", description="Compute platform", ) servers_count: int = Field(..., ge=0, description="Number of registered MCP servers") agents_count: int = Field(..., ge=0, description="Number of registered agents") skills_count: int = Field(..., ge=0, description="Number of registered skills") peers_count: int = Field(..., ge=0, description="Number of federated peers") search_backend: str = Field( ..., pattern="^(faiss|documentdb)$", description="Search backend type", ) embeddings_provider: str = Field(..., min_length=1, max_length=100) uptime_hours: int = Field(..., ge=0, description="Instance uptime in hours") search_queries_total: int = Field( default=0, ge=0, description="Lifetime semantic search query count" ) search_queries_24h: int = Field(default=0, ge=0, description="Search queries in last 24 hours") search_queries_1h: int = Field(default=0, ge=0, description="Search queries in last hour") ts: str = Field(..., description="ISO 8601 timestamp") @field_validator("ts") @classmethod def validate_timestamp(cls, v: str) -> str: """Validate ISO 8601 timestamp format.""" try: datetime.fromisoformat(v.replace("Z", "+00:00")) except ValueError as e: raise ValueError(f"Invalid ISO 8601 timestamp: {e}") from e return v model_config = ConfigDict( json_schema_extra={ "example": { "event": "heartbeat", "registry_id": "c546a650-8af9-4721-9efb-7df221b2a0d9", "v": "1.0.16", "cloud": "aws", "compute": "ecs", "servers_count": 15, "agents_count": 8, "skills_count": 23, "peers_count": 2, "search_backend": "documentdb", "embeddings_provider": "sentence-transformers", "uptime_hours": 48, "search_queries_total": 150, "search_queries_24h": 12, "search_queries_1h": 3, "ts": "2026-03-18T12:00:00Z", } } ) ================================================ FILE: terraform/telemetry-collector/lambda/index-setup/index.py ================================================ """ One-time Lambda function to create DocumentDB indexes. Runs in the VPC, connects to DocumentDB, creates all required indexes. After successful execution, this Lambda can be deleted. """ import json import logging import os from urllib.parse import quote_plus import boto3 import pymongo from botocore.exceptions import ClientError logger = logging.getLogger() logger.setLevel(logging.INFO) secretsmanager = boto3.client("secretsmanager") DOCUMENTDB_SECRET_ARN = os.environ["DOCUMENTDB_SECRET_ARN"] DOCUMENTDB_ENDPOINT = os.environ["DOCUMENTDB_ENDPOINT"] def _get_credentials() -> dict: """Get DocumentDB credentials from Secrets Manager.""" try: response = secretsmanager.get_secret_value(SecretId=DOCUMENTDB_SECRET_ARN) return json.loads(response["SecretString"]) except ClientError as e: logger.error(f"Failed to retrieve DocumentDB credentials: {e}") raise def _connect() -> pymongo.database.Database: """Connect to DocumentDB and return database handle.""" credentials = _get_credentials() username = quote_plus(credentials["username"]) password = quote_plus(credentials["password"]) db_name = credentials.get("database", "telemetry") connection_string = ( f"mongodb://{username}:{password}@" f"{DOCUMENTDB_ENDPOINT}/{db_name}?" f"authMechanism=SCRAM-SHA-1&authSource=admin" f"&tls=true&retryWrites=false" ) logger.info(f"Connecting to DocumentDB at {DOCUMENTDB_ENDPOINT}") client = pymongo.MongoClient(connection_string) server_info = client.server_info() logger.info(f"Connected to DocumentDB version {server_info.get('version')}") return client[db_name] def lambda_handler(event, context): """Lambda handler for index creation.""" logger.info("Starting DocumentDB index creation") results = { "startup_events_indexes": [], "heartbeat_events_indexes": [], "errors": [], } try: db = _connect() # Define indexes per collection index_specs = { "startup_events": [ ({"keys": [("received_at", 1)], "kwargs": {"expireAfterSeconds": 31536000}}, "TTL"), ({"keys": [("instance_id", 1)], "kwargs": {}}, "query"), ({"keys": [("v", 1), ("received_at", -1)], "kwargs": {}}, "query"), ], "heartbeat_events": [ ({"keys": [("received_at", 1)], "kwargs": {"expireAfterSeconds": 31536000}}, "TTL"), ({"keys": [("instance_id", 1)], "kwargs": {}}, "query"), ], } for collection_name, indexes in index_specs.items(): collection = db[collection_name] for spec, idx_type in indexes: try: name = collection.create_index(spec["keys"], **spec["kwargs"]) results[f"{collection_name}_indexes"].append( {"name": name, "type": idx_type, "status": "created"} ) logger.info(f"Created {idx_type} index on {collection_name}: {name}") except Exception as e: msg = f"Failed to create index on {collection_name}: {e}" logger.error(msg) results["errors"].append(msg) # Verify for coll_name in ["startup_events", "heartbeat_events"]: indexes = db[coll_name].index_information() count = db[coll_name].count_documents({}) logger.info(f"{coll_name}: {len(indexes)} indexes, {count} documents") return { "statusCode": 200, "body": json.dumps({"message": "Index creation completed", "results": results}), } except Exception as e: logger.error(f"Lambda execution failed: {e}", exc_info=True) return { "statusCode": 500, "body": json.dumps({"message": "Index creation failed", "error": str(e)}), } ================================================ FILE: terraform/telemetry-collector/lambda/index-setup/requirements.txt ================================================ pymongo>=4.6.0 ================================================ FILE: terraform/telemetry-collector/lambda.tf ================================================ # Lambda function resource "aws_lambda_function" "telemetry_collector" { filename = var.lambda_package_path function_name = "telemetry-collector" role = aws_iam_role.lambda_execution.arn handler = "index.lambda_handler" source_code_hash = filebase64sha256(var.lambda_package_path) runtime = "python3.13" timeout = 30 memory_size = 256 vpc_config { subnet_ids = aws_subnet.private[*].id security_group_ids = [aws_security_group.lambda.id] } environment { variables = { RATE_LIMIT_TABLE = aws_dynamodb_table.rate_limit.name DOCUMENTDB_SECRET_ARN = aws_secretsmanager_secret.documentdb_credentials.arn DOCUMENTDB_ENDPOINT = aws_docdb_cluster.telemetry.endpoint } } depends_on = [ aws_cloudwatch_log_group.telemetry_collector, aws_iam_role_policy.lambda_cloudwatch, aws_iam_role_policy.lambda_vpc, aws_iam_role_policy.lambda_dynamodb, aws_iam_role_policy.lambda_secrets ] tags = { Name = "telemetry-collector" } } # API Gateway HTTP API resource "aws_apigatewayv2_api" "telemetry" { name = "telemetry-collector-api" protocol_type = "HTTP" description = "Privacy-first telemetry collector API for MCP Gateway Registry" cors_configuration { allow_origins = var.cors_allowed_origins allow_methods = ["POST"] allow_headers = ["content-type"] max_age = 300 } tags = { Name = "telemetry-collector-api" } } # API Gateway integration with Lambda resource "aws_apigatewayv2_integration" "lambda" { api_id = aws_apigatewayv2_api.telemetry.id integration_type = "AWS_PROXY" integration_uri = aws_lambda_function.telemetry_collector.invoke_arn payload_format_version = "2.0" } # API Gateway route for POST /v1/collect resource "aws_apigatewayv2_route" "collect" { api_id = aws_apigatewayv2_api.telemetry.id route_key = "POST /v1/collect" target = "integrations/${aws_apigatewayv2_integration.lambda.id}" } # API Gateway stage (default stage) resource "aws_apigatewayv2_stage" "telemetry" { api_id = aws_apigatewayv2_api.telemetry.id name = "$default" auto_deploy = true access_log_settings { destination_arn = aws_cloudwatch_log_group.api_gateway.arn format = jsonencode({ requestId = "$context.requestId" requestTime = "$context.requestTime" httpMethod = "$context.httpMethod" routeKey = "$context.routeKey" status = "$context.status" protocol = "$context.protocol" responseLength = "$context.responseLength" }) } default_route_settings { throttling_burst_limit = 100 throttling_rate_limit = 50 } tags = { Name = "telemetry-collector-stage" } } # Lambda permission for API Gateway to invoke function resource "aws_lambda_permission" "api_gateway" { statement_id = "AllowAPIGatewayInvoke" action = "lambda:InvokeFunction" function_name = aws_lambda_function.telemetry_collector.function_name principal = "apigateway.amazonaws.com" source_arn = "${aws_apigatewayv2_api.telemetry.execution_arn}/*/*" } ================================================ FILE: terraform/telemetry-collector/main.tf ================================================ terraform { required_version = ">= 1.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } random = { source = "hashicorp/random" version = "~> 3.0" } archive = { source = "hashicorp/archive" version = "~> 2.0" } } } provider "aws" { region = var.aws_region default_tags { tags = { Project = "MCP-Gateway-Telemetry-Collector" ManagedBy = "Terraform" Environment = var.deployment_stage } } } ================================================ FILE: terraform/telemetry-collector/outputs.tf ================================================ output "collector_url" { description = "Telemetry collector API endpoint URL" value = "${trimsuffix(aws_apigatewayv2_stage.telemetry.invoke_url, "/")}/v1/collect" } output "api_gateway_id" { description = "API Gateway HTTP API ID" value = aws_apigatewayv2_api.telemetry.id } output "lambda_function_name" { description = "Lambda function name" value = aws_lambda_function.telemetry_collector.function_name } output "lambda_function_arn" { description = "Lambda function ARN" value = aws_lambda_function.telemetry_collector.arn } output "documentdb_endpoint" { description = "DocumentDB cluster endpoint" value = aws_docdb_cluster.telemetry.endpoint } output "documentdb_secret_arn" { description = "Secrets Manager ARN for DocumentDB credentials" value = aws_secretsmanager_secret.documentdb_credentials.arn } output "rate_limit_table_name" { description = "DynamoDB rate limiting table name" value = aws_dynamodb_table.rate_limit.name } output "cloudwatch_log_group" { description = "CloudWatch log group for Lambda function" value = aws_cloudwatch_log_group.telemetry_collector.name } output "vpc_id" { description = "VPC ID" value = aws_vpc.telemetry.id } output "custom_domain_url" { description = "Custom domain URL (if configured)" value = var.custom_domain != "" ? "https://${var.custom_domain}/v1/collect" : "Not configured" } output "bastion_public_ip" { description = "Public IP of the bastion host (if enabled)" value = var.bastion_enabled ? aws_instance.bastion[0].public_ip : "Bastion not enabled" } output "bastion_ssh_command" { description = "SSH command to connect to the bastion host" value = var.bastion_enabled ? "ssh -i ec2-user@${aws_instance.bastion[0].public_ip}" : "Bastion not enabled" } output "aws_region" { description = "AWS region of deployment" value = var.aws_region } ================================================ FILE: terraform/telemetry-collector/secrets.tf ================================================ # Secrets Manager secret for DocumentDB credentials resource "aws_secretsmanager_secret" "documentdb_credentials" { name = "telemetry-collector-docdb" description = "DocumentDB credentials for telemetry collector" tags = { Name = "telemetry-collector-documentdb-credentials" } } # Store DocumentDB credentials in Secrets Manager resource "aws_secretsmanager_secret_version" "documentdb_credentials" { secret_id = aws_secretsmanager_secret.documentdb_credentials.id secret_string = jsonencode({ username = aws_docdb_cluster.telemetry.master_username password = random_password.documentdb_master.result endpoint = aws_docdb_cluster.telemetry.endpoint port = aws_docdb_cluster.telemetry.port database = var.documentdb_database_name }) } ================================================ FILE: terraform/telemetry-collector/terraform.tfvars.example ================================================ # AWS Configuration aws_region = "us-east-1" # Deployment Stage # Options: "testing" or "production" deployment_stage = "testing" # DocumentDB Configuration # Instance classes: # - db.t3.medium: Testing (~$50/month) # - db.r5.large: Production (~$160/month) documentdb_instance_class = "db.t3.medium" # DocumentDB master username (default: telemetry_admin) documentdb_master_username = "telemetry_admin" # DocumentDB database name (default: telemetry) documentdb_database_name = "telemetry" # VPC Configuration vpc_cidr = "10.0.0.0/16" # CloudWatch Logs Retention (days) log_retention_days = 30 # Rate Limiting Configuration rate_limit_max_requests = 10 # Requests per minute per IP rate_limit_window_seconds = 60 # Time window in seconds # CORS Allowed Origins # Restrict which domains can submit telemetry via browser # cors_allowed_origins = ["https://mcpgateway.io", "https://app.mcpgateway.io"] # Optional: Custom Domain (Production Only) # Leave empty for testing, set for production # custom_domain = "telemetry.mcpgateway.io" # route53_zone_id = "Z1234567890ABC" # Optional: CloudWatch Alarms Email (Production Only) # Leave empty to disable alarms # alarm_email = "alerts@example.com" # Bastion host for DocumentDB access (free tier t2.micro) bastion_enabled = true # bastion_public_key = "ssh-rsa AAAA..." # your ~/.ssh/id_rsa.pub # bastion_allowed_cidrs = ["YOUR_IP/32"] # restrict to your IP for security ================================================ FILE: terraform/telemetry-collector/variables.tf ================================================ variable "aws_region" { description = "AWS region for deployment" type = string default = "us-east-1" } variable "deployment_stage" { description = "Deployment stage: testing or production" type = string default = "testing" validation { condition = contains(["testing", "production"], var.deployment_stage) error_message = "deployment_stage must be either 'testing' or 'production'" } } variable "documentdb_instance_class" { description = "DocumentDB instance class (db.t3.medium for testing, db.r5.large for production)" type = string default = "db.t3.medium" } variable "documentdb_master_username" { description = "DocumentDB master username" type = string default = "telemetry_admin" } variable "documentdb_database_name" { description = "DocumentDB database name" type = string default = "telemetry" } variable "vpc_cidr" { description = "CIDR block for VPC" type = string default = "10.0.0.0/16" } variable "log_retention_days" { description = "CloudWatch log retention in days" type = number default = 30 } variable "custom_domain" { description = "Optional custom domain for API Gateway (e.g., telemetry.mcpgateway.io)" type = string default = "" } variable "route53_zone_id" { description = "Optional Route53 hosted zone ID for custom domain" type = string default = "" } variable "alarm_email" { description = "Optional email address for CloudWatch alarms" type = string default = "" } variable "rate_limit_max_requests" { description = "Maximum requests per minute per IP" type = number default = 10 } variable "rate_limit_window_seconds" { description = "Rate limit time window in seconds" type = number default = 60 } variable "cors_allowed_origins" { description = "Origins allowed to submit telemetry (restrict to known registry domains)" type = list(string) default = ["https://mcpgateway.io", "https://app.mcpgateway.io"] } variable "lambda_package_path" { description = "Path to the Lambda deployment package zip file" type = string default = "lambda_function.zip" } # Bastion variables variable "bastion_enabled" { description = "Whether to create a bastion host for DocumentDB access" type = bool default = false } variable "bastion_public_key" { description = "SSH public key for bastion host access" type = string default = "" } variable "bastion_allowed_cidrs" { description = "CIDR blocks allowed to SSH to the bastion host" type = list(string) default = [] } ================================================ FILE: terraform/telemetry-collector/vpc.tf ================================================ # VPC for telemetry collector infrastructure resource "aws_vpc" "telemetry" { cidr_block = var.vpc_cidr enable_dns_hostnames = true enable_dns_support = true tags = { Name = "telemetry-collector-vpc" } } # Internet Gateway for NAT Gateway resource "aws_internet_gateway" "telemetry" { vpc_id = aws_vpc.telemetry.id tags = { Name = "telemetry-collector-igw" } } # Public subnets for NAT Gateway (2 AZs for high availability) resource "aws_subnet" "public" { count = 2 vpc_id = aws_vpc.telemetry.id cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index) availability_zone = data.aws_availability_zones.available.names[count.index] map_public_ip_on_launch = true tags = { Name = "telemetry-collector-public-${count.index + 1}" } } # Private subnets for Lambda and DocumentDB (2 AZs for DocumentDB requirement) resource "aws_subnet" "private" { count = 2 vpc_id = aws_vpc.telemetry.id cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 10) availability_zone = data.aws_availability_zones.available.names[count.index] tags = { Name = "telemetry-collector-private-${count.index + 1}" } } # Elastic IPs for NAT Gateways resource "aws_eip" "nat" { count = 2 domain = "vpc" tags = { Name = "telemetry-collector-nat-eip-${count.index + 1}" } depends_on = [aws_internet_gateway.telemetry] } # NAT Gateways for Lambda internet access (2 for high availability) resource "aws_nat_gateway" "telemetry" { count = 2 allocation_id = aws_eip.nat[count.index].id subnet_id = aws_subnet.public[count.index].id tags = { Name = "telemetry-collector-nat-${count.index + 1}" } depends_on = [aws_internet_gateway.telemetry] } # Route table for public subnets resource "aws_route_table" "public" { vpc_id = aws_vpc.telemetry.id route { cidr_block = "0.0.0.0/0" gateway_id = aws_internet_gateway.telemetry.id } tags = { Name = "telemetry-collector-public-rt" } } # Associate public subnets with public route table resource "aws_route_table_association" "public" { count = 2 subnet_id = aws_subnet.public[count.index].id route_table_id = aws_route_table.public.id } # Route tables for private subnets (one per AZ for NAT Gateway routing) resource "aws_route_table" "private" { count = 2 vpc_id = aws_vpc.telemetry.id route { cidr_block = "0.0.0.0/0" nat_gateway_id = aws_nat_gateway.telemetry[count.index].id } tags = { Name = "telemetry-collector-private-rt-${count.index + 1}" } } # Associate private subnets with private route tables resource "aws_route_table_association" "private" { count = 2 subnet_id = aws_subnet.private[count.index].id route_table_id = aws_route_table.private[count.index].id } # Security group for DocumentDB cluster (no inline rules to avoid cycle) resource "aws_security_group" "documentdb" { name = "telemetry-collector-documentdb-sg" description = "Security group for DocumentDB cluster - allow Lambda access on port 27017" vpc_id = aws_vpc.telemetry.id egress { description = "Allow all outbound" from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "telemetry-collector-documentdb-sg" } } # Security group for Lambda function # All rules are inline to prevent Terraform from removing standalone rules resource "aws_security_group" "lambda" { name = "telemetry-collector-lambda-sg" description = "Security group for Lambda function - allow outbound to DocumentDB and internet" vpc_id = aws_vpc.telemetry.id tags = { Name = "telemetry-collector-lambda-sg" } } # Standalone rules to avoid inline/standalone conflict and break SG cycles resource "aws_security_group_rule" "lambda_egress_https" { type = "egress" description = "HTTPS for AWS APIs (DynamoDB, Secrets Manager, CloudWatch)" from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.lambda.id } resource "aws_security_group_rule" "documentdb_ingress_from_lambda" { type = "ingress" description = "MongoDB protocol from Lambda" from_port = 27017 to_port = 27017 protocol = "tcp" security_group_id = aws_security_group.documentdb.id source_security_group_id = aws_security_group.lambda.id } resource "aws_security_group_rule" "lambda_egress_to_documentdb" { type = "egress" description = "DocumentDB access" from_port = 27017 to_port = 27017 protocol = "tcp" security_group_id = aws_security_group.lambda.id source_security_group_id = aws_security_group.documentdb.id } # Data source for available AZs data "aws_availability_zones" "available" { state = "available" } ================================================ FILE: test-keycloak-mcp.sh ================================================ #!/bin/bash # Test Keycloak MCP Gateway authentication # This script reads the token from the ingress.json file and tests MCP commands set -e # Get script directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOKEN_FILE="$SCRIPT_DIR/.oauth-tokens/ingress.json" # Colors for output GREEN='\033[0;32m' RED='\033[0;31m' YELLOW='\033[1;33m' NC='\033[0m' # No Color echo -e "${YELLOW}Testing Keycloak MCP Gateway Authentication${NC}" echo "==============================================" # Check if token file exists if [ ! -f "$TOKEN_FILE" ]; then echo -e "${RED}Error: Token file not found at $TOKEN_FILE${NC}" exit 1 fi # Extract token echo "Reading token from $TOKEN_FILE..." TOKEN=$(jq -r '.access_token' "$TOKEN_FILE") if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then echo -e "${RED}Error: Could not read access_token from file${NC}" exit 1 fi echo -e "${GREEN}Token loaded successfully${NC}" # Test 1: Basic connectivity (should get MCP protocol error) echo "" echo "Test 1: Basic authentication test..." RESPONSE=$(curl -s \ -H "X-Authorization: Bearer $TOKEN" \ -H "Accept: application/json" \ https://mcpgateway.ddns.net/currenttime/mcp) echo "Response: $RESPONSE" if echo "$RESPONSE" | grep -q "Not Acceptable.*text/event-stream"; then echo -e "${GREEN}✓ Authentication successful! (MCP protocol error is expected)${NC}" else echo -e "${RED}✗ Authentication may have failed${NC}" fi # Test 2: MCP Initialize echo "" echo "Test 2: MCP Initialize..." # Get session ID from headers using -v flag SESSION_ID=$(curl -s -v \ -H "X-Authorization: Bearer $TOKEN" \ -H "Accept: application/json, text/event-stream" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}' \ https://mcpgateway.ddns.net/currenttime/mcp 2>&1 | grep -i '< mcp-session-id:' | sed 's/.*< mcp-session-id: *//' | tr -d '\r') if [ -n "$SESSION_ID" ]; then echo "✓ Session established with ID: $SESSION_ID" # Send initialized notification to complete handshake echo "Completing initialization handshake..." curl -s \ -H "X-Authorization: Bearer $TOKEN" \ -H "Accept: application/json, text/event-stream" \ -H "Content-Type: application/json" \ -H "mcp-session-id: $SESSION_ID" \ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ https://mcpgateway.ddns.net/currenttime/mcp > /dev/null echo "✓ Handshake completed" else echo "✗ Failed to get session ID" fi RESPONSE2=$(curl -s \ -H "X-Authorization: Bearer $TOKEN" \ -H "Accept: application/json, text/event-stream" \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test-client","version":"1.0.0"}}}' \ https://mcpgateway.ddns.net/currenttime/mcp) echo "Initialize response:" echo "$RESPONSE2" | head -5 # Test 3: MCP Ping echo "" echo "Test 3: MCP Ping..." if [ -n "$SESSION_ID" ]; then RESPONSE3=$(curl -s \ -H "X-Authorization: Bearer $TOKEN" \ -H "Accept: application/json, text/event-stream" \ -H "Content-Type: application/json" \ -H "mcp-session-id: $SESSION_ID" \ -d '{"jsonrpc":"2.0","id":2,"method":"ping"}' \ https://mcpgateway.ddns.net/currenttime/mcp) echo "Ping response:" echo "$RESPONSE3" | head -5 else echo "Skipping ping test - no session ID" fi # Test 4: List tools echo "" echo "Test 4: MCP List Tools..." if [ -n "$SESSION_ID" ]; then RESPONSE4=$(curl -s \ -H "X-Authorization: Bearer $TOKEN" \ -H "Accept: application/json, text/event-stream" \ -H "Content-Type: application/json" \ -H "mcp-session-id: $SESSION_ID" \ -d '{"jsonrpc":"2.0","id":3,"method":"tools/list"}' \ https://mcpgateway.ddns.net/currenttime/mcp) echo "List tools response:" echo "$RESPONSE4" | head -10 else echo "Skipping tools/list test - no session ID" fi echo "" echo -e "${GREEN}Testing complete!${NC}" echo "" echo -e "${YELLOW}Key points:${NC}" echo "- Authentication uses only X-Authorization header (no Cognito headers needed)" echo "- Token has groups: ['mcp-servers-unrestricted'] for full access" echo "- Keycloak integration is working correctly" ================================================ FILE: tests/README.md ================================================ # MCP Gateway Registry Tests This directory contains the complete test infrastructure for the MCP Gateway Registry project. ## Directory Structure ``` tests/ ├── conftest.py # Root conftest with session fixtures and auto-mocking ├── test_infrastructure.py # Test to verify infrastructure works ├── fixtures/ # Test fixtures and utilities │ ├── __init__.py │ ├── constants.py # Test constants │ ├── factories.py # Factory Boy factories for test data │ ├── helpers.py # Helper functions for tests │ └── mocks/ # Mock implementations │ ├── __init__.py │ ├── mock_faiss.py # Mock FAISS index │ ├── mock_embeddings.py # Mock embeddings clients │ ├── mock_http.py # Mock HTTP clients │ └── mock_auth.py # Mock authentication ├── unit/ # Unit tests │ ├── __init__.py │ ├── conftest.py # Unit test fixtures │ ├── core/ # Core infrastructure tests │ ├── services/ # Service layer tests │ ├── search/ # Search and FAISS tests │ ├── embeddings/ # Embeddings tests │ ├── health/ # Health monitoring tests │ ├── auth/ # Auth tests │ └── api/ # API routes tests ├── integration/ # Integration tests │ ├── __init__.py │ └── conftest.py # Integration test fixtures └── auth_server/ # Auth server tests ├── __init__.py ├── conftest.py # Auth server fixtures └── fixtures/ # Auth-specific fixtures ├── __init__.py ├── mock_jwt.py # JWT utilities └── mock_providers.py # Mock auth providers ``` ## Key Features ### Auto-Mocking The root `conftest.py` automatically mocks heavy dependencies BEFORE they are imported: - **FAISS**: Mocked to avoid loading the native library - **sentence-transformers**: Mocked to avoid loading ML models - **litellm**: Mocked for embeddings testing This ensures tests run fast without downloading or loading large dependencies. ### Test Fixtures #### Session-Scoped Fixtures - `event_loop_policy`: Configures async event loop for tests - `tmp_test_dir`: Session-wide temporary directory #### Function-Scoped Fixtures - `test_settings`: Settings instance with temporary directories - `mock_settings`: Patches global settings with test settings - `sample_server_info`: Sample server data dictionary - `sample_agent_card`: Sample agent card data dictionary ### Factory Boy Factories Create realistic test data with `Factory Boy`: ```python from tests.fixtures.factories import ServerDetailFactory, AgentCardFactory # Create a server with defaults server = ServerDetailFactory() # Create with custom values server = ServerDetailFactory(name="custom.server", version="2.0.0") # Create multiple servers servers = [ServerDetailFactory() for _ in range(5)] # Create agent with skills from tests.fixtures.factories import create_agent_with_skills agent = create_agent_with_skills(num_skills=5) ``` ### Mock Implementations #### Mock FAISS Index ```python from tests.fixtures.mocks.mock_faiss import MockFaissIndex index = MockFaissIndex(dimension=384) vectors = np.random.randn(10, 384).astype(np.float32) ids = np.arange(10) index.add_with_ids(vectors, ids) # Search distances, indices = index.search(query_vector, k=5) ``` #### Mock Embeddings Client ```python from tests.fixtures.mocks.mock_embeddings import MockEmbeddingsClient client = MockEmbeddingsClient(dimension=384) embeddings = client.encode(["text 1", "text 2"]) # Returns deterministic embeddings based on text hash ``` #### Mock Authentication ```python from tests.fixtures.mocks.mock_auth import MockJWTValidator validator = MockJWTValidator() token = validator.create_token( username="testuser", groups=["users"], scopes=["read:servers"] ) payload = validator.validate_token(token) ``` ### Test Constants All test constants are centralized in `fixtures/constants.py`: ```python from tests.fixtures.constants import ( TEST_SERVER_NAME_1, TEST_AGENT_NAME_1, TEST_USER_GROUPS, VISIBILITY_PUBLIC, ) ``` ### Helper Functions Common test operations are in `fixtures/helpers.py`: ```python from tests.fixtures.helpers import ( create_test_server_file, create_test_agent_file, create_minimal_server_dict, assert_server_equals, ) # Create server file in temp directory server_file = create_test_server_file( servers_dir=tmp_path / "servers", server_name="test.server", server_data={"name": "test.server", ...} ) ``` ## Running Tests ### Run all tests ```bash pytest tests/ ``` ### Run specific test categories ```bash # Unit tests only pytest tests/unit/ # Integration tests only pytest tests/integration/ # Auth server tests only pytest tests/auth_server/ # Tests marked as 'unit' pytest -m unit # Tests marked as 'integration' pytest -m integration ``` ### Run with coverage ```bash pytest tests/ --cov=registry --cov-report=html ``` ### Run specific test file ```bash pytest tests/unit/services/test_server_service.py ``` ### Run with verbose output ```bash pytest tests/ -v ``` ## Writing Tests ### Unit Test Example ```python import pytest from tests.fixtures.factories import ServerDetailFactory class TestServerService: """Tests for server service.""" def test_get_server(self, mock_settings): """Test retrieving a server.""" # Arrange server = ServerDetailFactory() # Act # ... test logic # Assert assert server.name is not None ``` ### Integration Test Example ```python import pytest class TestServerRoutes: """Integration tests for server routes.""" @pytest.mark.integration async def test_list_servers(self, async_test_client): """Test listing servers via API.""" response = await async_test_client.get("/api/v1/servers") assert response.status_code == 200 ``` ### Auth Test Example ```python import pytest from tests.auth_server.fixtures.mock_jwt import create_mock_jwt_token class TestAuthentication: """Tests for authentication.""" def test_token_validation(self, mock_jwt_validator): """Test JWT token validation.""" token = mock_jwt_validator.create_token("testuser") payload = mock_jwt_validator.validate_token(token) assert payload["username"] == "testuser" ``` ## Test Markers Tests can be marked with pytest markers: - `@pytest.mark.unit`: Unit tests - `@pytest.mark.integration`: Integration tests - `@pytest.mark.auth`: Authentication tests - `@pytest.mark.slow`: Slow-running tests - `@pytest.mark.requires_models`: Tests needing real ML models Markers are automatically applied based on file location: - Files in `unit/` get `@pytest.mark.unit` - Files in `integration/` get `@pytest.mark.integration` - Files in `auth_server/` get `@pytest.mark.auth` ## Troubleshooting ### Import Errors If you get import errors, ensure you're running pytest from the project root: ```bash cd /home/ubuntu/mcp-gateway-registry-MAIN pytest tests/ ``` ### FAISS Not Mocked If FAISS loads during tests, ensure `conftest.py` is being loaded: ```bash pytest tests/ -v --setup-show ``` You should see the auto-mocking messages in the output. ### Async Tests Not Running Ensure `pytest-asyncio` is installed: ```bash uv pip install pytest-asyncio ``` ## Test Data Test data is generated using: 1. **Factory Boy** for model instances 2. **Helper functions** for file-based data 3. **Constants** for consistent values This ensures test data is: - Realistic - Consistent - Easy to maintain - Fast to generate ## Best Practices 1. **Use fixtures** for common setup 2. **Use factories** for creating test data 3. **Use constants** instead of hardcoding values 4. **Mock external dependencies** (HTTP, databases, etc.) 5. **Test one thing per test** function 6. **Use descriptive test names** 7. **Follow AAA pattern**: Arrange, Act, Assert 8. **Clean up** in fixture teardown if needed ## Coverage Goals - Minimum coverage: 80% - Target coverage: 90%+ - Critical paths: 100% Run coverage report: ```bash pytest tests/ --cov=registry --cov-report=html open htmlcov/index.html ``` ================================================ FILE: tests/__init__.py ================================================ """ Tests for the MCP Gateway Registry. This package contains unit tests, integration tests, and test fixtures for the registry service. """ ================================================ FILE: tests/auth_server/__init__.py ================================================ """Auth server tests.""" ================================================ FILE: tests/auth_server/conftest.py ================================================ """ Conftest for auth server tests. Provides fixtures specific to authentication server testing including mock JWT tokens, JWKS endpoints, and authentication providers. """ import logging import sys import time from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import jwt import pytest from tests.auth_server.fixtures.mock_jwt import ( create_expired_jwt_token, create_malformed_jwt_token, create_mock_jwt_token, ) from tests.fixtures.mocks.mock_auth import MockJWTValidator, MockSessionValidator logger = logging.getLogger(__name__) # ============================================================================= # AUTO-MOCKING FOR AUTH SERVER DEPENDENCIES # ============================================================================= def _setup_auth_server_mocks() -> None: """ Set up automatic mocking for auth server dependencies. This must run BEFORE importing auth_server modules to avoid missing dependency errors. """ # Add auth_server to Python path auth_server_path = Path(__file__).parent.parent.parent / "auth_server" if str(auth_server_path) not in sys.path: sys.path.insert(0, str(auth_server_path)) logger.info(f"Added auth_server to Python path: {auth_server_path}") # Mock metrics_middleware mock_metrics = MagicMock() mock_metrics.add_auth_metrics_middleware = MagicMock() sys.modules["metrics_middleware"] = mock_metrics logger.info("Auto-mocked: metrics_middleware") # Execute auto-mocking setup _setup_auth_server_mocks() # ============================================================================= # MOCK JWKS FIXTURES # ============================================================================= @pytest.fixture def mock_jwks_response() -> dict: """ Create a mock JWKS response with RSA public keys. Returns: Dictionary containing JWKS data """ return { "keys": [ { "kid": "test-key-id-1", "kty": "RSA", "alg": "RS256", "use": "sig", "n": "xGOr-H7A-PWgGZ8J0lYnBQTJHQLIvFKvSfBbQddPn8A", "e": "AQAB", }, { "kid": "test-key-id-2", "kty": "RSA", "alg": "RS256", "use": "sig", "n": "yHPr-I8B-QXhHa9K1mZoCRUKIHRMJwGLwGTcTgeQo9B", "e": "AQAB", }, ] } @pytest.fixture def mock_requests_get(mock_jwks_response): """ Mock requests.get for JWKS endpoint calls. Args: mock_jwks_response: JWKS response fixture Yields: Mock requests.get function """ with patch("requests.get") as mock_get: mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_response.raise_for_status.return_value = None mock_response.status_code = 200 mock_get.return_value = mock_response logger.debug("Mocked requests.get for JWKS endpoints") yield mock_get # ============================================================================= # JWT TOKEN FIXTURES # ============================================================================= @pytest.fixture def valid_jwt_token() -> str: """ Create a valid JWT token for testing. Returns: Valid JWT token string """ return create_mock_jwt_token( username="testuser", groups=["users", "developers"], scopes=["read:servers", "write:servers"], expires_in=3600, ) @pytest.fixture def expired_jwt_token() -> str: """ Create an expired JWT token for testing. Returns: Expired JWT token string """ return create_expired_jwt_token(username="testuser") @pytest.fixture def malformed_jwt_token() -> str: """ Create a malformed JWT token for testing. Returns: Malformed token string """ return create_malformed_jwt_token() @pytest.fixture def self_signed_token(auth_env_vars) -> str: """ Create a self-signed JWT token using the auth server's secret key. Args: auth_env_vars: Environment variables fixture Returns: Self-signed JWT token """ secret_key = auth_env_vars["SECRET_KEY"] now = int(time.time()) payload = { "iss": "mcp-auth-server", "aud": "mcp-registry", "sub": "testuser", "scope": "read:servers write:servers", "exp": now + 3600, "iat": now, "token_use": "access", "client_id": "user-generated", } return jwt.encode(payload, secret_key, algorithm="HS256") @pytest.fixture def m2m_token() -> str: """ Create a machine-to-machine JWT token for testing. Returns: M2M JWT token string """ return create_mock_jwt_token( username="service-account", scopes=["admin:all"], token_use="access", client_id="m2m-client", azp="m2m-client", ) # ============================================================================= # MOCK JWT VALIDATOR FIXTURES # ============================================================================= @pytest.fixture def mock_jwt_validator() -> MockJWTValidator: """ Create a mock JWT validator for testing. Returns: MockJWTValidator instance """ return MockJWTValidator(secret_key="test-jwt-secret") @pytest.fixture def mock_session_validator() -> MockSessionValidator: """ Create a mock session validator for testing. Returns: MockSessionValidator instance """ return MockSessionValidator(secret_key="test-session-secret") # ============================================================================= # ENVIRONMENT FIXTURES # ============================================================================= @pytest.fixture def auth_env_vars(monkeypatch) -> dict[str, str]: """ Set up environment variables for auth server testing. Args: monkeypatch: Pytest monkeypatch fixture Returns: Dictionary of environment variables set """ env_vars = { "SECRET_KEY": "test-secret-key-for-auth-testing-do-not-use-in-prod", "AUTH_PROVIDER": "cognito", "COGNITO_USER_POOL_ID": "us-east-1_TEST12345", "COGNITO_CLIENT_ID": "test-client-id", "COGNITO_CLIENT_SECRET": "test-client-secret", "AWS_REGION": "us-east-1", "MAX_TOKENS_PER_USER_PER_HOUR": "100", } for key, value in env_vars.items(): monkeypatch.setenv(key, value) logger.debug(f"Set up {len(env_vars)} auth environment variables") return env_vars @pytest.fixture def keycloak_env_vars(monkeypatch) -> dict[str, str]: """ Set up Keycloak environment variables for testing. Args: monkeypatch: Pytest monkeypatch fixture Returns: Dictionary of environment variables set """ env_vars = { "AUTH_PROVIDER": "keycloak", "KEYCLOAK_URL": "http://localhost:8080", "KEYCLOAK_EXTERNAL_URL": "https://keycloak.example.com", "KEYCLOAK_REALM": "test-realm", "KEYCLOAK_CLIENT_ID": "test-client", "KEYCLOAK_CLIENT_SECRET": "test-secret", "KEYCLOAK_M2M_CLIENT_ID": "m2m-client", "KEYCLOAK_M2M_CLIENT_SECRET": "m2m-secret", } for key, value in env_vars.items(): monkeypatch.setenv(key, value) logger.debug(f"Set up {len(env_vars)} Keycloak environment variables") return env_vars @pytest.fixture def entra_env_vars(monkeypatch) -> dict[str, str]: """ Set up Entra ID environment variables for testing. Args: monkeypatch: Pytest monkeypatch fixture Returns: Dictionary of environment variables set """ env_vars = { "AUTH_PROVIDER": "entra", "ENTRA_TENANT_ID": "test-tenant-id", "ENTRA_CLIENT_ID": "test-client-id", "ENTRA_CLIENT_SECRET": "test-client-secret", } for key, value in env_vars.items(): monkeypatch.setenv(key, value) logger.debug(f"Set up {len(env_vars)} Entra ID environment variables") return env_vars # ============================================================================= # MOCK PROVIDER FIXTURES # ============================================================================= @pytest.fixture def mock_cognito_provider(): """ Create a mock Cognito provider for testing. Returns: Mock Cognito provider """ provider = MagicMock() provider.validate_token = MagicMock( return_value={ "valid": True, "method": "cognito", "username": "testuser", "email": "testuser@example.com", "groups": ["users", "developers"], "scopes": [], "client_id": "test-client-id", "data": { "cognito:username": "testuser", "cognito:groups": ["users", "developers"], "email": "testuser@example.com", }, } ) provider.get_provider_info = MagicMock( return_value={ "provider_type": "cognito", "region": "us-east-1", "user_pool_id": "us-east-1_TEST12345", "client_id": "test-client-id", } ) provider.get_jwks = MagicMock(return_value={"keys": [{"kid": "test-key", "kty": "RSA"}]}) return provider @pytest.fixture def mock_keycloak_provider(): """ Create a mock Keycloak provider for testing. Returns: Mock Keycloak provider """ provider = MagicMock() provider.validate_token = MagicMock( return_value={ "valid": True, "method": "keycloak", "username": "testuser", "email": "testuser@example.com", "groups": ["users", "admins"], "scopes": ["openid", "profile"], "client_id": "test-client", "data": { "preferred_username": "testuser", "email": "testuser@example.com", "groups": ["users", "admins"], }, } ) provider.get_provider_info = MagicMock( return_value={ "provider_type": "keycloak", "realm": "test-realm", "keycloak_url": "http://localhost:8080", "client_id": "test-client", } ) provider.get_jwks = MagicMock(return_value={"keys": [{"kid": "test-key", "kty": "RSA"}]}) return provider @pytest.fixture def auth0_env_vars(monkeypatch) -> dict[str, str]: """ Set up Auth0 environment variables for testing. Args: monkeypatch: Pytest monkeypatch fixture Returns: Dictionary of environment variables set """ env_vars = { "AUTH_PROVIDER": "auth0", "AUTH0_DOMAIN": "test-tenant.auth0.com", "AUTH0_CLIENT_ID": "test-client-id", "AUTH0_CLIENT_SECRET": "test-client-secret", "AUTH0_AUDIENCE": "https://api.example.com", "AUTH0_GROUPS_CLAIM": "https://mcp-gateway/groups", } for key, value in env_vars.items(): monkeypatch.setenv(key, value) logger.debug(f"Set up {len(env_vars)} Auth0 environment variables") return env_vars @pytest.fixture def mock_auth0_provider(): """ Create a mock Auth0 provider for testing. Returns: Mock Auth0 provider """ provider = MagicMock() provider.validate_token = MagicMock( return_value={ "valid": True, "method": "auth0", "username": "testuser", "email": "testuser@example.com", "groups": ["registry-admins", "developers"], "scopes": ["openid", "profile", "email"], "client_id": "test-client-id", "data": { "nickname": "testuser", "email": "testuser@example.com", "https://mcp-gateway/groups": ["registry-admins", "developers"], }, } ) provider.get_provider_info = MagicMock( return_value={ "provider_type": "auth0", "domain": "test-tenant.auth0.com", "client_id": "test-client-id", } ) provider.get_jwks = MagicMock(return_value={"keys": [{"kid": "test-key", "kty": "RSA"}]}) return provider @pytest.fixture def mock_entra_provider(): """ Create a mock Entra ID provider for testing. Returns: Mock Entra ID provider """ provider = MagicMock() provider.validate_token = MagicMock( return_value={ "valid": True, "method": "entra", "username": "testuser@example.com", "email": "testuser@example.com", "groups": ["group-id-1", "group-id-2"], "scopes": ["openid", "profile", "email"], "client_id": "test-client-id", "data": { "preferred_username": "testuser@example.com", "email": "testuser@example.com", "groups": ["group-id-1", "group-id-2"], }, } ) provider.get_provider_info = MagicMock( return_value={ "provider_type": "entra", "tenant_id": "test-tenant-id", "client_id": "test-client-id", } ) provider.get_jwks = MagicMock(return_value={"keys": [{"kid": "test-key", "kty": "RSA"}]}) return provider # ============================================================================= # SESSION COOKIE FIXTURES # ============================================================================= @pytest.fixture def valid_session_cookie(auth_env_vars) -> str: """ Create a valid session cookie for testing. Args: auth_env_vars: Environment variables fixture Returns: Encrypted session cookie string """ from itsdangerous import URLSafeTimedSerializer secret_key = auth_env_vars["SECRET_KEY"] signer = URLSafeTimedSerializer(secret_key) session_data = { "username": "testuser", "email": "testuser@example.com", "groups": ["users", "developers"], "provider": "cognito", "auth_method": "oauth2", } return signer.dumps(session_data) @pytest.fixture def expired_session_cookie() -> str: """ Create an expired session cookie for testing. Returns: Expired session cookie string (with invalid signature) """ # Return a cookie with bad signature to simulate expiration return "invalid.signature.cookie" # ============================================================================= # SCOPES CONFIGURATION FIXTURES # ============================================================================= @pytest.fixture def mock_scopes_config() -> dict: """ Create a mock scopes configuration for testing. Returns: Dictionary containing scopes configuration """ return { "group_mappings": { "users": ["read:servers", "read:tools"], "developers": ["read:servers", "write:servers", "read:tools", "tools:call"], "admins": ["admin:all"], }, "read:servers": [ {"server": "test-server", "methods": ["initialize", "tools/list"], "tools": []} ], "write:servers": [ { "server": "test-server", "methods": ["initialize", "tools/list", "tools/call"], "tools": ["*"], } ], "admin:all": [{"server": "*", "methods": ["*"], "tools": ["*"]}], } @pytest.fixture def mock_scopes_config_file(tmp_path, mock_scopes_config): """ Create a temporary scopes.yml file for testing. Args: tmp_path: Pytest temporary path fixture mock_scopes_config: Mock scopes configuration Returns: Path to temporary scopes.yml file """ import yaml scopes_file = tmp_path / "scopes.yml" with open(scopes_file, "w") as f: yaml.dump(mock_scopes_config, f) logger.debug(f"Created mock scopes config file: {scopes_file}") return scopes_file @pytest.fixture def mock_scope_repository_with_data(mock_scopes_config): """ Create a mocked scope repository that returns data from mock_scopes_config. Args: mock_scopes_config: Mock scopes configuration fixture Returns: AsyncMock scope repository with get_server_scopes method """ mock_repo = AsyncMock() # Mock get_server_scopes to return the scope data from mock_scopes_config async def get_server_scopes_side_effect(scope_name: str): """Return server access rules for a scope from mock_scopes_config.""" # Return the scope data if it exists, otherwise empty list return mock_scopes_config.get(scope_name, []) # Mock get_group_mappings to return scopes for a group from mock_scopes_config async def get_group_mappings_side_effect(group_name: str): """Return scopes for a group from mock_scopes_config.""" group_mappings = mock_scopes_config.get("group_mappings", {}) return group_mappings.get(group_name, []) mock_repo.get_server_scopes.side_effect = get_server_scopes_side_effect mock_repo.get_group_mappings.side_effect = get_group_mappings_side_effect mock_repo.load_all = AsyncMock() mock_repo.list_groups.return_value = {} mock_repo.get_group.return_value = None mock_repo.get_scope_definition.return_value = None mock_repo.list_scope_definitions.return_value = [] return mock_repo # ============================================================================= # RATE LIMITING FIXTURES # ============================================================================= @pytest.fixture def mock_rate_limiter(): """ Create a mock rate limiter that tracks token generation. Returns: Dictionary to track rate limit state """ return {"counts": {}, "limit": 100} # ============================================================================= # OKTA FIXTURES # ============================================================================= @pytest.fixture def okta_env_vars(monkeypatch) -> dict[str, str]: """ Set up Okta environment variables for testing. Args: monkeypatch: Pytest monkeypatch fixture Returns: Dictionary of environment variables set """ env_vars = { "AUTH_PROVIDER": "okta", "OKTA_DOMAIN": "dev-123456.okta.com", "OKTA_CLIENT_ID": "test-client-id", "OKTA_CLIENT_SECRET": "test-client-secret", "OKTA_M2M_CLIENT_ID": "m2m-client-id", "OKTA_M2M_CLIENT_SECRET": "m2m-client-secret", } for key, value in env_vars.items(): monkeypatch.setenv(key, value) logger.debug(f"Set up {len(env_vars)} Okta environment variables") return env_vars @pytest.fixture def mock_okta_provider(): """ Create a mock Okta provider for testing. Returns: Mock Okta provider """ provider = MagicMock() provider.validate_token = MagicMock( return_value={ "valid": True, "method": "okta", "username": "testuser@example.com", "email": "testuser@example.com", "groups": ["users", "developers"], "scopes": ["openid", "profile", "email"], "client_id": "test-client-id", "data": { "sub": "testuser@example.com", "email": "testuser@example.com", "groups": ["users", "developers"], }, } ) provider.get_provider_info = MagicMock( return_value={ "provider_type": "okta", "okta_domain": "dev-123456.okta.com", "client_id": "test-client-id", } ) provider.get_jwks = MagicMock(return_value={"keys": [{"kid": "test-key", "kty": "RSA"}]}) return provider ================================================ FILE: tests/auth_server/fixtures/__init__.py ================================================ """Auth server test fixtures.""" ================================================ FILE: tests/auth_server/fixtures/mock_jwt.py ================================================ """ Mock JWT utilities for auth server testing. This module provides utilities for creating and validating mock JWT tokens in auth server tests. """ import logging import time from typing import Any import jwt logger = logging.getLogger(__name__) def create_mock_jwt_token( username: str, secret_key: str = "test-secret-key", algorithm: str = "HS256", groups: list[str] | None = None, scopes: list[str] | None = None, expires_in: int = 3600, token_use: str = "access", client_id: str = "test-client-id", **extra_claims: Any, ) -> str: """ Create a mock JWT token for testing. Args: username: Username for the token secret_key: Secret key for signing algorithm: JWT algorithm groups: List of user groups scopes: List of user scopes expires_in: Token expiration time in seconds token_use: Token use type (access, id, refresh) client_id: Client ID **extra_claims: Additional claims to include Returns: JWT token string """ now = int(time.time()) payload = { "sub": username, "username": username, "iat": now, "exp": now + expires_in, "token_use": token_use, "client_id": client_id, "iss": "test-issuer", "aud": "test-audience", } if groups: payload["cognito:groups"] = groups payload["groups"] = groups if scopes: payload["scope"] = " ".join(scopes) # Add extra claims payload.update(extra_claims) token = jwt.encode(payload, secret_key, algorithm=algorithm) logger.debug(f"Created mock JWT token for {username} with groups={groups}, scopes={scopes}") return token def decode_mock_jwt_token( token: str, secret_key: str = "test-secret-key", algorithm: str = "HS256", verify: bool = True ) -> dict[str, Any]: """ Decode a mock JWT token. Args: token: JWT token string secret_key: Secret key for verification algorithm: JWT algorithm verify: Whether to verify the signature Returns: Token payload dictionary Raises: jwt.InvalidTokenError: If token is invalid """ options = {} if verify else {"verify_signature": False} payload = jwt.decode(token, secret_key, algorithms=[algorithm], options=options) logger.debug(f"Decoded mock JWT token for {payload.get('username')}") return payload def create_expired_jwt_token( username: str, secret_key: str = "test-secret-key", algorithm: str = "HS256" ) -> str: """ Create an expired JWT token for testing expiration handling. Args: username: Username for the token secret_key: Secret key for signing algorithm: JWT algorithm Returns: Expired JWT token string """ now = int(time.time()) payload = { "sub": username, "username": username, "iat": now - 7200, # Issued 2 hours ago "exp": now - 3600, # Expired 1 hour ago "token_use": "access", } token = jwt.encode(payload, secret_key, algorithm=algorithm) logger.debug(f"Created expired mock JWT token for {username}") return token def create_malformed_jwt_token() -> str: """ Create a malformed JWT token for testing error handling. Returns: Malformed token string """ return "not.a.valid.jwt.token.format" ================================================ FILE: tests/auth_server/fixtures/mock_providers.py ================================================ """ Mock authentication provider implementations for testing. This module provides mock implementations of authentication providers (Cognito, Keycloak, Entra ID) for testing the auth server. """ import logging from typing import Any logger = logging.getLogger(__name__) class MockKeycloakProvider: """ Mock Keycloak authentication provider for testing. Simulates the Keycloak provider interface without requiring a real Keycloak server. """ def __init__( self, realm: str = "test-realm", server_url: str = "http://localhost:8080", client_id: str = "test-client", ): """ Initialize mock Keycloak provider. Args: realm: Keycloak realm name server_url: Keycloak server URL client_id: Client ID """ self.realm = realm self.server_url = server_url self.client_id = client_id self._valid_tokens: dict[str, dict[str, Any]] = {} def register_token( self, token: str, username: str, groups: list[str] | None = None, roles: list[str] | None = None, ) -> None: """ Register a valid token for testing. Args: token: JWT token string username: Username groups: List of groups roles: List of roles """ self._valid_tokens[token] = { "username": username, "groups": groups or [], "roles": roles or [], } logger.debug(f"Registered token for {username} in mock Keycloak") def validate_token(self, access_token: str) -> dict[str, Any]: """ Validate a JWT token. Args: access_token: JWT token to validate Returns: Validation result dictionary Raises: ValueError: If token is invalid """ if access_token in self._valid_tokens: token_info = self._valid_tokens[access_token] return { "valid": True, "method": "keycloak", "username": token_info["username"], "groups": token_info["groups"], "scopes": [], # Keycloak uses groups/roles, not scopes "client_id": self.client_id, "data": token_info, } raise ValueError("Invalid Keycloak token") def get_provider_info(self) -> dict[str, Any]: """ Get provider information. Returns: Provider info dictionary """ return { "provider_type": "keycloak", "realm": self.realm, "server_url": self.server_url, "client_id": self.client_id, } class MockCognitoValidator: """ Mock Cognito validator for testing. Simulates AWS Cognito token validation without requiring actual AWS Cognito. """ def __init__( self, region: str = "us-east-1", user_pool_id: str = "us-east-1_TEST12345", client_id: str = "test-client-id", ): """ Initialize mock Cognito validator. Args: region: AWS region user_pool_id: Cognito User Pool ID client_id: Client ID """ self.region = region self.user_pool_id = user_pool_id self.client_id = client_id self._valid_tokens: dict[str, dict[str, Any]] = {} def register_token( self, token: str, username: str, groups: list[str] | None = None, email: str | None = None ) -> None: """ Register a valid token for testing. Args: token: JWT token string username: Username (Cognito sub) groups: List of Cognito groups email: User email """ self._valid_tokens[token] = { "username": username, "groups": groups or [], "email": email or f"{username}@example.com", "email_verified": True, } logger.debug(f"Registered token for {username} in mock Cognito") def validate_token( self, access_token: str, user_pool_id: str, client_id: str, region: str | None = None ) -> dict[str, Any]: """ Validate a Cognito JWT token. Args: access_token: JWT token to validate user_pool_id: User Pool ID client_id: Client ID region: AWS region Returns: Validation result dictionary Raises: ValueError: If token is invalid """ if access_token in self._valid_tokens: token_info = self._valid_tokens[access_token] return { "valid": True, "method": "jwt", "username": token_info["username"], "groups": token_info["groups"], "scopes": [], "client_id": client_id, "data": { "cognito:username": token_info["username"], "cognito:groups": token_info["groups"], "email": token_info["email"], }, } raise ValueError("Invalid Cognito token") def get_provider_info(self) -> dict[str, Any]: """ Get provider information. Returns: Provider info dictionary """ return { "provider_type": "cognito", "region": self.region, "user_pool_id": self.user_pool_id, "client_id": self.client_id, } def create_mock_provider(provider_type: str = "cognito", **kwargs: Any) -> Any: """ Factory function to create mock authentication providers. Args: provider_type: Type of provider (cognito, keycloak, entra) **kwargs: Provider-specific configuration Returns: Mock provider instance Raises: ValueError: If provider type is not supported """ if provider_type == "cognito": return MockCognitoValidator(**kwargs) elif provider_type == "keycloak": return MockKeycloakProvider(**kwargs) else: raise ValueError(f"Unsupported provider type: {provider_type}") ================================================ FILE: tests/auth_server/unit/__init__.py ================================================ """Unit tests for auth_server.""" ================================================ FILE: tests/auth_server/unit/providers/__init__.py ================================================ """Unit tests for auth_server providers.""" ================================================ FILE: tests/auth_server/unit/providers/test_auth0.py ================================================ """ Unit tests for auth_server/providers/auth0.py Tests the Auth0 authentication provider implementation including token validation, JWKS handling, OAuth2 flows, and M2M authentication. """ import logging import time from unittest.mock import MagicMock, patch import jwt import pytest import requests logger = logging.getLogger(__name__) # Mark all tests in this file pytestmark = [pytest.mark.unit, pytest.mark.auth] # ============================================================================= # AUTH0 PROVIDER INITIALIZATION TESTS # ============================================================================= class TestAuth0ProviderInit: """Tests for Auth0Provider initialization.""" def test_provider_initialization_basic(self): """Test basic provider initialization.""" from auth_server.providers.auth0 import Auth0Provider # Act provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Assert assert provider.domain == "test-tenant.auth0.com" assert provider.client_id == "test-client" assert provider.client_secret == "test-secret" assert provider.audience is None def test_provider_initialization_with_audience(self): """Test initialization with API audience.""" from auth_server.providers.auth0 import Auth0Provider # Act provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", audience="https://api.example.com", ) # Assert assert provider.audience == "https://api.example.com" def test_provider_initialization_removes_trailing_slashes(self): """Test that trailing slashes are removed from domain.""" from auth_server.providers.auth0 import Auth0Provider # Act provider = Auth0Provider( domain="test-tenant.auth0.com/", client_id="test-client", client_secret="test-secret", ) # Assert assert not provider.domain.endswith("/") def test_provider_initialization_m2m_defaults(self): """Test M2M client defaults to main client.""" from auth_server.providers.auth0 import Auth0Provider # Act provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Assert assert provider.m2m_client_id == "test-client" assert provider.m2m_client_secret == "test-secret" def test_provider_initialization_separate_m2m_client(self): """Test initialization with separate M2M client.""" from auth_server.providers.auth0 import Auth0Provider # Act provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="web-client", client_secret="web-secret", m2m_client_id="m2m-client", m2m_client_secret="m2m-secret", ) # Assert assert provider.client_id == "web-client" assert provider.m2m_client_id == "m2m-client" assert provider.m2m_client_secret == "m2m-secret" def test_provider_initialization_custom_groups_claim(self): """Test initialization with custom groups claim.""" from auth_server.providers.auth0 import Auth0Provider # Act provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", groups_claim="https://custom-ns/roles", ) # Assert assert provider.groups_claim == "https://custom-ns/roles" def test_provider_endpoints(self): """Test that Auth0 endpoints are correctly constructed.""" from auth_server.providers.auth0 import Auth0Provider # Act provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Assert assert provider.auth_url == "https://test-tenant.auth0.com/authorize" assert provider.token_url == "https://test-tenant.auth0.com/oauth/token" assert provider.userinfo_url == "https://test-tenant.auth0.com/userinfo" assert provider.jwks_url == "https://test-tenant.auth0.com/.well-known/jwks.json" assert provider.logout_url == "https://test-tenant.auth0.com/v2/logout" assert provider.issuer == "https://test-tenant.auth0.com/" # ============================================================================= # JWKS RETRIEVAL TESTS # ============================================================================= class TestAuth0JWKS: """Tests for JWKS retrieval and caching.""" @patch("auth_server.providers.auth0.requests.get") def test_get_jwks_success(self, mock_get, mock_jwks_response): """Test successful JWKS retrieval.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act jwks = provider.get_jwks() # Assert assert "keys" in jwks assert len(jwks["keys"]) == 2 mock_get.assert_called_once() assert "/.well-known/jwks.json" in mock_get.call_args[0][0] @patch("auth_server.providers.auth0.requests.get") def test_get_jwks_caching(self, mock_get, mock_jwks_response): """Test that JWKS is cached and not fetched repeatedly.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act - call multiple times jwks1 = provider.get_jwks() jwks2 = provider.get_jwks() jwks3 = provider.get_jwks() # Assert - should only call once due to caching assert mock_get.call_count == 1 assert jwks1 == jwks2 == jwks3 @patch("auth_server.providers.auth0.requests.get") @patch("auth_server.providers.auth0.time.time") def test_get_jwks_cache_expiration(self, mock_time, mock_get, mock_jwks_response): """Test that JWKS cache expires after TTL.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # First call mock_time.return_value = 1000 provider.get_jwks() # Second call - cache should still be valid mock_time.return_value = 1100 provider.get_jwks() # Third call - cache should be expired (TTL is 3600 seconds) mock_time.return_value = 5000 provider.get_jwks() # Assert assert mock_get.call_count == 2 # First call + after expiration @patch("auth_server.providers.auth0.requests.get") def test_get_jwks_network_error(self, mock_get): """Test JWKS retrieval with network error.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_get.side_effect = requests.RequestException("Network error") provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act & Assert with pytest.raises(ValueError, match="Cannot retrieve JWKS"): provider.get_jwks() # ============================================================================= # TOKEN VALIDATION TESTS # ============================================================================= class TestAuth0TokenValidation: """Tests for JWT token validation.""" @patch("auth_server.providers.auth0.requests.get") def test_validate_token_success(self, mock_get, mock_jwks_response): """Test successful token validation.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_get.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) now = int(time.time()) payload = { "iss": "https://test-tenant.auth0.com/", "aud": "test-client", "sub": "auth0|user-123", "nickname": "testuser", "email": "testuser@example.com", "https://mcp-gateway/groups": ["registry-admins", "developers"], "scope": "openid profile email", "azp": "test-client", "exp": now + 3600, "iat": now, } with patch("auth_server.providers.auth0.jwt.get_unverified_header") as mock_header: with patch("auth_server.providers.auth0.jwt.decode") as mock_decode: mock_header.return_value = {"kid": "test-key-id-1"} mock_decode.return_value = payload with patch("jwt.PyJWK") as mock_pyjwk: mock_key = MagicMock() mock_pyjwk.return_value.key = mock_key # Act result = provider.validate_token("test-token") # Assert assert result["valid"] is True assert result["username"] == "testuser" assert result["email"] == "testuser@example.com" assert "registry-admins" in result["groups"] assert "developers" in result["groups"] assert result["method"] == "auth0" @patch("auth_server.providers.auth0.requests.get") def test_validate_token_with_permissions_fallback(self, mock_get, mock_jwks_response): """Test token validation falls back to permissions claim for groups.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_get.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) now = int(time.time()) payload = { "iss": "https://test-tenant.auth0.com/", "aud": "test-client", "sub": "auth0|user-123", "nickname": "testuser", "permissions": ["read:servers", "write:servers"], "exp": now + 3600, "iat": now, } with patch("auth_server.providers.auth0.jwt.get_unverified_header") as mock_header: with patch("auth_server.providers.auth0.jwt.decode") as mock_decode: mock_header.return_value = {"kid": "test-key-id-1"} mock_decode.return_value = payload with patch("jwt.PyJWK") as mock_pyjwk: mock_pyjwk.return_value.key = MagicMock() # Act result = provider.validate_token("test-token") # Assert assert result["valid"] is True assert result["groups"] == ["read:servers", "write:servers"] @patch("auth_server.providers.auth0.requests.get") def test_validate_token_expired(self, mock_get, mock_jwks_response): """Test validation of expired token.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_get.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) with patch("auth_server.providers.auth0.jwt.get_unverified_header") as mock_header: with patch("auth_server.providers.auth0.jwt.decode") as mock_decode: mock_header.return_value = {"kid": "test-key-id-1"} mock_decode.side_effect = jwt.ExpiredSignatureError("Token expired") # Act & Assert with pytest.raises(ValueError, match="expired"): provider.validate_token("expired-token") @patch("auth_server.providers.auth0.requests.get") def test_validate_token_no_kid(self, mock_get, mock_jwks_response): """Test validation of token without kid header.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_get.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) with patch("auth_server.providers.auth0.jwt.get_unverified_header") as mock_header: mock_header.return_value = {} # No kid # Act & Assert with pytest.raises(ValueError, match="missing 'kid'"): provider.validate_token("token-without-kid") @patch("auth_server.providers.auth0.requests.get") def test_validate_token_key_not_found(self, mock_get, mock_jwks_response): """Test validation when signing key is not found.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_get.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) with patch("auth_server.providers.auth0.jwt.get_unverified_header") as mock_header: mock_header.return_value = {"kid": "unknown-key-id"} # Act & Assert with pytest.raises(ValueError, match="No matching key found"): provider.validate_token("token-with-unknown-kid") @patch("auth_server.providers.auth0.requests.get") def test_validate_token_with_audience(self, mock_get, mock_jwks_response): """Test validation includes audience in valid audiences.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_get.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", audience="https://api.example.com", ) now = int(time.time()) payload = { "iss": "https://test-tenant.auth0.com/", "aud": "https://api.example.com", "sub": "auth0|user-123", "nickname": "testuser", "exp": now + 3600, "iat": now, } with patch("auth_server.providers.auth0.jwt.get_unverified_header") as mock_header: with patch("auth_server.providers.auth0.jwt.decode") as mock_decode: mock_header.return_value = {"kid": "test-key-id-1"} mock_decode.return_value = payload with patch("jwt.PyJWK") as mock_pyjwk: mock_pyjwk.return_value.key = MagicMock() # Act result = provider.validate_token("test-token") # Assert assert result["valid"] is True # Verify audience list includes both client_id and API audience decode_call = mock_decode.call_args assert "https://api.example.com" in decode_call[1]["audience"] assert "test-client" in decode_call[1]["audience"] # ============================================================================= # OAUTH2 FLOW TESTS # ============================================================================= class TestAuth0OAuth2: """Tests for OAuth2 authorization code flow.""" @patch("auth_server.providers.auth0.requests.post") def test_exchange_code_for_token_success(self, mock_post): """Test successful code exchange.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = { "access_token": "access-token-value", "id_token": "id-token-value", "refresh_token": "refresh-token-value", "token_type": "Bearer", "expires_in": 3600, } mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act result = provider.exchange_code_for_token( code="auth-code", redirect_uri="https://app.example.com/callback" ) # Assert assert result["access_token"] == "access-token-value" assert result["token_type"] == "Bearer" assert result["expires_in"] == 3600 mock_post.assert_called_once() @patch("auth_server.providers.auth0.requests.post") def test_exchange_code_for_token_error(self, mock_post): """Test code exchange with error.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_post.side_effect = requests.RequestException("Token endpoint error") provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act & Assert with pytest.raises(ValueError, match="Token exchange failed"): provider.exchange_code_for_token( code="invalid-code", redirect_uri="https://app.example.com/callback" ) def test_get_auth_url(self): """Test authorization URL generation.""" from auth_server.providers.auth0 import Auth0Provider # Arrange provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act auth_url = provider.get_auth_url( redirect_uri="https://app.example.com/callback", state="random-state", scope="openid email profile", ) # Assert assert "test-tenant.auth0.com/authorize" in auth_url assert "client_id=test-client" in auth_url assert "redirect_uri=https" in auth_url assert "state=random-state" in auth_url assert "scope=openid" in auth_url def test_get_auth_url_includes_audience(self): """Test authorization URL includes audience when configured.""" from auth_server.providers.auth0 import Auth0Provider # Arrange provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", audience="https://api.example.com", ) # Act auth_url = provider.get_auth_url( redirect_uri="https://app.example.com/callback", state="random-state", ) # Assert assert "audience=https" in auth_url def test_get_auth_url_no_audience(self): """Test authorization URL without audience parameter.""" from auth_server.providers.auth0 import Auth0Provider # Arrange provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act auth_url = provider.get_auth_url( redirect_uri="https://app.example.com/callback", state="random-state", ) # Assert assert "audience" not in auth_url def test_get_logout_url(self): """Test logout URL generation uses Auth0's returnTo parameter.""" from auth_server.providers.auth0 import Auth0Provider # Arrange provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act logout_url = provider.get_logout_url(redirect_uri="https://app.example.com/logout") # Assert assert "test-tenant.auth0.com/v2/logout" in logout_url assert "client_id=test-client" in logout_url assert "returnTo=https" in logout_url # ============================================================================= # USER INFO TESTS # ============================================================================= class TestAuth0UserInfo: """Tests for user information retrieval.""" @patch("auth_server.providers.auth0.requests.get") def test_get_user_info_success(self, mock_get): """Test successful user info retrieval.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = { "sub": "auth0|user-123", "nickname": "testuser", "email": "testuser@example.com", "email_verified": True, "name": "Test User", } mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act user_info = provider.get_user_info("access-token") # Assert assert user_info["nickname"] == "testuser" assert user_info["email"] == "testuser@example.com" @patch("auth_server.providers.auth0.requests.get") def test_get_user_info_error(self, mock_get): """Test user info retrieval with error.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_get.side_effect = requests.RequestException("UserInfo error") provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act & Assert with pytest.raises(ValueError, match="User info retrieval failed"): provider.get_user_info("invalid-token") # ============================================================================= # TOKEN REFRESH TESTS # ============================================================================= class TestAuth0TokenRefresh: """Tests for token refresh functionality.""" @patch("auth_server.providers.auth0.requests.post") def test_refresh_token_success(self, mock_post): """Test successful token refresh.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = { "access_token": "new-access-token", "refresh_token": "new-refresh-token", "token_type": "Bearer", "expires_in": 3600, } mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act result = provider.refresh_token("old-refresh-token") # Assert assert result["access_token"] == "new-access-token" assert result["token_type"] == "Bearer" @patch("auth_server.providers.auth0.requests.post") def test_refresh_token_error(self, mock_post): """Test token refresh with error.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_post.side_effect = requests.RequestException("Refresh failed") provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act & Assert with pytest.raises(ValueError, match="Token refresh failed"): provider.refresh_token("invalid-refresh-token") # ============================================================================= # M2M AUTHENTICATION TESTS # ============================================================================= class TestAuth0M2M: """Tests for machine-to-machine authentication.""" @patch("auth_server.providers.auth0.requests.post") def test_get_m2m_token_success(self, mock_post): """Test successful M2M token generation.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = { "access_token": "m2m-access-token", "token_type": "Bearer", "expires_in": 3600, } mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="web-client", client_secret="web-secret", audience="https://api.example.com", m2m_client_id="m2m-client", m2m_client_secret="m2m-secret", ) # Act result = provider.get_m2m_token() # Assert assert result["access_token"] == "m2m-access-token" assert result["token_type"] == "Bearer" # Should use M2M credentials call_data = mock_post.call_args[1]["data"] assert call_data["client_id"] == "m2m-client" assert call_data["client_secret"] == "m2m-secret" assert call_data["grant_type"] == "client_credentials" assert call_data["audience"] == "https://api.example.com" @patch("auth_server.providers.auth0.requests.post") def test_get_m2m_token_custom_credentials(self, mock_post): """Test M2M token generation with custom credentials.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = { "access_token": "custom-m2m-token", "token_type": "Bearer", "expires_in": 3600, } mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="default-client", client_secret="default-secret", audience="https://api.example.com", ) # Act result = provider.get_m2m_token( client_id="custom-client", client_secret="custom-secret", scope="custom-scope" ) # Assert assert result["access_token"] == "custom-m2m-token" call_data = mock_post.call_args[1]["data"] assert call_data["client_id"] == "custom-client" assert call_data["client_secret"] == "custom-secret" assert call_data["scope"] == "custom-scope" @patch("auth_server.providers.auth0.requests.post") def test_get_m2m_token_no_audience(self, mock_post): """Test M2M token without audience configured.""" from auth_server.providers.auth0 import Auth0Provider # Arrange mock_response = MagicMock() mock_response.json.return_value = { "access_token": "m2m-token", "token_type": "Bearer", } mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Act provider.get_m2m_token() # Assert - audience should not be in request data call_data = mock_post.call_args[1]["data"] assert "audience" not in call_data def test_validate_m2m_token(self): """Test that M2M token validation uses same method as regular tokens.""" from auth_server.providers.auth0 import Auth0Provider # Arrange provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", ) # Mock validate_token with patch.object(provider, "validate_token") as mock_validate: mock_validate.return_value = {"valid": True} # Act result = provider.validate_m2m_token("m2m-token") # Assert assert result["valid"] is True mock_validate.assert_called_once_with("m2m-token") # ============================================================================= # PROVIDER INFO TESTS # ============================================================================= class TestAuth0ProviderInfo: """Tests for provider information.""" def test_get_provider_info(self): """Test getting provider information.""" from auth_server.providers.auth0 import Auth0Provider # Arrange provider = Auth0Provider( domain="test-tenant.auth0.com", client_id="test-client", client_secret="test-secret", audience="https://api.example.com", ) # Act info = provider.get_provider_info() # Assert assert info["provider_type"] == "auth0" assert info["domain"] == "test-tenant.auth0.com" assert info["client_id"] == "test-client" assert info["audience"] == "https://api.example.com" assert "endpoints" in info assert "auth" in info["endpoints"] assert "token" in info["endpoints"] assert "userinfo" in info["endpoints"] assert "jwks" in info["endpoints"] assert "logout" in info["endpoints"] assert info["issuer"] == "https://test-tenant.auth0.com/" # ============================================================================= # FACTORY TESTS # ============================================================================= class TestAuth0Factory: """Tests for Auth0 provider factory creation.""" def test_factory_creates_auth0_provider(self, auth0_env_vars): """Test that factory creates Auth0 provider correctly.""" from auth_server.providers.factory import get_auth_provider # Act provider = get_auth_provider("auth0") # Assert from auth_server.providers.auth0 import Auth0Provider assert isinstance(provider, Auth0Provider) assert provider.domain == "test-tenant.auth0.com" assert provider.client_id == "test-client-id" assert provider.audience == "https://api.example.com" def test_factory_missing_domain(self, monkeypatch): """Test factory raises error when domain is missing.""" from auth_server.providers.factory import get_auth_provider # Arrange - set client_id and secret but not domain monkeypatch.setenv("AUTH0_CLIENT_ID", "test-client") monkeypatch.setenv("AUTH0_CLIENT_SECRET", "test-secret") monkeypatch.delenv("AUTH0_DOMAIN", raising=False) # Act & Assert with pytest.raises(ValueError, match="AUTH0_DOMAIN"): get_auth_provider("auth0") def test_factory_missing_client_id(self, monkeypatch): """Test factory raises error when client_id is missing.""" from auth_server.providers.factory import get_auth_provider # Arrange monkeypatch.setenv("AUTH0_DOMAIN", "test.auth0.com") monkeypatch.setenv("AUTH0_CLIENT_SECRET", "test-secret") monkeypatch.delenv("AUTH0_CLIENT_ID", raising=False) # Act & Assert with pytest.raises(ValueError, match="AUTH0_CLIENT_ID"): get_auth_provider("auth0") def test_factory_missing_client_secret(self, monkeypatch): """Test factory raises error when client_secret is missing.""" from auth_server.providers.factory import get_auth_provider # Arrange monkeypatch.setenv("AUTH0_DOMAIN", "test.auth0.com") monkeypatch.setenv("AUTH0_CLIENT_ID", "test-client") monkeypatch.delenv("AUTH0_CLIENT_SECRET", raising=False) # Act & Assert with pytest.raises(ValueError, match="AUTH0_CLIENT_SECRET"): get_auth_provider("auth0") ================================================ FILE: tests/auth_server/unit/providers/test_base.py ================================================ """ Unit tests for auth_server/providers/base.py Tests the abstract base class interface for authentication providers. """ import logging from typing import Any import pytest logger = logging.getLogger(__name__) # Mark all tests in this file pytestmark = [pytest.mark.unit, pytest.mark.auth] # ============================================================================= # BASE PROVIDER INTERFACE TESTS # ============================================================================= class TestAuthProviderInterface: """Tests for AuthProvider abstract base class.""" def test_auth_provider_is_abstract(self): """Test that AuthProvider is an abstract base class.""" from auth_server.providers.base import AuthProvider # Act & Assert - cannot instantiate abstract class with pytest.raises(TypeError): AuthProvider() def test_auth_provider_has_required_methods(self): """Test that AuthProvider defines all required abstract methods.""" import inspect from auth_server.providers.base import AuthProvider # Act abstract_methods = { name for name, method in inspect.getmembers(AuthProvider) if getattr(method, "__isabstractmethod__", False) } # Assert expected_methods = { "validate_token", "get_jwks", "exchange_code_for_token", "get_user_info", "get_auth_url", "get_logout_url", "refresh_token", "validate_m2m_token", "get_m2m_token", } assert abstract_methods == expected_methods class TestConcreteImplementation: """Tests for concrete implementation of AuthProvider.""" def test_concrete_provider_implementation(self): """Test that a concrete provider implements all methods.""" from auth_server.providers.base import AuthProvider # Arrange - create concrete implementation class TestProvider(AuthProvider): """Test implementation of AuthProvider.""" def validate_token(self, token: str, **kwargs: Any) -> dict[str, Any]: return {"valid": True, "username": "test"} def get_jwks(self) -> dict[str, Any]: return {"keys": []} def exchange_code_for_token(self, code: str, redirect_uri: str) -> dict[str, Any]: return {"access_token": "test"} def get_user_info(self, access_token: str) -> dict[str, Any]: return {"username": "test"} def get_auth_url(self, redirect_uri: str, state: str, scope: str = None) -> str: return "https://auth.example.com/authorize" def get_logout_url(self, redirect_uri: str) -> str: return "https://auth.example.com/logout" def refresh_token(self, refresh_token: str) -> dict[str, Any]: return {"access_token": "new_token"} def validate_m2m_token(self, token: str) -> dict[str, Any]: return {"valid": True} def get_m2m_token( self, client_id: str = None, client_secret: str = None, scope: str = None ) -> dict[str, Any]: return {"access_token": "m2m_token"} # Act provider = TestProvider() # Assert - can call all methods assert provider.validate_token("token")["valid"] is True assert "keys" in provider.get_jwks() assert "access_token" in provider.exchange_code_for_token("code", "uri") assert "username" in provider.get_user_info("token") assert provider.get_auth_url("uri", "state").startswith("https://") assert provider.get_logout_url("uri").startswith("https://") assert "access_token" in provider.refresh_token("token") assert provider.validate_m2m_token("token")["valid"] is True assert "access_token" in provider.get_m2m_token() class TestAuthProviderDocstrings: """Tests for documentation and interface contracts.""" def test_validate_token_docstring(self): """Test validate_token method has proper documentation.""" from auth_server.providers.base import AuthProvider # Act docstring = AuthProvider.validate_token.__doc__ # Assert assert docstring is not None assert "validate" in docstring.lower() assert "token" in docstring.lower() def test_get_jwks_docstring(self): """Test get_jwks method has proper documentation.""" from auth_server.providers.base import AuthProvider # Act docstring = AuthProvider.get_jwks.__doc__ # Assert assert docstring is not None assert "jwks" in docstring.lower() or "key set" in docstring.lower() def test_exchange_code_for_token_docstring(self): """Test exchange_code_for_token method has proper documentation.""" from auth_server.providers.base import AuthProvider # Act docstring = AuthProvider.exchange_code_for_token.__doc__ # Assert assert docstring is not None assert "exchange" in docstring.lower() or "authorization" in docstring.lower() assert "code" in docstring.lower() def test_get_user_info_docstring(self): """Test get_user_info method has proper documentation.""" from auth_server.providers.base import AuthProvider # Act docstring = AuthProvider.get_user_info.__doc__ # Assert assert docstring is not None assert "user" in docstring.lower() assert "info" in docstring.lower() class TestAuthProviderTypeHints: """Tests for type hints on abstract methods.""" def test_validate_token_signature(self): """Test validate_token has correct type hints.""" import inspect from auth_server.providers.base import AuthProvider # Act sig = inspect.signature(AuthProvider.validate_token) # Assert assert "token" in sig.parameters assert sig.parameters["token"].annotation is str # Return type should be Dict[str, Any] (or dict[str, Any] in Python 3.14+) return_str = str(sig.return_annotation).lower() assert "dict" in return_str def test_get_jwks_signature(self): """Test get_jwks has correct type hints.""" import inspect from auth_server.providers.base import AuthProvider # Act sig = inspect.signature(AuthProvider.get_jwks) # Assert # Should return Dict[str, Any] (or dict[str, Any] in Python 3.14+) return_str = str(sig.return_annotation).lower() assert "dict" in return_str def test_exchange_code_for_token_signature(self): """Test exchange_code_for_token has correct type hints.""" import inspect from auth_server.providers.base import AuthProvider # Act sig = inspect.signature(AuthProvider.exchange_code_for_token) # Assert assert "code" in sig.parameters assert "redirect_uri" in sig.parameters assert sig.parameters["code"].annotation is str assert sig.parameters["redirect_uri"].annotation is str ================================================ FILE: tests/auth_server/unit/providers/test_keycloak.py ================================================ """ Unit tests for auth_server/providers/keycloak.py Tests the Keycloak authentication provider implementation including token validation, JWKS handling, OAuth2 flows, and M2M authentication. """ import logging import time from unittest.mock import MagicMock, patch from urllib.parse import urlparse import jwt import pytest import requests logger = logging.getLogger(__name__) # Mark all tests in this file pytestmark = [pytest.mark.unit, pytest.mark.auth] # ============================================================================= # KEYCLOAK PROVIDER INITIALIZATION TESTS # ============================================================================= class TestKeycloakProviderInit: """Tests for KeycloakProvider initialization.""" def test_provider_initialization_basic(self): """Test basic provider initialization.""" from auth_server.providers.keycloak import KeycloakProvider # Act provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Assert assert provider.keycloak_url == "http://localhost:8080" assert provider.realm == "test-realm" assert provider.client_id == "test-client" assert provider.client_secret == "test-secret" def test_provider_initialization_with_external_url(self): """Test initialization with separate external URL.""" from auth_server.providers.keycloak import KeycloakProvider # Act provider = KeycloakProvider( keycloak_url="http://keycloak:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", keycloak_external_url="https://keycloak.example.com", ) # Assert assert provider.keycloak_url == "http://keycloak:8080" assert provider.keycloak_external_url == "https://keycloak.example.com" # Auth URL should use external URL assert urlparse(provider.auth_url).hostname == "keycloak.example.com" # Token URL should use internal URL assert urlparse(provider.token_url).hostname == "keycloak" def test_provider_initialization_removes_trailing_slashes(self): """Test that trailing slashes are removed from URLs.""" from auth_server.providers.keycloak import KeycloakProvider # Act provider = KeycloakProvider( keycloak_url="http://localhost:8080/", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Assert assert not provider.keycloak_url.endswith("/") assert not provider.keycloak_external_url.endswith("/") def test_provider_initialization_m2m_defaults(self): """Test M2M client defaults to main client.""" from auth_server.providers.keycloak import KeycloakProvider # Act provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Assert assert provider.m2m_client_id == "test-client" assert provider.m2m_client_secret == "test-secret" def test_provider_initialization_separate_m2m_client(self): """Test initialization with separate M2M client.""" from auth_server.providers.keycloak import KeycloakProvider # Act provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="web-client", client_secret="web-secret", m2m_client_id="m2m-client", m2m_client_secret="m2m-secret", ) # Assert assert provider.client_id == "web-client" assert provider.m2m_client_id == "m2m-client" assert provider.m2m_client_secret == "m2m-secret" # ============================================================================= # JWKS RETRIEVAL TESTS # ============================================================================= class TestKeycloakJWKS: """Tests for JWKS retrieval and caching.""" @patch("auth_server.providers.keycloak.requests.get") def test_get_jwks_success(self, mock_get, mock_jwks_response): """Test successful JWKS retrieval.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act jwks = provider.get_jwks() # Assert assert "keys" in jwks assert len(jwks["keys"]) == 2 mock_get.assert_called_once() assert "/protocol/openid-connect/certs" in mock_get.call_args[0][0] @patch("auth_server.providers.keycloak.requests.get") def test_get_jwks_caching(self, mock_get, mock_jwks_response): """Test that JWKS is cached and not fetched repeatedly.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act - call multiple times jwks1 = provider.get_jwks() jwks2 = provider.get_jwks() jwks3 = provider.get_jwks() # Assert - should only call once due to caching assert mock_get.call_count == 1 assert jwks1 == jwks2 == jwks3 @patch("auth_server.providers.keycloak.requests.get") @patch("auth_server.providers.keycloak.time.time") def test_get_jwks_cache_expiration(self, mock_time, mock_get, mock_jwks_response): """Test that JWKS cache expires after TTL.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # First call mock_time.return_value = 1000 provider.get_jwks() # Second call - cache should still be valid mock_time.return_value = 1100 provider.get_jwks() # Third call - cache should be expired (TTL is 3600 seconds) mock_time.return_value = 5000 provider.get_jwks() # Assert assert mock_get.call_count == 2 # First call + after expiration @patch("auth_server.providers.keycloak.requests.get") def test_get_jwks_network_error(self, mock_get): """Test JWKS retrieval with network error.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_get.side_effect = requests.RequestException("Network error") provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act & Assert with pytest.raises(ValueError, match="Cannot retrieve JWKS"): provider.get_jwks() # ============================================================================= # TOKEN VALIDATION TESTS # ============================================================================= class TestKeycloakTokenValidation: """Tests for JWT token validation.""" @patch("auth_server.providers.keycloak.requests.get") def test_validate_token_success(self, mock_get, mock_jwks_response): """Test successful token validation.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_get.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Create a mock token that will pass basic structure checks now = int(time.time()) payload = { "iss": "http://localhost:8080/realms/test-realm", "aud": "account", "sub": "user-123", "preferred_username": "testuser", "email": "testuser@example.com", "groups": ["users", "admins"], "scope": "openid profile email", "azp": "test-client", "exp": now + 3600, "iat": now, } # Mock JWT validation with patch("auth_server.providers.keycloak.jwt.get_unverified_header") as mock_header: with patch("auth_server.providers.keycloak.jwt.decode") as mock_decode: mock_header.return_value = {"kid": "test-key-id-1"} mock_decode.return_value = payload # Mock PyJWK - imported dynamically inside function so patch at source with patch("jwt.PyJWK") as mock_pyjwk: mock_key = MagicMock() mock_pyjwk.return_value.key = mock_key # Act result = provider.validate_token("test-token") # Assert assert result["valid"] is True assert result["username"] == "testuser" assert result["email"] == "testuser@example.com" assert "users" in result["groups"] assert "admins" in result["groups"] assert result["method"] == "keycloak" @patch("auth_server.providers.keycloak.requests.get") def test_validate_token_expired(self, mock_get, mock_jwks_response): """Test validation of expired token.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_get.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) with patch("auth_server.providers.keycloak.jwt.get_unverified_header") as mock_header: with patch("auth_server.providers.keycloak.jwt.decode") as mock_decode: mock_header.return_value = {"kid": "test-key-id-1"} mock_decode.side_effect = jwt.ExpiredSignatureError("Token expired") # Act & Assert with pytest.raises(ValueError, match="expired"): provider.validate_token("expired-token") @patch("auth_server.providers.keycloak.requests.get") def test_validate_token_no_kid(self, mock_get, mock_jwks_response): """Test validation of token without kid header.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_get.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) with patch("auth_server.providers.keycloak.jwt.get_unverified_header") as mock_header: mock_header.return_value = {} # No kid # Act & Assert with pytest.raises(ValueError, match="missing 'kid'"): provider.validate_token("token-without-kid") @patch("auth_server.providers.keycloak.requests.get") def test_validate_token_key_not_found(self, mock_get, mock_jwks_response): """Test validation when signing key is not found.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_get.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) with patch("auth_server.providers.keycloak.jwt.get_unverified_header") as mock_header: mock_header.return_value = {"kid": "unknown-key-id"} # Act & Assert with pytest.raises(ValueError, match="No matching key found"): provider.validate_token("token-with-unknown-kid") @patch("auth_server.providers.keycloak.requests.get") def test_validate_token_multiple_issuers(self, mock_get, mock_jwks_response): """Test validation with multiple valid issuers.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_get.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://keycloak:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", keycloak_external_url="https://keycloak.example.com", ) # Create payload with external issuer now = int(time.time()) payload = { "iss": "https://keycloak.example.com/realms/test-realm", "aud": "account", "sub": "user-123", "preferred_username": "testuser", "exp": now + 3600, "iat": now, } with patch("auth_server.providers.keycloak.jwt.get_unverified_header") as mock_header: with patch("auth_server.providers.keycloak.jwt.decode") as mock_decode: mock_header.return_value = {"kid": "test-key-id-1"} mock_decode.return_value = payload # Mock PyJWK - imported dynamically inside function so patch at source with patch("jwt.PyJWK") as mock_pyjwk: mock_key = MagicMock() mock_pyjwk.return_value.key = mock_key # Act result = provider.validate_token("test-token") # Assert assert result["valid"] is True # ============================================================================= # OAUTH2 FLOW TESTS # ============================================================================= class TestKeycloakOAuth2: """Tests for OAuth2 authorization code flow.""" @patch("auth_server.providers.keycloak.requests.post") def test_exchange_code_for_token_success(self, mock_post): """Test successful code exchange.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = { "access_token": "access-token-value", "id_token": "id-token-value", "refresh_token": "refresh-token-value", "token_type": "Bearer", "expires_in": 3600, } mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act result = provider.exchange_code_for_token( code="auth-code", redirect_uri="https://app.example.com/callback" ) # Assert assert result["access_token"] == "access-token-value" assert result["token_type"] == "Bearer" assert result["expires_in"] == 3600 mock_post.assert_called_once() @patch("auth_server.providers.keycloak.requests.post") def test_exchange_code_for_token_error(self, mock_post): """Test code exchange with error.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_post.side_effect = requests.RequestException("Token endpoint error") provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act & Assert with pytest.raises(ValueError, match="Token exchange failed"): provider.exchange_code_for_token( code="invalid-code", redirect_uri="https://app.example.com/callback" ) def test_get_auth_url(self): """Test authorization URL generation.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act auth_url = provider.get_auth_url( redirect_uri="https://app.example.com/callback", state="random-state", scope="openid email profile", ) # Assert assert "protocol/openid-connect/auth" in auth_url assert "client_id=test-client" in auth_url assert "redirect_uri=https" in auth_url assert "state=random-state" in auth_url assert "scope=openid" in auth_url def test_get_logout_url(self): """Test logout URL generation.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act logout_url = provider.get_logout_url(redirect_uri="https://app.example.com/logout") # Assert assert "protocol/openid-connect/logout" in logout_url assert "client_id=test-client" in logout_url assert "post_logout_redirect_uri=https" in logout_url # ============================================================================= # USER INFO TESTS # ============================================================================= class TestKeycloakUserInfo: """Tests for user information retrieval.""" @patch("auth_server.providers.keycloak.requests.get") def test_get_user_info_success(self, mock_get): """Test successful user info retrieval.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = { "sub": "user-123", "preferred_username": "testuser", "email": "testuser@example.com", "email_verified": True, "groups": ["users", "developers"], } mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act user_info = provider.get_user_info("access-token") # Assert assert user_info["preferred_username"] == "testuser" assert user_info["email"] == "testuser@example.com" assert "users" in user_info["groups"] @patch("auth_server.providers.keycloak.requests.get") def test_get_user_info_error(self, mock_get): """Test user info retrieval with error.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_get.side_effect = requests.RequestException("UserInfo error") provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act & Assert with pytest.raises(ValueError, match="User info retrieval failed"): provider.get_user_info("invalid-token") # ============================================================================= # TOKEN REFRESH TESTS # ============================================================================= class TestKeycloakTokenRefresh: """Tests for token refresh functionality.""" @patch("auth_server.providers.keycloak.requests.post") def test_refresh_token_success(self, mock_post): """Test successful token refresh.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = { "access_token": "new-access-token", "refresh_token": "new-refresh-token", "token_type": "Bearer", "expires_in": 3600, } mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act result = provider.refresh_token("old-refresh-token") # Assert assert result["access_token"] == "new-access-token" assert result["token_type"] == "Bearer" @patch("auth_server.providers.keycloak.requests.post") def test_refresh_token_error(self, mock_post): """Test token refresh with error.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_post.side_effect = requests.RequestException("Refresh failed") provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act & Assert with pytest.raises(ValueError, match="Token refresh failed"): provider.refresh_token("invalid-refresh-token") # ============================================================================= # M2M AUTHENTICATION TESTS # ============================================================================= class TestKeycloakM2M: """Tests for machine-to-machine authentication.""" @patch("auth_server.providers.keycloak.requests.post") def test_get_m2m_token_success(self, mock_post): """Test successful M2M token generation.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = { "access_token": "m2m-access-token", "token_type": "Bearer", "expires_in": 3600, } mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="web-client", client_secret="web-secret", m2m_client_id="m2m-client", m2m_client_secret="m2m-secret", ) # Act result = provider.get_m2m_token() # Assert assert result["access_token"] == "m2m-access-token" assert result["token_type"] == "Bearer" # Should use M2M credentials call_data = mock_post.call_args[1]["data"] assert call_data["client_id"] == "m2m-client" assert call_data["client_secret"] == "m2m-secret" assert call_data["grant_type"] == "client_credentials" @patch("auth_server.providers.keycloak.requests.post") def test_get_m2m_token_custom_credentials(self, mock_post): """Test M2M token generation with custom credentials.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.json.return_value = { "access_token": "custom-m2m-token", "token_type": "Bearer", "expires_in": 3600, } mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="default-client", client_secret="default-secret", ) # Act result = provider.get_m2m_token( client_id="custom-client", client_secret="custom-secret", scope="custom-scope" ) # Assert assert result["access_token"] == "custom-m2m-token" call_data = mock_post.call_args[1]["data"] assert call_data["client_id"] == "custom-client" assert call_data["client_secret"] == "custom-secret" assert call_data["scope"] == "custom-scope" def test_validate_m2m_token(self): """Test that M2M token validation uses same method as regular tokens.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Mock validate_token with patch.object(provider, "validate_token") as mock_validate: mock_validate.return_value = {"valid": True} # Act result = provider.validate_m2m_token("m2m-token") # Assert assert result["valid"] is True mock_validate.assert_called_once_with("m2m-token") # ============================================================================= # PROVIDER INFO TESTS # ============================================================================= class TestKeycloakProviderInfo: """Tests for provider information.""" def test_get_provider_info(self): """Test getting provider information.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act info = provider.get_provider_info() # Assert assert info["provider_type"] == "keycloak" assert info["realm"] == "test-realm" assert info["client_id"] == "test-client" assert "endpoints" in info assert "auth" in info["endpoints"] assert "token" in info["endpoints"] assert "userinfo" in info["endpoints"] @patch("auth_server.providers.keycloak.requests.get") def test_check_keycloak_health(self, mock_get): """Test Keycloak health check.""" from auth_server.providers.keycloak import KeycloakProvider # Arrange mock_response = MagicMock() mock_response.status_code = 200 mock_get.return_value = mock_response provider = KeycloakProvider( keycloak_url="http://localhost:8080", realm="test-realm", client_id="test-client", client_secret="test-secret", ) # Act is_healthy = provider._check_keycloak_health() # Assert assert is_healthy is True mock_get.assert_called_once() assert "/health/ready" in mock_get.call_args[0][0] ================================================ FILE: tests/auth_server/unit/providers/test_okta.py ================================================ """Unit tests for OktaProvider.""" import time from unittest.mock import MagicMock, patch import jwt as pyjwt import pytest from auth_server.providers.okta import OktaProvider # ============================================================================= # INITIALIZATION TESTS # ============================================================================= class TestOktaProviderInit: """Tests for OktaProvider initialization.""" def test_provider_initialization(self): """Test provider initializes with valid config.""" provider = OktaProvider( okta_domain="dev-123456.okta.com", client_id="test-client-id", client_secret="test-client-secret", ) assert provider.okta_domain == "dev-123456.okta.com" assert provider.client_id == "test-client-id" assert provider.issuer == "https://dev-123456.okta.com" assert provider.token_url == "https://dev-123456.okta.com/oauth2/v1/token" def test_provider_initialization_removes_https(self): """Test domain normalization strips https:// prefix.""" provider = OktaProvider( okta_domain="https://dev-123456.okta.com/", client_id="cid", client_secret="csecret", ) assert provider.okta_domain == "dev-123456.okta.com" def test_provider_initialization_m2m_defaults(self): """Test M2M credentials default to primary credentials.""" provider = OktaProvider( okta_domain="dev-123456.okta.com", client_id="web-client", client_secret="web-secret", ) assert provider.m2m_client_id == "web-client" assert provider.m2m_client_secret == "web-secret" # ============================================================================= # JWKS TESTS # ============================================================================= class TestOktaJWKS: """Tests for JWKS retrieval and caching.""" @patch("auth_server.providers.okta.requests.get") def test_get_jwks_success(self, mock_get): """Test successful JWKS retrieval.""" mock_jwks = {"keys": [{"kid": "key1", "kty": "RSA"}]} mock_response = MagicMock() mock_response.json.return_value = mock_jwks mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response provider = OktaProvider("dev-123.okta.com", "cid", "cs") result = provider.get_jwks() assert result == mock_jwks mock_get.assert_called_once() @patch("auth_server.providers.okta.requests.get") def test_get_jwks_caching(self, mock_get): """Test JWKS cache returns cached data within TTL.""" mock_jwks = {"keys": [{"kid": "key1", "kty": "RSA"}]} mock_response = MagicMock() mock_response.json.return_value = mock_jwks mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response provider = OktaProvider("dev-123.okta.com", "cid", "cs") provider.get_jwks() provider.get_jwks() # Should only fetch once due to caching assert mock_get.call_count == 1 @patch("auth_server.providers.okta.requests.get") def test_get_jwks_cache_expiration(self, mock_get): """Test JWKS cache expires after TTL.""" mock_jwks = {"keys": [{"kid": "key1", "kty": "RSA"}]} mock_response = MagicMock() mock_response.json.return_value = mock_jwks mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response provider = OktaProvider("dev-123.okta.com", "cid", "cs") # First call — populates cache provider.get_jwks() # Simulate TTL expiration by backdating the cache time provider._jwks_cache_time = provider._jwks_cache_time - 3601 # Second call should re-fetch provider.get_jwks() assert mock_get.call_count == 2 # ============================================================================= # TOKEN VALIDATION TESTS # ============================================================================= class TestOktaTokenValidation: """Tests for token validation.""" @patch("auth_server.providers.okta.requests.get") def test_validate_token_success(self, mock_get): """Test successful token validation with correct claim extraction.""" mock_jwks = {"keys": [{"kid": "test-key-id-1", "kty": "RSA"}]} mock_response = MagicMock() mock_response.json.return_value = mock_jwks mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response provider = OktaProvider("dev-123.okta.com", "test-client", "cs") now = int(time.time()) payload = { "iss": "https://dev-123.okta.com", "aud": "test-client", "sub": "user-123", "preferred_username": "testuser", "email": "testuser@example.com", "groups": ["users", "admins"], "scp": ["openid", "profile"], "cid": "test-client", "exp": now + 3600, "iat": now, } with patch("auth_server.providers.okta.jwt.get_unverified_header") as mock_header: with patch("auth_server.providers.okta.jwt.decode") as mock_decode: mock_header.return_value = {"kid": "test-key-id-1"} mock_decode.return_value = payload with patch("jwt.PyJWK") as mock_pyjwk: mock_pyjwk.return_value.key = MagicMock() result = provider.validate_token("test-token") assert result["valid"] is True assert result["username"] == "user-123" assert result["email"] == "testuser@example.com" assert "users" in result["groups"] assert "admins" in result["groups"] assert result["scopes"] == ["openid", "profile"] assert result["client_id"] == "test-client" assert result["method"] == "okta" def test_validate_token_expired(self): """Test expired token raises ValueError.""" provider = OktaProvider("dev-123.okta.com", "cid", "cs") with patch.object(provider, "get_jwks", return_value={"keys": [{"kid": "k1"}]}): with patch("auth_server.providers.okta.jwt.get_unverified_header") as mock_header: mock_header.return_value = {"kid": "k1"} with patch("jwt.PyJWK") as mock_pyjwk: mock_pyjwk.return_value.key = MagicMock() with patch("auth_server.providers.okta.jwt.decode") as mock_decode: from jwt.exceptions import ExpiredSignatureError mock_decode.side_effect = ExpiredSignatureError("Token has expired") with pytest.raises(ValueError, match="Token has expired"): provider.validate_token("expired-token") def test_validate_token_no_kid(self): """Test missing kid header raises ValueError.""" provider = OktaProvider("dev-123.okta.com", "cid", "cs") with patch.object(provider, "get_jwks", return_value={"keys": []}): with patch("auth_server.providers.okta.jwt.get_unverified_header") as mock_header: mock_header.return_value = {} # No kid with pytest.raises(ValueError, match="kid"): provider.validate_token("no-kid-token") def test_validate_token_self_signed(self): """Test self-signed token path delegates correctly.""" provider = OktaProvider("dev-123.okta.com", "cid", "cs") now = int(time.time()) token = pyjwt.encode( { "iss": "mcp-auth-server", "aud": "mcp-registry", "sub": "testuser", "email": "test@example.com", "groups": ["admin"], "scope": "read write", "token_use": "access", "exp": now + 3600, "iat": now, }, "development-secret-key", algorithm="HS256", ) result = provider.validate_token(token) assert result["method"] == "self_signed" assert result["username"] == "testuser" assert result["groups"] == ["admin"] assert result["scopes"] == ["read", "write"] # ============================================================================= # OAUTH2 FLOW TESTS # ============================================================================= class TestOktaOAuth2: """Tests for OAuth2 flows.""" @patch("auth_server.providers.okta.requests.post") def test_exchange_code_for_token(self, mock_post): """Test OAuth2 code exchange sends correct parameters.""" mock_response = MagicMock() mock_response.json.return_value = {"access_token": "at", "id_token": "it"} mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response provider = OktaProvider("dev-123.okta.com", "cid", "cs") result = provider.exchange_code_for_token("auth-code", "http://localhost/callback") assert result["access_token"] == "at" call_data = mock_post.call_args[1]["data"] assert call_data["grant_type"] == "authorization_code" assert call_data["code"] == "auth-code" assert call_data["client_id"] == "cid" def test_get_auth_url(self): """Test auth URL generation with correct parameters and default scope.""" provider = OktaProvider("dev-123.okta.com", "cid", "cs") url = provider.get_auth_url("http://localhost/callback", "state123") assert "https://dev-123.okta.com/oauth2/v1/authorize" in url assert "client_id=cid" in url assert "response_type=code" in url assert "state=state123" in url assert "openid" in url assert "email" in url assert "profile" in url assert "groups" in url def test_get_logout_url(self): """Test logout URL generation with correct parameters.""" provider = OktaProvider("dev-123.okta.com", "cid", "cs") url = provider.get_logout_url("http://localhost") assert "https://dev-123.okta.com/oauth2/v1/logout" in url assert "client_id=cid" in url assert "post_logout_redirect_uri" in url @patch("auth_server.providers.okta.requests.post") def test_refresh_token(self, mock_post): """Test token refresh sends correct parameters.""" mock_response = MagicMock() mock_response.json.return_value = {"access_token": "new-at"} mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response provider = OktaProvider("dev-123.okta.com", "cid", "cs") result = provider.refresh_token("refresh-tok") assert result["access_token"] == "new-at" call_data = mock_post.call_args[1]["data"] assert call_data["grant_type"] == "refresh_token" assert call_data["refresh_token"] == "refresh-tok" assert call_data["client_id"] == "cid" # ============================================================================= # M2M TESTS # ============================================================================= class TestOktaM2M: """Tests for M2M client credentials flow.""" @patch("auth_server.providers.okta.requests.post") def test_get_m2m_token(self, mock_post): """Test client credentials flow with M2M credentials.""" mock_response = MagicMock() mock_response.json.return_value = {"access_token": "m2m-token"} mock_response.raise_for_status.return_value = None mock_post.return_value = mock_response provider = OktaProvider( "dev-123.okta.com", "cid", "cs", m2m_client_id="m2m-cid", m2m_client_secret="m2m-cs", ) result = provider.get_m2m_token() assert result["access_token"] == "m2m-token" call_data = mock_post.call_args[1]["data"] assert call_data["grant_type"] == "client_credentials" assert call_data["client_id"] == "m2m-cid" assert call_data["client_secret"] == "m2m-cs" # ============================================================================= # PROVIDER INFO TESTS # ============================================================================= class TestOktaProviderInfo: """Tests for provider info.""" def test_get_provider_info(self): """Test provider info returns correct structure.""" provider = OktaProvider("dev-123.okta.com", "cid", "cs") info = provider.get_provider_info() assert info["provider_type"] == "okta" assert info["okta_domain"] == "dev-123.okta.com" assert info["client_id"] == "cid" assert info["issuer"] == "https://dev-123.okta.com" assert "endpoints" in info assert "auth" in info["endpoints"] assert "token" in info["endpoints"] assert "jwks" in info["endpoints"] # ============================================================================= # FACTORY INTEGRATION TESTS # ============================================================================= class TestOktaFactoryIntegration: """Tests for factory integration.""" def test_factory_creates_okta_provider(self, monkeypatch): """Factory returns OktaProvider when AUTH_PROVIDER=okta.""" monkeypatch.setenv("OKTA_DOMAIN", "dev-123.okta.com") monkeypatch.setenv("OKTA_CLIENT_ID", "test-cid") monkeypatch.setenv("OKTA_CLIENT_SECRET", "test-cs") import importlib import auth_server.providers.factory as factory_module importlib.reload(factory_module) provider = factory_module.get_auth_provider("okta") assert isinstance(provider, OktaProvider) assert provider.okta_domain == "dev-123.okta.com" ================================================ FILE: tests/auth_server/unit/test_server.py ================================================ """ Unit tests for auth_server/server.py Tests cover token validation, session management, scope validation, rate limiting, and helper functions. """ import logging import time from unittest.mock import AsyncMock, MagicMock, Mock, patch import jwt import pytest from fastapi.testclient import TestClient logger = logging.getLogger(__name__) # Mark all tests in this file pytestmark = [pytest.mark.unit, pytest.mark.auth] # ============================================================================= # HELPER FUNCTION TESTS # ============================================================================= class TestMaskingFunctions: """Tests for sensitive data masking functions.""" def test_mask_sensitive_id_short(self): """Test masking short IDs.""" from auth_server.server import mask_sensitive_id # Arrange short_id = "abc" # Act result = mask_sensitive_id(short_id) # Assert assert result == "***MASKED***" def test_mask_sensitive_id_normal(self): """Test masking normal length IDs.""" from auth_server.server import mask_sensitive_id # Arrange normal_id = "us-east-1_ABCD12345" # Act result = mask_sensitive_id(normal_id) # Assert assert result.startswith("us-e") assert result.endswith("2345") assert "..." in result def test_mask_token(self): """Test masking JWT tokens showing first 4 characters.""" from auth_server.server import mask_token # Arrange token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.test" # Act result = mask_token(token) # Assert assert result.startswith("eyJh") assert result.endswith("...") assert len(result) < len(token) def test_anonymize_ip_ipv4(self): """Test IPv4 anonymization.""" from auth_server.server import anonymize_ip # Arrange ipv4 = "192.168.1.100" # Act result = anonymize_ip(ipv4) # Assert assert result == "192.168.1.xxx" def test_anonymize_ip_ipv6(self): """Test IPv6 anonymization.""" from auth_server.server import anonymize_ip # Arrange ipv6 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334" # Act result = anonymize_ip(ipv6) # Assert assert result.endswith(":xxxx") assert "2001" in result def test_hash_username(self): """Test username hashing for privacy.""" from auth_server.server import hash_username # Arrange username = "testuser" # Act result = hash_username(username) # Assert assert result.startswith("user_") assert len(result) > len(username) # Same input produces same hash assert hash_username(username) == result class TestServerNameNormalization: """Tests for server name normalization and matching.""" def test_normalize_server_name_with_trailing_slash(self): """Test removing trailing slash.""" from auth_server.server import _normalize_server_name # Arrange name_with_slash = "test-server/" # Act result = _normalize_server_name(name_with_slash) # Assert assert result == "test-server" def test_normalize_server_name_without_trailing_slash(self): """Test name without trailing slash.""" from auth_server.server import _normalize_server_name # Arrange name = "test-server" # Act result = _normalize_server_name(name) # Assert assert result == "test-server" def test_server_names_match_exact(self): """Test exact server name matching.""" from auth_server.server import _server_names_match # Act & Assert assert _server_names_match("test-server", "test-server") def test_server_names_match_with_trailing_slash(self): """Test server name matching with trailing slash.""" from auth_server.server import _server_names_match # Act & Assert assert _server_names_match("test-server/", "test-server") assert _server_names_match("test-server", "test-server/") def test_server_names_match_wildcard(self): """Test wildcard matching.""" from auth_server.server import _server_names_match # Act & Assert assert _server_names_match("*", "any-server") assert _server_names_match("*", "another-server") class TestGroupToScopeMapping: """Tests for mapping IdP groups to MCP scopes.""" @pytest.mark.asyncio async def test_map_groups_to_scopes_basic(self, mock_scopes_config): """Test basic group to scope mapping.""" from auth_server.server import map_groups_to_scopes # Arrange - Mock the repository to return scopes for groups mock_repo = AsyncMock() mock_repo.get_group_mappings.side_effect = lambda group: { "users": ["read:servers", "read:tools"], "developers": ["write:servers"], }.get(group, []) with patch("auth_server.server.get_scope_repository", return_value=mock_repo): groups = ["users", "developers"] # Act scopes = await map_groups_to_scopes(groups) # Assert assert "read:servers" in scopes assert "write:servers" in scopes assert "read:tools" in scopes @pytest.mark.asyncio async def test_map_groups_to_scopes_no_duplicates(self, mock_scopes_config): """Test that duplicate scopes are removed.""" from auth_server.server import map_groups_to_scopes # Arrange - Mock the repository to return scopes for groups mock_repo = AsyncMock() # Both groups return "read:servers" to test deduplication mock_repo.get_group_mappings.side_effect = lambda group: { "users": ["read:servers", "read:tools"], "developers": ["read:servers", "write:servers"], }.get(group, []) with patch("auth_server.server.get_scope_repository", return_value=mock_repo): # Both groups have "read:servers" groups = ["users", "developers"] # Act scopes = await map_groups_to_scopes(groups) # Assert # Should only appear once (duplicates removed) assert scopes.count("read:servers") == 1 assert "write:servers" in scopes assert "read:tools" in scopes @pytest.mark.asyncio async def test_map_groups_to_scopes_unknown_group(self, mock_scopes_config): """Test mapping with unknown group.""" from auth_server.server import map_groups_to_scopes # Arrange - Mock repository to return empty list for unknown groups mock_repo = AsyncMock() mock_repo.get_group_mappings.return_value = [] with patch("auth_server.server.get_scope_repository", return_value=mock_repo): groups = ["unknown-group"] # Act scopes = await map_groups_to_scopes(groups) # Assert assert len(scopes) == 0 class TestScopeValidation: """Tests for scope-based access validation.""" @pytest.mark.asyncio async def test_validate_server_tool_access_allowed(self, mock_scope_repository_with_data): """Test access validation when allowed.""" from auth_server.server import validate_server_tool_access # Arrange with patch( "auth_server.server.get_scope_repository", return_value=mock_scope_repository_with_data ): server_name = "test-server" method = "initialize" tool_name = None user_scopes = ["read:servers"] # Act result = await validate_server_tool_access(server_name, method, tool_name, user_scopes) # Assert assert result is True @pytest.mark.asyncio async def test_validate_server_tool_access_denied(self, mock_scope_repository_with_data): """Test access validation when denied.""" from auth_server.server import validate_server_tool_access # Arrange with patch( "auth_server.server.get_scope_repository", return_value=mock_scope_repository_with_data ): server_name = "other-server" method = "initialize" tool_name = None user_scopes = ["read:servers"] # Only for test-server # Act result = await validate_server_tool_access(server_name, method, tool_name, user_scopes) # Assert assert result is False @pytest.mark.asyncio async def test_validate_server_tool_access_wildcard_server( self, mock_scope_repository_with_data ): """Test wildcard server access.""" from auth_server.server import validate_server_tool_access # Arrange with patch( "auth_server.server.get_scope_repository", return_value=mock_scope_repository_with_data ): server_name = "any-server" method = "initialize" tool_name = None user_scopes = ["admin:all"] # Act result = await validate_server_tool_access(server_name, method, tool_name, user_scopes) # Assert assert result is True @pytest.mark.asyncio async def test_validate_server_tool_access_tools_call(self, mock_scope_repository_with_data): """Test access validation for tools/call method.""" from auth_server.server import validate_server_tool_access # Arrange with patch( "auth_server.server.get_scope_repository", return_value=mock_scope_repository_with_data ): server_name = "test-server" method = "tools/call" tool_name = "test-tool" user_scopes = ["write:servers"] # Has wildcard tools # Act result = await validate_server_tool_access(server_name, method, tool_name, user_scopes) # Assert assert result is True def test_validate_scope_subset_valid(self): """Test that requested scopes are subset of user scopes.""" from auth_server.server import validate_scope_subset # Arrange user_scopes = ["read:servers", "write:servers", "admin:all"] requested_scopes = ["read:servers", "write:servers"] # Act result = validate_scope_subset(user_scopes, requested_scopes) # Assert assert result is True def test_validate_scope_subset_invalid(self): """Test that requested scopes exceed user scopes.""" from auth_server.server import validate_scope_subset # Arrange user_scopes = ["read:servers"] requested_scopes = ["read:servers", "write:servers"] # Act result = validate_scope_subset(user_scopes, requested_scopes) # Assert assert result is False class TestRateLimiting: """Tests for token generation rate limiting.""" def test_check_rate_limit_under_limit(self): """Test rate limiting when under limit.""" from auth_server.server import check_rate_limit, user_token_generation_counts # Arrange user_token_generation_counts.clear() username = "testuser" # Act result = check_rate_limit(username) # Assert assert result is True def test_check_rate_limit_exceeded(self, monkeypatch): """Test rate limiting when limit exceeded.""" from auth_server.server import check_rate_limit, user_token_generation_counts # Arrange monkeypatch.setenv("MAX_TOKENS_PER_USER_PER_HOUR", "3") from auth_server import server server.MAX_TOKENS_PER_USER_PER_HOUR = 3 user_token_generation_counts.clear() username = "testuser" # Generate tokens up to limit for _ in range(3): check_rate_limit(username) # Act - try one more result = check_rate_limit(username) # Assert assert result is False def test_check_rate_limit_cleanup_old_entries(self): """Test that old rate limit entries are cleaned up.""" from auth_server.server import check_rate_limit, user_token_generation_counts # Arrange user_token_generation_counts.clear() username = "testuser" current_time = int(time.time()) old_hour = (current_time // 3600) - 2 # 2 hours ago # Add old entry user_token_generation_counts[f"{username}:{old_hour}"] = 5 # Act check_rate_limit(username) # Assert - old entry should be removed assert f"{username}:{old_hour}" not in user_token_generation_counts # ============================================================================= # SESSION COOKIE VALIDATION TESTS # ============================================================================= class TestSessionCookieValidation: """Tests for session cookie validation.""" @pytest.mark.asyncio async def test_validate_session_cookie_valid(self, auth_env_vars, valid_session_cookie): """Test validating a valid session cookie.""" from itsdangerous import URLSafeTimedSerializer from auth_server.server import validate_session_cookie # Create a signer with the test SECRET_KEY test_signer = URLSafeTimedSerializer(auth_env_vars["SECRET_KEY"]) # Patch the module's signer to use test key (loaded at import time) with patch("auth_server.server.signer", test_signer): # Act result = await validate_session_cookie(valid_session_cookie) # Assert assert result["valid"] is True assert result["username"] == "testuser" assert result["method"] == "session_cookie" assert "users" in result["groups"] @pytest.mark.asyncio async def test_validate_session_cookie_expired(self, auth_env_vars): """Test validating an expired session cookie.""" from itsdangerous import URLSafeTimedSerializer from auth_server.server import validate_session_cookie # Create signer with test key test_signer = URLSafeTimedSerializer(auth_env_vars["SECRET_KEY"]) # Create cookie with far past timestamp old_data = {"username": "testuser", "groups": []} import time old_time = time.time() - 30000 # Way past max_age with patch("time.time", return_value=old_time): old_cookie = test_signer.dumps(old_data) # Patch the module's signer to use test key with patch("auth_server.server.signer", test_signer): # Act & Assert with pytest.raises(ValueError, match="expired"): await validate_session_cookie(old_cookie) @pytest.mark.asyncio async def test_validate_session_cookie_invalid_signature(self, auth_env_vars): """Test validating cookie with invalid signature.""" from auth_server.server import validate_session_cookie # Arrange invalid_cookie = "invalid.signature.data" # Act & Assert with pytest.raises(ValueError, match="Invalid session cookie"): await validate_session_cookie(invalid_cookie) # ============================================================================= # SIMPLIFIED COGNITO VALIDATOR TESTS # ============================================================================= class TestSimplifiedCognitoValidator: """Tests for SimplifiedCognitoValidator class.""" def test_validator_initialization(self): """Test validator initialization.""" from auth_server.server import SimplifiedCognitoValidator # Act validator = SimplifiedCognitoValidator(region="us-west-2") # Assert assert validator.default_region == "us-west-2" assert validator._jwks_cache == {} @patch("auth_server.server.requests.get") def test_get_jwks_success(self, mock_get, mock_jwks_response): """Test successful JWKS retrieval.""" from auth_server.server import SimplifiedCognitoValidator # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_response.raise_for_status.return_value = None mock_get.return_value = mock_response validator = SimplifiedCognitoValidator() user_pool_id = "us-east-1_TEST" region = "us-east-1" # Act jwks = validator._get_jwks(user_pool_id, region) # Assert assert "keys" in jwks assert len(jwks["keys"]) == 2 mock_get.assert_called_once() @patch("auth_server.server.requests.get") def test_get_jwks_cached(self, mock_get, mock_jwks_response): """Test JWKS caching.""" from auth_server.server import SimplifiedCognitoValidator # Arrange mock_response = MagicMock() mock_response.json.return_value = mock_jwks_response mock_get.return_value = mock_response validator = SimplifiedCognitoValidator() user_pool_id = "us-east-1_TEST" region = "us-east-1" # Act - call twice jwks1 = validator._get_jwks(user_pool_id, region) jwks2 = validator._get_jwks(user_pool_id, region) # Assert - should only call once due to caching assert mock_get.call_count == 1 assert jwks1 == jwks2 def test_validate_self_signed_token_valid(self, auth_env_vars, self_signed_token): """Test validating a valid self-signed token.""" from auth_server.server import SimplifiedCognitoValidator # Arrange validator = SimplifiedCognitoValidator() # Patch SECRET_KEY at module level (loaded at import time before fixture sets env) with patch("auth_server.server.SECRET_KEY", auth_env_vars["SECRET_KEY"]): # Act result = validator.validate_self_signed_token(self_signed_token) # Assert assert result["valid"] is True assert result["method"] == "self_signed" assert result["username"] == "testuser" assert "read:servers" in result["scopes"] def test_validate_self_signed_token_expired(self, auth_env_vars): """Test validating an expired self-signed token.""" from auth_server.server import SimplifiedCognitoValidator # Arrange validator = SimplifiedCognitoValidator() secret_key = auth_env_vars["SECRET_KEY"] now = int(time.time()) # Create expired token payload = { "iss": "mcp-auth-server", "aud": "mcp-registry", "sub": "testuser", "exp": now - 3600, # Expired 1 hour ago "iat": now - 7200, "token_use": "access", } expired_token = jwt.encode(payload, secret_key, algorithm="HS256") # Patch SECRET_KEY at module level (loaded at import time before fixture sets env) with patch("auth_server.server.SECRET_KEY", secret_key): # Act & Assert with pytest.raises(ValueError, match="expired"): validator.validate_self_signed_token(expired_token) # ============================================================================= # FASTAPI ENDPOINT TESTS # ============================================================================= class TestHealthEndpoint: """Tests for /health endpoint.""" @patch("auth_server.server.get_auth_provider") def test_health_check(self, mock_get_provider): """Test health check endpoint.""" # Arrange - import after mocking import auth_server.server as server_module client = TestClient(server_module.app) # Act response = client.get("/health") # Assert assert response.status_code == 200 data = response.json() assert data["status"] == "healthy" assert data["service"] == "simplified-auth-server" class TestValidateEndpoint: """Tests for /validate endpoint.""" @patch("auth_server.server.get_auth_provider") def test_validate_with_valid_token( self, mock_get_provider, mock_cognito_provider, auth_env_vars, mock_scope_repository_with_data, ): """Test validation with valid JWT token.""" # Arrange mock_get_provider.return_value = mock_cognito_provider import auth_server.server as server_module # Patch scope repository to return test data with patch( "auth_server.server.get_scope_repository", return_value=mock_scope_repository_with_data ): client = TestClient(server_module.app) # Act # URL format: /server-name/mcp-endpoint where endpoint is mcp, sse, or messages response = client.get( "/validate", headers={ "Authorization": "Bearer test-token", "X-Original-URL": "https://example.com/test-server/mcp", }, ) # Assert assert response.status_code == 200 data = response.json() assert data["valid"] is True assert data["username"] == "testuser" @patch("auth_server.server.get_auth_provider") def test_validate_missing_auth_header(self, mock_get_provider, auth_env_vars): """Test validation without Authorization header returns 401.""" # Arrange import auth_server.server as server_module client = TestClient(server_module.app) # Act response = client.get("/validate") # Assert assert response.status_code == 401 assert "Missing or invalid Authorization header" in response.json()["detail"] @patch("auth_server.server.get_auth_provider") def test_validate_with_session_cookie( self, mock_get_provider, auth_env_vars, valid_session_cookie, mock_scope_repository_with_data, ): """Test validation with valid session cookie.""" # Arrange from itsdangerous import URLSafeTimedSerializer import auth_server.server as server_module # Create signer with test SECRET_KEY (module's signer uses different key loaded at import) test_signer = URLSafeTimedSerializer(auth_env_vars["SECRET_KEY"]) with patch( "auth_server.server.get_scope_repository", return_value=mock_scope_repository_with_data ): with patch("auth_server.server.signer", test_signer): client = TestClient(server_module.app) # Act # URL format: /server-name/mcp-endpoint where endpoint is mcp, sse, or messages response = client.get( "/validate", headers={ "Cookie": f"mcp_gateway_session={valid_session_cookie}", "X-Original-URL": "https://example.com/test-server/mcp", }, ) # Assert assert response.status_code == 200 data = response.json() assert data["valid"] is True class TestConfigEndpoint: """Tests for /config endpoint.""" @patch("auth_server.server.get_auth_provider") def test_config_keycloak(self, mock_get_provider, mock_keycloak_provider): """Test config endpoint with Keycloak provider.""" # Arrange mock_get_provider.return_value = mock_keycloak_provider import auth_server.server as server_module client = TestClient(server_module.app) # Act response = client.get("/config") # Assert assert response.status_code == 200 data = response.json() assert data["auth_type"] == "keycloak" class TestGenerateTokenEndpoint: """Tests for /internal/tokens endpoint.""" @patch("auth_server.server.get_auth_provider") def test_generate_token_success(self, mock_get_provider, auth_env_vars): """Test successful token generation using Keycloak M2M.""" # Arrange import auth_server.server as server_module # Mock Keycloak provider mock_provider = Mock() mock_provider.get_provider_info.return_value = {"provider_type": "keycloak"} # M2M token uses fixed scopes for IdP compatibility, not user-requested scopes mock_provider.get_m2m_token.return_value = { "access_token": "mock_keycloak_m2m_token", "refresh_token": None, "expires_in": 28800, "refresh_expires_in": 0, "scope": "openid email profile", } mock_get_provider.return_value = mock_provider client = TestClient(server_module.app) request_data = { "user_context": {"username": "testuser", "scopes": ["read:servers", "write:servers"]}, "requested_scopes": ["read:servers"], "expires_in_hours": 8, "description": "Test token", } # Act response = client.post("/internal/tokens", json=request_data) # Assert assert response.status_code == 200 data = response.json() assert "access_token" in data assert data["access_token"] == "mock_keycloak_m2m_token" assert data["token_type"] == "Bearer" # Scope in response comes from Keycloak M2M client configuration assert data["scope"] == "openid email profile" # Verify Keycloak M2M was called with IdP-compatible scopes mock_provider.get_m2m_token.assert_called_once_with(scope="openid email profile") @patch("auth_server.server.get_auth_provider") def test_generate_token_missing_username(self, mock_get_provider, auth_env_vars): """Test token generation without username.""" # Arrange import auth_server.server as server_module client = TestClient(server_module.app) request_data = { "user_context": {"scopes": ["read:servers"]}, "requested_scopes": ["read:servers"], "expires_in_hours": 8, } # Act response = client.post("/internal/tokens", json=request_data) # Assert assert response.status_code == 400 assert "Username is required" in response.json()["detail"] @patch("auth_server.server.get_auth_provider") def test_generate_token_invalid_scopes(self, mock_get_provider, auth_env_vars): """Test token generation with invalid scopes.""" # Arrange import auth_server.server as server_module client = TestClient(server_module.app) request_data = { "user_context": {"username": "testuser", "scopes": ["read:servers"]}, "requested_scopes": ["admin:all"], # User doesn't have this "expires_in_hours": 8, } # Act response = client.post("/internal/tokens", json=request_data) # Assert assert response.status_code == 403 assert "exceed user permissions" in response.json()["detail"] @patch("auth_server.server.get_auth_provider") def test_generate_token_rate_limit(self, mock_get_provider, auth_env_vars, monkeypatch): """Test token generation rate limiting.""" # Arrange monkeypatch.setenv("MAX_TOKENS_PER_USER_PER_HOUR", "2") import auth_server.server as server_module server_module.MAX_TOKENS_PER_USER_PER_HOUR = 2 server_module.user_token_generation_counts.clear() # Mock Keycloak provider for successful token generation mock_provider = Mock() mock_provider.get_provider_info.return_value = {"provider_type": "keycloak"} mock_provider.get_m2m_token.return_value = { "access_token": "mock_keycloak_m2m_token", "refresh_token": None, "expires_in": 28800, "refresh_expires_in": 0, "scope": "read:servers", } mock_get_provider.return_value = mock_provider client = TestClient(server_module.app) request_data = { "user_context": {"username": "testuser", "scopes": ["read:servers"]}, "requested_scopes": ["read:servers"], "expires_in_hours": 8, } # Act - generate tokens up to limit for _ in range(2): response = client.post("/internal/tokens", json=request_data) assert response.status_code == 200 # Try one more - should fail response = client.post("/internal/tokens", json=request_data) # Assert assert response.status_code == 429 assert "Rate limit exceeded" in response.json()["detail"] class TestReloadScopesEndpoint: """Tests for /internal/reload-scopes endpoint.""" @patch("registry.common.scopes_loader.reload_scopes_config") @patch("auth_server.server.get_auth_provider") def test_reload_scopes_success_with_jwt( self, mock_get_provider, mock_reload_scopes, auth_env_vars ): """Test successful scopes reload using self-signed JWT.""" # Arrange mock_reload_scopes.return_value = {"group_mappings": {}} import jwt import auth_server.server as server_module # Patch module-level SECRET_KEY to match the test env var # (it may already be set to a different value from earlier test imports) secret_key = auth_env_vars["SECRET_KEY"] original_secret_key = server_module.SECRET_KEY server_module.SECRET_KEY = secret_key try: client = TestClient(server_module.app) now = int(time.time()) token = jwt.encode( { "iss": "mcp-auth-server", "aud": "mcp-registry", "sub": "registry-service", "purpose": "reload-scopes", "token_use": "access", "iat": now, "exp": now + 30, }, secret_key, algorithm="HS256", ) # Act response = client.post( "/internal/reload-scopes", headers={"Authorization": f"Bearer {token}"} ) # Assert assert response.status_code == 200 data = response.json() assert "successfully" in data["message"] finally: server_module.SECRET_KEY = original_secret_key @patch("auth_server.server.get_auth_provider") def test_reload_scopes_no_auth(self, mock_get_provider): """Test scopes reload without authentication.""" # Arrange import auth_server.server as server_module client = TestClient(server_module.app) # Act response = client.post("/internal/reload-scopes") # Assert assert response.status_code == 401 @patch("auth_server.server.get_auth_provider") def test_reload_scopes_invalid_jwt(self, mock_get_provider, auth_env_vars): """Test scopes reload with an invalid JWT token.""" # Arrange import auth_server.server as server_module client = TestClient(server_module.app) # Act response = client.post( "/internal/reload-scopes", headers={"Authorization": "Bearer invalid-token"} ) # Assert assert response.status_code == 401 @patch("registry.common.scopes_loader.reload_scopes_config") @patch("auth_server.server.get_auth_provider") def test_reload_scopes_basic_auth_rejected(self, mock_get_provider, auth_env_vars): """Test that Basic Auth is rejected (no longer supported).""" # Arrange import base64 import auth_server.server as server_module client = TestClient(server_module.app) credentials = base64.b64encode(b"testadmin:testadminpass").decode() # Act response = client.post( "/internal/reload-scopes", headers={"Authorization": f"Basic {credentials}"} ) # Assert - Basic Auth is no longer supported assert response.status_code == 401 assert "Unsupported authentication scheme" in response.json()["detail"] # ============================================================================= # NETWORK-TRUSTED MODE TESTS # ============================================================================= class TestNetworkTrustedMode: """Tests for network-trusted auth bypass mode (issue #357).""" def test_network_trusted_bypasses_registry_api(self): """When enabled, registry API requests bypass JWT validation.""" # Arrange import auth_server.server as server_module token_map = _make_legacy_token_map("test-api-key") with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "test-api-key"), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) # Act response = client.get( "/validate", headers={ "Authorization": "Bearer test-api-key", "X-Original-URL": "https://example.com/api/servers", }, ) # Assert assert response.status_code == 200 data = response.json() assert data["valid"] is True assert data["username"] == "network-user" assert data["client_id"] == "network-trusted" assert data["method"] == "network-trusted" assert "mcp-servers-unrestricted/read" in data["scopes"] assert "mcp-servers-unrestricted/execute" in data["scopes"] assert response.headers["X-Auth-Method"] == "network-trusted" assert response.headers["X-Username"] == "network-user" def test_network_trusted_missing_auth_falls_through_to_jwt(self): """Missing Authorization header falls through to JWT/session validation. Before issue #871 the static-token block terminated with a 401. After the fix the block falls through so Okta JWT / self-signed JWT callers still work. An absent Authorization header ultimately reaches the JWT block which returns 401 with a different detail message. """ # Arrange import auth_server.server as server_module token_map = _make_legacy_token_map("test-api-key") with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "test-api-key"), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) # Act response = client.get( "/validate", headers={ "X-Original-URL": "https://example.com/api/servers", }, ) # Assert: 401 comes from the downstream JWT block, not the static # token block. The detail text changed to the JWT-block message. assert response.status_code == 401 assert "Missing or invalid Authorization header" in response.json()["detail"] @patch("auth_server.server.get_auth_provider") def test_network_trusted_does_not_bypass_mcp_gateway( self, mock_get_provider, auth_env_vars, ): """MCP server access still requires full validation even when bypass is enabled.""" # Arrange import auth_server.server as server_module mock_provider = MagicMock() mock_provider.validate_token = AsyncMock(side_effect=ValueError("Invalid token")) mock_get_provider.return_value = mock_provider token_map = _make_legacy_token_map("test-api-key") with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "test-api-key"), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) # Act - request to an MCP server path, not /api/ or /v0.1/ response = client.get( "/validate", headers={ "Authorization": "Bearer test-api-key", "X-Original-URL": "https://example.com/mcpserver/messages", }, ) # Assert - should NOT be bypassed, falls through to normal validation assert response.status_code != 200 or response.json().get("method") != "network-trusted" def test_network_trusted_disabled_by_default(self, auth_env_vars): """Default behavior requires full authentication, no bypass.""" # Arrange import auth_server.server as server_module with patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", False): client = TestClient(server_module.app) # Act response = client.get( "/validate", headers={ "Authorization": "Bearer network-trusted", "X-Original-URL": "https://example.com/api/servers", }, ) # Assert - should NOT return network-trusted response if response.status_code == 200: assert response.json().get("method") != "network-trusted" def test_network_trusted_bypasses_v01_api(self): """When enabled, /v0.1/* requests also bypass JWT validation.""" # Arrange import auth_server.server as server_module token_map = _make_legacy_token_map("test-api-key") with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "test-api-key"), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) # Act response = client.get( "/validate", headers={ "Authorization": "Bearer test-api-key", "X-Original-URL": "https://example.com/v0.1/servers", }, ) # Assert assert response.status_code == 200 data = response.json() assert data["valid"] is True assert data["username"] == "network-user" assert data["method"] == "network-trusted" def test_network_trusted_valid_api_token(self): """When REGISTRY_API_TOKEN is set, matching Bearer token is accepted.""" # Arrange import auth_server.server as server_module token_map = _make_legacy_token_map("my-secret-key") with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "my-secret-key"), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) # Act response = client.get( "/validate", headers={ "Authorization": "Bearer my-secret-key", "X-Original-URL": "https://example.com/api/servers", }, ) # Assert assert response.status_code == 200 data = response.json() assert data["valid"] is True assert data["method"] == "network-trusted" @patch("auth_server.server.get_auth_provider") def test_network_trusted_invalid_api_token_falls_through_to_jwt( self, mock_get_provider, auth_env_vars, ): """A mismatched Bearer now falls through to JWT validation (issue #871). Pre-#871 the static-token block returned 403 "Invalid API token". After #871 a mismatched bearer is handed to the JWT block. When the JWT provider rejects it, the final response does NOT contain the old static-token-block detail text. """ # Arrange - provider returns an invalid-token result mock_provider = MagicMock() mock_provider.validate_token = MagicMock(side_effect=ValueError("Invalid token")) mock_get_provider.return_value = mock_provider import auth_server.server as server_module token_map = _make_legacy_token_map("my-secret-key") with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "my-secret-key"), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) # Act response = client.get( "/validate", headers={ "Authorization": "Bearer wrong-key", "X-Original-URL": "https://example.com/api/servers", }, ) # Assert: response is no longer the static-token block's 403 with # "Invalid API token". The terminal status depends on the JWT # provider's failure handling (pre-existing 500 path wraps # ValueError), but either way it must NOT be the old 403 body. assert response.status_code != 403 assert "Invalid API token" not in response.json().get("detail", "") def test_network_trusted_disabled_when_no_token_configured(self): """When REGISTRY_API_TOKEN is empty, static token auth is disabled (falls back to JWT).""" # Arrange import auth_server.server as server_module # Simulate: enabled flag was set to False at startup because token was empty with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", False), patch.object(server_module, "REGISTRY_API_TOKEN", ""), ): client = TestClient(server_module.app) # Act response = client.get( "/validate", headers={ "Authorization": "Bearer anything-goes", "X-Original-URL": "https://example.com/api/servers", }, ) # Assert - should NOT return network-trusted (falls through to JWT validation) if response.status_code == 200: assert response.json().get("method") != "network-trusted" def test_network_trusted_skips_bypass_when_session_cookie_present(self): """When session cookie is present, bypass is skipped for normal cookie auth flow.""" # Arrange import auth_server.server as server_module token_map = _make_legacy_token_map("test-api-key") with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "test-api-key"), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) # Act - send with session cookie but no Bearer token response = client.get( "/validate", headers={ "X-Original-URL": "https://example.com/api/servers", "Cookie": "mcp_gateway_session=some-session-value", }, ) # Assert - should NOT get 401 from bypass (bypass was skipped) # It will fail session validation, but not with the bypass 401 message if response.status_code == 401: assert "Authorization header required" not in response.json().get("detail", "") def test_network_trusted_non_bearer_scheme_falls_through_to_jwt(self): """Non-Bearer scheme now falls through to JWT validation (issue #871). Before #871 the static-token block returned 401 with detail mentioning "Bearer scheme". After #871 the block falls through; the JWT block returns 401 with its own detail message. """ # Arrange import auth_server.server as server_module token_map = _make_legacy_token_map("test-api-key") with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "test-api-key"), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) # Act - send Basic auth instead of Bearer response = client.get( "/validate", headers={ "Authorization": "Basic dXNlcjpwYXNz", "X-Original-URL": "https://example.com/api/servers", }, ) # Assert: 401 from JWT block, not the old "Bearer scheme" detail assert response.status_code == 401 assert "Bearer scheme" not in response.json()["detail"] @patch("auth_server.server.get_auth_provider") def test_network_trusted_empty_bearer_falls_through_to_jwt( self, mock_get_provider, auth_env_vars, ): """Empty Bearer token now falls through to JWT validation (issue #871).""" # Arrange - provider rejects empty token mock_provider = MagicMock() mock_provider.validate_token = MagicMock(side_effect=ValueError("Empty token")) mock_get_provider.return_value = mock_provider import auth_server.server as server_module token_map = _make_legacy_token_map("test-api-key") with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "test-api-key"), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) # Act - send Bearer with empty token response = client.get( "/validate", headers={ "Authorization": "Bearer ", "X-Original-URL": "https://example.com/api/servers", }, ) # Assert: fall-through → JWT block rejects → no longer the old 403 # "Invalid API token" detail. assert response.status_code != 403 assert "Invalid API token" not in response.json().get("detail", "") # ============================================================================= # HELPER UNIT TESTS (issue #871) # ============================================================================= def _make_legacy_token_map(token: str) -> dict[str, dict]: """Build a _STATIC_TOKEN_MAP with just the legacy entry for test helpers.""" return { "legacy": { "key_bytes": token.encode("utf-8"), "groups": ["mcp-registry-admin"], "scopes": [ "mcp-registry-admin", "mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute", ], "username_override": "network-user", "client_id_override": "network-trusted", }, } class TestCheckRegistryStaticToken: """Unit tests for the _check_registry_static_token helper. Updated for issue #779 (multi-key map iteration). """ def test_legacy_match_returns_network_trusted_identity(self): """Matching bearer for legacy key returns the back-compat identity dict.""" import auth_server.server as server_module token_map = _make_legacy_token_map("expected-token") with patch.object(server_module, "_STATIC_TOKEN_MAP", token_map): identity = server_module._check_registry_static_token("expected-token") assert identity is not None assert identity["username"] == "network-user" assert identity["client_id"] == "network-trusted" assert identity["groups"] == ["mcp-registry-admin"] assert "mcp-servers-unrestricted/read" in identity["scopes"] assert "mcp-servers-unrestricted/execute" in identity["scopes"] def test_mismatch_returns_none(self): """Non-matching bearer returns None (not an exception, not a falsy dict).""" import auth_server.server as server_module token_map = _make_legacy_token_map("expected-token") with patch.object(server_module, "_STATIC_TOKEN_MAP", token_map): assert server_module._check_registry_static_token("something-else") is None def test_empty_bearer_returns_none(self): """Empty-string bearer must not match any configured token.""" import auth_server.server as server_module token_map = _make_legacy_token_map("expected-token") with patch.object(server_module, "_STATIC_TOKEN_MAP", token_map): assert server_module._check_registry_static_token("") is None def test_empty_map_returns_none(self): """When no keys are configured, any bearer returns None.""" import auth_server.server as server_module with patch.object(server_module, "_STATIC_TOKEN_MAP", {}): assert server_module._check_registry_static_token("any-token") is None def test_uses_timing_safe_comparison(self): """Guard against regression: must use hmac.compare_digest, not ==.""" import inspect import auth_server.server as server_module source = inspect.getsource(server_module._check_registry_static_token) assert "hmac.compare_digest" in source def test_multi_key_match_returns_correct_identity(self): """With multiple keys, the matched entry's identity is returned.""" import auth_server.server as server_module token_map = { "monitoring": { "key_bytes": b"aaaa" * 8, "groups": ["mcp-readonly"], "scopes": ["mcp-readonly/read"], }, "deploy": { "key_bytes": b"bbbb" * 8, "groups": ["mcp-registry-admin"], "scopes": ["mcp-servers-unrestricted/read"], }, } with patch.object(server_module, "_STATIC_TOKEN_MAP", token_map): identity = server_module._check_registry_static_token("bbbb" * 8) assert identity is not None assert identity["username"] == "deploy" assert identity["client_id"] == "deploy" assert identity["groups"] == ["mcp-registry-admin"] def test_multi_key_no_match_returns_none(self): """With multiple keys, a non-matching bearer returns None.""" import auth_server.server as server_module token_map = { "monitoring": { "key_bytes": b"aaaa" * 8, "groups": ["mcp-readonly"], "scopes": ["mcp-readonly/read"], }, } with patch.object(server_module, "_STATIC_TOKEN_MAP", token_map): assert server_module._check_registry_static_token("wrong-token") is None def test_legacy_username_override_preserved(self): """Legacy entry uses username_override / client_id_override for back-compat.""" import auth_server.server as server_module token_map = _make_legacy_token_map("legacy-token") with patch.object(server_module, "_STATIC_TOKEN_MAP", token_map): identity = server_module._check_registry_static_token("legacy-token") assert identity["username"] == "network-user" assert identity["client_id"] == "network-trusted" def test_non_legacy_key_uses_name_as_username(self): """Non-legacy entries use the key name as username and client_id.""" import auth_server.server as server_module token_map = { "ci-pipeline": { "key_bytes": b"x" * 32, "groups": ["mcp-registry-admin"], "scopes": ["admin/all"], }, } with patch.object(server_module, "_STATIC_TOKEN_MAP", token_map): identity = server_module._check_registry_static_token("x" * 32) assert identity["username"] == "ci-pipeline" assert identity["client_id"] == "ci-pipeline" # ============================================================================= # JWT / STATIC TOKEN COEXISTENCE TESTS (issue #871) # ============================================================================= class TestStaticTokenFallthrough: """Tests verifying that static-token mode accepts Okta/self-signed JWTs as ADDITIONAL credentials, not as replacements. See issue #871. """ @patch("auth_server.server.get_auth_provider") def test_valid_jwt_accepted_when_static_token_enabled( self, mock_get_provider, mock_cognito_provider, auth_env_vars, mock_scope_repository_with_data, ): """A valid IdP JWT must be accepted on /api/* even when static-token mode is on. Pre-#871 the static-token block returned 403 here. """ # Arrange mock_get_provider.return_value = mock_cognito_provider import auth_server.server as server_module token_map = _make_legacy_token_map("static-key") with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "static-key"), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), patch( "auth_server.server.get_scope_repository", return_value=mock_scope_repository_with_data, ), ): client = TestClient(server_module.app) # Act: send a non-matching Bearer that the JWT provider accepts response = client.get( "/validate", headers={ "Authorization": "Bearer some-valid-idp-jwt", "X-Original-URL": "https://example.com/api/servers", }, ) # Assert: JWT path wins; response is 200 but NOT network-trusted. assert response.status_code == 200 data = response.json() assert data["valid"] is True assert data["method"] != "network-trusted" # The cognito mock returns method="cognito". assert data["username"] == "testuser" def test_static_token_match_still_returns_network_trusted(self): """The happy path for the static token is unchanged by #871.""" import auth_server.server as server_module token_map = _make_legacy_token_map("static-key") with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "static-key"), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) response = client.get( "/validate", headers={ "Authorization": "Bearer static-key", "X-Original-URL": "https://example.com/api/servers", }, ) assert response.status_code == 200 data = response.json() assert data["method"] == "network-trusted" assert data["client_id"] == "network-trusted" assert response.headers["X-Auth-Method"] == "network-trusted" @patch("auth_server.server.get_auth_provider") def test_mismatched_bearer_and_invalid_jwt_returns_401( self, mock_get_provider, auth_env_vars, ): """Bearer that matches neither static token nor any valid JWT returns 401 from the JWT block (previously 403 from static-token block). """ # Arrange - provider rejects the token mock_provider = MagicMock() mock_provider.validate_token = MagicMock(side_effect=ValueError("Invalid token")) mock_get_provider.return_value = mock_provider import auth_server.server as server_module token_map = _make_legacy_token_map("static-key") with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "static-key"), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) response = client.get( "/validate", headers={ "Authorization": "Bearer neither-static-nor-jwt", "X-Original-URL": "https://example.com/api/servers", }, ) # Assert: the terminal rejection is no longer the static-token # block's 403 "Invalid API token". Downstream JWT failure # semantics (401 on empty / 500 on provider ValueError etc.) are # out of scope for #871; we only assert the removal of the old # static-token rejection. assert "Invalid API token" not in response.json().get("detail", "") # ============================================================================= # OAUTH TOKEN STORAGE CONFIGURATION TESTS # ============================================================================= class TestOAuthTokenStorageConfiguration: """Tests for OAUTH_STORE_TOKENS_IN_SESSION configuration.""" def test_oauth_store_tokens_default_true(self, monkeypatch): """Test that OAUTH_STORE_TOKENS_IN_SESSION defaults to True.""" # Arrange - ensure env var is not set monkeypatch.delenv("OAUTH_STORE_TOKENS_IN_SESSION", raising=False) # Act - test the parsing logic (module is already imported at test collection) import os result = os.environ.get("OAUTH_STORE_TOKENS_IN_SESSION", "true").lower() == "true" # Assert assert result is True def test_oauth_store_tokens_env_true(self, monkeypatch): """Test OAUTH_STORE_TOKENS_IN_SESSION=true is parsed correctly.""" # Arrange import os monkeypatch.setenv("OAUTH_STORE_TOKENS_IN_SESSION", "true") # Act result = os.environ.get("OAUTH_STORE_TOKENS_IN_SESSION", "true").lower() == "true" # Assert assert result is True def test_oauth_store_tokens_env_false(self, monkeypatch): """Test OAUTH_STORE_TOKENS_IN_SESSION=false is parsed correctly.""" # Arrange import os monkeypatch.setenv("OAUTH_STORE_TOKENS_IN_SESSION", "false") # Act result = os.environ.get("OAUTH_STORE_TOKENS_IN_SESSION", "true").lower() == "true" # Assert assert result is False def test_oauth_store_tokens_env_false_uppercase(self, monkeypatch): """Test OAUTH_STORE_TOKENS_IN_SESSION=FALSE (case insensitive).""" # Arrange import os monkeypatch.setenv("OAUTH_STORE_TOKENS_IN_SESSION", "FALSE") # Act result = os.environ.get("OAUTH_STORE_TOKENS_IN_SESSION", "true").lower() == "true" # Assert assert result is False def test_session_data_includes_tokens_when_enabled(self): """Test session data includes OAuth tokens when OAUTH_STORE_TOKENS_IN_SESSION=true.""" # Arrange mapped_user = { "username": "testuser", "email": "test@example.com", "name": "Test User", "groups": ["users"], } provider = "entra" token_data = { "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6InRlc3QifQ...", "refresh_token": "refresh_token_value", "expires_in": 3600, } # Act - simulate the session data creation logic session_data = { "username": mapped_user["username"], "email": mapped_user.get("email"), "name": mapped_user.get("name"), "groups": mapped_user.get("groups", []), "provider": provider, "auth_method": "oauth2", } # Simulate OAUTH_STORE_TOKENS_IN_SESSION=true oauth_store_tokens = True if oauth_store_tokens: session_data.update( { "access_token": token_data.get("access_token"), "refresh_token": token_data.get("refresh_token"), "token_expires_in": token_data.get("expires_in"), "token_obtained_at": 1234567890, } ) # Assert assert "access_token" in session_data assert "refresh_token" in session_data assert "token_expires_in" in session_data assert "token_obtained_at" in session_data assert session_data["access_token"] == token_data["access_token"] def test_session_data_excludes_tokens_when_disabled(self): """Test session data excludes OAuth tokens when OAUTH_STORE_TOKENS_IN_SESSION=false.""" # Arrange mapped_user = { "username": "testuser", "email": "test@example.com", "name": "Test User", "groups": ["users"], } provider = "entra" token_data = { "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6InRlc3QifQ...", "refresh_token": "refresh_token_value", "expires_in": 3600, } # Act - simulate the session data creation logic session_data = { "username": mapped_user["username"], "email": mapped_user.get("email"), "name": mapped_user.get("name"), "groups": mapped_user.get("groups", []), "provider": provider, "auth_method": "oauth2", } # Simulate OAUTH_STORE_TOKENS_IN_SESSION=false oauth_store_tokens = False if oauth_store_tokens: session_data.update( { "access_token": token_data.get("access_token"), "refresh_token": token_data.get("refresh_token"), "token_expires_in": token_data.get("expires_in"), "token_obtained_at": 1234567890, } ) # Assert - tokens should NOT be in session_data assert "access_token" not in session_data assert "refresh_token" not in session_data assert "token_expires_in" not in session_data assert "token_obtained_at" not in session_data # But user info should still be present assert session_data["username"] == "testuser" assert session_data["email"] == "test@example.com" assert session_data["provider"] == "entra" def test_session_data_size_reduction_when_disabled(self): """Test that disabling token storage significantly reduces session data size.""" # Arrange - simulate a large Entra ID token (typical size ~2000+ chars) large_access_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6InRlc3QifQ." + "a" * 2000 large_refresh_token = "refresh_" + "b" * 500 mapped_user = { "username": "testuser@example.com", "email": "testuser@example.com", "name": "Test User", "groups": ["group1", "group2"], } token_data = { "access_token": large_access_token, "refresh_token": large_refresh_token, "expires_in": 3600, } # Act - create session with tokens enabled session_with_tokens = { "username": mapped_user["username"], "email": mapped_user.get("email"), "name": mapped_user.get("name"), "groups": mapped_user.get("groups", []), "provider": "entra", "auth_method": "oauth2", "access_token": token_data.get("access_token"), "refresh_token": token_data.get("refresh_token"), "token_expires_in": token_data.get("expires_in"), "token_obtained_at": 1234567890, } # Act - create session without tokens session_without_tokens = { "username": mapped_user["username"], "email": mapped_user.get("email"), "name": mapped_user.get("name"), "groups": mapped_user.get("groups", []), "provider": "entra", "auth_method": "oauth2", } # Assert - session without tokens should be much smaller import json size_with_tokens = len(json.dumps(session_with_tokens)) size_without_tokens = len(json.dumps(session_without_tokens)) # Session without tokens should be significantly smaller assert size_without_tokens < size_with_tokens # With large tokens, the difference should be substantial (>2000 bytes) assert size_with_tokens - size_without_tokens > 2000 # Session without tokens should be under cookie limit (4096 bytes) assert size_without_tokens < 4096 # ============================================================================= # OAUTH2 CALLBACK TOKEN STORAGE INTEGRATION TESTS # ============================================================================= class TestOAuth2CallbackTokenStorage: """Test that OAUTH_STORE_TOKENS_IN_SESSION controls actual session cookie content.""" def _call_oauth2_callback( self, store_tokens: bool, ) -> dict: """Call the real oauth2_callback endpoint and return decoded session data. Args: store_tokens: Value for OAUTH_STORE_TOKENS_IN_SESSION flag Returns: Decoded session cookie data dict """ from itsdangerous import URLSafeTimedSerializer from auth_server.server import ( SECRET_KEY, app, signer, ) mock_token_data = { "access_token": "mock-access-token-value", "refresh_token": "mock-refresh-token-value", "expires_in": 3600, "id_token": "mock-id-token", } mock_user_info = { "sub": "testuser", "email": "test@example.com", "name": "Test User", } temp_session_data = { "state": "test-state", "provider": "github", "callback_uri": "http://localhost:8888/oauth2/callback/github", } temp_cookie = signer.dumps(temp_session_data) client = TestClient(app, raise_server_exceptions=False) with ( patch("auth_server.server.OAUTH_STORE_TOKENS_IN_SESSION", store_tokens), patch( "auth_server.server.exchange_code_for_token", new_callable=AsyncMock, return_value=mock_token_data, ), patch( "auth_server.server.get_user_info", new_callable=AsyncMock, return_value=mock_user_info, ), patch( "auth_server.server.map_user_info", return_value={ "username": "testuser", "email": "test@example.com", "name": "Test User", "groups": [], }, ), ): response = client.get( "/oauth2/callback/github", params={"code": "test-code", "state": "test-state"}, cookies={"oauth2_temp_session": temp_cookie}, follow_redirects=False, ) # Extract session cookie from redirect response assert response.status_code == 302 session_cookie = response.cookies.get("mcp_gateway_session") assert session_cookie is not None, "Session cookie not set in response" # Decode session cookie decoder = URLSafeTimedSerializer(SECRET_KEY) return decoder.loads(session_cookie) def test_tokens_excluded_when_disabled(self): """oauth2_callback stores id_token but omits metadata when flag is False.""" session_data = self._call_oauth2_callback(store_tokens=False) assert session_data["username"] == "testuser" assert session_data["auth_method"] == "oauth2" # id_token is always stored for OIDC logout (issue #490) assert session_data["id_token"] == "mock-id-token" # Credentials are never stored (removed in issue #490) assert "access_token" not in session_data assert "refresh_token" not in session_data # Metadata only stored when flag is True assert "token_expires_in" not in session_data assert "token_obtained_at" not in session_data def test_tokens_included_when_enabled(self): """oauth2_callback stores id_token and metadata when flag is True.""" session_data = self._call_oauth2_callback(store_tokens=True) assert session_data["username"] == "testuser" assert session_data["auth_method"] == "oauth2" # id_token is always stored for OIDC logout (issue #490) assert session_data["id_token"] == "mock-id-token" # Credentials are never stored (removed in issue #490) assert "access_token" not in session_data assert "refresh_token" not in session_data # Metadata is stored when flag is True assert session_data["token_expires_in"] == 3600 assert "token_obtained_at" in session_data # ============================================================================= # MULTI-KEY STATIC TOKEN PARSER TESTS (issue #779) # ============================================================================= class TestParseRegistryApiKeys: """Unit tests for _parse_registry_api_keys config parser.""" def test_empty_string_returns_empty_list(self): """Empty raw string produces no entries.""" import auth_server.server as server_module result = server_module._parse_registry_api_keys("") assert result == [] def test_valid_single_entry(self): """A single valid entry parses correctly.""" import json import auth_server.server as server_module raw = json.dumps( { "deploy-pipeline": { "key": "a" * 32, "groups": ["mcp-registry-admin"], } } ) result = server_module._parse_registry_api_keys(raw) assert len(result) == 1 assert result[0].name == "deploy-pipeline" assert result[0].key == "a" * 32 assert result[0].groups == ["mcp-registry-admin"] def test_valid_multiple_entries(self): """Multiple valid entries parse correctly.""" import json import auth_server.server as server_module raw = json.dumps( { "monitoring": {"key": "m" * 32, "groups": ["mcp-readonly"]}, "deploy": {"key": "d" * 32, "groups": ["mcp-registry-admin"]}, } ) result = server_module._parse_registry_api_keys(raw) assert len(result) == 2 names = {e.name for e in result} assert names == {"monitoring", "deploy"} def test_malformed_json_raises(self): """Non-JSON input raises ValueError.""" import auth_server.server as server_module with pytest.raises(ValueError, match="not valid JSON"): server_module._parse_registry_api_keys("{bad json") def test_non_object_json_raises(self): """A JSON array (not object) raises ValueError.""" import auth_server.server as server_module with pytest.raises(ValueError, match="must be a JSON object"): server_module._parse_registry_api_keys('[{"key":"abc"}]') def test_reserved_name_legacy_raises(self): """The name 'legacy' is reserved and must be rejected.""" import json import auth_server.server as server_module raw = json.dumps( { "legacy": {"key": "x" * 32, "groups": ["admin"]}, } ) with pytest.raises(ValueError, match="reserved"): server_module._parse_registry_api_keys(raw) def test_reserved_name_network_user_raises(self): """The name 'network-user' is reserved.""" import json import auth_server.server as server_module raw = json.dumps( { "network-user": {"key": "x" * 32, "groups": ["admin"]}, } ) with pytest.raises(ValueError, match="reserved"): server_module._parse_registry_api_keys(raw) def test_reserved_name_network_trusted_raises(self): """The name 'network-trusted' is reserved.""" import json import auth_server.server as server_module raw = json.dumps( { "network-trusted": {"key": "x" * 32, "groups": ["admin"]}, } ) with pytest.raises(ValueError, match="reserved"): server_module._parse_registry_api_keys(raw) def test_key_too_short_raises(self): """Key shorter than 32 chars raises.""" import json import auth_server.server as server_module raw = json.dumps( { "short-key": {"key": "abc", "groups": ["admin"]}, } ) with pytest.raises(ValueError, match="Invalid entry"): server_module._parse_registry_api_keys(raw) def test_empty_groups_raises(self): """Empty groups list raises.""" import json import auth_server.server as server_module raw = json.dumps( { "no-groups": {"key": "x" * 32, "groups": []}, } ) with pytest.raises(ValueError, match="Invalid entry"): server_module._parse_registry_api_keys(raw) def test_duplicate_key_value_raises(self): """Two entries with the same key value raises.""" import json import auth_server.server as server_module same_key = "k" * 32 raw = json.dumps( { "entry-a": {"key": same_key, "groups": ["g1"]}, "entry-b": {"key": same_key, "groups": ["g2"]}, } ) with pytest.raises(ValueError, match="Duplicate key value"): server_module._parse_registry_api_keys(raw) def test_invalid_name_format_raises(self): """Name with uppercase or special chars raises.""" import json import auth_server.server as server_module raw = json.dumps( { "Invalid-Name!": {"key": "x" * 32, "groups": ["admin"]}, } ) with pytest.raises(ValueError, match="Invalid"): server_module._parse_registry_api_keys(raw) def test_entry_not_object_raises(self): """Entry value that is not a dict raises.""" import json import auth_server.server as server_module raw = json.dumps( { "bad-entry": "just-a-string", } ) with pytest.raises(ValueError, match="must be an object"): server_module._parse_registry_api_keys(raw) def test_empty_object_returns_empty_list(self): """An empty JSON object '{}' returns an empty list.""" import auth_server.server as server_module result = server_module._parse_registry_api_keys("{}") assert result == [] # ============================================================================= # MULTI-KEY BUILD TOKEN MAP TESTS (issue #779) # ============================================================================= class TestBuildStaticTokenMap: """Unit tests for _build_static_token_map startup builder.""" @pytest.mark.asyncio async def test_disabled_flag_does_nothing(self): """When REGISTRY_STATIC_TOKEN_AUTH_ENABLED is False, map stays empty.""" import auth_server.server as server_module with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", False), patch.object(server_module, "_STATIC_TOKEN_MAP", {}), ): await server_module._build_static_token_map() assert server_module._STATIC_TOKEN_MAP == {} @pytest.mark.asyncio async def test_legacy_only_builds_single_entry(self): """With only REGISTRY_API_TOKEN set (no REGISTRY_API_KEYS), map has one legacy entry.""" import auth_server.server as server_module with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "t" * 32), patch.object(server_module, "_REGISTRY_API_KEYS_RAW", ""), patch.object(server_module, "_STATIC_TOKEN_MAP", {}), ): await server_module._build_static_token_map() assert "legacy" in server_module._STATIC_TOKEN_MAP assert len(server_module._STATIC_TOKEN_MAP) == 1 legacy = server_module._STATIC_TOKEN_MAP["legacy"] assert legacy["username_override"] == "network-user" assert legacy["client_id_override"] == "network-trusted" @pytest.mark.asyncio async def test_bad_json_disables_feature(self): """Malformed REGISTRY_API_KEYS disables static-token auth (fail-closed).""" import auth_server.server as server_module with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", ""), patch.object(server_module, "_REGISTRY_API_KEYS_RAW", "{bad json"), patch.object(server_module, "_STATIC_TOKEN_MAP", {}), ): await server_module._build_static_token_map() assert server_module.REGISTRY_STATIC_TOKEN_AUTH_ENABLED is False @pytest.mark.asyncio async def test_valid_keys_plus_legacy_merged(self): """Both REGISTRY_API_KEYS and REGISTRY_API_TOKEN produce merged map.""" import json import auth_server.server as server_module raw = json.dumps( { "monitoring": {"key": "m" * 32, "groups": ["mcp-readonly"]}, } ) mock_repo = AsyncMock() mock_repo.get_group_mappings.return_value = ["mcp-readonly/read"] with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", "t" * 32), patch.object(server_module, "_REGISTRY_API_KEYS_RAW", raw), patch.object(server_module, "_STATIC_TOKEN_MAP", {}), patch( "auth_server.server.get_scope_repository", return_value=mock_repo, ), ): await server_module._build_static_token_map() assert "monitoring" in server_module._STATIC_TOKEN_MAP assert "legacy" in server_module._STATIC_TOKEN_MAP assert len(server_module._STATIC_TOKEN_MAP) == 2 @pytest.mark.asyncio async def test_zero_keys_warns_but_stays_enabled(self): """Empty REGISTRY_API_KEYS and empty REGISTRY_API_TOKEN logs warning.""" import auth_server.server as server_module with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "REGISTRY_API_TOKEN", ""), patch.object(server_module, "_REGISTRY_API_KEYS_RAW", ""), patch.object(server_module, "_STATIC_TOKEN_MAP", {}), ): await server_module._build_static_token_map() assert server_module._STATIC_TOKEN_MAP == {} # Feature stays enabled (callers just fall through to JWT) assert server_module.REGISTRY_STATIC_TOKEN_AUTH_ENABLED is True # ============================================================================= # MULTI-KEY VALIDATE INTEGRATION TESTS (issue #779) # ============================================================================= class TestMultiKeyStaticTokenValidate: """Integration tests for multi-key static token through /validate.""" def test_named_key_returns_key_name_as_username(self): """A named key match returns the key name as X-Username.""" import auth_server.server as server_module token_map = { "ci-runner": { "key_bytes": ("c" * 32).encode("utf-8"), "groups": ["mcp-registry-admin"], "scopes": ["mcp-servers-unrestricted/read"], }, } with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) response = client.get( "/validate", headers={ "Authorization": f"Bearer {'c' * 32}", "X-Original-URL": "https://example.com/api/servers", }, ) assert response.status_code == 200 data = response.json() assert data["username"] == "ci-runner" assert data["client_id"] == "ci-runner" assert data["method"] == "network-trusted" assert response.headers["X-Username"] == "ci-runner" def test_readonly_key_gets_limited_scopes(self): """A read-only key gets only the scopes configured for its groups.""" import auth_server.server as server_module token_map = { "readonly-monitor": { "key_bytes": ("r" * 32).encode("utf-8"), "groups": ["mcp-readonly"], "scopes": ["mcp-readonly/read"], }, } with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) response = client.get( "/validate", headers={ "Authorization": f"Bearer {'r' * 32}", "X-Original-URL": "https://example.com/api/servers", }, ) assert response.status_code == 200 data = response.json() assert data["scopes"] == ["mcp-readonly/read"] assert data["groups"] == ["mcp-readonly"] def test_key_with_empty_scopes_still_matches(self): """A key whose groups map to no scopes still matches (but will 403 at registry).""" import auth_server.server as server_module token_map = { "empty-scope-key": { "key_bytes": ("e" * 32).encode("utf-8"), "groups": ["ghost-group"], "scopes": [], }, } with ( patch.object(server_module, "REGISTRY_STATIC_TOKEN_AUTH_ENABLED", True), patch.object(server_module, "_STATIC_TOKEN_MAP", token_map), ): client = TestClient(server_module.app) response = client.get( "/validate", headers={ "Authorization": f"Bearer {'e' * 32}", "X-Original-URL": "https://example.com/api/servers", }, ) assert response.status_code == 200 data = response.json() assert data["scopes"] == [] assert data["username"] == "empty-scope-key" ================================================ FILE: tests/conftest.py ================================================ """ Root conftest for pytest configuration and shared fixtures. This module provides session-scoped fixtures and auto-mocking configuration that applies to all tests. """ # ============================================================================= # SSL PATH MOCKING (BEFORE ANY IMPORTS) # ============================================================================= # This must run FIRST to avoid permission errors when nginx_service is imported import errno import os _original_stat = os.stat def _patched_stat(path, *args, **kwargs): """Patched stat that handles SSL paths gracefully in CI environments.""" path_str = str(path).lower() if "ssl" in path_str or "privkey" in path_str or "fullchain" in path_str: # Raise FileNotFoundError with proper errno for SSL paths # This simulates missing certs and is properly handled by Path.exists() raise FileNotFoundError(errno.ENOENT, "No such file or directory", str(path)) return _original_stat(path, *args, **kwargs) # Apply the patch immediately os.stat = _patched_stat # ============================================================================= # NOW SAFE TO IMPORT # ============================================================================= import logging import sys import tempfile from collections.abc import Generator from pathlib import Path from typing import Any from unittest.mock import AsyncMock, patch import pytest from tests.fixtures.mocks.mock_embeddings import ( create_mock_litellm_module, create_mock_st_module, ) from tests.fixtures.mocks.mock_faiss import create_mock_faiss_module logger = logging.getLogger(__name__) # ============================================================================= # ENVIRONMENT SETUP (BEFORE ANY IMPORTS) # ============================================================================= # Set environment variables for test environment BEFORE any app code imports # This ensures Settings loads the correct values for tests def pytest_configure(config): """ Pytest hook that runs BEFORE test collection. This runs before any imports happen, ensuring environment variables are set before Settings() is created. Also registers custom markers. Args: config: Pytest config object """ # Set MongoDB connection to localhost for tests # (Docker deployments use 'mongodb' hostname from docker-compose.yml) os.environ["DOCUMENTDB_HOST"] = "localhost" os.environ["DOCUMENTDB_PORT"] = "27017" # Keep mongodb-ce as storage backend for integration tests os.environ["STORAGE_BACKEND"] = "mongodb-ce" # Use directConnection for single-node MongoDB in tests # (AWS DocumentDB clusters should NOT use directConnection) os.environ["DOCUMENTDB_DIRECT_CONNECTION"] = "true" # Disable TLS for local MongoDB in tests # (AWS DocumentDB requires TLS, but local MongoDB CE does not) os.environ["DOCUMENTDB_USE_TLS"] = "false" # Disable registration gate for all tests by default # (dedicated gate tests mock settings directly) os.environ["REGISTRATION_GATE_ENABLED"] = "false" print( "Test environment configured: DOCUMENTDB_HOST=localhost, STORAGE_BACKEND=mongodb-ce, DOCUMENTDB_DIRECT_CONNECTION=true, DOCUMENTDB_USE_TLS=false" ) # Force reload settings if it's already been imported # This is needed because Settings() is created at module level try: import registry.core.config as config_module # Recreate the settings object with the new environment variables config_module.settings = config_module.Settings() print(f"Reloaded settings with documentdb_host={config_module.settings.documentdb_host}") except ImportError: # Settings hasn't been imported yet, which is fine pass # Register custom markers config.addinivalue_line("markers", "unit: Unit tests that test single components in isolation") config.addinivalue_line( "markers", "integration: Integration tests that test multiple components together" ) config.addinivalue_line("markers", "requires_models: Tests that require real ML models (slow)") config.addinivalue_line("markers", "auth: Authentication and authorization tests") config.addinivalue_line("markers", "agents: A2A agent service tests") config.addinivalue_line("markers", "servers: MCP server service tests") config.addinivalue_line("markers", "api: API route tests") config.addinivalue_line("markers", "search: Search functionality tests") config.addinivalue_line("markers", "slow: Tests that take a long time to run") # ============================================================================= # AUTO-MOCKING SETUP (BEFORE IMPORTS) # ============================================================================= # This section must run BEFORE any registry code imports the real libraries def _setup_auto_mocking() -> None: """ Set up automatic mocking for heavy dependencies. This function mocks FAISS and sentence-transformers BEFORE they are imported by the application code, avoiding loading large ML models during tests. """ # Mock FAISS mock_faiss = create_mock_faiss_module() sys.modules["faiss"] = mock_faiss logger.info("Auto-mocked: faiss") # Mock sentence_transformers mock_st = create_mock_st_module() sys.modules["sentence_transformers"] = mock_st logger.info("Auto-mocked: sentence_transformers") # Mock litellm mock_litellm = create_mock_litellm_module() sys.modules["litellm"] = mock_litellm logger.info("Auto-mocked: litellm") # Execute auto-mocking setup _setup_auto_mocking() # Now we can safely import registry modules from registry.core.config import Settings # noqa: E402 # ============================================================================= # SESSION-SCOPED FIXTURES # ============================================================================= @pytest.fixture(scope="session") def tmp_test_dir() -> Generator[Path, None, None]: """ Create a temporary directory for test files that persists for the session. Yields: Path to temporary directory """ temp_dir = tempfile.mkdtemp(prefix="mcp_registry_test_") temp_path = Path(temp_dir) logger.info(f"Created session temp directory: {temp_path}") yield temp_path # Cleanup handled by OS temp dir cleanup # ============================================================================= # FUNCTION-SCOPED FIXTURES # ============================================================================= @pytest.fixture def tmp_path(tmp_path_factory) -> Path: """ Create a temporary directory for a single test. Args: tmp_path_factory: Pytest's tmp_path_factory fixture Returns: Path to temporary directory """ return tmp_path_factory.mktemp("test") @pytest.fixture def test_settings(tmp_path: Path) -> Settings: """ Create test settings with temporary directories. This fixture provides a Settings instance with all paths pointing to temporary directories to avoid conflicts with actual data. Args: tmp_path: Temporary directory path Returns: Test Settings instance """ # Create subdirectories servers_dir = tmp_path / "servers" agents_dir = tmp_path / "agents" models_dir = tmp_path / "models" logs_dir = tmp_path / "logs" servers_dir.mkdir(parents=True, exist_ok=True) agents_dir.mkdir(parents=True, exist_ok=True) models_dir.mkdir(parents=True, exist_ok=True) logs_dir.mkdir(parents=True, exist_ok=True) # Override settings with test values settings = Settings( secret_key="test-secret-key-for-testing-only", session_cookie_name="test_session", auth_server_url="http://localhost:8888", embeddings_provider="sentence-transformers", embeddings_model_name="all-MiniLM-L6-v2", embeddings_model_dimensions=384, documentdb_host="localhost", # Use localhost for tests documentdb_port=27017, documentdb_use_tls=False, # Disable TLS for local MongoDB in tests documentdb_direct_connection=True, # Use direct connection for single-node MongoDB ) # Patch path properties to use temp directories # Save original property descriptors (not computed values) for restoration original_servers_dir_prop = type(settings).__dict__.get("servers_dir") original_agents_dir_prop = type(settings).__dict__.get("agents_dir") original_embeddings_model_dir_prop = type(settings).__dict__.get("embeddings_model_dir") original_log_dir_prop = type(settings).__dict__.get("log_dir") # Mock the path properties with temp directory values type(settings).servers_dir = property(lambda self: servers_dir) type(settings).agents_dir = property(lambda self: agents_dir) type(settings).embeddings_model_dir = property(lambda self: models_dir) type(settings).log_dir = property(lambda self: logs_dir) logger.debug(f"Created test settings with temp dirs in {tmp_path}") yield settings # Restore original property descriptors (not fixed values) if original_servers_dir_prop is not None: type(settings).servers_dir = original_servers_dir_prop if original_agents_dir_prop is not None: type(settings).agents_dir = original_agents_dir_prop if original_embeddings_model_dir_prop is not None: type(settings).embeddings_model_dir = original_embeddings_model_dir_prop if original_log_dir_prop is not None: type(settings).log_dir = original_log_dir_prop @pytest.fixture def mock_settings(test_settings: Settings, monkeypatch): """ Mock the global settings instance with test settings. This fixture patches registry.core.config.settings to use test settings for the duration of the test. Args: test_settings: Test settings instance monkeypatch: Pytest monkeypatch fixture Returns: Test settings instance """ monkeypatch.setattr("registry.core.config.settings", test_settings) logger.debug("Patched global settings with test settings") return test_settings @pytest.fixture def mock_scope_repository(): """ Mock scope repository to avoid DocumentDB access. Returns: AsyncMock instance with common scope repository methods """ mock = AsyncMock() mock.load_all = AsyncMock() mock.get_group_mappings.return_value = [] mock.list_groups.return_value = {} # Return empty dict, not list mock.get_group.return_value = None mock.get_scope_definition.return_value = None mock.list_scope_definitions.return_value = [] return mock @pytest.fixture def mock_server_repository(): """ Mock server repository to avoid DocumentDB access. Returns: AsyncMock instance with common server repository methods """ mock = AsyncMock() mock.load_all.return_value = {} # Return empty dict of servers mock.list_all.return_value = {} # Return empty dict of servers, not list mock.get.return_value = None mock.save.return_value = None mock.delete.return_value = None mock.delete_with_versions.return_value = 0 mock.create.return_value = True mock.update.return_value = True mock.get_state.return_value = False mock.set_state.return_value = True return mock @pytest.fixture def mock_agent_repository(): """ Mock agent repository to avoid DocumentDB access. Returns: AsyncMock instance with common agent repository methods """ mock = AsyncMock() mock.load_all.return_value = [] mock.list_all.return_value = [] mock.get.return_value = None mock.save.return_value = None mock.delete.return_value = None mock.create.return_value = True mock.update.return_value = True mock.get_state.return_value = False mock.get_all_states.return_value = {} mock.save_state.return_value = True mock.set_state.return_value = True return mock @pytest.fixture def mock_search_repository(): """ Mock search repository to avoid DocumentDB/FAISS access. Returns: AsyncMock instance with common search repository methods """ mock = AsyncMock() mock.initialize.return_value = None mock.add_embedding.return_value = None mock.search.return_value = [] mock.hybrid_search.return_value = [] mock.index_server.return_value = None mock.index_agent.return_value = None return mock @pytest.fixture def mock_federation_config_repository(): """ Mock federation config repository to avoid DocumentDB access. Returns: AsyncMock instance with common federation config methods """ mock = AsyncMock() mock.get_config.return_value = None mock.save_config.return_value = None mock.list_configs.return_value = [] return mock @pytest.fixture def mock_security_scan_repository(): """ Mock security scan repository to avoid DocumentDB access. Returns: AsyncMock instance with common security scan methods """ mock = AsyncMock() mock.save_scan.return_value = None mock.get_scan.return_value = None mock.list_scans.return_value = [] return mock @pytest.fixture def mock_virtual_server_repository(): """ Mock virtual server repository to avoid DocumentDB access. Returns: AsyncMock instance with common virtual server repository methods """ mock = AsyncMock() mock.ensure_indexes = AsyncMock() mock.get.return_value = None mock.list_all.return_value = [] mock.list_enabled.return_value = [] mock.create = AsyncMock() mock.update = AsyncMock() mock.delete.return_value = True mock.get_state.return_value = False mock.set_state.return_value = True return mock @pytest.fixture def mock_backend_session_repository(): """ Mock backend session repository to avoid DocumentDB access. Returns: AsyncMock instance with common backend session repository methods """ mock = AsyncMock() mock.ensure_indexes = AsyncMock() mock.get_backend_session.return_value = None mock.store_backend_session = AsyncMock() mock.delete_backend_session = AsyncMock() mock.create_client_session = AsyncMock() mock.validate_client_session.return_value = False return mock @pytest.fixture def mock_skill_security_scan_repository(): """ Mock skill security scan repository to avoid DocumentDB access. Returns: AsyncMock instance with common skill security scan methods """ mock = AsyncMock() mock.create.return_value = True mock.get_latest.return_value = None mock.get.return_value = None mock.list_all.return_value = [] mock.query_by_status.return_value = [] mock.load_all.return_value = None return mock @pytest.fixture(autouse=True) def mock_all_repositories( mock_scope_repository, mock_server_repository, mock_agent_repository, mock_search_repository, mock_federation_config_repository, mock_security_scan_repository, mock_virtual_server_repository, mock_backend_session_repository, mock_skill_security_scan_repository, ): """ Auto-mock all repository factory functions to prevent DocumentDB access. This fixture automatically applies to all tests and prevents any accidental DocumentDB connections during test execution. Args: mock_scope_repository: Mock scope repository mock_server_repository: Mock server repository mock_agent_repository: Mock agent repository mock_search_repository: Mock search repository mock_federation_config_repository: Mock federation config repository mock_security_scan_repository: Mock security scan repository mock_virtual_server_repository: Mock virtual server repository mock_backend_session_repository: Mock backend session repository Yields: None """ # Most tests only need registry patches, not auth_server patches # Only patch auth_server for auth_server tests (they have their own conftest) with ( patch( "registry.repositories.factory.get_scope_repository", return_value=mock_scope_repository ), patch( "registry.repositories.factory.get_server_repository", return_value=mock_server_repository, ), patch( "registry.repositories.factory.get_agent_repository", return_value=mock_agent_repository ), patch( "registry.repositories.factory.get_search_repository", return_value=mock_search_repository, ), patch( "registry.repositories.factory.get_federation_config_repository", return_value=mock_federation_config_repository, ), patch( "registry.repositories.factory.get_security_scan_repository", return_value=mock_security_scan_repository, ), patch( "registry.repositories.factory.get_virtual_server_repository", return_value=mock_virtual_server_repository, ), patch( "registry.repositories.factory.get_backend_session_repository", return_value=mock_backend_session_repository, ), patch( "registry.repositories.factory.get_skill_security_scan_repository", return_value=mock_skill_security_scan_repository, ), ): logger.debug("Auto-mocked all repository factory functions") yield @pytest.fixture def sample_server_info() -> dict[str, Any]: """ Create sample server information for testing. Returns: Dictionary with sample server data """ return { "name": "com.example.test-server", "description": "A test MCP server for unit tests", "version": "1.0.0", "title": "Test Server", "repository": { "url": "https://github.com/example/test-server", "source": "github", "id": "test-repo-123", }, "websiteUrl": "https://example.com/test-server", "packages": [ { "registryType": "npm", "identifier": "@example/test-server", "version": "1.0.0", "transport": {"type": "stdio", "command": "uvx", "args": ["test-server"]}, "runtimeHint": "uvx", } ], "_meta": { "tools": [ { "name": "get_data", "description": "Retrieve data from source", "inputSchema": {"type": "object", "properties": {"id": {"type": "string"}}}, } ], "prompts": [], "resources": [], }, } @pytest.fixture def sample_agent_card() -> dict[str, Any]: """ Create sample agent card for testing. Returns: Dictionary with sample agent card data """ return { "protocolVersion": "1.0", "name": "test-agent", "description": "A test agent for unit tests", "url": "http://localhost:9000/test-agent", "version": "1.0", "capabilities": {"streaming": False, "tools": True}, "defaultInputModes": ["text/plain"], "defaultOutputModes": ["text/plain", "application/json"], "skills": [ { "id": "data-retrieval", "name": "Data Retrieval", "description": "Retrieve data from various sources", "tags": ["data", "retrieval"], "examples": ["Get customer data", "Fetch order information"], } ], "path": "/agents/test-agent", "tags": ["test", "data"], "isEnabled": True, "numStars": 4.5, "license": "MIT", "visibility": "public", "trustLevel": "unverified", } def pytest_collection_modifyitems(config, items): """ Modify test collection to add markers automatically. Args: config: Pytest config object items: List of collected test items """ for item in items: # Auto-mark tests based on file location if "unit/" in str(item.fspath): item.add_marker(pytest.mark.unit) elif "integration/" in str(item.fspath): item.add_marker(pytest.mark.integration) elif "auth_server/" in str(item.fspath): item.add_marker(pytest.mark.auth) # ============================================================================= # DEPLOYMENT MODE FIXTURES # ============================================================================= @pytest.fixture def client_registry_only(mock_settings) -> Generator[Any, None, None]: """Test client with registry-only deployment mode.""" from fastapi.testclient import TestClient from registry.core.config import DeploymentMode, RegistryMode object.__setattr__(mock_settings, "deployment_mode", DeploymentMode.REGISTRY_ONLY) object.__setattr__(mock_settings, "registry_mode", RegistryMode.FULL) from registry.main import app with TestClient(app) as client: yield client @pytest.fixture def client_skills_only(mock_settings) -> Generator[Any, None, None]: """Test client with skills-only registry mode.""" from fastapi.testclient import TestClient from registry.core.config import DeploymentMode, RegistryMode object.__setattr__(mock_settings, "deployment_mode", DeploymentMode.REGISTRY_ONLY) object.__setattr__(mock_settings, "registry_mode", RegistryMode.SKILLS_ONLY) from registry.main import app with TestClient(app) as client: yield client ================================================ FILE: tests/e2e/__init__.py ================================================ ================================================ FILE: tests/e2e/test_virtual_mcp_latency.py ================================================ """ MCP Virtual Server Latency Benchmarks. Measures and compares latency between the virtual MCP server (routed through nginx/Lua) and direct backend MCP servers. Reports min, max, mean, median, p95, and p99 for each method, plus routing overhead. Usage: python3 tests/e2e/test_virtual_mcp_latency.py """ import json import logging import os import statistics import subprocess import sys import time import urllib.error import urllib.request from typing import ( Any, ) logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) VIRTUAL_SERVER_URL = "http://localhost/virtual/e2e-multi-backend/mcp" DIRECT_CURRENTTIME_URL = "http://localhost:8000/mcp" DIRECT_FAKETOOLS_URL = "http://localhost:8002/mcp" WARMUP_ITERATIONS = 3 MEASURED_ITERATIONS = 20 REQUEST_TIMEOUT_SECONDS = 30 def _refresh_token() -> str: """Refresh the admin-bot M2M token and return the access token.""" script_path = os.path.join(PROJECT_ROOT, "scripts", "refresh_m2m_token.sh") token_path = os.path.join(PROJECT_ROOT, ".oauth-tokens", "admin-bot-token.json") logger.info("Refreshing admin-bot token...") result = subprocess.run( ["bash", script_path, "admin-bot"], capture_output=True, text=True, cwd=PROJECT_ROOT, ) if result.returncode != 0: logger.error("Token refresh failed: %s", result.stderr) raise RuntimeError(f"Token refresh failed: {result.stderr}") with open(token_path) as f: token_data = json.load(f) access_token = token_data.get("access_token") if not access_token: raise RuntimeError("No access_token found in token file") logger.info("Token refreshed successfully") return access_token def _parse_sse_response( raw_body: str, ) -> dict[str, Any] | None: """Parse an SSE or plain JSON response body. SSE responses have lines like: event: message data: {"jsonrpc":"2.0", ...} Plain JSON responses are just the JSON object directly. Returns the parsed JSON-RPC result dict, or None on failure. """ raw_body = raw_body.strip() # Try plain JSON first if raw_body.startswith("{"): try: return json.loads(raw_body) except json.JSONDecodeError: pass # Parse SSE: find last data: line (some servers send multiple events) last_data_line = None for line in raw_body.splitlines(): stripped = line.strip() if stripped.startswith("data:"): last_data_line = stripped[len("data:") :].strip() if last_data_line: try: return json.loads(last_data_line) except json.JSONDecodeError: logger.warning("Failed to parse SSE data line: %s", last_data_line) logger.warning("Could not parse response body:\n%s", raw_body[:500]) return None def _send_mcp_request( url: str, payload: dict[str, Any], token: str | None = None, session_id: str | None = None, ) -> tuple[dict[str, Any] | None, str | None]: """Send an MCP JSON-RPC request and return (parsed_response, session_id). Args: url: The MCP endpoint URL. payload: JSON-RPC request body. token: Optional Bearer token for authorization. session_id: Optional MCP session ID header. Returns: Tuple of (parsed JSON-RPC response dict, session ID from response). """ body = json.dumps(payload).encode("utf-8") headers = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", } if token: headers["Authorization"] = f"Bearer {token}" if session_id: headers["Mcp-Session-Id"] = session_id req = urllib.request.Request( url, data=body, headers=headers, method="POST", ) try: with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SECONDS) as resp: resp_body = resp.read().decode("utf-8") resp_session = resp.headers.get("Mcp-Session-Id", session_id) parsed = _parse_sse_response(resp_body) return parsed, resp_session except urllib.error.HTTPError as e: error_body = e.read().decode("utf-8", errors="replace") if e.fp else "" logger.error("HTTP %d from %s: %s", e.code, url, error_body[:300]) return None, session_id except urllib.error.URLError as e: logger.error("URL error for %s: %s", url, e.reason) return None, session_id except Exception as e: logger.error("Request to %s failed: %s", url, e) return None, session_id def _initialize_session( url: str, token: str | None = None, ) -> str | None: """Send an MCP initialize request and return the session ID.""" payload = { "jsonrpc": "2.0", "id": 0, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": { "name": "latency-benchmark", "version": "1.0.0", }, }, } resp, session_id = _send_mcp_request(url, payload, token=token) if resp is None: logger.error("Failed to initialize session at %s", url) return None if "error" in resp: logger.error("Initialize error at %s: %s", url, resp["error"]) return None logger.info("Session initialized at %s, session_id=%s", url, session_id) # Send initialized notification notification = { "jsonrpc": "2.0", "method": "notifications/initialized", } _send_mcp_request(url, notification, token=token, session_id=session_id) return session_id def _timed_request( url: str, payload: dict[str, Any], token: str | None = None, session_id: str | None = None, ) -> tuple[float, dict[str, Any] | None]: """Send an MCP request and return (elapsed_ms, parsed_response).""" start = time.perf_counter() resp, _ = _send_mcp_request(url, payload, token=token, session_id=session_id) elapsed_ms = (time.perf_counter() - start) * 1000.0 return elapsed_ms, resp def _run_benchmark( label: str, url: str, payload: dict[str, Any], token: str | None = None, session_id: str | None = None, warmup: int = WARMUP_ITERATIONS, iterations: int = MEASURED_ITERATIONS, ) -> dict[str, float] | None: """Run a benchmark: warmup + measured iterations. Returns dict with min, max, mean, median, p95, p99 in ms, or None if all iterations failed. """ logger.info("Benchmarking [%s] ...", label) # Warmup for i in range(warmup): elapsed, resp = _timed_request(url, payload, token=token, session_id=session_id) if resp is None: logger.warning(" warmup %d/%d: FAILED", i + 1, warmup) else: logger.debug(" warmup %d/%d: %.1f ms", i + 1, warmup, elapsed) # Measured iterations latencies: list[float] = [] failures = 0 for i in range(iterations): elapsed, resp = _timed_request(url, payload, token=token, session_id=session_id) if resp is None or "error" in (resp or {}): failures += 1 logger.warning( " iteration %d/%d: FAILED (resp=%s)", i + 1, iterations, resp, ) else: latencies.append(elapsed) logger.debug(" iteration %d/%d: %.1f ms", i + 1, iterations, elapsed) if not latencies: logger.error(" All %d iterations failed for [%s]", iterations, label) return None if failures > 0: logger.warning(" %d/%d iterations failed for [%s]", failures, iterations, label) latencies.sort() p95_idx = max(0, int(len(latencies) * 0.95) - 1) p99_idx = max(0, int(len(latencies) * 0.99) - 1) result = { "min": min(latencies), "max": max(latencies), "mean": statistics.mean(latencies), "median": statistics.median(latencies), "p95": latencies[p95_idx], "p99": latencies[p99_idx], "count": len(latencies), "failures": failures, } logger.info( " [%s] mean=%.1f ms, median=%.1f ms, p95=%.1f ms (%d ok, %d fail)", label, result["mean"], result["median"], result["p95"], len(latencies), failures, ) return result def _compute_overhead( virtual_stats: dict[str, float], direct_stats: dict[str, float], ) -> dict[str, float]: """Compute overhead = virtual - direct for each stat.""" return { key: virtual_stats[key] - direct_stats[key] for key in ("min", "max", "mean", "median", "p95", "p99") } def _print_table( rows: list[tuple[str, dict[str, float] | None]], ) -> None: """Print a formatted results table.""" header = ( f"{'Method':<34} | {'Min(ms)':>8} | {'Max(ms)':>8} | " f"{'Mean(ms)':>9} | {'Median(ms)':>11} | {'P95(ms)':>8} | {'P99(ms)':>8}" ) separator = ( f"{'-' * 34}-+-{'-' * 8}-+-{'-' * 8}-+-{'-' * 9}-+-{'-' * 11}-+-{'-' * 8}-+-{'-' * 8}" ) print() print("=" * len(header)) print("MCP LATENCY BENCHMARK RESULTS") print(f" Warmup: {WARMUP_ITERATIONS}, Measured: {MEASURED_ITERATIONS} iterations") print("=" * len(header)) print() print(header) print(separator) for label, stats in rows: if stats is None: print(f"{label:<34} | {'FAILED':>8} | {'':>8} | {'':>9} | {'':>11} | {'':>8} | {'':>8}") else: print( f"{label:<34} | {stats['min']:>8.1f} | {stats['max']:>8.1f} | " f"{stats['mean']:>9.1f} | {stats['median']:>11.1f} | " f"{stats['p95']:>8.1f} | {stats['p99']:>8.1f}" ) print(separator) print() def _build_method_configs() -> list[dict[str, Any]]: """Build the list of method benchmark configurations. Returns a list of dicts, each with: name: display name for the method virtual_payload: JSON-RPC payload for virtual server direct_url: URL of the direct backend (or None to skip) direct_payload: JSON-RPC payload for direct backend (or None) """ return [ { "name": "ping", "virtual_payload": { "jsonrpc": "2.0", "id": 1, "method": "ping", }, "direct_url": DIRECT_CURRENTTIME_URL, "direct_payload": { "jsonrpc": "2.0", "id": 1, "method": "ping", }, }, { "name": "tools/list", "virtual_payload": { "jsonrpc": "2.0", "id": 1, "method": "tools/list", }, "direct_url": DIRECT_CURRENTTIME_URL, "direct_payload": { "jsonrpc": "2.0", "id": 1, "method": "tools/list", }, }, { "name": "tools/call get_time", "virtual_payload": { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_time", "arguments": {"timezone": "UTC"}, }, }, "direct_url": DIRECT_CURRENTTIME_URL, "direct_payload": { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "current_time_by_timezone", "arguments": {"timezone": "UTC"}, }, }, }, { "name": "tools/call quantum_flux", "virtual_payload": { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "quantum_flux_analyzer", "arguments": {"energy_level": 5}, }, }, "direct_url": DIRECT_FAKETOOLS_URL, "direct_payload": { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "quantum_flux_analyzer", "arguments": {"energy_level": 5}, }, }, }, { "name": "resources/list", "virtual_payload": { "jsonrpc": "2.0", "id": 1, "method": "resources/list", }, "direct_url": DIRECT_CURRENTTIME_URL, "direct_payload": { "jsonrpc": "2.0", "id": 1, "method": "resources/list", }, }, { "name": "prompts/list", "virtual_payload": { "jsonrpc": "2.0", "id": 1, "method": "prompts/list", }, "direct_url": DIRECT_CURRENTTIME_URL, "direct_payload": { "jsonrpc": "2.0", "id": 1, "method": "prompts/list", }, }, ] def main() -> None: """Run the MCP latency benchmarks.""" start_time = time.time() # Refresh token token = _refresh_token() # Initialize sessions logger.info("Initializing virtual server session...") virtual_session = _initialize_session(VIRTUAL_SERVER_URL, token=token) if not virtual_session: logger.error("Failed to initialize virtual server session. Aborting.") sys.exit(1) logger.info("Initializing direct currenttime session...") direct_ct_session = _initialize_session(DIRECT_CURRENTTIME_URL) if not direct_ct_session: logger.error("Failed to initialize direct currenttime session. Aborting.") sys.exit(1) logger.info("Initializing direct realserverfaketools session...") direct_ft_session = _initialize_session(DIRECT_FAKETOOLS_URL) if not direct_ft_session: logger.error("Failed to initialize direct faketools session. Aborting.") sys.exit(1) # Map direct URLs to their sessions direct_sessions = { DIRECT_CURRENTTIME_URL: direct_ct_session, DIRECT_FAKETOOLS_URL: direct_ft_session, } method_configs = _build_method_configs() table_rows: list[tuple[str, dict[str, float] | None]] = [] for config in method_configs: method_name = config["name"] # Benchmark virtual server virtual_stats = _run_benchmark( label=f"virtual {method_name}", url=VIRTUAL_SERVER_URL, payload=config["virtual_payload"], token=token, session_id=virtual_session, ) table_rows.append((f"[virtual] {method_name}", virtual_stats)) # Benchmark direct backend direct_url = config.get("direct_url") direct_payload = config.get("direct_payload") direct_stats = None if direct_url and direct_payload: direct_session = direct_sessions.get(direct_url) direct_stats = _run_benchmark( label=f"direct {method_name}", url=direct_url, payload=direct_payload, session_id=direct_session, ) table_rows.append((f"[direct] {method_name}", direct_stats)) # Compute overhead if virtual_stats and direct_stats: overhead = _compute_overhead(virtual_stats, direct_stats) table_rows.append((" overhead", overhead)) else: table_rows.append((" overhead", None)) else: table_rows.append((f"[direct] {method_name}", None)) table_rows.append((" overhead", None)) # Blank separator row placeholder - we add a visual break in printing table_rows.append(("", None)) # Filter out blank separator entries for the table but print blank lines _print_results_table(table_rows) elapsed = time.time() - start_time minutes = int(elapsed // 60) seconds = elapsed % 60 if minutes > 0: logger.info("Benchmark completed in %d minutes and %.1f seconds", minutes, seconds) else: logger.info("Benchmark completed in %.1f seconds", seconds) def _print_results_table( rows: list[tuple[str, dict[str, float] | None]], ) -> None: """Print formatted results table with visual grouping.""" header = ( f"{'Method':<34} | {'Min(ms)':>8} | {'Max(ms)':>8} | " f"{'Mean(ms)':>9} | {'Median(ms)':>11} | {'P95(ms)':>8} | {'P99(ms)':>8}" ) separator = ( f"{'-' * 34}-+-{'-' * 8}-+-{'-' * 8}-+-{'-' * 9}-+-{'-' * 11}-+-{'-' * 8}-+-{'-' * 8}" ) print() print("=" * len(header)) print("MCP LATENCY BENCHMARK RESULTS") print(f" Warmup: {WARMUP_ITERATIONS}, Measured: {MEASURED_ITERATIONS} iterations") print("=" * len(header)) print() print(header) print(separator) for label, stats in rows: if label == "": # Visual separator between method groups print(separator) continue if stats is None: print(f"{label:<34} | {'FAILED':>8} | {'':>8} | {'':>9} | {'':>11} | {'':>8} | {'':>8}") else: print( f"{label:<34} | {stats['min']:>8.1f} | {stats['max']:>8.1f} | " f"{stats['mean']:>9.1f} | {stats['median']:>11.1f} | " f"{stats['p95']:>8.1f} | {stats['p99']:>8.1f}" ) print() if __name__ == "__main__": main() ================================================ FILE: tests/e2e/test_virtual_mcp_protocol.py ================================================ #!/usr/bin/env python3 """ E2E tests for the Virtual MCP Server protocol. Tests the full MCP JSON-RPC protocol through the virtual server endpoint, verifying initialize, ping, tools/list, tools/call, resources, prompts, and error handling behaviors. Usage: python3 tests/e2e/test_virtual_mcp_protocol.py """ import json import subprocess import sys import time import urllib.error import urllib.request from pathlib import Path from typing import Any PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent VIRTUAL_SERVER_ENDPOINT = "http://localhost/virtual/e2e-multi-backend/mcp" TOKEN_REFRESH_SCRIPT = str(PROJECT_ROOT / "scripts" / "refresh_m2m_token.sh") TOKEN_FILE = str(PROJECT_ROOT / ".oauth-tokens" / "admin-bot-token.json") CLIENT_NAME = "admin-bot" EXPECTED_TOOLS = [ "get_time", "quantum_flux_analyzer", "synth_patterns", "synthetic_data_generator", ] def _refresh_token() -> str: """Refresh the OAuth token and return the access token string. Returns: The access token string. Raises: RuntimeError: If the token refresh fails. """ result = subprocess.run( ["bash", TOKEN_REFRESH_SCRIPT, CLIENT_NAME], capture_output=True, text=True, cwd=str(PROJECT_ROOT), ) if result.returncode != 0: raise RuntimeError(f"Token refresh failed (exit {result.returncode}):\n{result.stderr}") with open(TOKEN_FILE) as f: token_data = json.load(f) access_token = token_data.get("access_token") if not access_token: raise RuntimeError("No access_token in token file after refresh") return access_token def _build_headers( token: str, session_id: str | None = None, ) -> dict[str, str]: """Build HTTP headers for MCP requests.""" headers = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Authorization": f"Bearer {token}", } if session_id: headers["mcp-session-id"] = session_id return headers def _parse_response( raw: str, content_type: str, ) -> dict[str, Any]: """Parse a response that may be JSON or SSE format. Returns: Parsed JSON dict from the response body. """ if "text/event-stream" in content_type: for line in raw.strip().split("\n"): if line.startswith("data: "): return json.loads(line[6:]) raise ValueError("No valid JSON data line found in SSE response") return json.loads(raw) def _send_request( payload: dict[str, Any], token: str, session_id: str | None = None, ) -> tuple[dict[str, Any], dict[str, str]]: """Send a JSON-RPC request to the virtual MCP endpoint. Returns: Tuple of (parsed_response_body, response_headers_dict). """ headers = _build_headers(token, session_id) data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( VIRTUAL_SERVER_ENDPOINT, data=data, headers=headers, method="POST", ) try: with urllib.request.urlopen(req, timeout=30) as resp: raw = resp.read().decode("utf-8") content_type = resp.headers.get("content-type", "") resp_headers = {k.lower(): v for k, v in resp.headers.items()} resp_headers["_status"] = str(resp.status) parsed = _parse_response(raw, content_type) return parsed, resp_headers except urllib.error.HTTPError as e: error_body = e.read().decode("utf-8") content_type = e.headers.get("content-type", "") if e.headers else "" resp_headers = {"_status": str(e.code)} if e.headers: resp_headers.update({k.lower(): v for k, v in e.headers.items()}) try: parsed = _parse_response(error_body, content_type) except (json.JSONDecodeError, ValueError): parsed = {"raw_error": error_body, "http_code": e.code} return parsed, resp_headers def _send_raw_http( method: str, token: str, session_id: str | None = None, body: bytes | None = None, ) -> tuple[int, str, dict[str, str]]: """Send a raw HTTP request (GET/DELETE/POST) and return status, body, headers. Returns: Tuple of (http_status_code, response_body, response_headers_dict). """ headers = _build_headers(token, session_id) if method == "GET": headers["Accept"] = "text/event-stream" req = urllib.request.Request( VIRTUAL_SERVER_ENDPOINT, data=body, headers=headers, method=method, ) try: with urllib.request.urlopen(req, timeout=30) as resp: raw = resp.read().decode("utf-8") resp_headers = {k.lower(): v for k, v in resp.headers.items()} return resp.status, raw, resp_headers except urllib.error.HTTPError as e: error_body = e.read().decode("utf-8") resp_headers = {} if e.headers: resp_headers = {k.lower(): v for k, v in e.headers.items()} return e.code, error_body, resp_headers class VirtualMCPProtocolTests: """E2E test suite for the Virtual MCP Server protocol.""" def __init__(self) -> None: self._token: str = "" self._session_id: str | None = None self._request_id: int = 0 self._results: list[tuple[str, bool, str]] = [] def _next_id(self) -> int: self._request_id += 1 return self._request_id def _record( self, name: str, passed: bool, detail: str = "", ) -> None: self._results.append((name, passed, detail)) status = "PASS" if passed else "FAIL" msg = f" [{status}] {name}" if detail: msg += f" -- {detail}" print(msg) def setup(self) -> None: """Refresh the token before running tests.""" print("Refreshing OAuth token...") self._token = _refresh_token() print("Token obtained successfully.\n") # ------------------------------------------------------------------ # Test cases # ------------------------------------------------------------------ def test_01_initialize(self) -> None: """Verify initialize returns capabilities and session ID.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "e2e-test-client", "version": "1.0.0"}, }, } body, headers = _send_request(payload, self._token) try: result = body.get("result", {}) assert "protocolVersion" in result, "Missing protocolVersion" assert result["protocolVersion"] == "2025-11-25", ( f"Expected negotiated version '2025-11-25', got '{result['protocolVersion']}'" ) caps = result.get("capabilities", {}) assert "tools" in caps, "Missing tools capability" assert "resources" in caps, "Missing resources capability" assert "prompts" in caps, "Missing prompts capability" assert "serverInfo" in result, "Missing serverInfo" session_id = headers.get("mcp-session-id", "") assert session_id.startswith("vs-"), ( f"Session ID should start with 'vs-', got: {session_id}" ) self._session_id = session_id self._record("initialize", True) except AssertionError as e: self._record("initialize", False, str(e)) def test_01a_initialize_version_negotiation(self) -> None: """Verify server echoes back supported protocol version.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "e2e-test-client", "version": "1.0.0"}, }, } body, _ = _send_request(payload, self._token) try: result = body.get("result", {}) assert result.get("protocolVersion") == "2024-11-05", ( f"Expected '2024-11-05' echoed back, got '{result.get('protocolVersion')}'" ) self._record("initialize version negotiation (old)", True) except AssertionError as e: self._record("initialize version negotiation (old)", False, str(e)) def test_01b_initialize_version_unsupported(self) -> None: """Verify server returns its latest version for unsupported client version.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "initialize", "params": { "protocolVersion": "9999-01-01", "capabilities": {}, "clientInfo": {"name": "e2e-test-client", "version": "1.0.0"}, }, } body, _ = _send_request(payload, self._token) try: result = body.get("result", {}) version = result.get("protocolVersion", "") assert version == "2025-11-25", ( f"Expected server's latest version '2025-11-25', got '{version}'" ) self._record("initialize version negotiation (unsupported)", True) except AssertionError as e: self._record("initialize version negotiation (unsupported)", False, str(e)) def test_01c_notifications_initialized_returns_202(self) -> None: """Verify notifications/initialized returns HTTP 202 with no body.""" payload = { "jsonrpc": "2.0", "method": "notifications/initialized", } data = json.dumps(payload).encode("utf-8") status, body, _ = _send_raw_http("POST", self._token, self._session_id, data) try: assert status == 202, f"Expected HTTP 202 Accepted, got {status}" assert body.strip() == "", f"Expected empty body for 202, got: '{body[:100]}'" self._record("notifications/initialized -> 202", True) except AssertionError as e: self._record("notifications/initialized -> 202", False, str(e)) def test_01d_get_returns_405(self) -> None: """Verify HTTP GET returns 405 Method Not Allowed (no SSE support).""" status, _, _ = _send_raw_http("GET", self._token, self._session_id) try: assert status == 405, f"Expected HTTP 405 for GET, got {status}" self._record("GET -> 405 Method Not Allowed", True) except AssertionError as e: self._record("GET -> 405 Method Not Allowed", False, str(e)) def test_01e_delete_returns_405(self) -> None: """Verify HTTP DELETE returns 405 Method Not Allowed.""" status, _, _ = _send_raw_http("DELETE", self._token, self._session_id) try: assert status == 405, f"Expected HTTP 405 for DELETE, got {status}" self._record("DELETE -> 405 Method Not Allowed", True) except AssertionError as e: self._record("DELETE -> 405 Method Not Allowed", False, str(e)) def test_02_ping(self) -> None: """Verify ping returns empty result.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "ping", } body, _ = _send_request(payload, self._token, self._session_id) try: assert "result" in body, f"No result key in response: {body}" assert body["result"] == {}, f"Expected empty result, got: {body['result']}" self._record("ping", True) except AssertionError as e: self._record("ping", False, str(e)) def test_03_tools_list(self) -> None: """Verify tools/list returns exactly 4 expected tools.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "tools/list", } body, _ = _send_request(payload, self._token, self._session_id) try: result = body.get("result", {}) tools = result.get("tools", []) tool_names = sorted([t["name"] for t in tools]) assert tool_names == sorted(EXPECTED_TOOLS), ( f"Expected tools {sorted(EXPECTED_TOOLS)}, got {tool_names}" ) for tool in tools: assert "inputSchema" in tool, f"Tool '{tool['name']}' missing inputSchema key" self._record("tools/list", True, f"{len(tools)} tools found") except (AssertionError, KeyError) as e: self._record("tools/list", False, str(e)) def test_04_call_get_time(self) -> None: """Call get_time with timezone=UTC and verify response.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "tools/call", "params": { "name": "get_time", "arguments": {"timezone": "UTC"}, }, } body, _ = _send_request(payload, self._token, self._session_id) try: result = body.get("result", {}) content = result.get("content", []) assert len(content) > 0, "Expected non-empty content array" assert content[0].get("type") == "text", ( f"Expected type 'text', got '{content[0].get('type')}'" ) assert content[0].get("text"), "Expected non-empty text" self._record("tools/call get_time", True) except (AssertionError, KeyError, IndexError) as e: self._record("tools/call get_time", False, str(e)) def test_05_call_quantum_flux_analyzer(self) -> None: """Call quantum_flux_analyzer with energy_level=7.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "tools/call", "params": { "name": "quantum_flux_analyzer", "arguments": {"energy_level": 7}, }, } body, _ = _send_request(payload, self._token, self._session_id) try: result = body.get("result", {}) content = result.get("content", []) assert len(content) > 0, "Expected non-empty content array" assert content[0].get("type") == "text", ( f"Expected type 'text', got '{content[0].get('type')}'" ) assert content[0].get("text"), "Expected non-empty text" self._record("tools/call quantum_flux_analyzer", True) except (AssertionError, KeyError, IndexError) as e: self._record("tools/call quantum_flux_analyzer", False, str(e)) def test_06_call_synth_patterns(self) -> None: """Call synth_patterns with input_patterns=["a","b"].""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "tools/call", "params": { "name": "synth_patterns", "arguments": {"input_patterns": ["a", "b"]}, }, } body, _ = _send_request(payload, self._token, self._session_id) try: result = body.get("result", {}) content = result.get("content", []) assert len(content) > 0, "Expected non-empty content array" assert content[0].get("type") == "text", ( f"Expected type 'text', got '{content[0].get('type')}'" ) assert content[0].get("text"), "Expected non-empty text" self._record("tools/call synth_patterns", True) except (AssertionError, KeyError, IndexError) as e: self._record("tools/call synth_patterns", False, str(e)) def test_07_call_synthetic_data_generator(self) -> None: """Call synthetic_data_generator with schema and record_count.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "tools/call", "params": { "name": "synthetic_data_generator", "arguments": { "schema": {"name": "string"}, "record_count": 2, }, }, } body, _ = _send_request(payload, self._token, self._session_id) try: result = body.get("result", {}) content = result.get("content", []) assert len(content) > 0, "Expected non-empty content array" assert content[0].get("type") == "text", ( f"Expected type 'text', got '{content[0].get('type')}'" ) assert content[0].get("text"), "Expected non-empty text" self._record("tools/call synthetic_data_generator", True) except (AssertionError, KeyError, IndexError) as e: self._record("tools/call synthetic_data_generator", False, str(e)) def test_08_resources_list(self) -> None: """Verify resources/list returns a response with resources key.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "resources/list", } body, _ = _send_request(payload, self._token, self._session_id) try: result = body.get("result", {}) assert "resources" in result, f"Expected 'resources' key in result, got: {result}" self._record("resources/list", True) except AssertionError as e: self._record("resources/list", False, str(e)) def test_09_resources_read_error(self) -> None: """Verify resources/read for non-existent resource returns error.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "resources/read", "params": {"uri": "config://app"}, } body, _ = _send_request(payload, self._token, self._session_id) try: assert "error" in body, f"Expected error response, got: {body}" self._record("resources/read error", True) except AssertionError as e: self._record("resources/read error", False, str(e)) def test_10_prompts_list(self) -> None: """Verify prompts/list returns a response with prompts key.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "prompts/list", } body, _ = _send_request(payload, self._token, self._session_id) try: result = body.get("result", {}) assert "prompts" in result, f"Expected 'prompts' key in result, got: {result}" self._record("prompts/list", True) except AssertionError as e: self._record("prompts/list", False, str(e)) def test_11_prompts_get_error(self) -> None: """Verify prompts/get for non-existent prompt returns error.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "prompts/get", "params": {"name": "system_prompt_for_agent"}, } body, _ = _send_request(payload, self._token, self._session_id) try: assert "error" in body, f"Expected error response, got: {body}" self._record("prompts/get error", True) except AssertionError as e: self._record("prompts/get error", False, str(e)) def test_12_error_nonexistent_tool(self) -> None: """Verify calling a non-existent tool returns an error.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "tools/call", "params": { "name": "nonexistent_tool", "arguments": {}, }, } body, _ = _send_request(payload, self._token, self._session_id) try: assert "error" in body, f"Expected error for nonexistent tool, got: {body}" self._record("error: non-existent tool", True) except AssertionError as e: self._record("error: non-existent tool", False, str(e)) def test_13_error_unknown_method(self) -> None: """Verify sending an unknown method returns an error.""" payload = { "jsonrpc": "2.0", "id": self._next_id(), "method": "unknown/method", } body, _ = _send_request(payload, self._token, self._session_id) try: assert "error" in body, f"Expected error for unknown method, got: {body}" self._record("error: unknown method", True) except AssertionError as e: self._record("error: unknown method", False, str(e)) # ------------------------------------------------------------------ # Runner # ------------------------------------------------------------------ def run_all(self) -> bool: """Run all tests and return True if all passed.""" print("=" * 60) print("Virtual MCP Protocol E2E Tests") print("=" * 60) print(f"Endpoint: {VIRTUAL_SERVER_ENDPOINT}") print() try: self.setup() except Exception as e: print(f"SETUP FAILED: {e}") return False test_methods = sorted( [m for m in dir(self) if m.startswith("test_")], ) start = time.time() for method_name in test_methods: method = getattr(self, method_name) try: method() except Exception as e: test_label = method_name.replace("test_", "").lstrip("0123456789_") self._record(test_label, False, f"Unhandled exception: {e}") elapsed = time.time() - start # Summary passed = sum(1 for _, ok, _ in self._results if ok) failed = sum(1 for _, ok, _ in self._results if not ok) total = len(self._results) print() print("-" * 60) print(f"Results: {passed}/{total} passed, {failed} failed ({elapsed:.1f}s)") print("-" * 60) if failed > 0: print("\nFailed tests:") for name, ok, detail in self._results: if not ok: print(f" - {name}: {detail}") return failed == 0 def main() -> None: suite = VirtualMCPProtocolTests() success = suite.run_all() sys.exit(0 if success else 1) if __name__ == "__main__": main() ================================================ FILE: tests/e2e/test_virtual_mcp_stress.py ================================================ """ MCP Virtual Server stress tests. Runs concurrent workloads against the virtual MCP server endpoint to validate behavior under load. Measures throughput, latency percentiles, and error rates across multiple scenarios. Usage: python3 tests/e2e/test_virtual_mcp_stress.py """ import json import logging import random import subprocess import sys import threading import time import urllib.error import urllib.request from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) PROJECT_ROOT = "/home/ubuntu/mcp-gateway-registry-MAIN" TOKEN_SCRIPT = f"{PROJECT_ROOT}/scripts/refresh_m2m_token.sh" TOKEN_FILE = f"{PROJECT_ROOT}/.oauth-tokens/admin-bot-token.json" MCP_ENDPOINT = "http://localhost/virtual/e2e-multi-backend/mcp" CLIENT_NAME = "admin-bot" NUM_THREADS = 20 REQUESTS_PER_THREAD = 50 SESSION_STORM_THREADS = 10 SESSION_STORM_CALLS = 10 ERROR_RATE_THRESHOLD = 10.0 _request_id_counter = 0 _request_id_lock = threading.Lock() def _next_request_id() -> int: """Return a globally unique, thread-safe request ID.""" global _request_id_counter with _request_id_lock: _request_id_counter += 1 return _request_id_counter def _refresh_token() -> str: """Refresh the OAuth token and return the access_token string.""" subprocess.run( ["bash", TOKEN_SCRIPT, CLIENT_NAME], check=True, capture_output=True, ) with open(TOKEN_FILE) as f: data = json.load(f) token = data["access_token"] logger.info("Token refreshed successfully (length=%d)", len(token)) return token def _parse_sse_response(body: str) -> dict[str, Any] | None: """Parse an SSE response body, extracting the JSON from data: lines.""" for line in body.splitlines(): stripped = line.strip() if stripped.startswith("data:"): payload = stripped[len("data:") :].strip() if payload: return json.loads(payload) return None def _parse_response(body: str) -> dict[str, Any] | None: """Parse either plain JSON or SSE response body.""" body = body.strip() if not body: return None # Try plain JSON first if body.startswith("{"): return json.loads(body) # Try SSE return _parse_sse_response(body) def _send_request( payload: dict[str, Any], token: str, session_id: str | None = None, timeout: float = 30.0, ) -> tuple[dict[str, Any] | None, dict[str, str]]: """Send an MCP JSON-RPC request and return (parsed_body, response_headers).""" data = json.dumps(payload).encode("utf-8") req = urllib.request.Request( MCP_ENDPOINT, data=data, method="POST", ) req.add_header("Content-Type", "application/json") req.add_header("Accept", "application/json, text/event-stream") req.add_header("Authorization", f"Bearer {token}") if session_id: req.add_header("Mcp-Session-Id", session_id) resp = urllib.request.urlopen(req, timeout=timeout) headers = {k.lower(): v for k, v in resp.getheaders()} body = resp.read().decode("utf-8") parsed = _parse_response(body) return parsed, headers def _initialize_session(token: str) -> str: """Perform an MCP initialize handshake and return the session ID.""" init_payload = { "jsonrpc": "2.0", "id": _next_request_id(), "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "stress-test", "version": "1.0.0"}, }, } _, headers = _send_request(init_payload, token) session_id = headers.get("mcp-session-id", "") if not session_id: raise RuntimeError("No Mcp-Session-Id header in initialize response") logger.info("Session initialized: %s", session_id) # Send initialized notification (no id field) notif_payload = { "jsonrpc": "2.0", "method": "notifications/initialized", "params": {}, } _send_request(notif_payload, token, session_id=session_id) return session_id def _build_payload(method: str) -> dict[str, Any]: """Build a JSON-RPC payload for the given method shorthand.""" rid = _next_request_id() if method == "ping": return {"jsonrpc": "2.0", "id": rid, "method": "ping"} elif method == "tools/list": return {"jsonrpc": "2.0", "id": rid, "method": "tools/list"} elif method == "tools/call_get_time": return { "jsonrpc": "2.0", "id": rid, "method": "tools/call", "params": {"name": "get_time", "arguments": {"timezone": "UTC"}}, } elif method == "tools/call_quantum": return { "jsonrpc": "2.0", "id": rid, "method": "tools/call", "params": {"name": "quantum_flux_analyzer", "arguments": {"energy_level": 5}}, } elif method == "resources/list": return {"jsonrpc": "2.0", "id": rid, "method": "resources/list"} elif method == "prompts/list": return {"jsonrpc": "2.0", "id": rid, "method": "prompts/list"} else: raise ValueError(f"Unknown method: {method}") class _StressResult: """Thread-safe accumulator for stress test results.""" def __init__(self) -> None: self._lock = threading.Lock() self.successes: int = 0 self.failures: int = 0 self.latencies: list[float] = [] self.errors: list[str] = [] def record_success(self, latency_ms: float) -> None: with self._lock: self.successes += 1 self.latencies.append(latency_ms) def record_failure(self, error: str) -> None: with self._lock: self.failures += 1 self.errors.append(error) @property def total(self) -> int: return self.successes + self.failures @property def error_rate(self) -> float: if self.total == 0: return 0.0 return (self.failures / self.total) * 100.0 def _print_scenario_report(name: str, result: _StressResult, duration: float) -> None: """Print a formatted report for a single scenario.""" throughput = result.total / duration if duration > 0 else 0.0 p50 = p95 = p99 = max_lat = 0.0 if result.latencies: sorted_lat = sorted(result.latencies) p50 = _percentile(sorted_lat, 50) p95 = _percentile(sorted_lat, 95) p99 = _percentile(sorted_lat, 99) max_lat = sorted_lat[-1] print(f"\n=== Scenario: {name} ===") print(f"Total requests: {result.total}") print(f"Successful: {result.successes}") print(f"Failed: {result.failures}") print(f"Error rate: {result.error_rate:.1f}%") print(f"Duration: {duration:.1f}s") print(f"Throughput: {throughput:.1f} req/s") print(f"Latency (ms): p50={p50:.1f} p95={p95:.1f} p99={p99:.1f} max={max_lat:.1f}") if result.errors: unique_errors = {} for e in result.errors: short = e[:120] unique_errors[short] = unique_errors.get(short, 0) + 1 print(f"Error summary ({len(result.errors)} total):") for err, count in sorted(unique_errors.items(), key=lambda x: -x[1])[:5]: print(f" [{count}x] {err}") def _percentile(sorted_data: list[float], pct: float) -> float: """Compute a percentile from sorted data.""" if not sorted_data: return 0.0 k = (len(sorted_data) - 1) * (pct / 100.0) f = int(k) c = f + 1 if c >= len(sorted_data): return sorted_data[-1] d = k - f return sorted_data[f] + d * (sorted_data[c] - sorted_data[f]) def _worker_repeated_requests( method: str, token: str, session_id: str, count: int, result: _StressResult, ) -> None: """Worker function: send `count` requests of a given method.""" for _ in range(count): payload = _build_payload(method) start = time.perf_counter() try: _send_request(payload, token, session_id=session_id) elapsed_ms = (time.perf_counter() - start) * 1000.0 result.record_success(elapsed_ms) except Exception as exc: elapsed_ms = (time.perf_counter() - start) * 1000.0 result.record_failure(str(exc)) def _worker_mixed_requests( token: str, session_id: str, count: int, result: _StressResult, ) -> None: """Worker function: send `count` requests with random method mix.""" methods = [ "ping", "tools/list", "tools/call_get_time", "tools/call_quantum", "resources/list", "prompts/list", ] for _ in range(count): method = random.choice(methods) payload = _build_payload(method) start = time.perf_counter() try: _send_request(payload, token, session_id=session_id) elapsed_ms = (time.perf_counter() - start) * 1000.0 result.record_success(elapsed_ms) except Exception as exc: elapsed_ms = (time.perf_counter() - start) * 1000.0 result.record_failure(str(exc)) def _worker_session_storm( token: str, calls_per_session: int, result: _StressResult, ) -> None: """Worker function: create a new session, then make tool calls on it.""" try: sid = _initialize_session(token) except Exception as exc: # Count the initialize failure plus all planned calls as failures for _ in range(calls_per_session + 1): result.record_failure(f"session init: {exc}") return for _ in range(calls_per_session): payload = _build_payload("tools/call_get_time") start = time.perf_counter() try: _send_request(payload, token, session_id=sid) elapsed_ms = (time.perf_counter() - start) * 1000.0 result.record_success(elapsed_ms) except Exception as exc: elapsed_ms = (time.perf_counter() - start) * 1000.0 result.record_failure(str(exc)) def _run_scenario_concurrent( name: str, method: str, token: str, session_id: str, num_threads: int = NUM_THREADS, requests_per_thread: int = REQUESTS_PER_THREAD, ) -> _StressResult: """Run a scenario where all threads call the same method.""" result = _StressResult() start = time.perf_counter() with ThreadPoolExecutor(max_workers=num_threads) as pool: futures = [ pool.submit( _worker_repeated_requests, method, token, session_id, requests_per_thread, result, ) for _ in range(num_threads) ] for f in as_completed(futures): f.result() # propagate exceptions from workers duration = time.perf_counter() - start _print_scenario_report(name, result, duration) return result def _run_scenario_mixed( name: str, token: str, session_id: str, num_threads: int = NUM_THREADS, requests_per_thread: int = REQUESTS_PER_THREAD, ) -> _StressResult: """Run a mixed-workload scenario.""" result = _StressResult() start = time.perf_counter() with ThreadPoolExecutor(max_workers=num_threads) as pool: futures = [ pool.submit( _worker_mixed_requests, token, session_id, requests_per_thread, result, ) for _ in range(num_threads) ] for f in as_completed(futures): f.result() duration = time.perf_counter() - start _print_scenario_report(name, result, duration) return result def _run_scenario_session_storm( name: str, token: str, num_threads: int = SESSION_STORM_THREADS, calls_per_session: int = SESSION_STORM_CALLS, ) -> _StressResult: """Run the session-storm scenario: each thread creates its own session.""" result = _StressResult() start = time.perf_counter() with ThreadPoolExecutor(max_workers=num_threads) as pool: futures = [ pool.submit( _worker_session_storm, token, calls_per_session, result, ) for _ in range(num_threads) ] for f in as_completed(futures): f.result() duration = time.perf_counter() - start _print_scenario_report(name, result, duration) return result def main() -> int: """Run all stress test scenarios and return 0 if all pass, 1 otherwise.""" print("=" * 60) print("MCP Virtual Server Stress Tests") print("=" * 60) # Refresh token logger.info("Refreshing OAuth token...") token = _refresh_token() # Initialize a shared session for scenarios 1-3 logger.info("Initializing shared MCP session...") session_id = _initialize_session(token) results: list[tuple[str, _StressResult]] = [] # Scenario 1: Concurrent tools/list logger.info("Starting scenario: Concurrent tools/list") r1 = _run_scenario_concurrent( "Concurrent tools/list", "tools/list", token, session_id, ) results.append(("Concurrent tools/list", r1)) # Refresh token between scenarios to avoid expiry token = _refresh_token() # Scenario 2: Concurrent tools/call (get_time) logger.info("Starting scenario: Concurrent tools/call (get_time)") r2 = _run_scenario_concurrent( "Concurrent tools/call (get_time)", "tools/call_get_time", token, session_id, ) results.append(("Concurrent tools/call (get_time)", r2)) # Refresh token token = _refresh_token() # Scenario 3: Mixed workload logger.info("Starting scenario: Mixed workload") r3 = _run_scenario_mixed( "Mixed workload", token, session_id, ) results.append(("Mixed workload", r3)) # Refresh token token = _refresh_token() # Scenario 4: Session storm logger.info("Starting scenario: Session storm") r4 = _run_scenario_session_storm( "Session storm", token, ) results.append(("Session storm", r4)) # Final summary print("\n" + "=" * 60) print("SUMMARY") print("=" * 60) all_passed = True for name, result in results: status = "PASS" if result.error_rate < ERROR_RATE_THRESHOLD else "FAIL" if status == "FAIL": all_passed = False print(f" [{status}] {name}: error_rate={result.error_rate:.1f}%") if all_passed: print("\nAll scenarios PASSED (error rate < 10%)") return 0 else: print("\nSome scenarios FAILED (error rate >= 10%)") return 1 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: tests/e2e_agent_skills_test.py ================================================ #!/usr/bin/env python3 """ End-to-End Test Script for Agent Skills API. This script exercises all Agent Skills related API endpoints using the RegistryClient and produces a report at the end. Usage: # Run with defaults (localhost, .token) uv run python tests/e2e_agent_skills_test.py # Run with custom registry URL uv run python tests/e2e_agent_skills_test.py --registry-url https://myregistry.com # Run with custom token file uv run python tests/e2e_agent_skills_test.py --token-file /path/to/token # Run with debug output uv run python tests/e2e_agent_skills_test.py --debug """ import argparse import json import logging import sys import time from dataclasses import dataclass from datetime import datetime from enum import Enum from pathlib import Path from typing import ( Any, ) # Add api directory to path for imports sys.path.insert(0, str(Path(__file__).parent.parent / "api")) from registry_client import ( RegistryClient, SkillRegistrationRequest, ) # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) # Test Constants TEST_SKILL_MD_URL = "https://github.com/anthropics/skills/blob/main/skills/mcp-builder/SKILL.md" TEST_SKILL_NAME = "e2e-test-mcp-builder" TEST_SKILL_DESCRIPTION = "E2E Test: Build and configure MCP servers" TEST_SKILL_TAGS = ["e2e-test", "mcp", "builder", "automation"] class TestStatus(Enum): """Test result status.""" PASSED = "PASSED" FAILED = "FAILED" SKIPPED = "SKIPPED" @dataclass class TestResult: """Individual test result.""" name: str status: TestStatus duration_ms: float message: str = "" details: dict[str, Any] | None = None class AgentSkillsE2ETest: """End-to-end test runner for Agent Skills API using RegistryClient.""" def __init__( self, registry_url: str, token: str, ): """Initialize the test runner. Args: registry_url: Base URL of the registry token: JWT authentication token """ self.client = RegistryClient(registry_url, token) self.results: list[TestResult] = [] self.skill_path: str | None = None def _record_result( self, name: str, status: TestStatus, duration_ms: float, message: str = "", details: dict[str, Any] | None = None, ) -> None: """Record a test result.""" result = TestResult( name=name, status=status, duration_ms=duration_ms, message=message, details=details, ) self.results.append(result) status_str = f"[{status.value}]" logger.info(f"{status_str} {name}: {message} ({duration_ms:.2f}ms)") def test_register_skill(self) -> bool: """Test registering a new skill.""" test_name = "Register Skill" start_time = time.time() try: request = SkillRegistrationRequest( name=TEST_SKILL_NAME, skill_md_url=TEST_SKILL_MD_URL, description=TEST_SKILL_DESCRIPTION, tags=TEST_SKILL_TAGS, visibility="public", ) skill = self.client.register_skill(request) duration_ms = (time.time() - start_time) * 1000 self.skill_path = skill.path self._record_result( test_name, TestStatus.PASSED, duration_ms, f"Skill registered at {skill.path}", {"skill": skill.model_dump()}, ) return True except Exception as e: duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Exception: {str(e)}", ) return False def test_list_skills(self) -> bool: """Test listing skills.""" test_name = "List Skills" start_time = time.time() try: response = self.client.list_skills() duration_ms = (time.time() - start_time) * 1000 # Check if our test skill is in the list skill_names = [s.name for s in response.skills] has_test_skill = TEST_SKILL_NAME in skill_names if has_test_skill: self._record_result( test_name, TestStatus.PASSED, duration_ms, f"Found {len(response.skills)} skills, test skill present", {"total_count": response.total_count}, ) return True else: self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Test skill not found in {len(response.skills)} skills", ) return False except Exception as e: duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Exception: {str(e)}", ) return False def test_get_skill(self) -> bool: """Test getting skill details.""" test_name = "Get Skill Details" start_time = time.time() if not self.skill_path: self._record_result( test_name, TestStatus.SKIPPED, 0, "No skill path available", ) return False try: skill = self.client.get_skill(self.skill_path) duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.PASSED, duration_ms, f"Retrieved skill: {skill.name}", {"skill": skill.model_dump()}, ) return True except Exception as e: duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Exception: {str(e)}", ) return False def test_update_skill(self) -> bool: """Test updating skill.""" test_name = "Update Skill" start_time = time.time() if not self.skill_path: self._record_result( test_name, TestStatus.SKIPPED, 0, "No skill path available", ) return False try: # Note: PUT requires full request body with name and skill_md_url request = SkillRegistrationRequest( name=TEST_SKILL_NAME, skill_md_url=TEST_SKILL_MD_URL, description=f"{TEST_SKILL_DESCRIPTION} (updated)", tags=TEST_SKILL_TAGS + ["updated"], ) updated = self.client.update_skill(self.skill_path, request) duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.PASSED, duration_ms, "Skill updated successfully", {"skill": updated.model_dump()}, ) return True except Exception as e: duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Exception: {str(e)}", ) return False def test_disable_skill(self) -> bool: """Test disabling skill using toggle endpoint.""" test_name = "Disable Skill" start_time = time.time() if not self.skill_path: self._record_result( test_name, TestStatus.SKIPPED, 0, "No skill path available", ) return False try: response = self.client.toggle_skill(self.skill_path, enabled=False) duration_ms = (time.time() - start_time) * 1000 if not response.is_enabled: self._record_result( test_name, TestStatus.PASSED, duration_ms, "Skill disabled successfully", ) return True else: self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Skill still enabled: {response.is_enabled}", ) return False except Exception as e: duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Exception: {str(e)}", ) return False def test_enable_skill(self) -> bool: """Test enabling skill using toggle endpoint.""" test_name = "Enable Skill" start_time = time.time() if not self.skill_path: self._record_result( test_name, TestStatus.SKIPPED, 0, "No skill path available", ) return False try: response = self.client.toggle_skill(self.skill_path, enabled=True) duration_ms = (time.time() - start_time) * 1000 if response.is_enabled: self._record_result( test_name, TestStatus.PASSED, duration_ms, "Skill enabled successfully", ) return True else: self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Skill still disabled: {response.is_enabled}", ) return False except Exception as e: duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Exception: {str(e)}", ) return False def test_health_check(self) -> bool: """Test skill health check.""" test_name = "Health Check" start_time = time.time() if not self.skill_path: self._record_result( test_name, TestStatus.SKIPPED, 0, "No skill path available", ) return False try: response = self.client.check_skill_health(self.skill_path) duration_ms = (time.time() - start_time) * 1000 if response.healthy: self._record_result( test_name, TestStatus.PASSED, duration_ms, "SKILL.md is accessible", {"status_code": response.status_code}, ) return True else: self._record_result( test_name, TestStatus.FAILED, duration_ms, f"SKILL.md not accessible: {response.error}", ) return False except Exception as e: duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Exception: {str(e)}", ) return False def test_get_content(self) -> bool: """Test getting SKILL.md content.""" test_name = "Get SKILL.md Content" start_time = time.time() if not self.skill_path: self._record_result( test_name, TestStatus.SKIPPED, 0, "No skill path available", ) return False try: response = self.client.get_skill_content(self.skill_path) duration_ms = (time.time() - start_time) * 1000 content_len = len(response.content) if content_len > 0: self._record_result( test_name, TestStatus.PASSED, duration_ms, f"Retrieved {content_len} characters of content", ) return True else: self._record_result( test_name, TestStatus.FAILED, duration_ms, "Empty content returned", ) return False except Exception as e: duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Exception: {str(e)}", ) return False def test_rate_skill(self) -> bool: """Test rating a skill.""" test_name = "Rate Skill" start_time = time.time() if not self.skill_path: self._record_result( test_name, TestStatus.SKIPPED, 0, "No skill path available", ) return False try: response = self.client.rate_skill(self.skill_path, rating=5) duration_ms = (time.time() - start_time) * 1000 avg_rating = response.get("average_rating", 0) self._record_result( test_name, TestStatus.PASSED, duration_ms, f"Rated 5 stars, average: {avg_rating}", ) return True except Exception as e: duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Exception: {str(e)}", ) return False def test_get_rating(self) -> bool: """Test getting skill rating.""" test_name = "Get Rating" start_time = time.time() if not self.skill_path: self._record_result( test_name, TestStatus.SKIPPED, 0, "No skill path available", ) return False try: response = self.client.get_skill_rating(self.skill_path) duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.PASSED, duration_ms, f"Rating: {response.num_stars} stars", ) return True except Exception as e: duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Exception: {str(e)}", ) return False def test_search_skills(self) -> bool: """Test searching for skills.""" test_name = "Search Skills" start_time = time.time() try: response = self.client.search_skills(query="mcp builder") duration_ms = (time.time() - start_time) * 1000 if response.total_count > 0: self._record_result( test_name, TestStatus.PASSED, duration_ms, f"Found {response.total_count} matching skills", ) return True else: self._record_result( test_name, TestStatus.FAILED, duration_ms, "No matching skills found", ) return False except Exception as e: duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Exception: {str(e)}", ) return False def test_delete_skill(self) -> bool: """Test deleting skill (cleanup).""" test_name = "Delete Skill (Cleanup)" start_time = time.time() if not self.skill_path: self._record_result( test_name, TestStatus.SKIPPED, 0, "No skill path available", ) return False try: self.client.delete_skill(self.skill_path) duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.PASSED, duration_ms, "Skill deleted successfully", ) return True except Exception as e: duration_ms = (time.time() - start_time) * 1000 self._record_result( test_name, TestStatus.FAILED, duration_ms, f"Exception: {str(e)}", ) return False def run_all_tests(self) -> bool: """Run all tests in sequence.""" logger.info("=" * 60) logger.info("Starting Agent Skills E2E Tests") logger.info(f"Registry URL: {self.client.registry_url}") logger.info(f"Test Skill URL: {TEST_SKILL_MD_URL}") logger.info("=" * 60) # Run tests in order self.test_register_skill() self.test_list_skills() self.test_get_skill() self.test_update_skill() self.test_disable_skill() self.test_enable_skill() self.test_health_check() self.test_get_content() self.test_rate_skill() self.test_get_rating() self.test_search_skills() self.test_delete_skill() return self._print_report() def _print_report(self) -> bool: """Print test report and return success status.""" passed = sum(1 for r in self.results if r.status == TestStatus.PASSED) failed = sum(1 for r in self.results if r.status == TestStatus.FAILED) skipped = sum(1 for r in self.results if r.status == TestStatus.SKIPPED) total_time = sum(r.duration_ms for r in self.results) print("\n") print("=" * 70) print(" AGENT SKILLS E2E TEST REPORT") print("=" * 70) print(f" Registry URL: {self.client.registry_url}") print(f" Test Run: {datetime.now().isoformat()}") print("=" * 70) print("\n TEST RESULTS:") print(" " + "-" * 66) for result in self.results: if result.status == TestStatus.PASSED: status_color = "\033[92m" # Green elif result.status == TestStatus.FAILED: status_color = "\033[91m" # Red else: status_color = "\033[93m" # Yellow reset_color = "\033[0m" status_str = f"{status_color}[{result.status.value}]{reset_color}" print(f" {status_str} {result.name:35} {result.duration_ms:>10.2f}ms") if result.message: print(f" {result.message}") print(" " + "-" * 66) print("\n SUMMARY:") print(f" Total Tests: {len(self.results)}") print(f" \033[92mPassed:\033[0m {passed}") print(f" \033[91mFailed:\033[0m {failed}") print(f" \033[93mSkipped:\033[0m {skipped}") print(f" Total Time: {total_time:.2f}ms ({total_time / 1000:.2f}s)") if failed > 0: print(f"\n \033[91m*** {failed} TEST(S) FAILED ***\033[0m") else: print("\n \033[92m*** ALL TESTS PASSED ***\033[0m") print("=" * 70) print() return failed == 0 def _load_token( token_file: str, ) -> str: """Load JWT token from file. Args: token_file: Path to token file Returns: JWT token string Raises: FileNotFoundError: If token file not found ValueError: If token file is empty or invalid """ token_path = Path(token_file) if not token_path.exists(): raise FileNotFoundError(f"Token file not found: {token_file}") content = token_path.read_text().strip() if not content: raise ValueError(f"Token file is empty: {token_file}") # Handle JSON token files (like ingress.json or .token) if content.startswith("{"): try: data = json.loads(content) # Try different possible token field names at top level for key in ["access_token", "token", "jwt"]: if key in data: return data[key] # Check for nested tokens object (common format from auth endpoints) if "tokens" in data and isinstance(data["tokens"], dict): tokens = data["tokens"] for key in ["access_token", "token", "jwt"]: if key in tokens: return tokens[key] raise ValueError(f"No token field found in JSON file: {token_file}") except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON in token file: {e}") from e # Plain text token return content def main() -> int: """Main entry point.""" parser = argparse.ArgumentParser( description="End-to-End Test Script for Agent Skills API", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Run with defaults uv run python tests/e2e_agent_skills_test.py # Run with custom registry uv run python tests/e2e_agent_skills_test.py --registry-url https://myregistry.com # Run with debug output uv run python tests/e2e_agent_skills_test.py --debug """, ) parser.add_argument( "--registry-url", default="http://localhost", help="Registry base URL (default: http://localhost)", ) parser.add_argument( "--token-file", default=".token", help="Path to token file (default: .token)", ) parser.add_argument( "--debug", action="store_true", help="Enable debug logging", ) args = parser.parse_args() if args.debug: logging.getLogger().setLevel(logging.DEBUG) try: token = _load_token(args.token_file) logger.info(f"Loaded token from {args.token_file}") except (FileNotFoundError, ValueError) as e: logger.error(f"ERROR: {e}") return 1 test_runner = AgentSkillsE2ETest( registry_url=args.registry_url, token=token, ) success = test_runner.run_all_tests() return 0 if success else 1 if __name__ == "__main__": sys.exit(main()) ================================================ FILE: tests/fixtures/__init__.py ================================================ """ Test fixtures and factories for MCP Gateway Registry tests. This package provides: - Factory Boy factories for generating test data - Mock implementations for external dependencies - Helper functions for common test operations - Test constants and configuration """ ================================================ FILE: tests/fixtures/constants.py ================================================ """ Test constants for MCP Gateway Registry tests. This module defines constants used across test modules to ensure consistency. """ # Test Server Names TEST_SERVER_NAME_1: str = "com.example.test-server-1" TEST_SERVER_NAME_2: str = "com.example.test-server-2" TEST_SERVER_NAME_AUTH: str = "com.example.auth-server" TEST_SERVER_NAME_TIME: str = "com.example.currenttime" # Test URLs TEST_SERVER_URL_1: str = "http://localhost:8080/test-server-1" TEST_SERVER_URL_2: str = "http://localhost:8080/test-server-2" TEST_SERVER_URL_AUTH: str = "http://localhost:8080/auth-server" # Test Agent Names TEST_AGENT_NAME_1: str = "test-agent-1" TEST_AGENT_NAME_2: str = "test-agent-2" TEST_AGENT_PATH_1: str = "/agents/test-agent-1" TEST_AGENT_PATH_2: str = "/agents/test-agent-2" # Test Agent URLs TEST_AGENT_URL_1: str = "http://localhost:9000/agent-1" TEST_AGENT_URL_2: str = "http://localhost:9000/agent-2" # Test User Information TEST_USERNAME: str = "testuser" TEST_USER_EMAIL: str = "testuser@example.com" # Test Authentication TEST_JWT_SECRET: str = "test-secret-key-for-jwt-tokens" TEST_SESSION_COOKIE_NAME: str = "mcp_gateway_session" # Test Groups and Scopes TEST_USER_GROUPS: list[str] = ["users", "developers"] TEST_ADMIN_GROUPS: list[str] = ["admins", "users"] TEST_USER_SCOPES: list[str] = ["read:servers", "read:agents"] TEST_ADMIN_SCOPES: list[str] = ["read:servers", "write:servers", "read:agents", "write:agents"] # Test Tags TEST_TAGS_DATA: list[str] = ["data", "analytics", "ml"] TEST_TAGS_WEB: list[str] = ["web", "api", "rest"] TEST_TAGS_AUTH: list[str] = ["auth", "security", "oauth"] # Test Embeddings TEST_EMBEDDING_DIM: int = 384 TEST_MODEL_NAME: str = "all-MiniLM-L6-v2" # Test Search TEST_SEARCH_QUERY: str = "data processing server" TEST_SEARCH_LIMIT: int = 10 # Test Tool Information TEST_TOOL_NAME_1: str = "get_data" TEST_TOOL_NAME_2: str = "process_data" TEST_TOOL_DESCRIPTION_1: str = "Retrieve data from source" TEST_TOOL_DESCRIPTION_2: str = "Process and transform data" # Test Skill Information TEST_SKILL_ID_1: str = "data-retrieval" TEST_SKILL_ID_2: str = "data-processing" TEST_SKILL_NAME_1: str = "Data Retrieval" TEST_SKILL_NAME_2: str = "Data Processing" # Test Repository TEST_REPO_URL: str = "https://github.com/example/test-server" TEST_REPO_SOURCE: str = "github" # Test Package TEST_PACKAGE_IDENTIFIER: str = "@example/test-server" TEST_PACKAGE_VERSION: str = "1.0.0" TEST_PACKAGE_REGISTRY_TYPE: str = "npm" # Test Pagination DEFAULT_PAGE_SIZE: int = 20 TEST_CURSOR: str = "test-cursor-value" # Test Timeouts TEST_TIMEOUT_SHORT: int = 1 TEST_TIMEOUT_MEDIUM: int = 5 TEST_TIMEOUT_LONG: int = 30 # Test Ratings TEST_RATING_LOW: float = 2.5 TEST_RATING_MEDIUM: float = 3.5 TEST_RATING_HIGH: float = 4.5 TEST_RATING_MAX: float = 5.0 # Test Visibility VISIBILITY_PUBLIC: str = "public" VISIBILITY_PRIVATE: str = "private" VISIBILITY_GROUP: str = "group-restricted" # Test Trust Levels TRUST_UNVERIFIED: str = "unverified" TRUST_COMMUNITY: str = "community" TRUST_VERIFIED: str = "verified" TRUST_TRUSTED: str = "trusted" # Test Protocol Versions PROTOCOL_VERSION_1_0: str = "1.0" PROTOCOL_VERSION_2024_11_05: str = "2024-11-05" # Test Transport Types TRANSPORT_STDIO: str = "stdio" TRANSPORT_HTTP: str = "streamable-http" TRANSPORT_SSE: str = "sse" # Test Security Schemes SECURITY_TYPE_BEARER: str = "http" SECURITY_TYPE_OAUTH2: str = "oauth2" SECURITY_TYPE_API_KEY: str = "apiKey" SECURITY_SCHEME_BEARER: str = "bearer" # Test Capabilities DEFAULT_CAPABILITIES: dict[str, bool] = {"streaming": False, "tools": True, "prompts": False} # Test MIME Types MIME_TEXT_PLAIN: str = "text/plain" MIME_APPLICATION_JSON: str = "application/json" MIME_TEXT_HTML: str = "text/html" # Test Status Codes HTTP_OK: int = 200 HTTP_CREATED: int = 201 HTTP_NO_CONTENT: int = 204 HTTP_BAD_REQUEST: int = 400 HTTP_UNAUTHORIZED: int = 401 HTTP_FORBIDDEN: int = 403 HTTP_NOT_FOUND: int = 404 HTTP_CONFLICT: int = 409 HTTP_INTERNAL_ERROR: int = 500 ================================================ FILE: tests/fixtures/factories.py ================================================ """ Factory Boy factories for generating test data. This module provides factories for creating test instances of domain models with realistic default data. """ import logging from datetime import UTC, datetime from typing import Any import factory from factory import fuzzy from registry.schemas import ( AgentCard, AgentInfo, Package, Repository, SecurityScheme, ServerDetail, Skill, StdioTransport, StreamableHttpTransport, ) from registry.schemas.agent_models import AgentProvider from tests.fixtures.constants import ( DEFAULT_CAPABILITIES, MIME_APPLICATION_JSON, MIME_TEXT_PLAIN, PROTOCOL_VERSION_1_0, TEST_PACKAGE_IDENTIFIER, TEST_PACKAGE_REGISTRY_TYPE, TEST_PACKAGE_VERSION, TEST_REPO_SOURCE, TEST_REPO_URL, TEST_SKILL_ID_1, TEST_SKILL_NAME_1, TEST_TAGS_DATA, TEST_TOOL_DESCRIPTION_1, TEST_TOOL_NAME_1, TRUST_UNVERIFIED, VISIBILITY_PUBLIC, ) logger = logging.getLogger(__name__) class RepositoryFactory(factory.Factory): """Factory for creating Repository instances.""" class Meta: model = Repository url = TEST_REPO_URL source = TEST_REPO_SOURCE id = factory.Sequence(lambda n: f"test-repo-{n}") subfolder = None class StdioTransportFactory(factory.Factory): """Factory for creating StdioTransport instances.""" class Meta: model = StdioTransport type = "stdio" command = "uvx" args = factory.LazyAttribute(lambda _: ["test-server"]) env = None class StreamableHttpTransportFactory(factory.Factory): """Factory for creating StreamableHttpTransport instances.""" class Meta: model = StreamableHttpTransport type = "streamable-http" url = factory.Sequence(lambda n: f"http://localhost:8080/server-{n}") headers = None class PackageFactory(factory.Factory): """Factory for creating Package instances.""" class Meta: model = Package registryType = TEST_PACKAGE_REGISTRY_TYPE identifier = TEST_PACKAGE_IDENTIFIER version = TEST_PACKAGE_VERSION registryBaseUrl = "https://registry.npmjs.org" transport = factory.LazyAttribute(lambda _: StdioTransportFactory().model_dump()) runtimeHint = "uvx" class ServerDetailFactory(factory.Factory): """Factory for creating ServerDetail instances.""" class Meta: model = ServerDetail name = factory.Sequence(lambda n: f"com.example.server-{n}") description = factory.Faker("sentence") version = fuzzy.FuzzyChoice(["1.0.0", "1.1.0", "2.0.0"]) title = factory.Faker("word") repository = factory.SubFactory(RepositoryFactory) websiteUrl = factory.Faker("url") packages = factory.LazyAttribute(lambda _: [PackageFactory()]) meta = None class SecuritySchemeFactory(factory.Factory): """Factory for creating SecurityScheme instances.""" class Meta: model = SecurityScheme type = "http" scheme = "bearer" in_ = None name = None bearer_format = "JWT" flows = None openid_connect_url = None class AgentProviderFactory(factory.Factory): """Factory for creating AgentProvider instances.""" class Meta: model = AgentProvider organization = factory.Faker("company") url = factory.Faker("url") class SkillFactory(factory.Factory): """Factory for creating Skill instances.""" class Meta: model = Skill id = factory.Sequence(lambda n: f"skill-{n}") name = factory.Faker("word") description = factory.Faker("sentence") tags = factory.LazyAttribute(lambda _: TEST_TAGS_DATA.copy()) examples = factory.LazyAttribute(lambda _: ["Example usage of this skill"]) input_modes = factory.LazyAttribute(lambda _: [MIME_TEXT_PLAIN]) output_modes = factory.LazyAttribute(lambda _: [MIME_TEXT_PLAIN, MIME_APPLICATION_JSON]) security = None class AgentCardFactory(factory.Factory): """Factory for creating AgentCard instances.""" class Meta: model = AgentCard # Required A2A fields protocol_version = PROTOCOL_VERSION_1_0 name = factory.Sequence(lambda n: f"test-agent-{n}") description = factory.Faker("sentence") url = factory.Sequence(lambda n: f"http://localhost:9000/agent-{n}") version = fuzzy.FuzzyChoice(["1.0", "1.1", "2.0"]) capabilities = factory.LazyAttribute(lambda _: DEFAULT_CAPABILITIES.copy()) default_input_modes = factory.LazyAttribute(lambda _: [MIME_TEXT_PLAIN]) default_output_modes = factory.LazyAttribute(lambda _: [MIME_TEXT_PLAIN]) skills = factory.LazyAttribute(lambda _: [SkillFactory()]) # Optional A2A fields preferred_transport = "JSONRPC" provider = factory.SubFactory(AgentProviderFactory) icon_url = factory.Faker("url") documentation_url = factory.Faker("url") security_schemes = factory.Dict({}) security = None supports_authenticated_extended_card = False metadata = factory.Dict({}) # MCP Gateway Registry extensions path = factory.Sequence(lambda n: f"/agents/test-agent-{n}") tags = factory.LazyAttribute(lambda _: TEST_TAGS_DATA.copy()) # Note: AgentCard model does not have a 'streaming' attribute. Streaming capability # should be accessed via capabilities.get("streaming", False). See bug documentation: # .scratchpad/fixes/registry/fix-agent-streaming-attribute.md is_enabled = True rating_details = factory.List([]) license = "MIT" # Registry metadata registered_at = factory.LazyFunction(lambda: datetime.now(UTC)) updated_at = factory.LazyFunction(lambda: datetime.now(UTC)) registered_by = factory.Faker("user_name") # Access control visibility = VISIBILITY_PUBLIC allowed_groups = factory.List([]) # Validation and trust signature = None trust_level = TRUST_UNVERIFIED class AgentInfoFactory(factory.Factory): """Factory for creating AgentInfo instances.""" class Meta: model = AgentInfo name = factory.Sequence(lambda n: f"test-agent-{n}") description = factory.Faker("sentence") path = factory.Sequence(lambda n: f"/agents/test-agent-{n}") url = factory.Sequence(lambda n: f"http://localhost:9000/agent-{n}") tags = factory.LazyAttribute(lambda _: TEST_TAGS_DATA.copy()) skills = factory.LazyAttribute(lambda _: [TEST_SKILL_NAME_1]) num_skills = 1 is_enabled = True provider = factory.Faker("company") streaming = False trust_level = TRUST_UNVERIFIED # Helper functions for creating multiple instances def create_server_with_tools( name: str | None = None, num_tools: int = 3, **kwargs: Any ) -> ServerDetail: """ Create a ServerDetail with multiple tools in metadata. Args: name: Server name (auto-generated if not provided) num_tools: Number of tools to create **kwargs: Additional ServerDetail attributes Returns: ServerDetail instance with tools in metadata """ server = ServerDetailFactory(name=name, **kwargs) # Add tools to metadata tools = [] for i in range(num_tools): tools.append( { "name": f"{TEST_TOOL_NAME_1}_{i}", "description": f"{TEST_TOOL_DESCRIPTION_1} {i}", "inputSchema": {"type": "object", "properties": {}}, } ) server.meta = {"tools": tools, "prompts": [], "resources": []} return server def create_agent_with_skills( name: str | None = None, num_skills: int = 3, **kwargs: Any ) -> AgentCard: """ Create an AgentCard with multiple skills. Args: name: Agent name (auto-generated if not provided) num_skills: Number of skills to create **kwargs: Additional AgentCard attributes Returns: AgentCard instance with multiple skills """ skills = [ SkillFactory(id=f"{TEST_SKILL_ID_1}_{i}", name=f"{TEST_SKILL_NAME_1} {i}") for i in range(num_skills) ] return AgentCardFactory(name=name, skills=skills, **kwargs) def create_multiple_servers(count: int = 5, **kwargs: Any) -> list[ServerDetail]: """ Create multiple ServerDetail instances. Args: count: Number of servers to create **kwargs: Additional ServerDetail attributes Returns: List of ServerDetail instances """ return [ServerDetailFactory(**kwargs) for _ in range(count)] def create_multiple_agents(count: int = 5, **kwargs: Any) -> list[AgentCard]: """ Create multiple AgentCard instances. Args: count: Number of agents to create **kwargs: Additional AgentCard attributes Returns: List of AgentCard instances """ return [AgentCardFactory(**kwargs) for _ in range(count)] def create_server_dict(name: str | None = None, **kwargs: Any) -> dict[str, Any]: """ Create a server dictionary (not a Pydantic model). Useful for testing JSON serialization/deserialization. Args: name: Server name **kwargs: Additional server attributes Returns: Server dictionary """ server = ServerDetailFactory(name=name, **kwargs) return server.model_dump(by_alias=True, exclude_none=True) def create_agent_dict(name: str | None = None, **kwargs: Any) -> dict[str, Any]: """ Create an agent dictionary (not a Pydantic model). Useful for testing JSON serialization/deserialization. Args: name: Agent name **kwargs: Additional agent attributes Returns: Agent dictionary """ agent = AgentCardFactory(name=name, **kwargs) return agent.model_dump(by_alias=True, exclude_none=True) ================================================ FILE: tests/fixtures/helpers.py ================================================ """ Test helper functions for MCP Gateway Registry tests. This module provides utility functions for common test operations. """ import json import tempfile from pathlib import Path from typing import Any from registry.schemas import AgentCard, ServerDetail def create_temp_directory() -> Path: """ Create a temporary directory for test files. Returns: Path to the temporary directory """ temp_dir = tempfile.mkdtemp() return Path(temp_dir) def write_json_file(file_path: Path, data: dict[str, Any]) -> None: """ Write data to a JSON file. Args: file_path: Path to the JSON file data: Dictionary to write as JSON """ with open(file_path, "w") as f: json.dump(data, f, indent=2, default=str) def read_json_file(file_path: Path) -> dict[str, Any]: """ Read data from a JSON file. Args: file_path: Path to the JSON file Returns: Dictionary loaded from JSON """ with open(file_path) as f: return json.load(f) def create_test_server_file( servers_dir: Path, server_name: str, server_data: dict[str, Any] ) -> Path: """ Create a server JSON file in the test servers directory. Args: servers_dir: Path to servers directory server_name: Name of the server server_data: Server data dictionary Returns: Path to the created server file """ servers_dir.mkdir(parents=True, exist_ok=True) server_file = servers_dir / f"{server_name}.json" write_json_file(server_file, server_data) return server_file def create_test_agent_file(agents_dir: Path, agent_name: str, agent_data: dict[str, Any]) -> Path: """ Create an agent JSON file in the test agents directory. Args: agents_dir: Path to agents directory agent_name: Name of the agent agent_data: Agent data dictionary Returns: Path to the created agent file """ agents_dir.mkdir(parents=True, exist_ok=True) agent_file = agents_dir / f"{agent_name}.json" write_json_file(agent_file, agent_data) return agent_file def assert_server_equals( actual: ServerDetail, expected: ServerDetail, check_meta: bool = False ) -> None: """ Assert that two ServerDetail objects are equal. Args: actual: Actual server detail expected: Expected server detail check_meta: Whether to check the _meta field """ assert actual.name == expected.name assert actual.description == expected.description assert actual.version == expected.version assert actual.title == expected.title if check_meta: assert actual.meta == expected.meta def assert_agent_equals( actual: AgentCard, expected: AgentCard, check_timestamps: bool = False ) -> None: """ Assert that two AgentCard objects are equal. Args: actual: Actual agent card expected: Expected agent card check_timestamps: Whether to check timestamp fields """ assert actual.name == expected.name assert actual.description == expected.description assert actual.url == expected.url assert actual.version == expected.version assert actual.path == expected.path if check_timestamps: assert actual.registered_at == expected.registered_at assert actual.updated_at == expected.updated_at def create_mock_jwt_payload( username: str, groups: list[str] | None = None, scopes: list[str] | None = None, extra_claims: dict[str, Any] | None = None, ) -> dict[str, Any]: """ Create a mock JWT payload for testing. Args: username: Username for the token groups: Optional list of groups scopes: Optional list of scopes extra_claims: Optional extra claims to add Returns: JWT payload dictionary """ payload = { "sub": username, "username": username, "token_use": "access", "iat": 1000000000, "exp": 2000000000, } if groups: payload["cognito:groups"] = groups payload["groups"] = groups if scopes: payload["scope"] = " ".join(scopes) if extra_claims: payload.update(extra_claims) return payload def create_test_state_file( state_path: Path, server_states: dict[str, dict[str, Any]] | None = None ) -> None: """ Create a server state JSON file for testing. Args: state_path: Path to the state file server_states: Dictionary mapping server names to their state data """ if server_states is None: server_states = {} write_json_file(state_path, server_states) def create_test_agent_state_file( state_path: Path, agent_states: dict[str, bool] | None = None ) -> None: """ Create an agent state JSON file for testing. Args: state_path: Path to the state file agent_states: Dictionary mapping agent paths to enabled status """ if agent_states is None: agent_states = {} write_json_file(state_path, agent_states) def normalize_text_for_comparison(text: str) -> str: """ Normalize text for comparison in tests. Removes extra whitespace and converts to lowercase. Args: text: Text to normalize Returns: Normalized text """ return " ".join(text.lower().split()) def extract_error_message(response_data: dict[str, Any]) -> str: """ Extract error message from API response. Args: response_data: Response data dictionary Returns: Error message string """ if isinstance(response_data, dict): return response_data.get("error") or response_data.get("detail") or "Unknown error" return str(response_data) def create_minimal_server_dict( name: str, description: str = "Test server", version: str = "1.0.0" ) -> dict[str, Any]: """ Create a minimal server dictionary for testing. Args: name: Server name description: Server description version: Server version Returns: Minimal server dictionary """ return {"name": name, "description": description, "version": version} def create_minimal_agent_dict( name: str, url: str, description: str = "Test agent", version: str = "1.0" ) -> dict[str, Any]: """ Create a minimal agent dictionary for testing. Args: name: Agent name url: Agent URL description: Agent description version: Agent version Returns: Minimal agent dictionary """ return { "name": name, "url": url, "description": description, "version": version, "protocolVersion": "1.0", "capabilities": {}, "defaultInputModes": ["text/plain"], "defaultOutputModes": ["text/plain"], "skills": [], } ================================================ FILE: tests/fixtures/mocks/__init__.py ================================================ """Mock implementations for external dependencies.""" ================================================ FILE: tests/fixtures/mocks/mock_auth.py ================================================ """ Mock authentication implementations for testing. This module provides mock implementations of authentication components. """ import logging import time from typing import Any import jwt logger = logging.getLogger(__name__) class MockJWTValidator: """ Mock JWT token validator for testing. Provides a simple JWT validation implementation that doesn't require actual authentication providers. """ def __init__(self, secret_key: str = "test-secret-key", algorithm: str = "HS256"): """ Initialize mock JWT validator. Args: secret_key: Secret key for JWT signing/validation algorithm: JWT algorithm """ self.secret_key = secret_key self.algorithm = algorithm def create_token( self, username: str, groups: list[str] | None = None, scopes: list[str] | None = None, expires_in: int = 3600, extra_claims: dict[str, Any] | None = None, ) -> str: """ Create a test JWT token. Args: username: Username for the token groups: List of groups scopes: List of scopes expires_in: Token expiration time in seconds extra_claims: Additional claims to include Returns: JWT token string """ now = int(time.time()) payload = { "sub": username, "username": username, "iat": now, "exp": now + expires_in, "token_use": "access", } if groups: payload["cognito:groups"] = groups payload["groups"] = groups if scopes: payload["scope"] = " ".join(scopes) if extra_claims: payload.update(extra_claims) token = jwt.encode(payload, self.secret_key, algorithm=self.algorithm) logger.debug(f"Created mock JWT token for {username}") return token def validate_token(self, token: str) -> dict[str, Any]: """ Validate a JWT token. Args: token: JWT token string Returns: Token payload dictionary Raises: jwt.InvalidTokenError: If token is invalid """ payload = jwt.decode(token, self.secret_key, algorithms=[self.algorithm]) logger.debug(f"Validated mock JWT token for {payload.get('username')}") return payload class MockSessionValidator: """ Mock session validator for testing cookie-based sessions. """ def __init__(self, secret_key: str = "test-secret-key"): """ Initialize mock session validator. Args: secret_key: Secret key for session signing """ self.secret_key = secret_key def create_session( self, username: str, groups: list[str] | None = None, **extra_data: Any ) -> str: """ Create a test session cookie value. Args: username: Username groups: List of groups **extra_data: Additional session data Returns: Session cookie value """ from itsdangerous import URLSafeTimedSerializer serializer = URLSafeTimedSerializer(self.secret_key) data = {"username": username, "groups": groups or []} data.update(extra_data) session_value = serializer.dumps(data) logger.debug(f"Created mock session for {username}") return session_value def validate_session(self, session_value: str, max_age: int = 28800) -> dict[str, Any]: """ Validate a session cookie. Args: session_value: Session cookie value max_age: Maximum age in seconds Returns: Session data dictionary Raises: Exception: If session is invalid or expired """ from itsdangerous import URLSafeTimedSerializer serializer = URLSafeTimedSerializer(self.secret_key) data = serializer.loads(session_value, max_age=max_age) logger.debug(f"Validated mock session for {data.get('username')}") return data def create_mock_auth_headers( token: str | None = None, username: str | None = None, scopes: list[str] | None = None ) -> dict[str, str]: """ Create mock authentication headers for testing. Args: token: JWT token username: Username (if not using token) scopes: List of scopes (if not using token) Returns: Dictionary of HTTP headers """ headers = {} if token: headers["Authorization"] = f"Bearer {token}" elif username: # Create a simple mock token validator = MockJWTValidator() token = validator.create_token(username, scopes=scopes) headers["Authorization"] = f"Bearer {token}" return headers def create_mock_cognito_user_attributes( username: str, email: str | None = None, groups: list[str] | None = None ) -> list[dict[str, str]]: """ Create mock Cognito user attributes. Args: username: Username email: Email address groups: List of groups Returns: List of attribute dictionaries """ attributes = [ {"Name": "sub", "Value": username}, {"Name": "email", "Value": email or f"{username}@example.com"}, {"Name": "email_verified", "Value": "true"}, ] if groups: attributes.append({"Name": "cognito:groups", "Value": ",".join(groups)}) return attributes ================================================ FILE: tests/fixtures/mocks/mock_embeddings.py ================================================ """ Mock embeddings implementation for testing. This module provides mock implementations of embedding models to avoid loading large ML models during tests. """ import hashlib import logging from typing import Any import numpy as np logger = logging.getLogger(__name__) class MockEmbeddingsClient: """ Mock embeddings client that generates deterministic embeddings from text. This mock generates embeddings based on text hash to ensure consistent results across test runs without requiring real ML models. """ def __init__(self, model_name: str = "all-MiniLM-L6-v2", dimension: int = 384): """ Initialize mock embeddings client. Args: model_name: Name of the model (for logging) dimension: Dimension of the embeddings to generate """ self.model_name = model_name self.dimension = dimension logger.debug(f"Created MockEmbeddingsClient: {model_name}, dim={dimension}") def encode( self, texts: str | list[str], normalize_embeddings: bool = False, show_progress_bar: bool = False, **kwargs: Any, ) -> np.ndarray: """ Generate mock embeddings for input texts. Creates deterministic embeddings based on text hash to ensure consistency in tests. Args: texts: Single text string or list of texts normalize_embeddings: Whether to normalize the embeddings show_progress_bar: Whether to show progress (ignored) **kwargs: Additional arguments (ignored) Returns: Array of embeddings (shape: [n, dimension]) """ if isinstance(texts, str): texts = [texts] embeddings = [] for text in texts: # Generate deterministic embedding from text hash embedding = self._generate_embedding(text) embeddings.append(embedding) result = np.array(embeddings, dtype=np.float32) if normalize_embeddings: # L2 normalization norms = np.linalg.norm(result, axis=1, keepdims=True) norms = np.where(norms == 0, 1, norms) # Avoid division by zero result = result / norms logger.debug(f"Generated {len(texts)} mock embeddings, shape={result.shape}") return result def _generate_embedding(self, text: str) -> np.ndarray: """ Generate a deterministic embedding from text. Uses hash of the text to seed random generation for consistency. Args: text: Input text Returns: Embedding vector (shape: [dimension]) """ # Use hash of text as seed for reproducibility text_hash = hashlib.sha256(text.encode()).hexdigest() seed = int(text_hash[:8], 16) # Generate deterministic "embedding" rng = np.random.RandomState(seed) embedding = rng.randn(self.dimension).astype(np.float32) # Normalize to make it more realistic embedding = embedding / np.linalg.norm(embedding) return embedding class MockSentenceTransformer: """ Mock SentenceTransformer class for testing. Mimics the interface of sentence_transformers.SentenceTransformer. """ def __init__(self, model_name_or_path: str, **kwargs: Any): """ Initialize mock sentence transformer. Args: model_name_or_path: Model name or path **kwargs: Additional arguments (ignored) """ self.model_name = model_name_or_path self.dimension = 384 # Default dimension for MiniLM self._client = MockEmbeddingsClient(model_name_or_path, self.dimension) logger.debug(f"Created MockSentenceTransformer: {model_name_or_path}") def encode(self, sentences: str | list[str], **kwargs: Any) -> np.ndarray: """ Encode sentences to embeddings. Args: sentences: Single sentence or list of sentences **kwargs: Additional arguments passed to client Returns: Array of embeddings """ return self._client.encode(sentences, **kwargs) def get_sentence_embedding_dimension(self) -> int: """Get the embedding dimension.""" return self.dimension def create_mock_st_module() -> Any: """ Create a mock sentence_transformers module for testing. Returns: Mock sentence_transformers module object """ class MockSTModule: """Mock sentence_transformers module.""" SentenceTransformer = MockSentenceTransformer return MockSTModule() def create_mock_litellm_module() -> Any: """ Create a mock litellm module for testing. Returns: Mock litellm module object """ class MockLiteLLMModule: """Mock litellm module.""" class MockEmbedding: """Mock embedding response.""" def __init__(self, embedding: list[float]): self.embedding = embedding class MockEmbeddingResponse: """Mock embedding API response.""" def __init__(self, embeddings: list[list[float]]): self.data = [{"embedding": emb, "index": i} for i, emb in enumerate(embeddings)] @staticmethod def embedding( model: str, input: str | list[str], **kwargs: Any ) -> "MockLiteLLMModule.MockEmbeddingResponse": """ Mock LiteLLM embedding function. Args: model: Model name input: Text or list of texts **kwargs: Additional arguments Returns: Mock embedding response """ if isinstance(input, str): input = [input] client = MockEmbeddingsClient(model, dimension=1024) embeddings_array = client.encode(input) embeddings = [emb.tolist() for emb in embeddings_array] logger.debug(f"Mock LiteLLM generated {len(embeddings)} embeddings") return MockLiteLLMModule.MockEmbeddingResponse(embeddings) return MockLiteLLMModule() ================================================ FILE: tests/fixtures/mocks/mock_faiss.py ================================================ """ Mock FAISS implementation for testing. This module provides mock implementations of FAISS classes to avoid loading the actual FAISS library during tests. """ import logging from typing import Any import numpy as np logger = logging.getLogger(__name__) class MockFaissIndex: """ Mock implementation of FAISS index for testing. This mock simulates FAISS index behavior without requiring the actual FAISS library to be loaded. """ def __init__(self, dimension: int = 384): """ Initialize mock FAISS index. Args: dimension: Dimension of the embeddings """ self.dimension = dimension self._vectors: dict[int, np.ndarray] = {} self._next_id: int = 0 logger.debug(f"Created MockFaissIndex with dimension {dimension}") @property def d(self) -> int: """Get the dimension of the index.""" return self.dimension @property def ntotal(self) -> int: """Get the total number of vectors in the index.""" return len(self._vectors) def add_with_ids(self, vectors: np.ndarray, ids: np.ndarray) -> None: """ Add vectors with specific IDs to the index. Args: vectors: Array of vectors to add (shape: [n, d]) ids: Array of IDs for the vectors (shape: [n]) """ if vectors.shape[1] != self.dimension: raise ValueError( f"Vector dimension {vectors.shape[1]} does not match index dimension {self.dimension}" ) for i, vector_id in enumerate(ids): self._vectors[int(vector_id)] = vectors[i] logger.debug(f"Added {len(ids)} vectors to mock index (total: {self.ntotal})") def add(self, vectors: np.ndarray) -> None: """ Add vectors to the index with auto-generated IDs. Args: vectors: Array of vectors to add (shape: [n, d]) """ if vectors.shape[1] != self.dimension: raise ValueError( f"Vector dimension {vectors.shape[1]} does not match index dimension {self.dimension}" ) n = vectors.shape[0] ids = np.arange(self._next_id, self._next_id + n) self.add_with_ids(vectors, ids) self._next_id += n def search(self, query_vectors: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray]: """ Search for nearest neighbors. Args: query_vectors: Query vectors (shape: [n, d]) k: Number of nearest neighbors to return Returns: Tuple of (distances, indices) arrays """ if query_vectors.shape[1] != self.dimension: raise ValueError( f"Query dimension {query_vectors.shape[1]} does not match index dimension {self.dimension}" ) n_queries = query_vectors.shape[0] n_vectors = self.ntotal if n_vectors == 0: # No vectors in index, return empty results distances = np.full((n_queries, k), float("inf"), dtype=np.float32) indices = np.full((n_queries, k), -1, dtype=np.int64) return distances, indices # Calculate distances for all vectors all_ids = np.array(list(self._vectors.keys()), dtype=np.int64) all_vectors = np.array([self._vectors[vid] for vid in all_ids]) distances_list = [] indices_list = [] for query_vector in query_vectors: # Calculate L2 distances dists = np.linalg.norm(all_vectors - query_vector, axis=1) # Get top k k_actual = min(k, len(dists)) top_k_indices = np.argsort(dists)[:k_actual] # Build result arrays result_distances = np.full(k, float("inf"), dtype=np.float32) result_indices = np.full(k, -1, dtype=np.int64) result_distances[:k_actual] = dists[top_k_indices] result_indices[:k_actual] = all_ids[top_k_indices] distances_list.append(result_distances) indices_list.append(result_indices) distances = np.array(distances_list) indices = np.array(indices_list) logger.debug(f"Searched {n_queries} queries, found {k} neighbors each") return distances, indices def remove_ids(self, ids: np.ndarray) -> int: """ Remove vectors with specific IDs from the index. Args: ids: Array of IDs to remove Returns: Number of vectors removed """ removed = 0 for vector_id in ids: if int(vector_id) in self._vectors: del self._vectors[int(vector_id)] removed += 1 logger.debug(f"Removed {removed} vectors from mock index (remaining: {self.ntotal})") return removed def reset(self) -> None: """Reset the index to empty state.""" self._vectors.clear() self._next_id = 0 logger.debug("Reset mock index") class MockIndexIDMap: """ Mock implementation of FAISS IndexIDMap wrapper. This wraps a MockFaissIndex to provide ID mapping functionality. """ def __init__(self, index: MockFaissIndex): """ Initialize mock IndexIDMap. Args: index: Underlying mock index """ self.index = index logger.debug("Created MockIndexIDMap") @property def d(self) -> int: """Get the dimension of the index.""" return self.index.d @property def ntotal(self) -> int: """Get the total number of vectors.""" return self.index.ntotal def add_with_ids(self, vectors: np.ndarray, ids: np.ndarray) -> None: """Add vectors with IDs.""" self.index.add_with_ids(vectors, ids) def search(self, query_vectors: np.ndarray, k: int) -> tuple[np.ndarray, np.ndarray]: """Search for nearest neighbors.""" return self.index.search(query_vectors, k) def remove_ids(self, ids: np.ndarray) -> int: """Remove vectors by IDs.""" return self.index.remove_ids(ids) def reset(self) -> None: """Reset the index.""" self.index.reset() def create_mock_faiss_module() -> Any: """ Create a mock FAISS module for testing. This returns a module-like object that can be used to replace the faiss import in tests. Returns: Mock FAISS module object """ class MockFaissModule: """Mock FAISS module.""" @staticmethod def IndexFlatL2(d: int) -> MockFaissIndex: """Create a flat L2 index.""" logger.debug(f"Creating MockFaissIndex with dimension {d}") return MockFaissIndex(d) @staticmethod def IndexFlatIP(d: int) -> MockFaissIndex: """Create a flat Inner Product index (for cosine similarity).""" logger.debug(f"Creating MockFaissIndex (IP) with dimension {d}") return MockFaissIndex(d) @staticmethod def IndexIDMap(index: MockFaissIndex) -> MockIndexIDMap: """Create an ID map wrapper.""" logger.debug("Creating MockIndexIDMap") return MockIndexIDMap(index) @staticmethod def read_index(filepath: str) -> MockFaissIndex: """ Mock read_index that returns an empty index. In real tests, the index will be populated separately. """ logger.debug(f"Mock reading FAISS index from {filepath}") return MockFaissIndex() @staticmethod def write_index(index: MockFaissIndex, filepath: str) -> None: """Mock write_index that does nothing.""" logger.debug(f"Mock writing FAISS index to {filepath}") return MockFaissModule() ================================================ FILE: tests/fixtures/mocks/mock_http.py ================================================ """ Mock HTTP client implementations for testing. This module provides mock implementations of HTTP clients to avoid making real network requests during tests. """ import logging from typing import Any logger = logging.getLogger(__name__) class MockResponse: """ Mock HTTP response object. Mimics the interface of httpx.Response. """ def __init__( self, status_code: int = 200, json_data: dict[str, Any] | None = None, text: str = "", headers: dict[str, str] | None = None, ): """ Initialize mock response. Args: status_code: HTTP status code json_data: JSON response data text: Response text headers: Response headers """ self.status_code = status_code self._json_data = json_data or {} self.text = text or "" self.headers = headers or {} self.content = text.encode() if text else b"" def json(self) -> dict[str, Any]: """Get JSON response data.""" return self._json_data def raise_for_status(self) -> None: """Raise exception for error status codes.""" if self.status_code >= 400: raise Exception(f"HTTP {self.status_code}") def __repr__(self) -> str: return f"MockResponse(status={self.status_code})" class MockAsyncClient: """ Mock async HTTP client. Mimics the interface of httpx.AsyncClient. """ def __init__(self, responses: dict[str, MockResponse] | None = None): """ Initialize mock async client. Args: responses: Dictionary mapping URLs to mock responses """ self.responses = responses or {} self.request_history: list[dict[str, Any]] = [] async def get(self, url: str, **kwargs: Any) -> MockResponse: """ Mock GET request. Args: url: Request URL **kwargs: Additional request arguments Returns: Mock response """ self.request_history.append({"method": "GET", "url": url, "kwargs": kwargs}) if url in self.responses: return self.responses[url] return MockResponse(status_code=404, json_data={"error": "Not found"}) async def post(self, url: str, **kwargs: Any) -> MockResponse: """ Mock POST request. Args: url: Request URL **kwargs: Additional request arguments Returns: Mock response """ self.request_history.append({"method": "POST", "url": url, "kwargs": kwargs}) if url in self.responses: return self.responses[url] return MockResponse(status_code=200, json_data={"success": True}) async def __aenter__(self): """Async context manager entry.""" return self async def __aexit__(self, exc_type, exc_val, exc_tb): """Async context manager exit.""" pass def create_mock_httpx_client(responses: dict[str, MockResponse] | None = None) -> MockAsyncClient: """ Create a mock httpx async client. Args: responses: Dictionary mapping URLs to mock responses Returns: Mock async client """ return MockAsyncClient(responses) def create_mock_mcp_server_response( tools: list[dict[str, Any]] | None = None, prompts: list[dict[str, Any]] | None = None, resources: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: """ Create a mock MCP server response. Args: tools: List of tool definitions prompts: List of prompt definitions resources: List of resource definitions Returns: Mock server response dictionary """ return { "jsonrpc": "2.0", "id": 1, "result": {"tools": tools or [], "prompts": prompts or [], "resources": resources or []}, } def create_mock_tool_definition( name: str, description: str = "Test tool", input_schema: dict[str, Any] | None = None ) -> dict[str, Any]: """ Create a mock MCP tool definition. Args: name: Tool name description: Tool description input_schema: Tool input schema Returns: Mock tool definition """ return { "name": name, "description": description, "inputSchema": input_schema or {"type": "object", "properties": {}}, } ================================================ FILE: tests/fixtures/skill_scan_medium_output.json ================================================ { "findings": [ { "file_path": "SKILL.md", "line_number": 8, "severity": "MEDIUM", "threat_names": ["weak-validation"], "threat_summary": "Input validation could be strengthened", "analyzer": "meta", "is_safe": true }, { "file_path": "SKILL.md", "line_number": 22, "severity": "LOW", "threat_names": ["info-disclosure"], "threat_summary": "Skill may expose internal path information", "analyzer": "static", "is_safe": true } ], "summary": { "total_findings": 2, "critical": 0, "high": 0, "medium": 1, "low": 1 }, "scan_status": "completed" } ================================================ FILE: tests/fixtures/skill_scan_safe_output.json ================================================ { "findings": [], "summary": { "total_findings": 0, "critical": 0, "high": 0, "medium": 0, "low": 0 }, "scan_status": "completed" } ================================================ FILE: tests/fixtures/skill_scan_unsafe_output.json ================================================ { "findings": [ { "file_path": "SKILL.md", "line_number": 15, "severity": "CRITICAL", "threat_names": ["prompt-injection"], "threat_summary": "Detected prompt injection pattern in skill instructions", "analyzer": "static", "is_safe": false }, { "file_path": "SKILL.md", "line_number": 42, "severity": "HIGH", "threat_names": ["data-exfiltration"], "threat_summary": "Detected data exfiltration pattern sending data to external endpoint", "analyzer": "behavioral", "is_safe": false } ], "summary": { "total_findings": 2, "critical": 1, "high": 1, "medium": 0, "low": 0 }, "scan_status": "completed" } ================================================ FILE: tests/integration/__init__.py ================================================ """Integration tests for MCP Gateway Registry.""" ================================================ FILE: tests/integration/conftest.py ================================================ """ Conftest for integration tests. Provides fixtures specific to integration tests that involve multiple components working together. """ import logging from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient logger = logging.getLogger(__name__) @pytest.fixture(scope="function", autouse=True) def reset_mongodb_client(): """Reset MongoDB client singleton before each test to pick up correct settings.""" from registry.repositories.documentdb import client # Clear the global client cache so next test creates a new one with correct settings client._client = None client._database = None yield # Cleanup is handled by TestClient teardown @pytest.fixture(autouse=True) def mock_security_scanner(): """Mock security scanner for integration tests to avoid mcp-scanner dependency.""" from registry.schemas.security import SecurityScanConfig, SecurityScanResult mock_service = MagicMock() # Return config with scanning disabled to avoid scan during registration mock_service.get_scan_config.return_value = SecurityScanConfig( enabled=False, scan_on_registration=False, block_unsafe_servers=False ) # If scan is called anyway, return a passing result mock_service.scan_server = AsyncMock( return_value=SecurityScanResult( server_url="http://localhost:9000/mcp", server_path="/test-server", scan_timestamp="2025-01-01T00:00:00Z", is_safe=True, critical_issues=0, high_severity=0, medium_severity=0, low_severity=0, analyzers_used=["yara"], raw_output={}, scan_failed=False, ) ) with patch("registry.api.server_routes.security_scanner_service", mock_service): yield mock_service @pytest.fixture def test_client(mock_settings) -> Generator[TestClient, None, None]: """ Create a FastAPI test client for integration tests. Args: mock_settings: Test settings fixture Yields: FastAPI TestClient instance """ from registry.main import app with TestClient(app) as client: logger.debug("Created FastAPI test client") yield client @pytest.fixture async def async_test_client(mock_settings): """ Create an async FastAPI test client for integration tests. Args: mock_settings: Test settings fixture Yields: Async test client """ from httpx import AsyncClient from registry.main import app async with AsyncClient(app=app, base_url="http://test") as client: logger.debug("Created async FastAPI test client") yield client ================================================ FILE: tests/integration/test_agentcore_sync_integration.py ================================================ """Integration tests for AgentCore auto-registration sync flow. Tests the SyncOrchestrator end-to-end with mocked external dependencies (boto3 AWS calls and registry HTTP calls). Validates discovery -> registration -> manifest generation pipeline. """ from __future__ import annotations import json from unittest.mock import MagicMock, patch # --------------------------------------------------------------------------- # Sample data # --------------------------------------------------------------------------- ACCOUNT_ID = "111122223333" REGION = "us-east-1" GATEWAY_CUSTOM_JWT = { "gatewayId": "gw-jwt-1", "gatewayArn": "arn:aws:bedrock:us-east-1:111122223333:gateway/gw-jwt-1", "gatewayUrl": "https://gateway-jwt.example.com", "name": "jwt-gateway", "description": "OAuth2 gateway", "status": "READY", "authorizerType": "CUSTOM_JWT", "authorizerConfiguration": { "customJWTAuthorizer": { "discoveryUrl": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_pnikLWYzO/.well-known/openid-configuration", "allowedClients": ["7kqi2l0n47mnfmhfapsf29ch4h"], } }, "targets": [], } GATEWAY_IAM = { "gatewayId": "gw-iam-1", "gatewayArn": "arn:aws:bedrock:us-east-1:111122223333:gateway/gw-iam-1", "gatewayUrl": "https://gateway-iam.example.com", "name": "iam-gateway", "description": "IAM gateway", "status": "READY", "authorizerType": "AWS_IAM", "targets": [], } GATEWAY_NONE = { "gatewayId": "gw-none-1", "gatewayArn": "arn:aws:bedrock:us-east-1:111122223333:gateway/gw-none-1", "gatewayUrl": "https://gateway-none.example.com", "name": "none-gateway", "description": "No-auth gateway", "status": "READY", "authorizerType": "NONE", "targets": [], } MCP_RUNTIME = { "agentRuntimeId": "rt-mcp-1", "agentRuntimeArn": "arn:aws:bedrock:us-east-1:111122223333:runtime/rt-mcp-1", "agentRuntimeName": "test-mcp-runtime", "description": "Test MCP runtime", "status": "READY", "protocolConfiguration": {"serverProtocol": "MCP"}, "endpoints": [], } HTTP_RUNTIME = { "agentRuntimeId": "rt-http-1", "agentRuntimeArn": "arn:aws:bedrock:us-east-1:111122223333:runtime/rt-http-1", "agentRuntimeName": "test-http-runtime", "description": "Test HTTP runtime", "status": "READY", "protocolConfiguration": {"serverProtocol": "HTTP"}, "endpoints": [], } # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _mock_sts(): """Create a mock STS client that returns a fixed account ID.""" mock = MagicMock() mock.get_caller_identity.return_value = {"Account": ACCOUNT_ID} return mock def _mock_agentcore_client(gateways=None, runtimes=None): """Create a mock bedrock-agentcore-control client.""" client = MagicMock() # list_gateways gw_items = [] for gw in gateways or []: gw_items.append({"gatewayId": gw["gatewayId"], "status": gw["status"]}) client.list_gateways.return_value = {"items": gw_items} # get_gateway -- return the full gateway dict for each ID def _get_gateway(gatewayIdentifier): for gw in gateways or []: if gw["gatewayId"] == gatewayIdentifier: return dict(gw) return {} client.get_gateway.side_effect = _get_gateway # list_gateway_targets -- return empty by default client.list_gateway_targets.return_value = {"items": []} client.get_gateway_target.return_value = {} # list_agent_runtimes rt_items = [] for rt in runtimes or []: rt_items.append({"agentRuntimeId": rt["agentRuntimeId"], "status": rt["status"]}) client.list_agent_runtimes.return_value = {"agentRuntimes": rt_items} # get_agent_runtime def _get_runtime(agentRuntimeId): for rt in runtimes or []: if rt["agentRuntimeId"] == agentRuntimeId: return dict(rt) return {} client.get_agent_runtime.side_effect = _get_runtime # list_agent_runtime_endpoints client.list_agent_runtime_endpoints.return_value = {"runtimeEndpoints": []} return client def _build_orchestrator( gateways=None, runtimes=None, dry_run=False, overwrite=False, include_mcp_targets=False, registry_client=None, manifest_path="/tmp/test_manifest.json", ): """Build a SyncOrchestrator with mocked AWS and registry dependencies.""" mock_ac_client = _mock_agentcore_client(gateways=gateways, runtimes=runtimes) mock_sts = _mock_sts() def _boto3_client(service, **kwargs): if service == "sts": return mock_sts if service == "bedrock-agentcore-control": return mock_ac_client return MagicMock() with ( patch("cli.agentcore.registration.boto3") as reg_boto3, patch("cli.agentcore.discovery.boto3") as disc_boto3, ): reg_boto3.client.side_effect = _boto3_client disc_boto3.client.side_effect = _boto3_client from cli.agentcore.discovery import AgentCoreScanner from cli.agentcore.registration import RegistrationBuilder, SyncOrchestrator scanner = AgentCoreScanner(region=REGION) scanner.client = mock_ac_client builder = RegistrationBuilder(region=REGION) if registry_client is None: registry_client = MagicMock() orch = SyncOrchestrator( scanner=scanner, builder=builder, registry_client=registry_client, dry_run=dry_run, overwrite=overwrite, include_mcp_targets=include_mcp_targets, manifest_path=manifest_path, ) return orch, registry_client # --------------------------------------------------------------------------- # End-to-end flow: discovery -> registration -> manifest # --------------------------------------------------------------------------- class TestEndToEndFlow: """Full sync pipeline with gateways and runtimes.""" def test_gateway_discovery_registration_manifest(self): """CUSTOM_JWT gateway: register and collect manifest entry with OIDC metadata.""" orch, registry = _build_orchestrator(gateways=[GATEWAY_CUSTOM_JWT]) orch.sync_gateways() # Gateway registered assert len(orch.results) == 1 assert orch.results[0]["status"] == "registered" assert orch.results[0]["resource_type"] == "gateway" registry.register_service.assert_called_once() # Manifest entry collected assert len(orch._manifest_entries) == 1 entry = orch._manifest_entries[0] assert entry["server_path"] == "/jwt-gateway" assert "cognito-idp" in entry["discovery_url"] assert entry["allowed_clients"] == ["7kqi2l0n47mnfmhfapsf29ch4h"] assert entry["idp_vendor"] == "cognito" def test_mcp_runtime_registered_as_server(self): """MCP runtime -> registered as MCP Server via register_service.""" orch, registry = _build_orchestrator(runtimes=[MCP_RUNTIME]) orch.sync_runtimes() assert len(orch.results) == 1 assert orch.results[0]["status"] == "registered" assert orch.results[0]["registration_type"] == "mcp_server" assert orch.results[0]["resource_type"] == "runtime" registry.register_service.assert_called_once() registry.register_agent.assert_not_called() def test_http_runtime_registered_as_agent(self): """HTTP runtime -> registered as A2A Agent via register_agent.""" orch, registry = _build_orchestrator(runtimes=[HTTP_RUNTIME]) orch.sync_runtimes() assert len(orch.results) == 1 assert orch.results[0]["status"] == "registered" assert orch.results[0]["registration_type"] == "agent" registry.register_agent.assert_called_once() registry.register_service.assert_not_called() def test_full_sync_gateways_and_runtimes(self): """Sync both gateways and runtimes in a single run.""" orch, registry = _build_orchestrator( gateways=[GATEWAY_NONE], runtimes=[MCP_RUNTIME, HTTP_RUNTIME], ) orch.sync_gateways() orch.sync_runtimes() assert len(orch.results) == 3 statuses = [r["status"] for r in orch.results] assert all(s == "registered" for s in statuses) # 1 gateway + 1 MCP runtime = 2 register_service calls assert registry.register_service.call_count == 2 # 1 HTTP runtime = 1 register_agent call assert registry.register_agent.call_count == 1 # --------------------------------------------------------------------------- # Dry-run mode # --------------------------------------------------------------------------- class TestDryRunMode: """Dry-run: no registry calls, manifest entries collected but not written.""" def test_dry_run_skips_registry_calls(self): orch, registry = _build_orchestrator( gateways=[GATEWAY_CUSTOM_JWT, GATEWAY_NONE], runtimes=[MCP_RUNTIME, HTTP_RUNTIME], dry_run=True, ) orch.sync_gateways() orch.sync_runtimes() # No registry calls registry.register_service.assert_not_called() registry.register_agent.assert_not_called() # All results are dry_run assert len(orch.results) == 4 assert all(r["status"] == "dry_run" for r in orch.results) def test_dry_run_collects_manifest_entries(self): """Dry-run still collects manifest entries for CUSTOM_JWT gateways.""" orch, _ = _build_orchestrator( gateways=[GATEWAY_CUSTOM_JWT], dry_run=True, ) orch.sync_gateways() assert len(orch._manifest_entries) == 1 assert orch._manifest_entries[0]["idp_vendor"] == "cognito" def test_dry_run_does_not_write_manifest(self, tmp_path): """Dry-run mode does not create the manifest file.""" manifest_file = tmp_path / "manifest.json" orch, _ = _build_orchestrator( gateways=[GATEWAY_CUSTOM_JWT], dry_run=True, manifest_path=str(manifest_file), ) orch.sync_gateways() orch.write_manifest() assert not manifest_file.exists() # --------------------------------------------------------------------------- # Mixed deployment: CUSTOM_JWT, IAM, NONE gateways # --------------------------------------------------------------------------- class TestMixedDeployment: """Mixed authorizer types in a single sync run.""" def test_mixed_gateways_all_registered(self): """All three authorizer types register successfully.""" orch, registry = _build_orchestrator( gateways=[GATEWAY_CUSTOM_JWT, GATEWAY_IAM, GATEWAY_NONE], ) orch.sync_gateways() # All 3 gateways registered assert len(orch.results) == 3 assert all(r["status"] == "registered" for r in orch.results) assert registry.register_service.call_count == 3 def test_only_custom_jwt_collects_manifest(self): """Only CUSTOM_JWT gateways produce manifest entries.""" orch, _ = _build_orchestrator( gateways=[GATEWAY_CUSTOM_JWT, GATEWAY_IAM, GATEWAY_NONE], ) orch.sync_gateways() assert len(orch._manifest_entries) == 1 assert orch._manifest_entries[0]["gateway_arn"] == GATEWAY_CUSTOM_JWT["gatewayArn"] def test_mixed_with_runtimes(self): """Mixed gateways + mixed runtimes in a single sync.""" orch, registry = _build_orchestrator( gateways=[GATEWAY_IAM, GATEWAY_NONE], runtimes=[MCP_RUNTIME, HTTP_RUNTIME], ) orch.sync_gateways() orch.sync_runtimes() assert len(orch.results) == 4 types = {r["resource_type"] for r in orch.results} assert types == {"gateway", "runtime"} # 2 gateways + 1 MCP runtime = 3 register_service assert registry.register_service.call_count == 3 # 1 HTTP runtime = 1 register_agent assert registry.register_agent.call_count == 1 def test_iam_gateway_auth_scheme_is_bearer(self): """IAM gateways get auth_scheme=bearer in registration.""" orch, registry = _build_orchestrator(gateways=[GATEWAY_IAM]) orch.sync_gateways() assert len(orch.results) == 1 assert orch.results[0]["status"] == "registered" call_args = registry.register_service.call_args reg = call_args[0][0] assert reg.auth_scheme == "bearer" def test_none_gateway_auth_scheme_is_none(self): """NONE gateways get auth_scheme=none in registration.""" orch, registry = _build_orchestrator(gateways=[GATEWAY_NONE]) orch.sync_gateways() call_args = registry.register_service.call_args reg = call_args[0][0] assert reg.auth_scheme == "none" # --------------------------------------------------------------------------- # Manifest file writing # --------------------------------------------------------------------------- class TestManifestWriting: """Tests for token refresh manifest file output.""" def test_manifest_written_with_correct_structure(self, tmp_path): """Manifest file contains correct OIDC metadata for CUSTOM_JWT gateways.""" manifest_file = tmp_path / "manifest.json" orch, _ = _build_orchestrator( gateways=[GATEWAY_CUSTOM_JWT], manifest_path=str(manifest_file), ) orch.sync_gateways() orch.write_manifest() data = json.loads(manifest_file.read_text()) assert len(data) == 1 entry = data[0] assert entry["server_path"] == "/jwt-gateway" assert entry["gateway_arn"] == GATEWAY_CUSTOM_JWT["gatewayArn"] assert "cognito-idp" in entry["discovery_url"] assert entry["allowed_clients"] == ["7kqi2l0n47mnfmhfapsf29ch4h"] assert entry["idp_vendor"] == "cognito" def test_no_manifest_for_non_jwt_gateways(self, tmp_path): """IAM and NONE gateways produce no manifest entries.""" manifest_file = tmp_path / "manifest.json" orch, _ = _build_orchestrator( gateways=[GATEWAY_IAM, GATEWAY_NONE], manifest_path=str(manifest_file), ) orch.sync_gateways() orch.write_manifest() # No manifest file created (no CUSTOM_JWT gateways) assert not manifest_file.exists() def test_runtimes_produce_no_manifest_entries(self, tmp_path): """Runtimes do not contribute to the manifest.""" manifest_file = tmp_path / "manifest.json" orch, _ = _build_orchestrator( runtimes=[MCP_RUNTIME, HTTP_RUNTIME], manifest_path=str(manifest_file), ) orch.sync_runtimes() orch.write_manifest() assert not manifest_file.exists() ================================================ FILE: tests/integration/test_deployment_mode_integration.py ================================================ """Integration tests for deployment mode configuration endpoints. These tests require a running MongoDB instance. They are skipped in CI where MongoDB is not available. """ from unittest.mock import AsyncMock, patch import pytest # Skip all tests in this module - requires MongoDB running pytestmark = pytest.mark.skip(reason="Requires MongoDB running - not available in CI environment") @pytest.fixture def mock_peer_federation(): """Mock peer federation service to avoid MongoDB event loop issues.""" mock_service = AsyncMock() mock_service.registered_peers = [] mock_service.load_peers_and_state = AsyncMock() mock_scheduler = AsyncMock() mock_scheduler.start = AsyncMock() mock_scheduler.stop = AsyncMock() with ( patch( "registry.main.get_peer_federation_service", return_value=mock_service, ), patch( "registry.main.get_peer_sync_scheduler", return_value=mock_scheduler, ), ): yield mock_service @pytest.fixture def mock_auth_admin(): """Mock authentication returning admin user context.""" admin_context = { "username": "admin", "groups": ["mcp-registry-admin"], "scopes": [ "mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute", ], "is_admin": True, "can_modify_servers": True, } with patch( "registry.api.server_routes.nginx_proxied_auth", return_value=admin_context, ): yield admin_context @pytest.fixture def integration_client(mock_settings, mock_peer_federation): """Test client with peer federation mocked to avoid event loop issues.""" from fastapi.testclient import TestClient from registry.main import app with TestClient(app) as client: yield client @pytest.mark.integration class TestDeploymentModeIntegration: """Integration tests for deployment mode endpoints.""" def test_config_endpoint_returns_mode(self, integration_client): """Config endpoint should return deployment mode fields.""" response = integration_client.get("/api/config") assert response.status_code == 200 data = response.json() assert "deployment_mode" in data assert "registry_mode" in data assert "nginx_updates_enabled" in data assert "features" in data assert "gateway_proxy" in data["features"] assert "mcp_servers" in data["features"] assert "agents" in data["features"] assert "skills" in data["features"] assert "federation" in data["features"] def test_health_includes_deployment_mode(self, integration_client): """Health endpoint should include deployment mode info.""" response = integration_client.get("/health") assert response.status_code == 200 data = response.json() assert "deployment_mode" in data assert "registry_mode" in data assert "nginx_updates_enabled" in data def test_server_registration_works_in_registry_only(self, integration_client, mock_auth_admin): """Server registration should not 500 in registry-only mode.""" response = integration_client.post( "/api/servers/register", json={ "server_name": "test-server", "path": "/test-server", "transport": "sse", "proxy_pass_url": "http://localhost:8080/mcp", }, ) assert response.status_code != 500 def test_server_toggle_works_in_registry_only(self, integration_client, mock_auth_admin): """Server toggle should not fail due to nginx in registry-only mode.""" response = integration_client.post( "/api/servers/test-server/toggle", json={"enabled": True}, ) assert response.status_code != 500 ================================================ FILE: tests/integration/test_mongodb_connectivity.py ================================================ """ Simple MongoDB connectivity tests. These tests verify basic MongoDB connectivity and CRUD operations without complex fixture dependencies. """ import pytest from motor.motor_asyncio import AsyncIOMotorClient @pytest.mark.integration @pytest.mark.asyncio class TestMongoDBConnectivity: """Test basic MongoDB connectivity.""" @pytest.mark.skip(reason="Requires MongoDB running - not available in CI environment") async def test_mongodb_connection(self): """Test that we can connect to MongoDB.""" # Arrange - Use localhost with directConnection for single server client = AsyncIOMotorClient( "mongodb://localhost:27017", directConnection=True, # Bypass replica set discovery serverSelectionTimeoutMS=5000, ) # Act & Assert - connection happens on first operation try: # Ping the server await client.admin.command("ping") assert True, "Successfully connected to MongoDB" finally: client.close() @pytest.mark.skip(reason="Requires MongoDB running - not available in CI environment") async def test_mongodb_create_and_read_document(self): """Test basic CRUD: create and read a document.""" # Arrange - Use localhost with directConnection client = AsyncIOMotorClient("mongodb://localhost:27017", directConnection=True) db = client["test_mcp_registry"] collection = db["test_connectivity"] try: # Act - Insert a test document test_doc = { "test_id": "connectivity_test_1", "message": "Hello MongoDB", "status": "testing", } result = await collection.insert_one(test_doc) # Assert - Document was inserted assert result.inserted_id is not None # Act - Read the document back found_doc = await collection.find_one({"test_id": "connectivity_test_1"}) # Assert - Document matches what we inserted assert found_doc is not None assert found_doc["message"] == "Hello MongoDB" assert found_doc["status"] == "testing" finally: # Cleanup await collection.delete_many({"test_id": "connectivity_test_1"}) client.close() @pytest.mark.skip(reason="Requires MongoDB running - not available in CI environment") async def test_mongodb_update_and_delete_document(self): """Test basic CRUD: update and delete a document.""" # Arrange - Use localhost with directConnection client = AsyncIOMotorClient("mongodb://localhost:27017", directConnection=True) db = client["test_mcp_registry"] collection = db["test_connectivity"] try: # Act - Insert a test document test_doc = {"test_id": "connectivity_test_2", "value": 100, "status": "initial"} await collection.insert_one(test_doc) # Act - Update the document await collection.update_one( {"test_id": "connectivity_test_2"}, {"$set": {"value": 200, "status": "updated"}} ) # Assert - Document was updated updated_doc = await collection.find_one({"test_id": "connectivity_test_2"}) assert updated_doc["value"] == 200 assert updated_doc["status"] == "updated" # Act - Delete the document delete_result = await collection.delete_one({"test_id": "connectivity_test_2"}) # Assert - Document was deleted assert delete_result.deleted_count == 1 # Verify document is gone deleted_doc = await collection.find_one({"test_id": "connectivity_test_2"}) assert deleted_doc is None finally: # Cleanup (just in case) await collection.delete_many({"test_id": "connectivity_test_2"}) client.close() ================================================ FILE: tests/integration/test_peer_federation_e2e.py ================================================ """ End-to-end integration tests for peer federation. Tests full federation flow including: - Peer CRUD operations via repository - Sync operations with mock peer registry - Orphan detection and handling - Local override preservation Requires MongoDB running on localhost:27017. """ import os from unittest.mock import AsyncMock, MagicMock, patch import pytest from registry.schemas.peer_federation_schema import ( PeerRegistryConfig, PeerSyncStatus, ) def _mongodb_available() -> bool: """Check if MongoDB is available for testing.""" try: import pymongo client = pymongo.MongoClient( "mongodb://localhost:27017/", serverSelectionTimeoutMS=1000, directConnection=True, ) client.admin.command("ping") client.close() return True except Exception: return False def _documentdb_available() -> bool: """Check if DocumentDB (with TLS cert) is available.""" # Check if the TLS certificate exists return os.path.exists("global-bundle.pem") # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def peer_config(): """Create a sample peer configuration for testing.""" return PeerRegistryConfig( peer_id="test-peer-001", name="Test Peer Registry", endpoint="http://localhost:9999", enabled=True, sync_mode="all", sync_interval_minutes=30, ) @pytest.fixture def peer_config_whitelist(): """Create a peer configuration with whitelist mode.""" return PeerRegistryConfig( peer_id="test-peer-whitelist", name="Whitelist Peer", endpoint="http://localhost:9998", enabled=True, sync_mode="whitelist", whitelist_servers=["/allowed-server-1", "/allowed-server-2"], whitelist_agents=["/allowed-agent-1"], ) @pytest.fixture def peer_config_tag_filter(): """Create a peer configuration with tag filter mode.""" return PeerRegistryConfig( peer_id="test-peer-tags", name="Tag Filter Peer", endpoint="http://localhost:9997", enabled=True, sync_mode="tag_filter", tag_filters=["production", "verified"], ) @pytest.fixture def mock_servers(): """Sample server data from a peer registry.""" return [ { "path": "/server-1", "server_name": "Test Server 1", "tags": ["production"], "endpoint": "http://server1.example.com", }, { "path": "/server-2", "server_name": "Test Server 2", "tags": ["development"], "endpoint": "http://server2.example.com", }, { "path": "/allowed-server-1", "server_name": "Allowed Server 1", "tags": ["verified"], "endpoint": "http://allowed1.example.com", }, ] @pytest.fixture def mock_agents(): """Sample agent data from a peer registry.""" return [ { "path": "/agent-1", "name": "Test Agent 1", "description": "A test agent for production use", "url": "https://agent1.example.com", "version": "1.0.0", "tags": ["production", "verified"], "skills": [ { "id": "skill-1", "name": "Skill 1", "description": "A production skill", "tags": ["production"], } ], }, { "path": "/agent-2", "name": "Test Agent 2", "description": "An experimental test agent", "url": "https://agent2.example.com", "version": "0.1.0", "tags": ["experimental"], "skills": [ { "id": "skill-2", "name": "Skill 2", "description": "An experimental skill", "tags": ["experimental"], } ], }, { "path": "/allowed-agent-1", "name": "Allowed Agent 1", "description": "An allowed agent for whitelist testing", "url": "https://allowed1.example.com", "version": "1.0.0", "tags": [], "skills": [], }, ] # ============================================================================= # REPOSITORY INTEGRATION TESTS # ============================================================================= @pytest.mark.asyncio @pytest.mark.integration @pytest.mark.skipif( not _documentdb_available(), reason="DocumentDB/TLS certificate not available", ) class TestPeerFederationRepositoryIntegration: """Integration tests for peer federation repository with MongoDB.""" async def test_documentdb_repository_crud(self, peer_config): """Test full CRUD cycle with DocumentDB repository.""" # Skip if MongoDB not available if os.environ.get("STORAGE_BACKEND", "mongodb-ce") == "file": pytest.skip("Requires MongoDB storage backend") from registry.repositories.documentdb.peer_federation_repository import ( DocumentDBPeerFederationRepository, ) repo = DocumentDBPeerFederationRepository() try: # Create created = await repo.create_peer(peer_config) assert created.peer_id == peer_config.peer_id assert created.name == peer_config.name assert created.created_at is not None # Read retrieved = await repo.get_peer(peer_config.peer_id) assert retrieved is not None assert retrieved.peer_id == peer_config.peer_id # List peers = await repo.list_peers() assert any(p.peer_id == peer_config.peer_id for p in peers) # List enabled only enabled_peers = await repo.list_peers(enabled=True) assert any(p.peer_id == peer_config.peer_id for p in enabled_peers) # Update updated = await repo.update_peer(peer_config.peer_id, {"name": "Updated Name"}) assert updated.name == "Updated Name" # Sync status sync_status = PeerSyncStatus( peer_id=peer_config.peer_id, is_healthy=True, current_generation=5, ) await repo.update_sync_status(peer_config.peer_id, sync_status) retrieved_status = await repo.get_sync_status(peer_config.peer_id) assert retrieved_status is not None assert retrieved_status.current_generation == 5 finally: # Cleanup try: await repo.delete_peer(peer_config.peer_id) except Exception: pass async def test_documentdb_repository_duplicate_peer_id_rejected(self, peer_config): """Test that duplicate peer IDs are rejected.""" if os.environ.get("STORAGE_BACKEND", "mongodb-ce") == "file": pytest.skip("Requires MongoDB storage backend") from registry.repositories.documentdb.peer_federation_repository import ( DocumentDBPeerFederationRepository, ) repo = DocumentDBPeerFederationRepository() try: # Create first peer await repo.create_peer(peer_config) # Try to create duplicate duplicate = PeerRegistryConfig( peer_id=peer_config.peer_id, # Same ID name="Duplicate Peer", endpoint="http://duplicate.example.com", ) with pytest.raises(ValueError, match="already exists"): await repo.create_peer(duplicate) finally: try: await repo.delete_peer(peer_config.peer_id) except Exception: pass async def test_documentdb_repository_delete_cascade(self, peer_config): """Test that deleting a peer also deletes its sync status.""" if os.environ.get("STORAGE_BACKEND", "mongodb-ce") == "file": pytest.skip("Requires MongoDB storage backend") from registry.repositories.documentdb.peer_federation_repository import ( DocumentDBPeerFederationRepository, ) repo = DocumentDBPeerFederationRepository() try: # Create peer await repo.create_peer(peer_config) # Update sync status sync_status = PeerSyncStatus(peer_id=peer_config.peer_id) await repo.update_sync_status(peer_config.peer_id, sync_status) # Verify sync status exists status = await repo.get_sync_status(peer_config.peer_id) assert status is not None # Delete peer await repo.delete_peer(peer_config.peer_id) # Verify sync status also deleted status_after = await repo.get_sync_status(peer_config.peer_id) assert status_after is None except Exception: # Cleanup if test fails try: await repo.delete_peer(peer_config.peer_id) except Exception: pass raise # ============================================================================= # SERVICE INTEGRATION TESTS # ============================================================================= @pytest.mark.asyncio @pytest.mark.integration class TestPeerFederationServiceIntegration: """Integration tests for peer federation service.""" async def test_service_sync_with_mock_peer( self, peer_config, mock_servers, mock_agents, ): """Test sync operation with mocked peer registry client.""" from registry.services.peer_federation_service import ( PeerFederationService, get_peer_federation_service, ) # Reset singleton for clean test PeerFederationService._instance = None service = get_peer_federation_service() # Mock the repository using AsyncMock mock_repo = MagicMock() mock_repo.create_peer = AsyncMock(return_value=peer_config) mock_repo.get_peer = AsyncMock( side_effect=lambda peer_id: peer_config if peer_id == peer_config.peer_id else None ) mock_repo.update_sync_status = AsyncMock(side_effect=lambda *args: args[1]) mock_repo.get_sync_status = AsyncMock( side_effect=lambda peer_id: PeerSyncStatus(peer_id=peer_id) ) mock_repo.list_peers = AsyncMock(return_value=[peer_config]) mock_repo.list_sync_statuses = AsyncMock( return_value=[PeerSyncStatus(peer_id=peer_config.peer_id)] ) mock_repo.load_all = AsyncMock(return_value=None) service._repo = mock_repo # Add peer to cache manually (since we're mocking) service.registered_peers[peer_config.peer_id] = peer_config service.peer_sync_status[peer_config.peer_id] = PeerSyncStatus(peer_id=peer_config.peer_id) # Mock the peer registry client mock_client = MagicMock() mock_client.fetch_servers = MagicMock(return_value=mock_servers) mock_client.fetch_agents = MagicMock(return_value=mock_agents) # Mock server and agent services with patch( "registry.services.peer_federation_service.PeerRegistryClient", return_value=mock_client, ): with patch( "registry.services.peer_federation_service.server_service" ) as mock_server_svc: with patch( "registry.services.peer_federation_service.agent_service" ) as mock_agent_svc: mock_server_svc.registered_servers = {} mock_server_svc.register_server = AsyncMock(return_value={"success": True}) mock_server_svc.update_server = AsyncMock(return_value=True) mock_server_svc.get_server_info = AsyncMock(return_value=None) mock_server_svc.get_all_servers = AsyncMock(return_value={}) mock_agent_svc.registered_agents = {} mock_agent_svc.register_agent = AsyncMock(side_effect=lambda agent: agent) mock_agent_svc.update_agent = AsyncMock(return_value=MagicMock()) mock_agent_svc.get_agent_info = AsyncMock(return_value=None) mock_agent_svc.get_all_agents = AsyncMock(return_value=[]) # Execute sync result = await service.sync_peer(peer_config.peer_id) # Verify result assert result.success is True assert result.peer_id == peer_config.peer_id assert result.servers_synced == len(mock_servers) assert result.agents_synced == len(mock_agents) async def test_service_filter_by_whitelist( self, peer_config_whitelist, mock_servers, mock_agents, ): """Test that whitelist filtering works correctly.""" from registry.services.peer_federation_service import PeerFederationService # Create fresh service instance PeerFederationService._instance = None service = PeerFederationService.__new__(PeerFederationService) service._initialized = False service.__init__() # Test server filtering filtered_servers = service._filter_servers_by_config(mock_servers, peer_config_whitelist) # Should only include whitelisted servers assert len(filtered_servers) == 1 assert filtered_servers[0]["path"] == "/allowed-server-1" # Test agent filtering filtered_agents = service._filter_agents_by_config(mock_agents, peer_config_whitelist) # Should only include whitelisted agents assert len(filtered_agents) == 1 assert filtered_agents[0]["path"] == "/allowed-agent-1" async def test_service_filter_by_tags( self, peer_config_tag_filter, mock_servers, mock_agents, ): """Test that tag filtering works correctly.""" from registry.services.peer_federation_service import PeerFederationService # Create fresh service instance PeerFederationService._instance = None service = PeerFederationService.__new__(PeerFederationService) service._initialized = False service.__init__() # Test server filtering (should match "production" or "verified") filtered_servers = service._filter_servers_by_config(mock_servers, peer_config_tag_filter) # Should include server-1 (production) and allowed-server-1 (verified) assert len(filtered_servers) == 2 paths = [s["path"] for s in filtered_servers] assert "/server-1" in paths assert "/allowed-server-1" in paths # Test agent filtering (should match "production" or "verified") filtered_agents = service._filter_agents_by_config(mock_agents, peer_config_tag_filter) # Should only include agent-1 (has both production and verified) assert len(filtered_agents) == 1 assert filtered_agents[0]["path"] == "/agent-1" # ============================================================================= # ORPHAN DETECTION TESTS # ============================================================================= @pytest.mark.asyncio @pytest.mark.integration class TestOrphanDetection: """Tests for orphan detection functionality.""" async def test_detect_orphaned_servers(self, peer_config): """Test detection of orphaned servers after sync.""" from registry.services.peer_federation_service import PeerFederationService # Create fresh service instance PeerFederationService._instance = None service = PeerFederationService.__new__(PeerFederationService) service._initialized = False service.__init__() # Simulate existing synced servers (some still exist, some orphaned) existing_servers = { f"/{peer_config.peer_id}/server-1": { "path": f"/{peer_config.peer_id}/server-1", "sync_metadata": { "source_peer_id": peer_config.peer_id, "original_path": "/server-1", }, }, f"/{peer_config.peer_id}/server-orphan": { "path": f"/{peer_config.peer_id}/server-orphan", "sync_metadata": { "source_peer_id": peer_config.peer_id, "original_path": "/server-orphan", }, }, } # Mock server service with existing synced servers using AsyncMock with patch("registry.services.peer_federation_service.server_service") as mock_server_svc: with patch("registry.services.peer_federation_service.agent_service") as mock_agent_svc: # Use AsyncMock for async methods mock_server_svc.get_all_servers = AsyncMock(return_value=existing_servers) mock_agent_svc.get_all_agents = AsyncMock(return_value=[]) # Current servers in peer (server-1 exists, server-orphan doesn't) current_server_paths = ["/server-1"] current_agent_paths = [] orphaned_servers, orphaned_agents = await service.detect_orphaned_items( peer_config.peer_id, current_server_paths, current_agent_paths ) # server-orphan should be detected as orphaned assert len(orphaned_servers) == 1 assert f"/{peer_config.peer_id}/server-orphan" in orphaned_servers assert len(orphaned_agents) == 0 async def test_local_override_preserved(self, peer_config): """Test that locally overridden items are not updated during sync.""" from registry.services.peer_federation_service import PeerFederationService # Create fresh service instance PeerFederationService._instance = None service = PeerFederationService.__new__(PeerFederationService) service._initialized = False service.__init__() # Item with local override overridden_item = { "path": "/server-overridden", "sync_metadata": { "source_peer_id": peer_config.peer_id, "local_overrides": True, }, } # Test is_locally_overridden assert service.is_locally_overridden(overridden_item) is True # Item without local override normal_item = { "path": "/server-normal", "sync_metadata": { "source_peer_id": peer_config.peer_id, "local_overrides": False, }, } assert service.is_locally_overridden(normal_item) is False # Item without sync_metadata new_item = {"path": "/server-new"} assert service.is_locally_overridden(new_item) is False # ============================================================================= # FILE REPOSITORY INTEGRATION TESTS # ============================================================================= @pytest.mark.asyncio @pytest.mark.integration class TestFilePeerFederationRepository: """Integration tests for file-based peer federation repository.""" async def test_file_repository_crud(self, peer_config, tmp_path): """Test CRUD operations with file repository.""" import warnings from registry.repositories.file.peer_federation_repository import ( FilePeerFederationRepository, ) # Suppress deprecation warning for this test with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) peers_dir = tmp_path / "peers" sync_state_file = tmp_path / "sync_state.json" repo = FilePeerFederationRepository( peers_dir=peers_dir, sync_state_file=sync_state_file, ) # Load (should be empty) await repo.load_all() assert len(await repo.list_peers()) == 0 # Create created = await repo.create_peer(peer_config) assert created.peer_id == peer_config.peer_id # Verify file was created peer_file = peers_dir / f"{peer_config.peer_id}.json" assert peer_file.exists() # Read retrieved = await repo.get_peer(peer_config.peer_id) assert retrieved is not None assert retrieved.name == peer_config.name # Update updated = await repo.update_peer(peer_config.peer_id, {"name": "Updated Name"}) assert updated.name == "Updated Name" # Sync status sync_status = PeerSyncStatus(peer_id=peer_config.peer_id) await repo.update_sync_status(peer_config.peer_id, sync_status) # Verify sync state file was created assert sync_state_file.exists() # Delete await repo.delete_peer(peer_config.peer_id) # Verify file was removed assert not peer_file.exists() # Verify peer is gone retrieved_after = await repo.get_peer(peer_config.peer_id) assert retrieved_after is None ================================================ FILE: tests/integration/test_search_integration.py ================================================ """ Integration tests for the search pipeline. This module tests the full search flow from registration to semantic search, including filters and visibility controls. """ import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import status from registry.search.service import FaissService from registry.services.agent_service import agent_service from registry.services.server_service import server_service from tests.fixtures.factories import AgentCardFactory from tests.fixtures.mocks.mock_embeddings import MockEmbeddingsClient logger = logging.getLogger(__name__) # Skip all tests in this file due to MongoDB connection timeouts pytestmark = pytest.mark.skip( reason="MongoDB connection timeout during search repository initialization" ) # ============================================================================= # AUTH DEPENDENCY OVERRIDES # ============================================================================= @pytest.fixture def mock_auth_dependencies(): """ Mock authentication dependencies using dependency_overrides. Returns: Dict with admin and regular user contexts """ from registry.auth.dependencies import enhanced_auth, nginx_proxied_auth from registry.main import app admin_user_context = { "username": "testadmin", "is_admin": True, "groups": ["admin"], "scopes": ["admin"], "accessible_servers": ["all"], "accessible_agents": ["all"], "accessible_services": ["all"], "ui_permissions": { "list_service": ["all"], "toggle_service": ["all"], "register_service": ["all"], "modify_service": ["all"], }, "auth_method": "session", } def mock_enhanced_auth_override(): return admin_user_context def mock_nginx_proxied_auth_override(): return admin_user_context # Override dependencies at the app level app.dependency_overrides[enhanced_auth] = mock_enhanced_auth_override app.dependency_overrides[nginx_proxied_auth] = mock_nginx_proxied_auth_override yield {"admin": admin_user_context} # Cleanup app.dependency_overrides.clear() @pytest.fixture def mock_nginx_service(): """Mock nginx service.""" with patch("registry.core.nginx_service.nginx_service") as mock_nginx: mock_nginx.generate_config = MagicMock() mock_nginx.reload_nginx = MagicMock() mock_nginx.generate_config_async = AsyncMock() yield mock_nginx @pytest.fixture def mock_health_service(): """Mock health service.""" with patch("registry.health.service.health_service") as mock_health: mock_health.initialize = AsyncMock() mock_health.shutdown = AsyncMock() mock_health.broadcast_health_update = AsyncMock() yield mock_health @pytest.fixture(autouse=True) def setup_search_environment( mock_settings, mock_auth_dependencies, mock_nginx_service, mock_health_service, ): """ Auto-use fixture to set up test environment with all mocks. This fixture runs automatically for all tests in this module. """ # Initialize services with clean state server_service.registered_servers = {} server_service.service_state = {} agent_service.registered_agents = {} agent_service.agent_enabled_state = {} yield # Cleanup server_service.registered_servers.clear() server_service.service_state.clear() agent_service.registered_agents.clear() agent_service.agent_enabled_state.clear() # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def mock_embeddings_client(): """Create a mock embeddings client for testing.""" return MockEmbeddingsClient(model_name="test-model", dimension=384) @pytest.fixture def search_test_servers() -> list[dict[str, Any]]: """ Create test servers with diverse content for search testing. Returns: List of server info dictionaries """ return [ { "path": "/database-server", "server_name": "database-tools", "description": "Server for database operations and queries", "tags": ["database", "sql", "query"], "num_tools": 3, "entity_type": "mcp_server", "tool_list": [ { "name": "query_database", "description": "Execute SQL queries on database", "parsed_description": { "main": "Execute SQL queries on database", "args": "query: string, database: string", }, "schema": {"type": "object"}, }, { "name": "list_tables", "description": "List all tables in database", "parsed_description": { "main": "List all tables in database", "args": "database: string", }, "schema": {"type": "object"}, }, { "name": "export_data", "description": "Export data from database to CSV", "parsed_description": { "main": "Export data from database to CSV", "args": "table: string, format: string", }, "schema": {"type": "object"}, }, ], }, { "path": "/weather-server", "server_name": "weather-api", "description": "Fetch weather data and forecasts", "tags": ["weather", "forecast", "climate"], "num_tools": 2, "entity_type": "mcp_server", "tool_list": [ { "name": "get_current_weather", "description": "Get current weather for a location", "parsed_description": { "main": "Get current weather for a location", "args": "location: string, units: string", }, "schema": {"type": "object"}, }, { "name": "get_forecast", "description": "Get weather forecast for next 7 days", "parsed_description": { "main": "Get weather forecast for next 7 days", "args": "location: string, days: integer", }, "schema": {"type": "object"}, }, ], }, { "path": "/file-server", "server_name": "file-operations", "description": "File system operations and file management", "tags": ["files", "filesystem", "storage"], "num_tools": 4, "entity_type": "mcp_server", "tool_list": [ { "name": "read_file", "description": "Read contents of a file", "parsed_description": { "main": "Read contents of a file", "args": "path: string", }, "schema": {"type": "object"}, }, { "name": "write_file", "description": "Write data to a file", "parsed_description": { "main": "Write data to a file", "args": "path: string, content: string", }, "schema": {"type": "object"}, }, { "name": "list_directory", "description": "List files in a directory", "parsed_description": { "main": "List files in a directory", "args": "path: string", }, "schema": {"type": "object"}, }, { "name": "delete_file", "description": "Delete a file from filesystem", "parsed_description": { "main": "Delete a file from filesystem", "args": "path: string", }, "schema": {"type": "object"}, }, ], }, { "path": "/search-server", "server_name": "web-search", "description": "Search the web and retrieve information", "tags": ["search", "web", "internet"], "num_tools": 2, "entity_type": "mcp_server", "tool_list": [ { "name": "web_search", "description": "Search the web using search engines", "parsed_description": { "main": "Search the web using search engines", "args": "query: string, limit: integer", }, "schema": {"type": "object"}, }, { "name": "scrape_webpage", "description": "Extract content from a webpage", "parsed_description": { "main": "Extract content from a webpage", "args": "url: string", }, "schema": {"type": "object"}, }, ], }, ] @pytest.fixture def search_test_agents() -> list[dict[str, Any]]: """ Create test agents with diverse content for search testing. Returns: List of agent card dictionaries """ agent1 = AgentCardFactory( name="data-analyst-agent", description="Analyze data and generate insights from databases", path="/agents/data-analyst", tags=["data", "analysis", "database"], skills=[ { "id": "data-analysis", "name": "Data Analysis", "description": "Analyze datasets and generate statistical insights", "tags": ["analysis", "statistics"], "examples": ["Analyze sales data", "Generate trend reports"], }, { "id": "database-query", "name": "Database Querying", "description": "Query databases and retrieve information", "tags": ["database", "sql"], "examples": ["Query customer records", "Extract transaction data"], }, ], visibility="public", ) agent2 = AgentCardFactory( name="weather-assistant", description="Provide weather information and forecasts", path="/agents/weather-assistant", tags=["weather", "forecast", "climate"], skills=[ { "id": "weather-info", "name": "Weather Information", "description": "Get current weather and forecasts", "tags": ["weather"], "examples": ["What's the weather today?", "Will it rain tomorrow?"], } ], visibility="public", ) agent3 = AgentCardFactory( name="code-reviewer", description="Review code and suggest improvements", path="/agents/code-reviewer", tags=["code", "review", "development"], skills=[ { "id": "code-review", "name": "Code Review", "description": "Review code for quality and best practices", "tags": ["code", "review"], "examples": ["Review my Python code", "Check this function"], } ], visibility="public", ) agent4 = AgentCardFactory( name="private-agent", description="Internal agent for internal use only", path="/agents/internal-agent", tags=["internal"], skills=[], visibility="internal", registered_by="testuser", ) agent5 = AgentCardFactory( name="group-restricted-agent", description="Agent accessible only to specific groups", path="/agents/group-agent", tags=["group", "restricted"], skills=[], visibility="group-restricted", allowed_groups=["admin", "developers"], registered_by="testadmin", ) return [ agent1.model_dump(), agent2.model_dump(), agent3.model_dump(), agent4.model_dump(), agent5.model_dump(), ] @pytest.fixture def mock_faiss_search_results(): """ Create mock FAISS search results for predictable testing. Returns: Dictionary mapping query patterns to search results """ return { "database": { "servers": [ { "entity_type": "mcp_server", "path": "/database-server", "server_name": "database-tools", "description": "Server for database operations and queries", "tags": ["database", "sql", "query"], "num_tools": 3, "is_enabled": True, "relevance_score": 0.92, "match_context": "Server for database operations and queries", "matching_tools": [], } ], "tools": [], "agents": [ { "entity_type": "a2a_agent", "path": "/agents/data-analyst", "agent_name": "data-analyst-agent", "description": "Analyze data and generate insights from databases", "tags": ["data", "analysis", "database"], "skills": ["Data Analysis", "Database Querying"], "visibility": "public", "is_enabled": True, "relevance_score": 0.88, "match_context": "Analyze data and generate insights from databases", } ], }, "weather": { "servers": [ { "entity_type": "mcp_server", "path": "/weather-server", "server_name": "weather-api", "description": "Fetch weather data and forecasts", "tags": ["weather", "forecast", "climate"], "num_tools": 2, "is_enabled": True, "relevance_score": 0.95, "match_context": "Fetch weather data and forecasts", "matching_tools": [], } ], "tools": [], "agents": [ { "entity_type": "a2a_agent", "path": "/agents/weather-assistant", "agent_name": "weather-assistant", "description": "Provide weather information and forecasts", "tags": ["weather", "forecast", "climate"], "skills": ["Weather Information"], "visibility": "public", "is_enabled": True, "relevance_score": 0.93, "match_context": "Provide weather information and forecasts", } ], }, "file operations": { "servers": [ { "entity_type": "mcp_server", "path": "/file-server", "server_name": "file-operations", "description": "File system operations and file management", "tags": ["files", "filesystem", "storage"], "num_tools": 4, "is_enabled": True, "relevance_score": 0.90, "match_context": "File system operations and file management", "matching_tools": [ { "tool_name": "read_file", "description": "Read contents of a file", "relevance_score": 0.85, "match_context": "Read contents of a file", } ], } ], "tools": [ { "entity_type": "tool", "server_path": "/file-server", "server_name": "file-operations", "tool_name": "read_file", "description": "Read contents of a file", "relevance_score": 0.85, "match_context": "Read contents of a file", } ], "agents": [], }, "empty query": {"servers": [], "tools": [], "agents": []}, } @pytest.fixture async def setup_search_data( mock_settings, search_test_servers, search_test_agents, mock_embeddings_client ): """ Set up test data in FAISS service for search testing. Args: mock_settings: Test settings fixture search_test_servers: Test servers fixture search_test_agents: Test agents fixture mock_embeddings_client: Mock embeddings client Yields: Initialized FAISS service with test data """ # Initialize FAISS service with mock embeddings faiss_service = FaissService() faiss_service.embedding_model = mock_embeddings_client faiss_service._initialize_new_index() # Add servers to FAISS for server in search_test_servers: await faiss_service.add_or_update_service( service_path=server["path"], server_info=server, is_enabled=True ) # Add agents to FAISS from registry.schemas.agent_models import AgentCard for agent_data in search_test_agents: agent_card = AgentCard(**agent_data) await faiss_service.add_or_update_agent( agent_path=agent_card.path, agent_card=agent_card, is_enabled=True ) # Register servers with server service for server in search_test_servers: server_service.registered_servers[server["path"]] = server server_service.service_state[server["path"]] = True # Register agents with agent service from registry.schemas.agent_models import AgentCard for agent_data in search_test_agents: agent_card = AgentCard(**agent_data) agent_service.registered_agents[agent_card.path] = agent_card yield faiss_service # Cleanup server_service.registered_servers.clear() server_service.service_state.clear() agent_service.registered_agents.clear() # ============================================================================= # SEMANTIC SEARCH TESTS # ============================================================================= @pytest.mark.integration @pytest.mark.search class TestSemanticSearchIntegration: """Tests for semantic search integration.""" def test_search_servers_basic(self, test_client, mock_faiss_search_results): """Test basic semantic search for servers.""" # Arrange search_query = "database" # Mock FAISS search to return predictable results with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=mock_faiss_search_results["database"], ): # Act response = test_client.post( "/api/search/semantic", json={"query": search_query, "max_results": 10} ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["query"] == search_query assert "servers" in data assert "tools" in data assert "agents" in data assert data["total_servers"] >= 0 assert data["total_tools"] >= 0 assert data["total_agents"] >= 0 def test_search_agents_basic(self, test_client, mock_faiss_search_results): """Test basic semantic search for agents.""" # Arrange search_query = "weather" # Mock FAISS search with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=mock_faiss_search_results["weather"], ): # Act response = test_client.post( "/api/search/semantic", json={"query": search_query, "max_results": 10} ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["query"] == search_query assert len(data["agents"]) >= 0 def test_search_mixed_results(self, test_client, mock_faiss_search_results): """Test semantic search returning both servers and agents.""" # Arrange search_query = "database" with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=mock_faiss_search_results["database"], ): # Act response = test_client.post( "/api/search/semantic", json={"query": search_query, "max_results": 10} ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_servers"] + data["total_agents"] >= 0 def test_search_with_tools(self, test_client, mock_faiss_search_results): """Test semantic search including tool matches.""" # Arrange search_query = "file operations" with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=mock_faiss_search_results["file operations"], ): # Act response = test_client.post( "/api/search/semantic", json={"query": search_query, "max_results": 10} ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() # Check tools in response if data["total_tools"] > 0: assert "tools" in data assert len(data["tools"]) > 0 # ============================================================================= # SEARCH FILTER TESTS # ============================================================================= @pytest.mark.integration @pytest.mark.search class TestSearchFilters: """Tests for search filtering functionality.""" def test_search_filter_mcp_server_only(self, test_client, mock_faiss_search_results): """Test search with mcp_server entity type filter.""" # Arrange search_query = "database" mock_result = { "servers": mock_faiss_search_results["database"]["servers"], "tools": [], "agents": [], } with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=mock_result, ): # Act response = test_client.post( "/api/search/semantic", json={"query": search_query, "entity_types": ["mcp_server"], "max_results": 10}, ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_agents"] == 0 def test_search_filter_agent_only(self, test_client, mock_faiss_search_results): """Test search with a2a_agent entity type filter.""" # Arrange search_query = "weather" mock_result = { "servers": [], "tools": [], "agents": mock_faiss_search_results["weather"]["agents"], } with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=mock_result, ): # Act response = test_client.post( "/api/search/semantic", json={"query": search_query, "entity_types": ["a2a_agent"], "max_results": 10}, ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_servers"] == 0 assert data["total_tools"] == 0 def test_search_filter_tool_only(self, test_client, mock_faiss_search_results): """Test search with tool entity type filter.""" # Arrange search_query = "file operations" mock_result = { "servers": [], "tools": mock_faiss_search_results["file operations"]["tools"], "agents": [], } with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=mock_result, ): # Act response = test_client.post( "/api/search/semantic", json={"query": search_query, "entity_types": ["tool"], "max_results": 10}, ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_servers"] == 0 assert data["total_agents"] == 0 def test_search_max_results_limit(self, test_client, mock_faiss_search_results): """Test search respects max_results parameter.""" # Arrange search_query = "database" max_results = 2 with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=mock_faiss_search_results["database"], ): # Act response = test_client.post( "/api/search/semantic", json={"query": search_query, "max_results": max_results} ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_servers"] <= max_results assert data["total_tools"] <= max_results assert data["total_agents"] <= max_results # ============================================================================= # VISIBILITY FILTERING TESTS # ============================================================================= @pytest.mark.integration @pytest.mark.search class TestSearchVisibilityFiltering: """Tests for search visibility filtering.""" def test_search_public_agents_admin(self, test_client, mock_faiss_search_results): """Test that admin users can see all agents.""" # Arrange - Auth is mocked to admin via autouse fixture all_agents_result = { "servers": [], "tools": [], "agents": [ { "entity_type": "a2a_agent", "path": "/agents/data-analyst", "agent_name": "data-analyst-agent", "description": "Public agent", "relevance_score": 0.9, "match_context": "Public agent", } ], } with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=all_agents_result, ): # Act response = test_client.post( "/api/search/semantic", json={"query": "agent", "max_results": 10} ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert "agents" in data def test_search_returns_agents_with_visibility_info( self, test_client, mock_faiss_search_results ): """Test that search results include agent visibility information.""" # Arrange agent_result = { "servers": [], "tools": [], "agents": [ { "entity_type": "a2a_agent", "path": "/agents/private-agent", "agent_name": "private-agent", "description": "Private agent", "relevance_score": 0.9, "match_context": "Private agent", } ], } with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=agent_result, ): # Act response = test_client.post( "/api/search/semantic", json={"query": "private", "max_results": 10} ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_agents"] >= 0 def test_search_group_restricted_agents(self, test_client, mock_faiss_search_results): """Test search with group-restricted agents.""" # Arrange group_agent_result = { "servers": [], "tools": [], "agents": [ { "entity_type": "a2a_agent", "path": "/agents/group-agent", "agent_name": "group-restricted-agent", "description": "Group restricted agent", "relevance_score": 0.9, "match_context": "Group agent", } ], } with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=group_agent_result, ): # Act response = test_client.post( "/api/search/semantic", json={"query": "group", "max_results": 10} ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_agents"] >= 0 def test_search_admin_sees_all_agents(self, test_client, mock_faiss_search_results): """Test that admin users can see all agents regardless of visibility.""" # Arrange all_agents_result = { "servers": [], "tools": [], "agents": [ { "entity_type": "a2a_agent", "path": "/agents/public-agent", "agent_name": "public-agent", "description": "Public agent", "relevance_score": 0.9, "match_context": "Public", }, { "entity_type": "a2a_agent", "path": "/agents/private-agent", "agent_name": "private-agent", "description": "Private agent", "relevance_score": 0.85, "match_context": "Private", }, ], } with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=all_agents_result, ): # Act response = test_client.post( "/api/search/semantic", json={"query": "agent", "max_results": 10} ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() # Admin should see all agents assert data["total_agents"] >= 0 # ============================================================================= # ERROR HANDLING TESTS # ============================================================================= @pytest.mark.integration @pytest.mark.search class TestSearchErrorHandling: """Tests for search error handling.""" def test_search_empty_query_validation(self, test_client): """Test that empty query is rejected.""" # Act response = test_client.post("/api/search/semantic", json={"query": "", "max_results": 10}) # Assert assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY def test_search_missing_query(self, test_client): """Test that missing query field is rejected.""" # Act response = test_client.post("/api/search/semantic", json={"max_results": 10}) # Assert assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY def test_search_service_unavailable(self, test_client): """Test handling of FAISS service errors.""" # Arrange with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, side_effect=RuntimeError("FAISS service unavailable"), ): # Act response = test_client.post( "/api/search/semantic", json={"query": "test", "max_results": 10} ) # Assert - should handle error gracefully assert response.status_code in [ status.HTTP_500_INTERNAL_SERVER_ERROR, status.HTTP_503_SERVICE_UNAVAILABLE, ] def test_search_invalid_entity_type(self, test_client): """Test handling of invalid entity type filter.""" # Arrange mock_result = {"servers": [], "tools": [], "agents": []} with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=mock_result, ): # Act response = test_client.post( "/api/search/semantic", json={"query": "test", "entity_types": ["invalid_type"], "max_results": 10}, ) # Assert - should handle gracefully or return validation error assert response.status_code in [ status.HTTP_200_OK, status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_ENTITY, ] def test_search_empty_results(self, test_client, mock_faiss_search_results): """Test search with no matching results.""" # Arrange with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=mock_faiss_search_results["empty query"], ): # Act response = test_client.post( "/api/search/semantic", json={"query": "nonexistent query", "max_results": 10} ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_servers"] == 0 assert data["total_tools"] == 0 assert data["total_agents"] == 0 assert len(data["servers"]) == 0 assert len(data["tools"]) == 0 assert len(data["agents"]) == 0 # ============================================================================= # SEARCH RANKING TESTS # ============================================================================= @pytest.mark.integration @pytest.mark.search class TestSearchRanking: """Tests for search result ranking and scoring.""" def test_search_results_sorted_by_relevance(self, test_client): """Test that search results are sorted by relevance score.""" # Arrange ranked_results = { "servers": [ { "entity_type": "mcp_server", "path": "/server-1", "server_name": "high-score", "description": "High relevance", "relevance_score": 0.95, "is_enabled": True, "tags": [], "num_tools": 0, "match_context": "High", "matching_tools": [], }, { "entity_type": "mcp_server", "path": "/server-2", "server_name": "medium-score", "description": "Medium relevance", "relevance_score": 0.75, "is_enabled": True, "tags": [], "num_tools": 0, "match_context": "Medium", "matching_tools": [], }, { "entity_type": "mcp_server", "path": "/server-3", "server_name": "low-score", "description": "Low relevance", "relevance_score": 0.55, "is_enabled": True, "tags": [], "num_tools": 0, "match_context": "Low", "matching_tools": [], }, ], "tools": [], "agents": [], } with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=ranked_results, ): # Act response = test_client.post( "/api/search/semantic", json={"query": "test", "max_results": 10} ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() # Check scores are in descending order if len(data["servers"]) > 1: for i in range(len(data["servers"]) - 1): assert ( data["servers"][i]["relevance_score"] >= data["servers"][i + 1]["relevance_score"] ) def test_search_relevance_scores_range(self, test_client, mock_faiss_search_results): """Test that relevance scores are in valid range (0-1).""" # Arrange with patch( "registry.api.search_routes.faiss_service.search_mixed", new_callable=AsyncMock, return_value=mock_faiss_search_results["database"], ): # Act response = test_client.post( "/api/search/semantic", json={"query": "database", "max_results": 10} ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() # Check all scores are in valid range for server in data["servers"]: assert 0.0 <= server["relevance_score"] <= 1.0 for tool in data["tools"]: assert 0.0 <= tool["relevance_score"] <= 1.0 for agent in data["agents"]: assert 0.0 <= agent["relevance_score"] <= 1.0 ================================================ FILE: tests/integration/test_server_lifecycle.py ================================================ """ Integration tests for server lifecycle (CRUD operations). This module tests the full lifecycle of server management including: - Registration - Listing - Retrieval - Updates - Deletion - Error handling NOTE: These tests are currently skipped due to data persistence issue where servers register successfully but don't appear in list/retrieve operations. """ import json import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import status as http_status from fastapi.testclient import TestClient logger = logging.getLogger(__name__) # Skip all tests in this file due to data persistence issue pytestmark = pytest.mark.skip( reason="Data persistence issue - servers register but don't appear in listings" ) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def mock_nginx_service(): """ Mock nginx service to avoid actual nginx operations. Returns: Mock nginx service instance """ with patch("registry.core.nginx_service.nginx_service") as mock_nginx: mock_nginx.generate_config = MagicMock() mock_nginx.reload_nginx = MagicMock() mock_nginx.generate_config_async = AsyncMock() yield mock_nginx @pytest.fixture def mock_faiss_service(): """ Mock FAISS service to avoid actual embedding operations. Returns: Mock FAISS service instance """ with patch("registry.search.service.faiss_service") as mock_faiss: mock_faiss.initialize = AsyncMock() mock_faiss.add_or_update_service = AsyncMock() mock_faiss.add_or_update_agent = AsyncMock() mock_faiss.remove_service = AsyncMock() mock_faiss.save_data = AsyncMock() yield mock_faiss @pytest.fixture def mock_health_service(): """ Mock health service to avoid actual health checks. Returns: Mock health service instance """ with patch("registry.health.service.health_service") as mock_health: mock_health.initialize = AsyncMock() mock_health.shutdown = AsyncMock() mock_health.broadcast_health_update = AsyncMock() mock_health.perform_immediate_health_check = AsyncMock(return_value=("healthy", None)) mock_health._get_service_health_data = MagicMock( return_value={"status": "healthy", "last_checked_iso": "2024-01-01T00:00:00"} ) yield mock_health @pytest.fixture def mock_agent_service(): """ Mock agent service to avoid actual agent operations. Returns: Mock agent service instance """ with patch("registry.services.agent_service.agent_service") as mock_agent: mock_agent.load_agents_and_state = AsyncMock() mock_agent.list_agents = MagicMock(return_value=[]) mock_agent.is_agent_enabled = MagicMock(return_value=False) yield mock_agent @pytest.fixture def mock_auth_dependencies(): """ Mock authentication dependencies using dependency_overrides. Returns: Dict with admin and regular user contexts """ from registry.auth.dependencies import ( enhanced_auth, nginx_proxied_auth, ) from registry.main import app admin_user_context = { "username": "testadmin", "is_admin": True, "groups": ["admin"], "scopes": ["admin"], "accessible_servers": [], "accessible_services": ["all"], "ui_permissions": { "list_service": ["all"], "toggle_service": ["all"], "register_service": ["all"], "modify_service": ["all"], }, "auth_method": "session", } regular_user_context = { "username": "testuser", "is_admin": False, "groups": ["users"], "scopes": ["read"], "accessible_servers": ["test-server"], "accessible_services": ["test-server"], "ui_permissions": { "list_service": ["test-server"], "toggle_service": [], "register_service": [], "modify_service": [], }, "auth_method": "session", } def mock_enhanced_auth_override(): return admin_user_context def mock_nginx_proxied_auth_override(): return admin_user_context def mock_user_has_permission( permission: str, service_name: str, permissions: dict[str, Any] ) -> bool: """Mock permission checker that always returns True for admin""" return True # Override dependencies at the app level app.dependency_overrides[enhanced_auth] = mock_enhanced_auth_override app.dependency_overrides[nginx_proxied_auth] = mock_nginx_proxied_auth_override # Patch the permission checker function with patch( "registry.auth.dependencies.user_has_ui_permission_for_service", mock_user_has_permission ): yield {"admin": admin_user_context, "regular": regular_user_context} # Cleanup app.dependency_overrides.clear() @pytest.fixture def test_server_data() -> dict[str, Any]: """ Create test server data for registration. Returns: Dictionary with server data """ return { "name": "Test Server", "description": "A test MCP server for integration tests", "path": "/test-server", "proxy_pass_url": "http://localhost:9000", "tags": "test,integration", "num_tools": 5, "license": "MIT", } @pytest.fixture def test_server_data_2() -> dict[str, Any]: """ Create second test server data for listing tests. Returns: Dictionary with server data """ return { "name": "Second Test Server", "description": "Another test MCP server", "path": "/second-server", "proxy_pass_url": "http://localhost:9001", "tags": "test,second", "num_tools": 3, "license": "Apache-2.0", } @pytest.fixture(autouse=True) def setup_test_environment( mock_settings, mock_nginx_service, mock_faiss_service, mock_health_service, mock_agent_service, mock_auth_dependencies, ): """ Auto-use fixture to set up test environment with all mocks. This fixture runs automatically for all tests in this module. """ # Initialize server service with clean state from registry.services.server_service import server_service server_service.registered_servers = {} server_service.service_state = {} yield # Cleanup after test server_service.registered_servers = {} server_service.service_state = {} # ============================================================================= # REGISTRATION TESTS # ============================================================================= @pytest.mark.integration class TestServerRegistration: """Test server registration functionality.""" def test_register_server_success( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test successful server registration.""" # Act response = test_client.post("/api/servers/register", data=test_server_data) # Assert if response.status_code != http_status.HTTP_201_CREATED: logger.error(f"Registration failed with status {response.status_code}") logger.error(f"Response body: {response.text}") assert response.status_code == http_status.HTTP_201_CREATED data = response.json() assert data["path"] == test_server_data["path"] assert data["name"] == test_server_data["name"] assert "registered successfully" in data["message"].lower() def test_register_server_duplicate_path( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test registering server with duplicate path.""" # Arrange - Register first server response1 = test_client.post("/api/servers/register", data=test_server_data) assert response1.status_code == http_status.HTTP_201_CREATED # Act - Try to register duplicate (overwrite=false) duplicate_data = test_server_data.copy() duplicate_data["overwrite"] = False response2 = test_client.post("/api/servers/register", data=duplicate_data) # Assert assert response2.status_code == http_status.HTTP_409_CONFLICT data = response2.json() assert "already exists" in data["reason"].lower() def test_register_server_overwrite_existing( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test overwriting existing server with overwrite=true.""" # Arrange - Register first server response1 = test_client.post("/api/servers/register", data=test_server_data) assert response1.status_code == http_status.HTTP_201_CREATED # Act - Overwrite with updated data updated_data = test_server_data.copy() updated_data["description"] = "Updated description" updated_data["overwrite"] = True response2 = test_client.post("/api/servers/register", data=updated_data) # Assert assert response2.status_code == http_status.HTTP_201_CREATED data = response2.json() assert data["path"] == test_server_data["path"] def test_register_server_without_leading_slash( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test path normalization (adds leading slash).""" # Arrange test_server_data["path"] = "no-leading-slash" # Act response = test_client.post("/api/servers/register", data=test_server_data) # Assert assert response.status_code == http_status.HTTP_201_CREATED data = response.json() assert data["path"] == "/no-leading-slash" def test_register_server_minimal_data(self, test_client: TestClient): """Test registration with only required fields.""" # Arrange minimal_data = { "name": "Minimal Server", "description": "Minimal test server", "path": "/minimal", "proxy_pass_url": "http://localhost:8888", } # Act response = test_client.post("/api/servers/register", data=minimal_data) # Assert assert response.status_code == http_status.HTTP_201_CREATED def test_register_server_with_tool_list( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test registration with tool_list_json.""" # Arrange tools = [ {"name": "get_data", "description": "Get data"}, {"name": "set_data", "description": "Set data"}, ] test_server_data["tool_list_json"] = json.dumps(tools) # Act response = test_client.post("/api/servers/register", data=test_server_data) # Assert assert response.status_code == http_status.HTTP_201_CREATED # ============================================================================= # LIST SERVERS TESTS # ============================================================================= @pytest.mark.integration class TestServerListing: """Test server listing functionality.""" def test_list_servers_empty(self, test_client: TestClient): """Test listing servers when none are registered.""" # Act response = test_client.get("/api/servers") # Assert assert response.status_code == http_status.HTTP_200_OK data = response.json() assert "servers" in data assert data["servers"] == [] def test_list_servers_with_single_server( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test listing servers with one registered server.""" # Arrange - Register a server reg_response = test_client.post("/api/servers/register", data=test_server_data) assert reg_response.status_code == http_status.HTTP_201_CREATED # Act response = test_client.get("/api/servers") # Assert assert response.status_code == http_status.HTTP_200_OK data = response.json() assert len(data["servers"]) == 1 server = data["servers"][0] assert server["display_name"] == test_server_data["name"] assert server["path"] == test_server_data["path"] assert server["description"] == test_server_data["description"] def test_list_servers_with_multiple_servers( self, test_client: TestClient, test_server_data: dict[str, Any], test_server_data_2: dict[str, Any], ): """Test listing multiple registered servers.""" # Arrange - Register two servers reg1 = test_client.post("/api/servers/register", data=test_server_data) reg2 = test_client.post("/api/servers/register", data=test_server_data_2) assert reg1.status_code == http_status.HTTP_201_CREATED assert reg2.status_code == http_status.HTTP_201_CREATED # Act response = test_client.get("/api/servers") # Assert assert response.status_code == http_status.HTTP_200_OK data = response.json() assert len(data["servers"]) == 2 # Verify both servers are present server_paths = [s["path"] for s in data["servers"]] assert test_server_data["path"] in server_paths assert test_server_data_2["path"] in server_paths def test_list_servers_with_query_filter( self, test_client: TestClient, test_server_data: dict[str, Any], test_server_data_2: dict[str, Any], ): """Test listing servers with search query filter.""" # Arrange - Register two servers test_client.post("/api/servers/register", data=test_server_data) test_client.post("/api/servers/register", data=test_server_data_2) # Act - Search for "second" response = test_client.get("/api/servers?query=second") # Assert assert response.status_code == http_status.HTTP_200_OK data = response.json() assert len(data["servers"]) == 1 assert data["servers"][0]["display_name"] == test_server_data_2["name"] def test_list_servers_includes_metadata( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test that server list includes all expected metadata.""" # Arrange test_client.post("/api/servers/register", data=test_server_data) # Act response = test_client.get("/api/servers") # Assert assert response.status_code == http_status.HTTP_200_OK data = response.json() server = data["servers"][0] # Check required fields assert "display_name" in server assert "path" in server assert "description" in server assert "proxy_pass_url" in server assert "is_enabled" in server assert "tags" in server assert "num_tools" in server assert "license" in server assert "health_status" in server # ============================================================================= # GET SERVER TESTS # ============================================================================= @pytest.mark.integration class TestServerRetrieval: """Test getting individual server details.""" def test_get_server_by_path_success( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test retrieving server details by path.""" # Arrange - Register server reg_response = test_client.post("/api/servers/register", data=test_server_data) assert reg_response.status_code == http_status.HTTP_201_CREATED # Act path = test_server_data["path"].lstrip("/") response = test_client.get(f"/api/server_details/{path}") # Assert assert response.status_code == http_status.HTTP_200_OK data = response.json() assert data["path"] == test_server_data["path"] assert data["server_name"] == test_server_data["name"] def test_get_server_nonexistent_path(self, test_client: TestClient): """Test retrieving server with non-existent path.""" # Act response = test_client.get("/api/server_details/nonexistent") # Assert assert response.status_code == http_status.HTTP_404_NOT_FOUND # ============================================================================= # UPDATE SERVER TESTS # ============================================================================= @pytest.mark.integration class TestServerUpdate: """ Test server update functionality. Note: The current API only supports updates via register with overwrite=true. The /edit endpoint is for web UI and returns HTML redirects, not suitable for API testing. """ def test_update_server_via_overwrite( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test updating server by re-registering with overwrite=true.""" # Arrange - Register server reg_response = test_client.post("/api/servers/register", data=test_server_data) assert reg_response.status_code == http_status.HTTP_201_CREATED # Act - Update by re-registering with overwrite=true updated_data = test_server_data.copy() updated_data["name"] = "Updated Test Server" updated_data["description"] = "Updated description" updated_data["num_tools"] = 10 updated_data["overwrite"] = True response = test_client.post("/api/servers/register", data=updated_data) # Assert assert response.status_code == http_status.HTTP_201_CREATED # Verify update by listing servers list_response = test_client.get("/api/servers") servers = list_response.json()["servers"] assert len(servers) == 1 # Should still be only one server updated_server = servers[0] assert updated_server["display_name"] == updated_data["name"] assert updated_server["description"] == updated_data["description"] assert updated_server["num_tools"] == updated_data["num_tools"] def test_update_server_reject_without_overwrite( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test that updating without overwrite=true fails.""" # Arrange - Register server test_client.post("/api/servers/register", data=test_server_data) # Act - Try to update without overwrite updated_data = test_server_data.copy() updated_data["name"] = "Updated Test Server" updated_data["overwrite"] = False response = test_client.post("/api/servers/register", data=updated_data) # Assert assert response.status_code == http_status.HTTP_409_CONFLICT # ============================================================================= # DELETE SERVER TESTS # ============================================================================= @pytest.mark.integration class TestServerDeletion: """Test server deletion functionality.""" def test_delete_server_success(self, test_client: TestClient, test_server_data: dict[str, Any]): """Test successful server deletion.""" # Arrange - Register server reg_response = test_client.post("/api/servers/register", data=test_server_data) assert reg_response.status_code == http_status.HTTP_201_CREATED # Act - Delete server response = test_client.post("/api/servers/remove", data={"path": test_server_data["path"]}) # Assert assert response.status_code == http_status.HTTP_200_OK data = response.json() assert "removed successfully" in data["message"].lower() # Verify deletion by listing servers list_response = test_client.get("/api/servers") servers = list_response.json()["servers"] assert len(servers) == 0 def test_delete_server_nonexistent(self, test_client: TestClient): """Test deleting non-existent server.""" # Act response = test_client.post("/api/servers/remove", data={"path": "/nonexistent"}) # Assert assert response.status_code == http_status.HTTP_404_NOT_FOUND data = response.json() # The response contains "no service registered at path" which includes conceptually "not found" assert "service" in data["reason"].lower() or "not found" in data["reason"].lower() def test_delete_server_without_leading_slash( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test path normalization in delete operation.""" # Arrange - Register server test_client.post("/api/servers/register", data=test_server_data) # Act - Delete without leading slash path_without_slash = test_server_data["path"].lstrip("/") response = test_client.post("/api/servers/remove", data={"path": path_without_slash}) # Assert assert response.status_code == http_status.HTTP_200_OK # ============================================================================= # TOGGLE SERVER TESTS # ============================================================================= @pytest.mark.integration class TestServerToggle: """Test server enable/disable toggle functionality.""" def test_toggle_server_enable(self, test_client: TestClient, test_server_data: dict[str, Any]): """Test enabling a server.""" # Arrange - Register server (defaults to disabled) test_client.post("/api/servers/register", data=test_server_data) # Act - Enable server response = test_client.post( "/api/servers/toggle", data={"path": test_server_data["path"], "new_state": True} ) # Assert assert response.status_code == http_status.HTTP_200_OK data = response.json() assert data["new_enabled_state"] is True def test_toggle_server_disable(self, test_client: TestClient, test_server_data: dict[str, Any]): """Test disabling a server.""" # Arrange - Register and enable server test_client.post("/api/servers/register", data=test_server_data) test_client.post( "/api/servers/toggle", data={"path": test_server_data["path"], "new_state": True} ) # Act - Disable server response = test_client.post( "/api/servers/toggle", data={"path": test_server_data["path"], "new_state": False} ) # Assert assert response.status_code == http_status.HTTP_200_OK data = response.json() assert data["new_enabled_state"] is False def test_toggle_server_nonexistent(self, test_client: TestClient): """Test toggling non-existent server.""" # Act response = test_client.post( "/api/servers/toggle", data={"path": "/nonexistent", "new_state": True} ) # Assert assert response.status_code == http_status.HTTP_404_NOT_FOUND # ============================================================================= # FULL LIFECYCLE TESTS # ============================================================================= @pytest.mark.integration class TestServerFullLifecycle: """Test complete server lifecycle (create -> read -> update -> delete).""" def test_full_crud_lifecycle(self, test_client: TestClient, test_server_data: dict[str, Any]): """Test complete CRUD lifecycle for a server.""" # CREATE create_response = test_client.post("/api/servers/register", data=test_server_data) assert create_response.status_code == http_status.HTTP_201_CREATED created_path = create_response.json()["path"] # READ - List all list_response = test_client.get("/api/servers") assert list_response.status_code == http_status.HTTP_200_OK servers = list_response.json()["servers"] assert len(servers) == 1 assert servers[0]["path"] == created_path # READ - Get specific path_param = created_path.lstrip("/") detail_response = test_client.get(f"/api/server_details/{path_param}") assert detail_response.status_code == http_status.HTTP_200_OK assert detail_response.json()["path"] == created_path # UPDATE - via overwrite registration update_data = test_server_data.copy() update_data["name"] = "Updated Server Name" update_data["description"] = "Updated description" update_data["num_tools"] = 99 update_data["overwrite"] = True update_response = test_client.post("/api/servers/register", data=update_data) assert update_response.status_code == http_status.HTTP_201_CREATED # Verify update list_after_update = test_client.get("/api/servers") servers_after_update = list_after_update.json()["servers"] assert len(servers_after_update) == 1 # Still only one server updated_server = servers_after_update[0] assert updated_server["display_name"] == update_data["name"] assert updated_server["num_tools"] == update_data["num_tools"] # DELETE delete_response = test_client.post("/api/servers/remove", data={"path": created_path}) assert delete_response.status_code == http_status.HTTP_200_OK # Verify deletion list_after_delete = test_client.get("/api/servers") assert len(list_after_delete.json()["servers"]) == 0 def test_lifecycle_with_toggle_operations( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test lifecycle including enable/disable operations.""" # CREATE create_response = test_client.post("/api/servers/register", data=test_server_data) assert create_response.status_code == http_status.HTTP_201_CREATED path = create_response.json()["path"] # TOGGLE - Enable enable_response = test_client.post( "/api/servers/toggle", data={"path": path, "new_state": True} ) assert enable_response.status_code == http_status.HTTP_200_OK assert enable_response.json()["new_enabled_state"] is True # Verify enabled state list_response = test_client.get("/api/servers") server = list_response.json()["servers"][0] assert server["is_enabled"] is True # TOGGLE - Disable disable_response = test_client.post( "/api/servers/toggle", data={"path": path, "new_state": False} ) assert disable_response.status_code == http_status.HTTP_200_OK # DELETE delete_response = test_client.post("/api/servers/remove", data={"path": path}) assert delete_response.status_code == http_status.HTTP_200_OK def test_multiple_servers_lifecycle( self, test_client: TestClient, test_server_data: dict[str, Any], test_server_data_2: dict[str, Any], ): """Test lifecycle with multiple servers.""" # CREATE multiple servers create1 = test_client.post("/api/servers/register", data=test_server_data) create2 = test_client.post("/api/servers/register", data=test_server_data_2) assert create1.status_code == http_status.HTTP_201_CREATED assert create2.status_code == http_status.HTTP_201_CREATED # LIST - Verify both present list_response = test_client.get("/api/servers") servers = list_response.json()["servers"] assert len(servers) == 2 # UPDATE first server via overwrite update_data = test_server_data.copy() update_data["name"] = "Updated First Server" update_data["overwrite"] = True update_response = test_client.post("/api/servers/register", data=update_data) assert update_response.status_code == http_status.HTTP_201_CREATED # DELETE first server delete_response = test_client.post( "/api/servers/remove", data={"path": test_server_data["path"]} ) assert delete_response.status_code == http_status.HTTP_200_OK # LIST - Verify only second remains list_after_delete = test_client.get("/api/servers") remaining_servers = list_after_delete.json()["servers"] assert len(remaining_servers) == 1 assert remaining_servers[0]["path"] == test_server_data_2["path"] # DELETE second server delete2_response = test_client.post( "/api/servers/remove", data={"path": test_server_data_2["path"]} ) assert delete2_response.status_code == http_status.HTTP_200_OK # LIST - Verify empty final_list = test_client.get("/api/servers") assert len(final_list.json()["servers"]) == 0 # ============================================================================= # ERROR HANDLING TESTS # ============================================================================= @pytest.mark.integration class TestServerErrorHandling: """Test error handling in server operations.""" def test_register_with_missing_required_fields(self, test_client: TestClient): """Test registration with missing required fields.""" # Act - Missing proxy_pass_url response = test_client.post( "/api/servers/register", data={"name": "Test", "description": "Test", "path": "/test"} ) # Assert assert response.status_code == http_status.HTTP_422_UNPROCESSABLE_ENTITY def test_update_preserves_path(self, test_client: TestClient, test_server_data: dict[str, Any]): """Test that update operation preserves the original path.""" # Arrange - Register server test_client.post("/api/servers/register", data=test_server_data) original_path = test_server_data["path"] # Act - Update server via overwrite update_data = test_server_data.copy() update_data["name"] = "Updated Name" update_data["proxy_pass_url"] = "http://localhost:9999" update_data["overwrite"] = True update_response = test_client.post("/api/servers/register", data=update_data) assert update_response.status_code == http_status.HTTP_201_CREATED # Assert - Path unchanged list_response = test_client.get("/api/servers") servers = list_response.json()["servers"] assert len(servers) == 1 assert servers[0]["path"] == original_path def test_operations_on_same_server_sequential( self, test_client: TestClient, test_server_data: dict[str, Any] ): """Test sequential operations on the same server.""" # CREATE create_resp = test_client.post("/api/servers/register", data=test_server_data) assert create_resp.status_code == http_status.HTTP_201_CREATED path = create_resp.json()["path"] # UPDATE 1 update_data_1 = test_server_data.copy() update_data_1["name"] = "Updated 1" update_data_1["overwrite"] = True update_resp = test_client.post("/api/servers/register", data=update_data_1) assert update_resp.status_code == http_status.HTTP_201_CREATED # TOGGLE toggle_resp = test_client.post( "/api/servers/toggle", data={"path": path, "new_state": True} ) assert toggle_resp.status_code == http_status.HTTP_200_OK # UPDATE 2 update_data_2 = test_server_data.copy() update_data_2["name"] = "Updated 2" update_data_2["overwrite"] = True update2_resp = test_client.post("/api/servers/register", data=update_data_2) assert update2_resp.status_code == http_status.HTTP_201_CREATED # DELETE delete_resp = test_client.post("/api/servers/remove", data={"path": path}) assert delete_resp.status_code == http_status.HTTP_200_OK # Verify final state list_resp = test_client.get("/api/servers") assert len(list_resp.json()["servers"]) == 0 ================================================ FILE: tests/integration/test_skill_api.py ================================================ """Integration tests for skill API endpoints.""" from unittest.mock import AsyncMock, patch import pytest # Sample skill data for testing SAMPLE_SKILL_DATA = { "name": "test-skill", "description": "A test skill for integration testing", "skill_md_url": "https://raw.githubusercontent.com/test/skills/main/SKILL.md", "tags": ["test", "integration"], "visibility": "public", } @pytest.fixture def skill_data(): """Sample skill data for testing.""" return SAMPLE_SKILL_DATA.copy() @pytest.fixture def mock_url_validation(): """Mock SKILL.md URL validation to avoid network requests.""" with patch( "registry.services.skill_service._validate_skill_md_url", new_callable=AsyncMock, ) as mock: mock.return_value = { "valid": True, "content_version": "abc123def456", "content_updated_at": None, } yield mock @pytest.fixture def mock_auth_admin(): """Mock authentication returning admin user context.""" admin_context = { "username": "admin", "groups": ["mcp-registry-admin"], "scopes": ["mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute"], "is_admin": True, "can_modify_servers": True, } with patch( "registry.api.skill_routes.nginx_proxied_auth", return_value=admin_context, ): yield admin_context @pytest.fixture def mock_auth_user(): """Mock authentication returning regular user context.""" user_context = { "username": "testuser", "groups": ["mcp-registry-user"], "scopes": ["mcp-servers-unrestricted/read"], "is_admin": False, "can_modify_servers": False, } with patch( "registry.api.skill_routes.nginx_proxied_auth", return_value=user_context, ): yield user_context @pytest.fixture def mock_skill_repository(): """Mock skill repository.""" mock_repo = AsyncMock() mock_repo.ensure_indexes = AsyncMock() mock_repo.create = AsyncMock() mock_repo.get = AsyncMock(return_value=None) mock_repo.list_all = AsyncMock(return_value=[]) mock_repo.list_filtered = AsyncMock(return_value=[]) mock_repo.update = AsyncMock() mock_repo.delete = AsyncMock(return_value=True) mock_repo.set_state = AsyncMock(return_value=True) mock_repo.get_state = AsyncMock(return_value=True) return mock_repo @pytest.fixture def mock_search_repository(): """Mock search repository.""" mock_repo = AsyncMock() mock_repo.index_skill = AsyncMock() mock_repo.remove_entity = AsyncMock() return mock_repo class TestSkillModels: """Test skill data model validation.""" def test_skill_card_creation(self): """Test SkillCard model creation.""" from registry.schemas.skill_models import SkillCard skill = SkillCard( path="/skills/test-skill", name="test-skill", description="A test skill", skill_md_url="https://test.com/SKILL.md", ) assert skill.path == "/skills/test-skill" assert skill.name == "test-skill" assert skill.is_enabled is True def test_skill_registration_request_validation(self, skill_data): """Test SkillRegistrationRequest validation.""" from registry.schemas.skill_models import SkillRegistrationRequest request = SkillRegistrationRequest(**skill_data) assert request.name == "test-skill" assert "test" in request.tags def test_skill_info_from_card(self): """Test creating SkillInfo from SkillCard.""" from registry.schemas.skill_models import SkillCard, SkillInfo skill = SkillCard( path="/skills/test", name="test", description="Test skill", skill_md_url="https://test.com/SKILL.md", tags=["tag1", "tag2"], ) from uuid import uuid4 info = SkillInfo( id=uuid4(), path=skill.path, name=skill.name, description=skill.description, skill_md_url=str(skill.skill_md_url), tags=skill.tags, is_enabled=skill.is_enabled, visibility=skill.visibility, ) assert info.path == "/skills/test" assert len(info.tags) == 2 class TestSkillService: """Test skill service functionality.""" @pytest.mark.asyncio async def test_register_skill( self, skill_data, mock_url_validation, mock_skill_repository, mock_search_repository, ): """Test skill registration.""" from registry.schemas.skill_models import SkillCard, SkillRegistrationRequest from registry.services.skill_service import SkillService # Setup mock created_skill = SkillCard( path="/skills/test-skill", name="test-skill", description="A test skill for integration testing", skill_md_url="https://raw.githubusercontent.com/test/skills/main/SKILL.md", tags=["test", "integration"], ) mock_skill_repository.create.return_value = created_skill service = SkillService() service._repo = mock_skill_repository service._search_repo = mock_search_repository request = SkillRegistrationRequest(**skill_data) result = await service.register_skill(request, owner="testuser") assert result.name == "test-skill" assert result.path == "/skills/test-skill" mock_skill_repository.create.assert_called_once() @pytest.mark.asyncio async def test_get_skill(self, mock_skill_repository, mock_search_repository): """Test getting a skill by path.""" from registry.schemas.skill_models import SkillCard from registry.services.skill_service import SkillService skill = SkillCard( path="/skills/test", name="test", description="Test", skill_md_url="https://test.com/SKILL.md", ) mock_skill_repository.get.return_value = skill service = SkillService() service._repo = mock_skill_repository service._search_repo = mock_search_repository result = await service.get_skill("/skills/test") assert result is not None assert result.name == "test" @pytest.mark.asyncio async def test_list_skills(self, mock_skill_repository, mock_search_repository): """Test listing skills.""" from registry.schemas.skill_models import SkillCard from registry.services.skill_service import SkillService skills = [ SkillCard( path="/skills/skill1", name="skill1", description="Skill 1", skill_md_url="https://test.com/SKILL.md", ), SkillCard( path="/skills/skill2", name="skill2", description="Skill 2", skill_md_url="https://test.com/SKILL.md", ), ] mock_skill_repository.list_filtered.return_value = skills service = SkillService() service._repo = mock_skill_repository service._search_repo = mock_search_repository result = await service.list_skills() assert len(result) == 2 @pytest.mark.asyncio async def test_toggle_skill(self, mock_skill_repository, mock_search_repository): """Test toggling skill enabled state.""" from registry.schemas.skill_models import SkillCard from registry.services.skill_service import SkillService skill = SkillCard( path="/skills/test", name="test", description="Test", skill_md_url="https://test.com/SKILL.md", is_enabled=True, ) mock_skill_repository.set_state.return_value = True mock_skill_repository.get.return_value = skill service = SkillService() service._repo = mock_skill_repository service._search_repo = mock_search_repository result = await service.toggle_skill("/skills/test", False) assert result is True mock_skill_repository.set_state.assert_called_once() @pytest.mark.asyncio async def test_delete_skill(self, mock_skill_repository, mock_search_repository): """Test skill deletion.""" from registry.services.skill_service import SkillService mock_skill_repository.delete.return_value = True service = SkillService() service._repo = mock_skill_repository service._search_repo = mock_search_repository result = await service.delete_skill("/skills/test") assert result is True mock_skill_repository.delete.assert_called_once() class TestSkillVisibility: """Test skill visibility filtering.""" @pytest.mark.asyncio async def test_public_skill_visible_to_anonymous( self, mock_skill_repository, mock_search_repository, ): """Test that public skills are visible to anonymous users.""" from registry.schemas.skill_models import SkillCard, VisibilityEnum from registry.services.skill_service import SkillService public_skill = SkillCard( path="/skills/public", name="public", description="Public skill", skill_md_url="https://test.com/SKILL.md", visibility=VisibilityEnum.PUBLIC, ) mock_skill_repository.list_filtered.return_value = [public_skill] service = SkillService() service._repo = mock_skill_repository service._search_repo = mock_search_repository result = await service.list_skills_for_user(user_context=None) assert len(result) == 1 assert result[0].name == "public" @pytest.mark.asyncio async def test_private_skill_hidden_from_others( self, mock_skill_repository, mock_search_repository, ): """Test that private skills are hidden from non-owners.""" from registry.schemas.skill_models import SkillCard, VisibilityEnum from registry.services.skill_service import SkillService private_skill = SkillCard( path="/skills/private", name="private", description="Private skill", skill_md_url="https://test.com/SKILL.md", visibility=VisibilityEnum.PRIVATE, owner="other_user", ) mock_skill_repository.list_filtered.return_value = [private_skill] mock_skill_repository.get.return_value = private_skill service = SkillService() service._repo = mock_skill_repository service._search_repo = mock_search_repository user_context = {"username": "testuser", "groups": [], "is_admin": False} result = await service.list_skills_for_user(user_context=user_context) assert len(result) == 0 @pytest.mark.asyncio async def test_admin_sees_all_skills( self, mock_skill_repository, mock_search_repository, ): """Test that admin users see all skills.""" from registry.schemas.skill_models import SkillCard, VisibilityEnum from registry.services.skill_service import SkillService skills = [ SkillCard( path="/skills/public", name="public", description="Public skill", skill_md_url="https://test.com/SKILL.md", visibility=VisibilityEnum.PUBLIC, ), SkillCard( path="/skills/private", name="private", description="Private skill", skill_md_url="https://test.com/SKILL.md", visibility=VisibilityEnum.PRIVATE, owner="other_user", ), ] mock_skill_repository.list_filtered.return_value = skills service = SkillService() service._repo = mock_skill_repository service._search_repo = mock_search_repository admin_context = {"username": "admin", "groups": [], "is_admin": True} result = await service.list_skills_for_user(user_context=admin_context) assert len(result) == 2 class TestToolValidation: """Test tool validation service.""" @pytest.mark.asyncio async def test_validate_tools_all_available(self): """Test validation when all tools are available.""" from registry.schemas.skill_models import SkillCard, ToolReference from registry.services.tool_validation_service import ToolValidationService skill = SkillCard( path="/skills/test", name="test", description="Test", skill_md_url="https://test.com/SKILL.md", allowed_tools=[ ToolReference(tool_name="Read"), ToolReference(tool_name="Write"), ], ) # Mock server repository mock_server_repo = AsyncMock() mock_server_repo.list_all.return_value = { "/filesystem": { "server_name": "filesystem", "tool_list": [ {"name": "Read"}, {"name": "Write"}, ], } } mock_server_repo.get_state.return_value = True service = ToolValidationService() service._server_repo = mock_server_repo result = await service.validate_tools_available(skill) assert result.all_available is True assert len(result.missing_tools) == 0 assert len(result.available_tools) == 2 @pytest.mark.asyncio async def test_validate_tools_some_missing(self): """Test validation when some tools are missing.""" from registry.schemas.skill_models import SkillCard, ToolReference from registry.services.tool_validation_service import ToolValidationService skill = SkillCard( path="/skills/test", name="test", description="Test", skill_md_url="https://test.com/SKILL.md", allowed_tools=[ ToolReference(tool_name="Read"), ToolReference(tool_name="NonExistent"), ], ) # Mock server repository mock_server_repo = AsyncMock() mock_server_repo.list_all.return_value = { "/filesystem": { "server_name": "filesystem", "tool_list": [ {"name": "Read"}, ], } } mock_server_repo.get_state.return_value = True service = ToolValidationService() service._server_repo = mock_server_repo result = await service.validate_tools_available(skill) assert result.all_available is False assert "NonExistent" in result.missing_tools assert "Read" in result.available_tools @pytest.mark.asyncio async def test_validate_no_tools_required(self): """Test validation when skill has no required tools.""" from registry.schemas.skill_models import SkillCard from registry.services.tool_validation_service import ToolValidationService skill = SkillCard( path="/skills/test", name="test", description="Test", skill_md_url="https://test.com/SKILL.md", allowed_tools=[], ) service = ToolValidationService() result = await service.validate_tools_available(skill) assert result.all_available is True assert len(result.missing_tools) == 0 assert len(result.available_tools) == 0 class TestPathUtils: """Test path utility functions.""" def test_normalize_skill_path_basic(self): """Test basic path normalization.""" from registry.utils.path_utils import normalize_skill_path assert normalize_skill_path("test") == "/skills/test" assert normalize_skill_path("/test") == "/skills/test" assert normalize_skill_path("/skills/test") == "/skills/test" def test_normalize_skill_path_duplicate_slashes(self): """Test path normalization removes duplicate slashes.""" from registry.utils.path_utils import normalize_skill_path assert normalize_skill_path("//test") == "/skills/test" assert normalize_skill_path("/skills//test") == "/skills/test" def test_extract_skill_name(self): """Test extracting skill name from path.""" from registry.utils.path_utils import extract_skill_name assert extract_skill_name("/skills/test") == "test" assert extract_skill_name("test") == "test" def test_validate_skill_name(self): """Test skill name validation.""" from registry.utils.path_utils import validate_skill_name assert validate_skill_name("test") is True assert validate_skill_name("test-skill") is True assert validate_skill_name("test-skill-v2") is True assert validate_skill_name("a1") is True assert validate_skill_name("TEST") is False assert validate_skill_name("test--skill") is False assert validate_skill_name("-test") is False assert validate_skill_name("test-") is False assert validate_skill_name("test_skill") is False ================================================ FILE: tests/integration/test_skill_scanner_repository.py ================================================ """ Property-based test for skill security scan repository create-then-retrieve round-trip. # Feature: skill-scanner-integration, Property 5: Repository create-then-retrieve round-trip **Validates: Requirements 6.2** """ import asyncio import tempfile from pathlib import Path from hypothesis import given, settings from hypothesis import strategies as st from registry.repositories.file.skill_security_scan_repository import ( FileSkillSecurityScanRepository, ) VALID_ANALYZERS = ["static", "behavioral", "llm", "meta", "virustotal", "ai-defense"] def _scan_result_dict_strategy(): """Strategy for generating valid scan result dicts with realistic fields.""" return st.fixed_dictionaries( { "skill_path": st.from_regex(r"/[a-z][a-z0-9\-]{0,30}", fullmatch=True), "scan_timestamp": st.from_regex( r"2026-0[1-9]-[012][0-9]T[01][0-9]:[0-5][0-9]:[0-5][0-9]Z", fullmatch=True, ), "is_safe": st.booleans(), "critical_issues": st.integers(min_value=0, max_value=50), "high_severity": st.integers(min_value=0, max_value=50), "medium_severity": st.integers(min_value=0, max_value=50), "low_severity": st.integers(min_value=0, max_value=50), "analyzers_used": st.lists( st.sampled_from(VALID_ANALYZERS), min_size=1, max_size=6, unique=True, ), "raw_output": st.just({}), "scan_failed": st.booleans(), "error_message": st.one_of(st.none(), st.text(min_size=1, max_size=100)), } ) class TestRepositoryCreateRetrieveRoundTrip: """Property 5: Repository create-then-retrieve round-trip.""" @given(scan_result=_scan_result_dict_strategy()) @settings(max_examples=50) def test_create_then_retrieve_preserves_fields(self, scan_result): """Persisting a scan result via create() and retrieving via get_latest() preserves all fields.""" async def _run(): with tempfile.TemporaryDirectory() as tmp_dir: repo = FileSkillSecurityScanRepository() repo._scans_dir = Path(tmp_dir) / "skill_security_scans" repo._scans = {} created = await repo.create(scan_result) assert created is True retrieved = await repo.get_latest(scan_result["skill_path"]) assert retrieved is not None for key, value in scan_result.items(): assert key in retrieved, f"Missing key: {key}" assert retrieved[key] == value, ( f"Mismatch for key '{key}': expected {value!r}, got {retrieved[key]!r}" ) asyncio.run(_run()) ================================================ FILE: tests/integration/test_telemetry_e2e.py ================================================ """ End-to-end integration tests for telemetry opt-out behavior. Tests cover: - Opt-out: MCP_TELEMETRY_DISABLED=1 suppresses all telemetry - Default state: startup ping + heartbeat both sent (heartbeat is opt-out, ON by default) - Heartbeat opt-out: MCP_TELEMETRY_OPT_OUT=1 disables heartbeat only - Debug mode: payloads logged, no network call made Live AWS tests (require deployed collector + AWS credentials) are marked with @pytest.mark.live and skipped in CI. Run manually with: uv run pytest tests/integration/test_telemetry_e2e.py -v -s -m live --no-cov """ import asyncio import logging from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest logger = logging.getLogger(__name__) pytestmark = pytest.mark.asyncio # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _mock_settings( storage_backend: str = "file", telemetry_enabled: bool = True, telemetry_opt_out: bool = False, telemetry_heartbeat_interval_minutes: int = 1440, telemetry_debug: bool = False, telemetry_endpoint: str = "https://telemetry.mcpgateway.io/v1/collect", embeddings_provider: str = "sentence-transformers", deployment_mode: str = "with-gateway", registry_mode: str = "full", auth_provider: str = "none", federation_static_token_auth_enabled: bool = False, ): """Return a configured MagicMock for settings.""" mock = MagicMock() mock.storage_backend = storage_backend mock.telemetry_enabled = telemetry_enabled mock.telemetry_opt_out = telemetry_opt_out mock.telemetry_heartbeat_interval_minutes = telemetry_heartbeat_interval_minutes mock.telemetry_debug = telemetry_debug mock.telemetry_endpoint = telemetry_endpoint mock.embeddings_provider = embeddings_provider mock.deployment_mode.value = deployment_mode mock.registry_mode.value = registry_mode mock.auth_provider = auth_provider mock.federation_static_token_auth_enabled = federation_static_token_auth_enabled return mock def _mock_repo_factory(): """Return mock repository that returns empty lists.""" repo = MagicMock() repo.list_all = AsyncMock(return_value=[]) repo.list_peers = AsyncMock(return_value=[]) return repo # --------------------------------------------------------------------------- # Class 1: Opt-out behaviour # --------------------------------------------------------------------------- class TestOptOut: """Verify that MCP_TELEMETRY_DISABLED=1 suppresses all telemetry.""" async def test_startup_ping_not_sent_when_disabled(self, monkeypatch): """No HTTP request made when telemetry is disabled.""" monkeypatch.setenv("MCP_TELEMETRY_DISABLED", "1") monkeypatch.delenv("MCP_TELEMETRY_OPT_OUT", raising=False) from registry.core.telemetry import _is_telemetry_enabled, send_startup_ping assert _is_telemetry_enabled() is False with patch("registry.core.telemetry.settings", _mock_settings()): with patch("registry.core.telemetry._send_telemetry") as mock_send: await send_startup_ping() mock_send.assert_not_called() async def test_heartbeat_not_started_when_disabled(self, monkeypatch): """Heartbeat scheduler does not start when telemetry is disabled.""" monkeypatch.setenv("MCP_TELEMETRY_DISABLED", "1") from registry.core.telemetry import _is_heartbeat_enabled, start_heartbeat_scheduler assert _is_heartbeat_enabled() is False with patch("registry.core.telemetry.settings", _mock_settings(telemetry_enabled=False)): with patch("registry.core.telemetry._send_telemetry") as mock_send: await start_heartbeat_scheduler() mock_send.assert_not_called() async def test_disabled_via_env_var_true_string(self, monkeypatch): """MCP_TELEMETRY_DISABLED=true also disables telemetry.""" monkeypatch.setenv("MCP_TELEMETRY_DISABLED", "true") from registry.core.telemetry import _is_telemetry_enabled assert _is_telemetry_enabled() is False async def test_disabled_via_env_var_yes_string(self, monkeypatch): """MCP_TELEMETRY_DISABLED=yes also disables telemetry.""" monkeypatch.setenv("MCP_TELEMETRY_DISABLED", "yes") from registry.core.telemetry import _is_telemetry_enabled assert _is_telemetry_enabled() is False async def test_enabled_by_default_no_env_var(self, monkeypatch): """Telemetry is enabled when no disable env var is set.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) with patch("registry.core.telemetry.settings", _mock_settings(telemetry_enabled=True)): from registry.core.telemetry import _is_telemetry_enabled assert _is_telemetry_enabled() is True # --------------------------------------------------------------------------- # Class 2: Default state (no opt-in) # --------------------------------------------------------------------------- class TestDefaultState: """By default, both startup ping and heartbeat are enabled (opt-out model).""" async def test_startup_ping_sent_by_default(self, monkeypatch): """Startup ping is sent when telemetry is enabled (default).""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) monkeypatch.delenv("MCP_TELEMETRY_OPT_OUT", raising=False) captured = [] async def fake_send(payload): captured.append(payload) with ( patch("registry.core.telemetry.settings", _mock_settings()), patch("registry.core.telemetry._send_telemetry", side_effect=fake_send), patch( "registry.core.telemetry._acquire_telemetry_lock", new=AsyncMock(return_value=True), ), patch( "registry.core.telemetry._get_or_create_instance_id", new=AsyncMock(return_value="test-instance-id"), ), ): from registry.core.telemetry import send_startup_ping await send_startup_ping() assert len(captured) == 1 assert captured[0]["event"] == "startup" async def test_heartbeat_enabled_by_default(self, monkeypatch): """Heartbeat is enabled by default (opt-out model).""" monkeypatch.delenv("MCP_TELEMETRY_OPT_OUT", raising=False) monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) with patch( "registry.core.telemetry.settings", _mock_settings(telemetry_opt_out=False), ): from registry.core.telemetry import _is_heartbeat_enabled assert _is_heartbeat_enabled() is True async def test_heartbeat_disabled_via_opt_out(self, monkeypatch): """Heartbeat scheduler exits immediately when MCP_TELEMETRY_OPT_OUT=1.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) monkeypatch.setenv("MCP_TELEMETRY_OPT_OUT", "1") with patch( "registry.core.telemetry.settings", _mock_settings(telemetry_opt_out=True), ): with patch("registry.core.telemetry._send_telemetry") as mock_send: from registry.core.telemetry import start_heartbeat_scheduler await start_heartbeat_scheduler() mock_send.assert_not_called() async def test_startup_payload_fields(self, monkeypatch): """Startup payload contains all required schema fields.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) with ( patch("registry.core.telemetry.settings", _mock_settings()), patch( "registry.repositories.stats_repository.get_search_counts", new_callable=AsyncMock, return_value={"total": 0, "last_24h": 0, "last_1h": 0}, ), ): from registry.core.telemetry import _build_startup_payload payload = await _build_startup_payload() required_fields = { "event", "schema_version", "v", "py", "os", "arch", "mode", "registry_mode", "storage", "auth", "federation", "search_queries_total", "ts", } assert required_fields.issubset(payload.keys()) assert payload["event"] == "startup" # --------------------------------------------------------------------------- # Class 3: Heartbeat (opt-out, on by default) # --------------------------------------------------------------------------- class TestHeartbeat: """Heartbeat is enabled by default (opt-out model) with aggregate counts.""" async def test_heartbeat_enabled_when_not_opted_out(self, monkeypatch): """Heartbeat is enabled when MCP_TELEMETRY_OPT_OUT is not set.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) monkeypatch.delenv("MCP_TELEMETRY_OPT_OUT", raising=False) with patch( "registry.core.telemetry.settings", _mock_settings(telemetry_opt_out=False), ): from registry.core.telemetry import _is_heartbeat_enabled assert _is_heartbeat_enabled() is True async def test_heartbeat_disabled_when_opted_out(self, monkeypatch): """Heartbeat is disabled when MCP_TELEMETRY_OPT_OUT=1.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) monkeypatch.setenv("MCP_TELEMETRY_OPT_OUT", "1") with patch( "registry.core.telemetry.settings", _mock_settings(telemetry_opt_out=True), ): from registry.core.telemetry import _is_heartbeat_enabled assert _is_heartbeat_enabled() is False async def test_heartbeat_payload_fields(self, monkeypatch): """Heartbeat payload contains all required schema fields.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) repo = _mock_repo_factory() with ( patch("registry.core.telemetry.settings", _mock_settings()), patch( "registry.api.system_routes.get_server_start_time", return_value=datetime.now(UTC), ), patch("registry.repositories.factory.get_server_repository", return_value=repo), patch("registry.repositories.factory.get_agent_repository", return_value=repo), patch("registry.repositories.factory.get_skill_repository", return_value=repo), patch( "registry.repositories.factory.get_peer_federation_repository", return_value=repo, ), patch( "registry.repositories.stats_repository.get_search_counts", new_callable=AsyncMock, return_value={"total": 0, "last_24h": 0, "last_1h": 0}, ), ): from registry.core.telemetry import _build_heartbeat_payload payload = await _build_heartbeat_payload() required_fields = { "event", "schema_version", "v", "servers_count", "agents_count", "skills_count", "peers_count", "search_backend", "embeddings_provider", "uptime_hours", "search_queries_total", "ts", } assert required_fields.issubset(payload.keys()) assert payload["event"] == "heartbeat" assert isinstance(payload["servers_count"], int) assert isinstance(payload["agents_count"], int) assert isinstance(payload["uptime_hours"], int) async def test_heartbeat_payload_search_backend_file(self, monkeypatch): """File storage maps to 'faiss' search backend in heartbeat payload.""" repo = _mock_repo_factory() with ( patch("registry.core.telemetry.settings", _mock_settings(storage_backend="file")), patch("registry.api.system_routes.get_server_start_time", return_value=None), patch("registry.repositories.factory.get_server_repository", return_value=repo), patch("registry.repositories.factory.get_agent_repository", return_value=repo), patch("registry.repositories.factory.get_skill_repository", return_value=repo), patch( "registry.repositories.factory.get_peer_federation_repository", return_value=repo, ), patch( "registry.repositories.stats_repository.get_search_counts", new_callable=AsyncMock, return_value={"total": 0, "last_24h": 0, "last_1h": 0}, ), ): from registry.core.telemetry import _build_heartbeat_payload payload = await _build_heartbeat_payload() assert payload["search_backend"] == "faiss" async def test_heartbeat_payload_search_backend_documentdb(self, monkeypatch): """DocumentDB storage maps to 'documentdb' search backend.""" repo = _mock_repo_factory() with ( patch( "registry.core.telemetry.settings", _mock_settings(storage_backend="documentdb"), ), patch("registry.api.system_routes.get_server_start_time", return_value=None), patch("registry.repositories.factory.get_server_repository", return_value=repo), patch("registry.repositories.factory.get_agent_repository", return_value=repo), patch("registry.repositories.factory.get_skill_repository", return_value=repo), patch( "registry.repositories.factory.get_peer_federation_repository", return_value=repo, ), patch( "registry.repositories.stats_repository.get_search_counts", new_callable=AsyncMock, return_value={"total": 0, "last_24h": 0, "last_1h": 0}, ), ): from registry.core.telemetry import _build_heartbeat_payload payload = await _build_heartbeat_payload() assert payload["search_backend"] == "documentdb" async def test_both_startup_and_heartbeat_sent_by_default(self, monkeypatch): """By default, startup ping fires AND heartbeat scheduler starts.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) monkeypatch.delenv("MCP_TELEMETRY_OPT_OUT", raising=False) events_sent = [] async def fake_send(payload): events_sent.append(payload["event"]) repo = _mock_repo_factory() fake_heartbeat_payload = { "event": "heartbeat", "version": "test", "servers_count": 0, "agents_count": 0, "skills_count": 0, } with ( patch("registry.core.telemetry.settings", _mock_settings(telemetry_opt_out=False)), patch("registry.core.telemetry._send_telemetry", side_effect=fake_send), patch( "registry.core.telemetry._acquire_telemetry_lock", new=AsyncMock(return_value=True), ), patch( "registry.core.telemetry._get_or_create_instance_id", new=AsyncMock(return_value="test-instance-id"), ), patch( "registry.api.system_routes.get_server_start_time", return_value=datetime.now(UTC), ), patch("registry.repositories.factory.get_server_repository", return_value=repo), patch("registry.repositories.factory.get_agent_repository", return_value=repo), patch("registry.repositories.factory.get_skill_repository", return_value=repo), patch( "registry.repositories.factory.get_peer_federation_repository", return_value=repo, ), patch( "registry.repositories.stats_repository.get_search_counts", new_callable=AsyncMock, return_value={"total": 0, "last_24h": 0, "last_1h": 0}, ), patch( "registry.core.telemetry._build_heartbeat_payload", new=AsyncMock(return_value=fake_heartbeat_payload), ), ): from registry.core.telemetry import ( send_startup_ping, start_heartbeat_scheduler, stop_heartbeat_scheduler, ) await send_startup_ping() await start_heartbeat_scheduler() # Give the background task time to run await asyncio.sleep(1.0) await stop_heartbeat_scheduler() assert "startup" in events_sent assert "heartbeat" in events_sent async def test_heartbeat_not_sent_twice_within_lock_window(self, monkeypatch): """Lock mechanism prevents sending heartbeat twice within the lock window.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) monkeypatch.delenv("MCP_TELEMETRY_OPT_OUT", raising=False) events_sent = [] async def fake_send(payload): events_sent.append(payload["event"]) repo = _mock_repo_factory() lock_results = iter([True, False]) async def mock_lock(*args, **kwargs): return next(lock_results) with ( patch("registry.core.telemetry.settings", _mock_settings(telemetry_opt_out=False)), patch("registry.core.telemetry._send_telemetry", side_effect=fake_send), patch("registry.core.telemetry._acquire_telemetry_lock", side_effect=mock_lock), patch( "registry.core.telemetry._get_or_create_instance_id", new=AsyncMock(return_value="test-instance-id"), ), patch( "registry.api.system_routes.get_server_start_time", return_value=datetime.now(UTC), ), patch("registry.repositories.factory.get_server_repository", return_value=repo), patch("registry.repositories.factory.get_agent_repository", return_value=repo), patch("registry.repositories.factory.get_skill_repository", return_value=repo), patch( "registry.repositories.factory.get_peer_federation_repository", return_value=repo, ), patch( "registry.repositories.stats_repository.get_search_counts", new_callable=AsyncMock, return_value={"total": 0, "last_24h": 0, "last_1h": 0}, ), ): from registry.core.telemetry import TelemetryScheduler scheduler = TelemetryScheduler() await scheduler._send_heartbeat() await scheduler._send_heartbeat() assert events_sent.count("heartbeat") == 1 # --------------------------------------------------------------------------- # Class 4: Debug mode # --------------------------------------------------------------------------- class TestDebugMode: """MCP_TELEMETRY_DEBUG=1 logs payloads without making network calls.""" async def test_debug_mode_logs_not_sends(self, monkeypatch, caplog): """In debug mode, payload is logged and no HTTP call is made.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) with ( patch("registry.core.telemetry.settings", _mock_settings(telemetry_debug=True)), patch( "registry.core.telemetry._get_or_create_instance_id", new=AsyncMock(return_value="debug-instance"), ), ): with patch("httpx.AsyncClient") as mock_http: from registry.core.telemetry import _send_telemetry with caplog.at_level(logging.INFO, logger="registry.core.telemetry"): await _send_telemetry({"event": "startup", "schema_version": "1"}) mock_http.assert_not_called() assert "Debug mode" in caplog.text assert "startup" in caplog.text async def test_debug_mode_shows_full_payload(self, monkeypatch, caplog): """Debug mode logs the complete payload as formatted JSON.""" with ( patch("registry.core.telemetry.settings", _mock_settings(telemetry_debug=True)), patch( "registry.core.telemetry._get_or_create_instance_id", new=AsyncMock(return_value="debug-instance"), ), ): from registry.core.telemetry import _send_telemetry with caplog.at_level(logging.INFO, logger="registry.core.telemetry"): await _send_telemetry( {"event": "heartbeat", "schema_version": "1", "servers_count": 42} ) assert "heartbeat" in caplog.text assert "42" in caplog.text # --------------------------------------------------------------------------- # Live AWS tests — skipped in CI, run manually with -m live # --------------------------------------------------------------------------- @pytest.mark.live @pytest.mark.skip( reason="Requires live AWS infrastructure — run manually with: pytest -m live --no-cov" ) class TestLiveCollector: """Live tests against the deployed AWS collector. See DEMO-GUIDE.md.""" pass ================================================ FILE: tests/integration/test_virtual_server_api.py ================================================ """Integration tests for virtual server API endpoints.""" from unittest.mock import AsyncMock, patch import pytest from fastapi import HTTPException from fastapi.testclient import TestClient from registry.api.virtual_server_routes import _normalize_virtual_path from registry.auth.dependencies import nginx_proxied_auth from registry.main import app from registry.schemas.virtual_server_models import VirtualServerConfig # Sample virtual server data for testing SAMPLE_VS_DATA = { "server_name": "Dev Essentials", "path": "/virtual/dev-essentials", "description": "Tools for everyday development", "tool_mappings": [ { "tool_name": "search", "backend_server_path": "/github", }, ], "required_scopes": [], "tags": ["dev", "productivity"], } ADMIN_CONTEXT = { "username": "admin", "groups": ["mcp-registry-admin"], "scopes": ["mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute"], "is_admin": True, "can_modify_servers": True, } USER_CONTEXT = { "username": "testuser", "groups": ["mcp-registry-user"], "scopes": ["mcp-servers-unrestricted/read"], "is_admin": False, "can_modify_servers": False, } @pytest.fixture def client(): """Create FastAPI test client.""" return TestClient(app) @pytest.fixture def mock_auth_admin(client): """Mock authentication returning admin user context.""" app.dependency_overrides[nginx_proxied_auth] = lambda: ADMIN_CONTEXT yield ADMIN_CONTEXT app.dependency_overrides.pop(nginx_proxied_auth, None) @pytest.fixture def mock_auth_user(client): """Mock authentication returning regular user context.""" app.dependency_overrides[nginx_proxied_auth] = lambda: USER_CONTEXT yield USER_CONTEXT app.dependency_overrides.pop(nginx_proxied_auth, None) @pytest.fixture def mock_vs_service(): """Mock virtual server service.""" mock = AsyncMock() mock.list_virtual_servers = AsyncMock(return_value=[]) mock.get_virtual_server = AsyncMock(return_value=None) mock.create_virtual_server = AsyncMock() mock.update_virtual_server = AsyncMock() mock.delete_virtual_server = AsyncMock(return_value=True) mock.toggle_virtual_server = AsyncMock(return_value=True) mock.resolve_tools = AsyncMock(return_value=[]) mock.rate_virtual_server = AsyncMock( return_value={ "average_rating": 4.0, "is_new_rating": True, "total_ratings": 1, } ) mock.get_virtual_server_rating = AsyncMock( return_value={ "num_stars": 4.0, "rating_details": [{"user": "testuser", "rating": 4}], } ) return mock @pytest.fixture def mock_catalog_service(): """Mock tool catalog service.""" mock = AsyncMock() mock.get_tool_catalog = AsyncMock(return_value=[]) return mock class TestListVirtualServers: """Tests for GET /api/virtual-servers.""" def test_list_empty(self, client, mock_auth_admin, mock_vs_service): """Test listing virtual servers when none exist.""" with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.get("/api/virtual-servers") assert response.status_code == 200 data = response.json() assert data["virtual_servers"] == [] assert data["total_count"] == 0 def test_list_with_user_auth(self, client, mock_auth_user, mock_vs_service): """Test that regular users can list virtual servers.""" with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.get("/api/virtual-servers") assert response.status_code == 200 class TestCreateVirtualServer: """Tests for POST /api/virtual-servers.""" def test_create_success(self, client, mock_auth_admin, mock_vs_service): """Test creating a virtual server.""" created_config = VirtualServerConfig( path="/virtual/dev-essentials", server_name="Dev Essentials", description="Tools for development", ) mock_vs_service.create_virtual_server.return_value = created_config with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.post( "/api/virtual-servers", json=SAMPLE_VS_DATA, ) assert response.status_code == 201 data = response.json() assert data["path"] == "/virtual/dev-essentials" assert data["server_name"] == "Dev Essentials" def test_create_requires_admin(self, client, mock_auth_user, mock_vs_service): """Test that creating requires admin permissions.""" with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.post( "/api/virtual-servers", json=SAMPLE_VS_DATA, ) assert response.status_code == 403 def test_create_validation_error(self, client, mock_auth_admin, mock_vs_service): """Test creating with invalid data returns 400.""" from registry.exceptions import VirtualServerValidationError mock_vs_service.create_virtual_server.side_effect = VirtualServerValidationError( "Backend server '/nonexistent' does not exist" ) with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.post( "/api/virtual-servers", json=SAMPLE_VS_DATA, ) assert response.status_code == 400 assert "does not exist" in response.json()["detail"] def test_create_duplicate_path_returns_409(self, client, mock_auth_admin, mock_vs_service): """Test creating virtual server with duplicate path returns 409.""" from registry.exceptions import VirtualServerAlreadyExistsError mock_vs_service.create_virtual_server.side_effect = VirtualServerAlreadyExistsError( "/virtual/dev-essentials" ) with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.post( "/api/virtual-servers", json=SAMPLE_VS_DATA, ) assert response.status_code == 409 class TestGetVirtualServer: """Tests for GET /api/virtual-servers/{path}.""" def test_get_existing(self, client, mock_auth_admin, mock_vs_service): """Test getting an existing virtual server.""" config = VirtualServerConfig( path="/virtual/dev-essentials", server_name="Dev Essentials", ) mock_vs_service.get_virtual_server.return_value = config with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.get("/api/virtual-servers/virtual/dev-essentials") assert response.status_code == 200 assert response.json()["server_name"] == "Dev Essentials" def test_get_not_found(self, client, mock_auth_admin, mock_vs_service): """Test getting a nonexistent virtual server.""" mock_vs_service.get_virtual_server.return_value = None with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.get("/api/virtual-servers/virtual/nonexistent") assert response.status_code == 404 class TestUpdateVirtualServer: """Tests for PUT /api/virtual-servers/{path}.""" def test_update_success(self, client, mock_auth_admin, mock_vs_service): """Test updating a virtual server.""" updated_config = VirtualServerConfig( path="/virtual/dev-essentials", server_name="Updated Name", ) mock_vs_service.update_virtual_server.return_value = updated_config with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.put( "/api/virtual-servers/virtual/dev-essentials", json={"server_name": "Updated Name"}, ) assert response.status_code == 200 assert response.json()["server_name"] == "Updated Name" def test_update_requires_admin(self, client, mock_auth_user, mock_vs_service): """Test that updating requires admin permissions.""" with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.put( "/api/virtual-servers/virtual/dev-essentials", json={"server_name": "Updated"}, ) assert response.status_code == 403 class TestDeleteVirtualServer: """Tests for DELETE /api/virtual-servers/{path}.""" def test_delete_success(self, client, mock_auth_admin, mock_vs_service): """Test deleting a virtual server.""" mock_vs_service.delete_virtual_server.return_value = True with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.delete( "/api/virtual-servers/virtual/dev-essentials", ) assert response.status_code == 204 def test_delete_requires_admin(self, client, mock_auth_user, mock_vs_service): """Test that deleting requires admin permissions.""" with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.delete( "/api/virtual-servers/virtual/dev-essentials", ) assert response.status_code == 403 def test_delete_not_found(self, client, mock_auth_admin, mock_vs_service): """Test deleting a nonexistent virtual server.""" from registry.exceptions import VirtualServerNotFoundError mock_vs_service.delete_virtual_server.side_effect = VirtualServerNotFoundError( "/virtual/nonexistent" ) with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.delete( "/api/virtual-servers/virtual/nonexistent", ) assert response.status_code == 404 class TestToggleVirtualServer: """Tests for POST /api/virtual-servers/{path}/toggle.""" def test_toggle_enable(self, client, mock_auth_admin, mock_vs_service): """Test enabling a virtual server.""" mock_vs_service.toggle_virtual_server.return_value = True with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.post( "/api/virtual-servers/virtual/dev-essentials/toggle", json={"enabled": True}, ) assert response.status_code == 200 data = response.json() assert data["is_enabled"] is True def test_toggle_requires_admin(self, client, mock_auth_user, mock_vs_service): """Test that toggling requires admin permissions.""" with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.post( "/api/virtual-servers/virtual/dev-essentials/toggle", json={"enabled": True}, ) assert response.status_code == 403 def test_toggle_not_found(self, client, mock_auth_admin, mock_vs_service): """Test toggling a nonexistent virtual server returns 404.""" from registry.exceptions import VirtualServerNotFoundError mock_vs_service.toggle_virtual_server.side_effect = VirtualServerNotFoundError( "/virtual/nonexistent" ) with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.post( "/api/virtual-servers/virtual/nonexistent/toggle", json={"enabled": True}, ) assert response.status_code == 404 def test_toggle_validation_error(self, client, mock_auth_admin, mock_vs_service): """Test toggling with validation error returns 400.""" from registry.exceptions import VirtualServerValidationError mock_vs_service.toggle_virtual_server.side_effect = VirtualServerValidationError( "Cannot enable virtual server with no tool mappings" ) with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.post( "/api/virtual-servers/virtual/empty/toggle", json={"enabled": True}, ) assert response.status_code == 400 assert "no tool mappings" in response.json()["detail"] class TestVirtualServerTools: """Tests for GET /api/virtual-servers/{path}/tools.""" def test_get_tools(self, client, mock_auth_admin, mock_vs_service): """Test getting resolved tools for a virtual server.""" from registry.schemas.virtual_server_models import ResolvedTool mock_vs_service.resolve_tools.return_value = [ ResolvedTool( name="github_search", original_name="search", backend_server_path="/github", description="Search repos", input_schema={"type": "object"}, ), ] with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.get( "/api/virtual-servers/virtual/dev-essentials/tools", ) assert response.status_code == 200 data = response.json() assert data["total_count"] == 1 assert data["tools"][0]["name"] == "github_search" def test_get_tools_not_found(self, client, mock_auth_admin, mock_vs_service): """Test getting tools for nonexistent server returns 404.""" from registry.exceptions import VirtualServerNotFoundError mock_vs_service.resolve_tools.side_effect = VirtualServerNotFoundError( "/virtual/nonexistent" ) with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.get( "/api/virtual-servers/virtual/nonexistent/tools", ) assert response.status_code == 404 class TestUpdateVirtualServerErrors: """Additional tests for PUT /api/virtual-servers/{path} error cases.""" @pytest.fixture def client(self): """Create FastAPI test client.""" return TestClient(app) @pytest.fixture def mock_auth_admin(self, client): """Mock authentication returning admin user context.""" app.dependency_overrides[nginx_proxied_auth] = lambda: ADMIN_CONTEXT yield ADMIN_CONTEXT app.dependency_overrides.pop(nginx_proxied_auth, None) @pytest.fixture def mock_vs_service(self): """Mock virtual server service.""" return AsyncMock() def test_update_not_found(self, client, mock_auth_admin, mock_vs_service): """Test updating a nonexistent virtual server returns 404.""" from registry.exceptions import VirtualServerNotFoundError mock_vs_service.update_virtual_server.side_effect = VirtualServerNotFoundError( "/virtual/nonexistent" ) with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.put( "/api/virtual-servers/virtual/nonexistent", json={"description": "Updated"}, ) assert response.status_code == 404 def test_update_validation_error(self, client, mock_auth_admin, mock_vs_service): """Test updating with invalid data returns 400.""" from registry.exceptions import VirtualServerValidationError mock_vs_service.update_virtual_server.side_effect = VirtualServerValidationError( "Tool mapping validation failed" ) with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.put( "/api/virtual-servers/virtual/dev-essentials", json={"description": "Updated"}, ) assert response.status_code == 400 class TestToolCatalog: """Tests for GET /api/tool-catalog.""" def test_get_catalog_empty(self, client, mock_auth_admin, mock_catalog_service): """Test getting tool catalog when empty.""" with patch( "registry.api.virtual_server_routes.get_tool_catalog_service", return_value=mock_catalog_service, ): response = client.get("/api/tool-catalog") assert response.status_code == 200 data = response.json() assert data["total_count"] == 0 assert data["tools"] == [] def test_get_catalog_with_filter(self, client, mock_auth_admin, mock_catalog_service): """Test getting tool catalog with server filter.""" from registry.schemas.virtual_server_models import ToolCatalogEntry mock_catalog_service.get_tool_catalog.return_value = [ ToolCatalogEntry( tool_name="search", server_path="/github", server_name="GitHub", description="Search repos", ), ] with patch( "registry.api.virtual_server_routes.get_tool_catalog_service", return_value=mock_catalog_service, ): response = client.get("/api/tool-catalog?server_path=/github") assert response.status_code == 200 data = response.json() assert data["total_count"] == 1 assert data["server_count"] == 1 class TestNormalizeVirtualPath: """Tests for _normalize_virtual_path edge cases.""" def test_path_already_normalized(self): """Test that a fully qualified path is returned as-is.""" assert _normalize_virtual_path("/virtual/dev-essentials") == "/virtual/dev-essentials" def test_path_without_leading_slash(self): """Test that virtual/... gets a leading slash prepended.""" assert _normalize_virtual_path("virtual/dev-essentials") == "/virtual/dev-essentials" def test_bare_slug(self): """Test that a bare slug gets /virtual/ prefix.""" assert _normalize_virtual_path("dev-essentials") == "/virtual/dev-essentials" def test_empty_path(self): """Test that an empty path is rejected as invalid.""" with pytest.raises(HTTPException) as exc_info: _normalize_virtual_path("") assert exc_info.value.status_code == 400 def test_path_with_double_dots(self): """Test path traversal attempt with '..' is rejected.""" with pytest.raises(HTTPException) as exc_info: _normalize_virtual_path("../../etc/passwd") assert exc_info.value.status_code == 400 assert "path traversal" in exc_info.value.detail def test_path_with_special_characters(self): """Test path with special characters is rejected.""" with pytest.raises(HTTPException) as exc_info: _normalize_virtual_path("my-server_v2") assert exc_info.value.status_code == 400 def test_path_that_is_just_virtual(self): """Test path that is just the word 'virtual'.""" result = _normalize_virtual_path("virtual") assert result == "/virtual/virtual" def test_path_with_encoded_characters(self): """Test path with URL-encoded characters is rejected.""" with pytest.raises(HTTPException) as exc_info: _normalize_virtual_path("my%20server") assert exc_info.value.status_code == 400 def test_path_with_trailing_slash(self): """Test path with trailing slash is rejected.""" with pytest.raises(HTTPException) as exc_info: _normalize_virtual_path("/virtual/dev-essentials/") assert exc_info.value.status_code == 400 def test_path_with_nested_virtual(self): """Test path with sub-paths is rejected (only single slug allowed).""" with pytest.raises(HTTPException) as exc_info: _normalize_virtual_path("/virtual/sub/path") assert exc_info.value.status_code == 400 class TestRateVirtualServer: """Tests for POST /api/virtual-servers/{path}/rate.""" def test_rate_success(self, client, mock_auth_user, mock_vs_service): """Test rating a virtual server successfully.""" with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.post( "/api/virtual-servers/virtual/dev-essentials/rate", json={"rating": 4}, ) assert response.status_code == 200 data = response.json() assert data["average_rating"] == 4.0 assert data["is_new_rating"] is True assert data["total_ratings"] == 1 def test_rate_not_found(self, client, mock_auth_user, mock_vs_service): """Test rating a nonexistent virtual server returns 404.""" from registry.exceptions import VirtualServerNotFoundError mock_vs_service.rate_virtual_server.side_effect = VirtualServerNotFoundError( "/virtual/nonexistent" ) with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.post( "/api/virtual-servers/virtual/nonexistent/rate", json={"rating": 4}, ) assert response.status_code == 404 def test_rate_invalid_rating(self, client, mock_auth_user, mock_vs_service): """Test rating with invalid value returns 400.""" mock_vs_service.rate_virtual_server.side_effect = ValueError( "Rating must be between 1 and 5" ) with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.post( "/api/virtual-servers/virtual/dev-essentials/rate", json={"rating": 10}, ) assert response.status_code == 400 assert "between 1 and 5" in response.json()["detail"] def test_rate_update_existing(self, client, mock_auth_user, mock_vs_service): """Test updating an existing rating.""" mock_vs_service.rate_virtual_server.return_value = { "average_rating": 5.0, "is_new_rating": False, "total_ratings": 1, } with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.post( "/api/virtual-servers/virtual/dev-essentials/rate", json={"rating": 5}, ) assert response.status_code == 200 data = response.json() assert data["is_new_rating"] is False class TestGetVirtualServerRating: """Tests for GET /api/virtual-servers/{path}/rating.""" def test_get_rating_success(self, client, mock_auth_user, mock_vs_service): """Test getting rating information successfully.""" with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.get( "/api/virtual-servers/virtual/dev-essentials/rating", ) assert response.status_code == 200 data = response.json() assert data["num_stars"] == 4.0 assert len(data["rating_details"]) == 1 assert data["rating_details"][0]["user"] == "testuser" def test_get_rating_not_found(self, client, mock_auth_user, mock_vs_service): """Test getting rating for nonexistent virtual server returns 404.""" from registry.exceptions import VirtualServerNotFoundError mock_vs_service.get_virtual_server_rating.side_effect = VirtualServerNotFoundError( "/virtual/nonexistent" ) with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.get( "/api/virtual-servers/virtual/nonexistent/rating", ) assert response.status_code == 404 def test_get_rating_empty(self, client, mock_auth_user, mock_vs_service): """Test getting rating for server with no ratings.""" mock_vs_service.get_virtual_server_rating.return_value = { "num_stars": 0.0, "rating_details": [], } with patch( "registry.api.virtual_server_routes.get_virtual_server_service", return_value=mock_vs_service, ): response = client.get( "/api/virtual-servers/virtual/dev-essentials/rating", ) assert response.status_code == 200 data = response.json() assert data["num_stars"] == 0.0 assert data["rating_details"] == [] ================================================ FILE: tests/integration/test_virtual_server_scopes_e2e.sh ================================================ #!/bin/bash # # End-to-end test for Virtual MCP Server scope-based access control # # This script tests: # 1. Creating a virtual server with required_scopes # 2. Creating a user group with matching scopes # 3. Creating an M2M service account in that group # 4. Creating a regular user in that group (for UI testing) # 5. Verifying the virtual server is accessible # 6. Cleanup # # Usage: # ./test_virtual_server_scopes_e2e.sh --registry-url --token-file # # Example: # ./test_virtual_server_scopes_e2e.sh \ # --registry-url http://localhost \ # --token-file .token # set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # Script directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" PROJECT_ROOT="$(dirname "$(dirname "$SCRIPT_DIR")")" # Default values REGISTRY_URL="" TOKEN_FILE="" CLEANUP_ON_EXIT=true # Test configuration VS_PATH="/virtual/scoped-tools-test" VS_CONFIG="$PROJECT_ROOT/cli/examples/virtual-server-scoped-example.json" GROUP_CONFIG="$PROJECT_ROOT/cli/examples/virtual-server-scoped-users.json" GROUP_NAME="virtual-scoped-tools-test-users" M2M_NAME="vs-scope-test-bot" USER_NAME="vs-scope-test-user" USER_EMAIL="vs-scope-test-user@example.com" # Temporary file for modified configs TEMP_VS_CONFIG="" TEMP_GROUP_CONFIG="" # Credentials file for UI testing CREDS_FILE="/tmp/.vs-creds" _log_info() { echo -e "${GREEN}[INFO]${NC} $1" } _log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" } _log_error() { echo -e "${RED}[ERROR]${NC} $1" } _log_step() { echo "" echo -e "${GREEN}========================================${NC}" echo -e "${GREEN}$1${NC}" echo -e "${GREEN}========================================${NC}" } _usage() { echo "Usage: $0 --registry-url --token-file [--no-cleanup]" echo "" echo "Options:" echo " --registry-url Registry base URL (e.g., http://localhost)" echo " --token-file Path to JWT token file" echo " --no-cleanup Skip cleanup on exit (useful for UI testing)" echo " Credentials will be saved to /tmp/.vs-creds" echo "" echo "Example:" echo " # Run with cleanup" echo " $0 --registry-url http://localhost --token-file .token" echo "" echo " # Run without cleanup for UI testing" echo " $0 --registry-url http://localhost --token-file .token --no-cleanup" echo " cat /tmp/.vs-creds # View saved credentials" exit 1 } _parse_args() { while [[ $# -gt 0 ]]; do case $1 in --registry-url) REGISTRY_URL="$2" shift 2 ;; --token-file) TOKEN_FILE="$2" shift 2 ;; --no-cleanup) CLEANUP_ON_EXIT=false shift ;; -h|--help) _usage ;; *) _log_error "Unknown option: $1" _usage ;; esac done if [[ -z "$REGISTRY_URL" ]]; then _log_error "Missing required argument: --registry-url" _usage fi if [[ -z "$TOKEN_FILE" ]]; then _log_error "Missing required argument: --token-file" _usage fi if [[ ! -f "$TOKEN_FILE" ]]; then _log_error "Token file not found: $TOKEN_FILE" exit 1 fi } _run_cmd() { local description="$1" shift _log_info "$description" uv run python "$PROJECT_ROOT/api/registry_management.py" \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ "$@" } _create_temp_configs() { # Create temporary configs with unique paths/names to avoid conflicts TEMP_VS_CONFIG=$(mktemp) TEMP_GROUP_CONFIG=$(mktemp) # Modify virtual server config with unique path cat "$VS_CONFIG" | \ sed "s|/virtual/scoped-tools|$VS_PATH|g" | \ sed 's|"server_name": ".*"|"server_name": "Scoped Tools Test"|' \ > "$TEMP_VS_CONFIG" # Modify group config with unique name cat "$GROUP_CONFIG" | \ sed "s|virtual-scoped-tools-users|$GROUP_NAME|g" | \ sed "s|virtual/scoped-tools|${VS_PATH#/}|g" | \ sed "s|/virtual/scoped-tools|$VS_PATH|g" \ > "$TEMP_GROUP_CONFIG" _log_info "Created temporary configs:" _log_info " Virtual Server: $TEMP_VS_CONFIG" _log_info " Group: $TEMP_GROUP_CONFIG" } _cleanup_temp_files() { if [[ -n "$TEMP_VS_CONFIG" && -f "$TEMP_VS_CONFIG" ]]; then rm -f "$TEMP_VS_CONFIG" fi if [[ -n "$TEMP_GROUP_CONFIG" && -f "$TEMP_GROUP_CONFIG" ]]; then rm -f "$TEMP_GROUP_CONFIG" fi } _cleanup() { if [[ "$CLEANUP_ON_EXIT" != "true" ]]; then _log_warn "Skipping cleanup (--no-cleanup specified)" _log_warn "Credentials saved to: $CREDS_FILE" _log_warn "Virtual server path: $VS_PATH" _log_warn "M2M account: $M2M_NAME" _log_warn "Regular user: $USER_NAME" _log_warn "Group: $GROUP_NAME" _cleanup_temp_files return fi _log_step "Cleanup" # Delete M2M account _log_info "Deleting M2M account: $M2M_NAME" uv run python "$PROJECT_ROOT/api/registry_management.py" \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ user-delete --username "$M2M_NAME" --force 2>/dev/null || \ _log_warn "M2M account may not exist or could not be deleted" # Delete regular user _log_info "Deleting regular user: $USER_NAME" uv run python "$PROJECT_ROOT/api/registry_management.py" \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ user-delete --username "$USER_NAME" --force 2>/dev/null || \ _log_warn "Regular user may not exist or could not be deleted" # Delete group _log_info "Deleting group: $GROUP_NAME" uv run python "$PROJECT_ROOT/api/registry_management.py" \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ group-delete --name "$GROUP_NAME" --force 2>/dev/null || \ _log_warn "Group may not exist or could not be deleted" # Delete virtual server _log_info "Deleting virtual server: $VS_PATH" uv run python "$PROJECT_ROOT/api/registry_management.py" \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ vs-delete --path "$VS_PATH" --force 2>/dev/null || \ _log_warn "Virtual server may not exist or could not be deleted" # Delete credentials file if [[ -f "$CREDS_FILE" ]]; then _log_info "Deleting credentials file: $CREDS_FILE" rm -f "$CREDS_FILE" fi _cleanup_temp_files _log_info "Cleanup complete" } _test_create_virtual_server() { _log_step "Step 1: Create Virtual Server with Scope-Based Access Control" # Delete existing virtual server if it exists (override mode) _log_info "Checking for existing virtual server..." uv run python "$PROJECT_ROOT/api/registry_management.py" \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ vs-delete --path "$VS_PATH" --force 2>/dev/null || true _log_info "Virtual server configuration:" cat "$TEMP_VS_CONFIG" | jq '.' _run_cmd "Creating virtual server..." vs-create --config "$TEMP_VS_CONFIG" _log_info "Verifying virtual server was created..." _run_cmd "Getting virtual server details..." vs-get --path "$VS_PATH" } _test_create_group() { _log_step "Step 2: Create User Group with Matching Scopes" # Delete existing group if it exists (override mode) _log_info "Checking for existing group..." uv run python "$PROJECT_ROOT/api/registry_management.py" \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ group-delete --name "$GROUP_NAME" --force 2>/dev/null || true _log_info "Group configuration:" cat "$TEMP_GROUP_CONFIG" | jq '.' # Import the group configuration _run_cmd "Importing group configuration..." import-group --file "$TEMP_GROUP_CONFIG" _log_info "Verifying group was created..." _run_cmd "Listing groups..." group-list } _test_create_m2m_account() { _log_step "Step 3: Create M2M Service Account in Group" # Delete existing M2M account if it exists (override mode) _log_info "Checking for existing M2M account..." uv run python "$PROJECT_ROOT/api/registry_management.py" \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ user-delete --username "$M2M_NAME" --force 2>/dev/null || true _log_info "Creating M2M service account..." M2M_OUTPUT=$(uv run python "$PROJECT_ROOT/api/registry_management.py" \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ user-create-m2m --name "$M2M_NAME" --groups "$GROUP_NAME" 2>&1) echo "$M2M_OUTPUT" # Extract credentials for later use CLIENT_ID=$(echo "$M2M_OUTPUT" | grep "Client ID:" | head -1 | sed 's/Client ID: //') CLIENT_SECRET=$(echo "$M2M_OUTPUT" | grep "Client Secret:" | head -1 | sed 's/Client Secret: //') _log_info "Verifying M2M account was created..." _run_cmd "Listing users..." user-list --search "$M2M_NAME" } _test_create_regular_user() { _log_step "Step 4: Create Regular User in Group" # Delete existing regular user if it exists (override mode) _log_info "Checking for existing regular user..." uv run python "$PROJECT_ROOT/api/registry_management.py" \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ user-delete --username "$USER_NAME" --force 2>/dev/null || true # Generate a random password USER_PASSWORD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) _log_info "Creating regular user: $USER_NAME" _log_info "Email: $USER_EMAIL" _log_info "Password: $USER_PASSWORD" # Create regular user with password uv run python "$PROJECT_ROOT/api/registry_management.py" \ --registry-url "$REGISTRY_URL" \ --token-file "$TOKEN_FILE" \ user-create-human \ --username "$USER_NAME" \ --email "$USER_EMAIL" \ --first-name "Test" \ --last-name "User" \ --password "$USER_PASSWORD" \ --groups "$GROUP_NAME" 2>&1 # Save credentials to file only if --no-cleanup was specified if [[ "$CLEANUP_ON_EXIT" != "true" ]]; then _log_info "Saving credentials to $CREDS_FILE" cat > "$CREDS_FILE" << EOF # Virtual Server Scope Test Credentials # Created: $(date -Iseconds) # Registry: $REGISTRY_URL # Test Configuration VS_PATH=$VS_PATH GROUP_NAME=$GROUP_NAME # M2M Service Account (for API/programmatic access) M2M_NAME=$M2M_NAME CLIENT_ID=$CLIENT_ID CLIENT_SECRET=$CLIENT_SECRET # Regular User (for UI testing) USER_NAME=$USER_NAME USER_EMAIL=$USER_EMAIL USER_PASSWORD=$USER_PASSWORD # To get a token for the M2M service account: # curl -X POST "\${KEYCLOAK_URL}/realms/mcp-gateway/protocol/openid-connect/token" \\ # -d "client_id=\${CLIENT_ID}" \\ # -d "client_secret=\${CLIENT_SECRET}" \\ # -d "grant_type=client_credentials" # To get a token for the regular user: # curl -X POST "\${KEYCLOAK_URL}/realms/mcp-gateway/protocol/openid-connect/token" \\ # -d "client_id=mcp-gateway-ui" \\ # -d "username=\${USER_NAME}" \\ # -d "password=\${USER_PASSWORD}" \\ # -d "grant_type=password" EOF chmod 600 "$CREDS_FILE" _log_info "Credentials saved to $CREDS_FILE" fi _log_info "Verifying regular user was created..." _run_cmd "Listing users..." user-list --search "$USER_NAME" } _test_verify_access() { _log_step "Step 5: Verify Virtual Server Access" _log_info "Testing virtual server listing..." _run_cmd "Listing virtual servers..." vs-list --json _log_info "Testing virtual server get..." _run_cmd "Getting virtual server..." vs-get --path "$VS_PATH" --json _log_info "Access verification complete" } _test_scope_enforcement() { _log_step "Step 6: Verify Scope-Based Tool Filtering" _log_info "The virtual server has the following scope configuration:" _log_info " - Server-level required_scopes: [virtual-scoped-tools/access]" _log_info " - Tool-level override for 'get-time': [virtual-scoped-tools/time-access]" _log_info "" _log_info "Users with only 'virtual-scoped-tools/access' scope will see:" _log_info " - search_cloudflare_documentation" _log_info "" _log_info "Users with both scopes will also see:" _log_info " - get-time (alias for current_time_by_timezone)" _log_info "" _log_info "Note: Full scope enforcement testing requires MCP client calls through the gateway." _log_info "This test verifies the configuration is correctly stored." # Verify the tool mappings include scope overrides VS_DETAILS=$(_run_cmd "Getting virtual server details as JSON..." vs-get --path "$VS_PATH" --json 2>/dev/null | tail -n +2) TOOL_COUNT=$(echo "$VS_DETAILS" | jq '.tool_mappings | length') _log_info "Tool count: $TOOL_COUNT" if [[ "$TOOL_COUNT" -eq 2 ]]; then _log_info "SUCCESS: Virtual server has expected 2 tool mappings" else _log_error "FAILED: Expected 2 tool mappings, got $TOOL_COUNT" exit 1 fi } main() { _parse_args "$@" _log_step "Virtual Server Scope-Based Access Control E2E Test" _log_info "Registry URL: $REGISTRY_URL" _log_info "Token File: $TOKEN_FILE" _log_info "Project Root: $PROJECT_ROOT" # Set up cleanup trap trap _cleanup EXIT # Create temporary configs _create_temp_configs # Run tests _test_create_virtual_server _test_create_group _test_create_m2m_account _test_create_regular_user _test_verify_access _test_scope_enforcement _log_step "All Tests Passed" _log_info "Virtual server scope-based access control is working correctly." } main "$@" ================================================ FILE: tests/security/test_container_security.py ================================================ """ Unit tests for Docker container security configuration. Tests verify that Dockerfiles follow CIS Docker Benchmark 4.1 requirements: - Non-root USER directive - No sudo package - HEALTHCHECK directives - Proper environment variables (PIP_NO_CACHE_DIR) """ import re from pathlib import Path import pytest # List of Dockerfiles to test DOCKERFILES = [ "Dockerfile", "docker/Dockerfile.auth", "docker/Dockerfile.registry", "docker/Dockerfile.registry-cpu", "docker/Dockerfile.mcp-server", "docker/Dockerfile.mcp-server-cpu", "docker/Dockerfile.mcp-server-light", "docker/Dockerfile.scopes-init", "docker/Dockerfile.metrics-db", "docker/keycloak/Dockerfile", "metrics-service/Dockerfile", "terraform/aws-ecs/grafana/Dockerfile", ] @pytest.fixture(scope="module") def repo_root() -> Path: """Get repository root directory.""" return Path(__file__).parent.parent.parent @pytest.mark.parametrize("dockerfile_path", DOCKERFILES) def test_dockerfile_has_user_directive(repo_root: Path, dockerfile_path: str): """Test that Dockerfile has USER directive (CIS Docker Benchmark 4.1).""" dockerfile = repo_root / dockerfile_path assert dockerfile.exists(), f"Dockerfile not found: {dockerfile}" content = dockerfile.read_text() # Check for USER directive user_pattern = re.compile(r"^USER\s+\w+", re.MULTILINE) assert user_pattern.search(content), f"{dockerfile_path}: Missing USER directive (CIS 4.1)" @pytest.mark.parametrize("dockerfile_path", DOCKERFILES) def test_dockerfile_user_not_root(repo_root: Path, dockerfile_path: str): """Test that Dockerfile does not run as root user.""" dockerfile = repo_root / dockerfile_path assert dockerfile.exists(), f"Dockerfile not found: {dockerfile}" content = dockerfile.read_text() # Find all USER directives user_lines = re.findall(r"^USER\s+(\w+)", content, re.MULTILINE) assert user_lines, f"{dockerfile_path}: No USER directive found" # Last USER directive should not be root last_user = user_lines[-1] assert last_user.lower() != "root", f"{dockerfile_path}: Last USER directive is 'root'" @pytest.mark.parametrize("dockerfile_path", DOCKERFILES) def test_dockerfile_no_sudo(repo_root: Path, dockerfile_path: str): """Test that Dockerfile does not install sudo package.""" dockerfile = repo_root / dockerfile_path assert dockerfile.exists(), f"Dockerfile not found: {dockerfile}" content = dockerfile.read_text() # Check that sudo is not being installed assert "sudo" not in content, f"{dockerfile_path}: Contains 'sudo' package (security risk)" @pytest.mark.parametrize( "dockerfile_path", [ f for f in DOCKERFILES if "scopes-init" not in f # Exclude one-shot init containers ], ) def test_dockerfile_has_healthcheck(repo_root: Path, dockerfile_path: str): """Test that Dockerfile has HEALTHCHECK directive. Note: One-shot init containers (scopes-init) are excluded as they don't need health checks - they run once and exit. """ dockerfile = repo_root / dockerfile_path assert dockerfile.exists(), f"Dockerfile not found: {dockerfile}" content = dockerfile.read_text() # Check for HEALTHCHECK directive healthcheck_pattern = re.compile(r"^HEALTHCHECK\s+", re.MULTILINE) assert healthcheck_pattern.search(content), f"{dockerfile_path}: Missing HEALTHCHECK directive" @pytest.mark.parametrize( "dockerfile_path", [ f for f in DOCKERFILES if not f.startswith("terraform/") # Exclude Grafana (Node.js) and not f.endswith("scopes-init") # Exclude busybox and not f.endswith("metrics-db") # Exclude alpine-based ], ) def test_python_dockerfile_has_pip_no_cache(repo_root: Path, dockerfile_path: str): """Test that Python Dockerfiles set PIP_NO_CACHE_DIR=1.""" dockerfile = repo_root / dockerfile_path assert dockerfile.exists(), f"Dockerfile not found: {dockerfile}" content = dockerfile.read_text() # Check if it's a Python-based image if re.search(r"FROM.*python", content, re.IGNORECASE): # Check for PIP_NO_CACHE_DIR assert "PIP_NO_CACHE_DIR" in content, ( f"{dockerfile_path}: Python image missing PIP_NO_CACHE_DIR" ) def test_docker_compose_has_security_options(repo_root: Path): """Test that docker-compose.yml has security hardening options.""" compose_file = repo_root / "docker-compose.yml" assert compose_file.exists(), "docker-compose.yml not found" content = compose_file.read_text() # Check for security_opt assert "security_opt:" in content, "docker-compose.yml missing security_opt" assert "no-new-privileges:true" in content, "docker-compose.yml missing no-new-privileges" # Check for cap_drop assert "cap_drop:" in content, "docker-compose.yml missing cap_drop" assert "- ALL" in content, "docker-compose.yml missing cap_drop: ALL" def test_docker_compose_mongodb_cap_add(repo_root: Path): """Test that all docker-compose files restore SETUID/SETGID for MongoDB after cap_drop ALL. MongoDB uses gosu to switch from root to the mongodb user at startup. gosu requires SETUID and SETGID capabilities. Without them, MongoDB fails with: 'error: failed switching to mongodb: operation not permitted'. Regression introduced in PR #624 and PR #651 where cap_drop: ALL was applied to all services without adding back the minimum capabilities required by MongoDB. Fixed in PR #688. """ compose_files = [ "docker-compose.yml", "docker-compose.prebuilt.yml", "docker-compose.podman.yml", ] for compose_filename in compose_files: compose_file = repo_root / compose_filename assert compose_file.exists(), f"{compose_filename} not found" content = compose_file.read_text() assert "cap_add:" in content, f"{compose_filename}: missing cap_add for MongoDB" assert "- SETUID" in content, ( f"{compose_filename}: missing SETUID in cap_add (required by MongoDB gosu)" ) assert "- SETGID" in content, ( f"{compose_filename}: missing SETGID in cap_add (required by MongoDB gosu)" ) def test_docker_compose_registry_port_mapping(repo_root: Path): """Test that docker-compose.yml maps nginx to high ports.""" compose_file = repo_root / "docker-compose.yml" assert compose_file.exists(), "docker-compose.yml not found" content = compose_file.read_text() # Check for port mapping 80:8080 and 443:8443 assert '"80:8080"' in content or "'80:8080'" in content, "Missing port mapping 80:8080" assert '"443:8443"' in content or "'443:8443'" in content, "Missing port mapping 443:8443" ================================================ FILE: tests/test_infrastructure.py ================================================ """ Test to verify test infrastructure is working correctly. This test file validates that all mocking, fixtures, and test utilities are functioning as expected. """ import numpy as np from tests.fixtures.constants import TEST_AGENT_NAME_1, TEST_SERVER_NAME_1 from tests.fixtures.factories import AgentCardFactory, ServerDetailFactory from tests.fixtures.helpers import create_minimal_agent_dict, create_minimal_server_dict from tests.fixtures.mocks.mock_auth import MockJWTValidator from tests.fixtures.mocks.mock_embeddings import MockEmbeddingsClient from tests.fixtures.mocks.mock_faiss import MockFaissIndex from tests.fixtures.mocks.mock_http import MockResponse class TestInfrastructure: """Test the test infrastructure components.""" def test_constants_imported(self): """Test that constants can be imported and accessed.""" assert TEST_SERVER_NAME_1 == "com.example.test-server-1" assert TEST_AGENT_NAME_1 == "test-agent-1" def test_mock_faiss_index(self): """Test MockFaissIndex basic functionality.""" index = MockFaissIndex(dimension=384) assert index.d == 384 assert index.ntotal == 0 # Add some vectors vectors = np.random.randn(5, 384).astype(np.float32) ids = np.array([1, 2, 3, 4, 5], dtype=np.int64) index.add_with_ids(vectors, ids) assert index.ntotal == 5 # Search query = np.random.randn(1, 384).astype(np.float32) distances, indices = index.search(query, k=3) assert distances.shape == (1, 3) assert indices.shape == (1, 3) def test_mock_embeddings_client(self): """Test MockEmbeddingsClient.""" client = MockEmbeddingsClient(dimension=384) texts = ["test sentence 1", "test sentence 2"] embeddings = client.encode(texts) assert embeddings.shape == (2, 384) assert embeddings.dtype == np.float32 def test_mock_jwt_validator(self): """Test MockJWTValidator.""" validator = MockJWTValidator() token = validator.create_token( username="testuser", groups=["users"], scopes=["read:servers"] ) assert isinstance(token, str) assert len(token) > 0 # Validate the token payload = validator.validate_token(token) assert payload["username"] == "testuser" assert "users" in payload["groups"] def test_mock_http_response(self): """Test MockResponse.""" response = MockResponse(status_code=200, json_data={"message": "success"}) assert response.status_code == 200 assert response.json() == {"message": "success"} def test_server_factory(self): """Test ServerDetailFactory.""" server = ServerDetailFactory() assert server.name is not None assert server.version is not None assert server.description is not None def test_agent_factory(self): """Test AgentCardFactory.""" agent = AgentCardFactory() assert agent.name is not None assert agent.url is not None assert agent.protocol_version == "1.0" def test_helpers_minimal_server(self): """Test helper function for creating minimal server.""" server_dict = create_minimal_server_dict("test.server") assert server_dict["name"] == "test.server" assert server_dict["description"] == "Test server" assert server_dict["version"] == "1.0.0" def test_helpers_minimal_agent(self): """Test helper function for creating minimal agent.""" agent_dict = create_minimal_agent_dict(name="test-agent", url="http://localhost:9000") assert agent_dict["name"] == "test-agent" assert agent_dict["url"] == "http://localhost:9000" assert agent_dict["protocolVersion"] == "1.0" def test_settings_fixture(self, test_settings): """Test that test_settings fixture works.""" assert test_settings.secret_key == "test-secret-key-for-testing-only" def test_sample_fixtures(self, sample_server_info, sample_agent_card): """Test sample data fixtures.""" assert sample_server_info["name"] == "com.example.test-server" assert sample_agent_card["name"] == "test-agent" ================================================ FILE: tests/unit/__init__.py ================================================ """Unit tests for MCP Gateway Registry.""" ================================================ FILE: tests/unit/api/__init__.py ================================================ """API routes unit tests.""" ================================================ FILE: tests/unit/api/test_agent_routes.py ================================================ """ Comprehensive unit tests for registry/api/agent_routes.py. This module tests all agent API endpoints including: - Helper functions (_normalize_path, _check_agent_permission, _filter_agents_by_access) - Agent registration, listing, health checks - Agent rating and rating retrieval - Agent toggling, retrieval, updates, and deletion - Agent discovery (skills-based and semantic) Test coverage includes: - Success cases (200, 201, 204) - Client errors (400, 403, 404, 409, 422) - Server errors (500) - Permission and access control - Input validation and normalization """ import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException, status from fastapi.testclient import TestClient from pydantic import ValidationError from registry.api.agent_routes import ( RatingRequest, _check_agent_permission, _filter_agents_by_access, _normalize_path, router, ) from registry.schemas.agent_models import AgentCard from tests.fixtures.factories import AgentCardFactory, SkillFactory logger = logging.getLogger(__name__) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def test_app(mock_user_context): """Create a test FastAPI application with agent routes.""" from fastapi import FastAPI app = FastAPI() app.include_router(router) # Override the auth dependency to return mock user context from registry.api.agent_routes import nginx_proxied_auth from registry.auth.csrf import verify_csrf_token_flexible app.dependency_overrides[nginx_proxied_auth] = lambda: mock_user_context app.dependency_overrides[verify_csrf_token_flexible] = lambda: None client = TestClient(app) yield client # Cleanup app.dependency_overrides.clear() @pytest.fixture def mock_user_context() -> dict[str, Any]: """Create a mock user context for authentication.""" return { "username": "testuser", "groups": ["test-group", "dev-group"], "scopes": ["read:agents", "write:agents"], "auth_method": "session", "provider": "local", "accessible_servers": ["all"], "accessible_services": ["all"], "accessible_agents": ["all"], "ui_permissions": { "publish_agent": ["all"], "toggle_service": ["all"], "modify_service": ["all"], }, "can_modify_servers": True, "is_admin": False, } @pytest.fixture def mock_admin_context() -> dict[str, Any]: """Create a mock admin user context.""" return { "username": "admin", "groups": ["mcp-registry-admin"], "scopes": ["admin:all"], "auth_method": "session", "provider": "local", "accessible_servers": ["all"], "accessible_services": ["all"], "accessible_agents": ["all"], "ui_permissions": { "publish_agent": ["all"], "toggle_service": ["all"], "modify_service": ["all"], }, "can_modify_servers": True, "is_admin": True, } @pytest.fixture def mock_limited_user_context() -> dict[str, Any]: """Create a mock user context with limited permissions.""" return { "username": "limiteduser", "groups": ["limited-group"], "scopes": ["read:agents"], "auth_method": "session", "provider": "local", "accessible_servers": ["/test-agent"], "accessible_services": ["/test-service"], "accessible_agents": ["/test-agent"], "ui_permissions": {}, "can_modify_servers": False, "is_admin": False, } @pytest.fixture def test_app_admin(mock_admin_context): """Create a test FastAPI application with admin auth.""" from fastapi import FastAPI app = FastAPI() app.include_router(router) from registry.api.agent_routes import nginx_proxied_auth from registry.auth.csrf import verify_csrf_token_flexible app.dependency_overrides[nginx_proxied_auth] = lambda: mock_admin_context app.dependency_overrides[verify_csrf_token_flexible] = lambda: None client = TestClient(app) yield client app.dependency_overrides.clear() @pytest.fixture def test_app_limited(mock_limited_user_context): """Create a test FastAPI application with limited user auth.""" from fastapi import FastAPI app = FastAPI() app.include_router(router) from registry.api.agent_routes import nginx_proxied_auth from registry.auth.csrf import verify_csrf_token_flexible app.dependency_overrides[nginx_proxied_auth] = lambda: mock_limited_user_context app.dependency_overrides[verify_csrf_token_flexible] = lambda: None client = TestClient(app) yield client app.dependency_overrides.clear() @pytest.fixture def sample_agent_card() -> AgentCard: """Create a sample agent card for testing.""" return AgentCardFactory( name="test-agent", path="/agents/test-agent", url="http://localhost:9000/test-agent", description="A test agent", version="1.0", visibility="public", is_enabled=True, registered_by="testuser", skills=[ SkillFactory( id="data-retrieval", name="Data Retrieval", description="Retrieve data from various sources", tags=["data", "retrieval"], ) ], tags=["test", "data"], num_stars=4.5, rating_details=[ {"username": "user1", "rating": 5}, {"username": "user2", "rating": 4}, ], ) @pytest.fixture def sample_internal_agent_card() -> AgentCard: """Create an internal agent card for testing.""" return AgentCardFactory( name="internal-agent", path="/agents/internal-agent", url="http://localhost:9000/internal-agent", visibility="internal", registered_by="testuser", is_enabled=True, ) @pytest.fixture def sample_group_restricted_agent_card() -> AgentCard: """Create a group-restricted agent card for testing.""" return AgentCardFactory( name="group-agent", path="/agents/group-agent", url="http://localhost:9000/group-agent", visibility="group-restricted", allowed_groups=["test-group"], registered_by="testuser", is_enabled=True, ) # ============================================================================= # HELPER FUNCTIONS TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestNormalizePath: """Tests for _normalize_path helper function.""" def test_normalize_path_with_leading_slash(self): """Test path normalization when path has leading slash.""" # Arrange path = "/agents/test-agent" # Act result = _normalize_path(path) # Assert assert result == "/agents/test-agent" def test_normalize_path_without_leading_slash(self): """Test path normalization adds leading slash.""" # Arrange path = "agents/test-agent" # Act result = _normalize_path(path) # Assert assert result == "/agents/test-agent" def test_normalize_path_removes_trailing_slash(self): """Test path normalization removes trailing slash.""" # Arrange path = "/agents/test-agent/" # Act result = _normalize_path(path) # Assert assert result == "/agents/test-agent" def test_normalize_path_auto_generate_from_agent_name(self): """Test path auto-generation from agent name.""" # Arrange path = None agent_name = "Test Agent" # Act result = _normalize_path(path, agent_name) # Assert assert result == "/test-agent" def test_normalize_path_none_without_agent_name_raises_error(self): """Test error when path is None and no agent_name provided.""" # Arrange path = None agent_name = None # Act & Assert with pytest.raises(ValueError, match="Path is required or agent_name must be provided"): _normalize_path(path, agent_name) def test_normalize_path_preserves_root_path(self): """Test that root path "/" is preserved.""" # Arrange path = "/" # Act result = _normalize_path(path) # Assert assert result == "/" @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestCheckAgentPermission: """Tests for _check_agent_permission helper function.""" def test_check_agent_permission_granted(self, mock_user_context): """Test permission check passes when user has permission.""" # Arrange permission = "publish_agent" agent_name = "test-agent" with patch("registry.auth.dependencies.user_has_ui_permission_for_service") as mock_check: mock_check.return_value = True # Act & Assert (no exception raised) _check_agent_permission(permission, agent_name, mock_user_context) mock_check.assert_called_once_with( permission, agent_name, mock_user_context["ui_permissions"], ) def test_check_agent_permission_denied(self, mock_user_context): """Test permission check raises HTTPException when denied.""" # Arrange permission = "publish_agent" agent_name = "test-agent" with patch("registry.auth.dependencies.user_has_ui_permission_for_service") as mock_check: mock_check.return_value = False # Act & Assert with pytest.raises(HTTPException) as exc_info: _check_agent_permission(permission, agent_name, mock_user_context) assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN assert "permission" in str(exc_info.value.detail).lower() @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestFilterAgentsByAccess: """Tests for _filter_agents_by_access helper function.""" def test_filter_agents_admin_sees_all( self, mock_admin_context, sample_agent_card, sample_internal_agent_card, sample_group_restricted_agent_card, ): """Test admin user can see all agents.""" # Arrange agents = [sample_agent_card, sample_internal_agent_card, sample_group_restricted_agent_card] # Act result = _filter_agents_by_access(agents, mock_admin_context) # Assert assert len(result) == 3 def test_filter_agents_public_visible_to_all(self, mock_user_context, sample_agent_card): """Test public agents are visible to all users.""" # Arrange agents = [sample_agent_card] # Act result = _filter_agents_by_access(agents, mock_user_context) # Assert assert len(result) == 1 assert result[0].path == sample_agent_card.path def test_filter_agents_internal_only_visible_to_owner( self, mock_user_context, sample_internal_agent_card ): """Test internal agents only visible to owner.""" # Arrange agents = [sample_internal_agent_card] # Act (user is the owner) result = _filter_agents_by_access(agents, mock_user_context) # Assert assert len(result) == 1 def test_filter_agents_internal_not_visible_to_others(self, mock_limited_user_context): """Test internal agents not visible to other users.""" # Arrange internal_agent = AgentCardFactory( visibility="internal", registered_by="differentuser", path="/agents/internal-agent", ) agents = [internal_agent] # Act result = _filter_agents_by_access(agents, mock_limited_user_context) # Assert assert len(result) == 0 def test_filter_agents_group_restricted_visible_to_group_members( self, mock_user_context, sample_group_restricted_agent_card ): """Test group-restricted agents visible to group members.""" # Arrange agents = [sample_group_restricted_agent_card] # User has 'test-group' which matches allowed_groups # Act result = _filter_agents_by_access(agents, mock_user_context) # Assert assert len(result) == 1 def test_filter_agents_group_restricted_not_visible_to_non_members( self, mock_limited_user_context, sample_group_restricted_agent_card ): """Test group-restricted agents not visible to non-members.""" # Arrange agents = [sample_group_restricted_agent_card] # limited user doesn't have 'test-group' # Act result = _filter_agents_by_access(agents, mock_limited_user_context) # Assert assert len(result) == 0 def test_filter_agents_respects_accessible_agents_list( self, mock_limited_user_context, sample_agent_card ): """Test filtering respects accessible_agents from UI-Scopes.""" # Arrange other_agent = AgentCardFactory( path="/agents/other-agent", visibility="public", ) agents = [sample_agent_card, other_agent] # limited user only has access to ['/test-agent'] mock_limited_user_context["accessible_agents"] = ["/agents/test-agent"] # Act result = _filter_agents_by_access(agents, mock_limited_user_context) # Assert assert len(result) == 1 assert result[0].path == "/agents/test-agent" # ============================================================================= # ENDPOINT TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestRegisterAgent: """Tests for POST /agents/register endpoint.""" @pytest.mark.asyncio async def test_register_agent_success(self, test_app, mock_user_context): """Test successful agent registration.""" # Arrange request_data = { "name": "new-agent", "description": "A new test agent", "url": "http://localhost:9000/new-agent", "version": "1.0", "tags": "test,new", "supportedProtocol": "a2a", } with ( patch("registry.api.agent_routes.agent_service") as mock_agent_service, patch("registry.utils.agent_validator.agent_validator") as mock_validator, patch("registry.search.service.faiss_service") as mock_faiss, ): mock_agent_service.get_agent_info = AsyncMock(return_value=None) mock_agent_service.register_agent = AsyncMock(return_value=True) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) mock_validation_result = MagicMock() mock_validation_result.is_valid = True mock_validation_result.errors = [] mock_validation_result.warnings = [] mock_validator.validate_agent_card = AsyncMock(return_value=mock_validation_result) mock_faiss.add_or_update_entity = AsyncMock() # Act response = test_app.post("/agents/register", json=request_data) # Assert assert response.status_code == status.HTTP_201_CREATED data = response.json() assert data["message"] == "Agent registered successfully" assert data["agent"]["name"] == "new-agent" assert data["agent"]["path"] == "/new-agent" @pytest.mark.asyncio async def test_register_agent_path_conflict( self, test_app, mock_user_context, sample_agent_card ): """Test agent registration fails with path conflict (409).""" # Arrange request_data = { "name": "test-agent", "description": "A test agent", "url": "http://localhost:9000/test-agent", "version": "1.0", "tags": "test", "supportedProtocol": "a2a", } with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) # Act response = test_app.post("/agents/register", json=request_data) # Assert assert response.status_code == status.HTTP_409_CONFLICT assert "already exists" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_register_agent_validation_failure(self, test_app, mock_user_context): """Test agent registration fails with validation error (422).""" # Arrange request_data = { "name": "invalid-agent", "description": "Invalid agent", "url": "http://localhost:9000/invalid", "version": "1.0", "tags": "test", "supportedProtocol": "a2a", } with ( patch("registry.api.agent_routes.agent_service") as mock_agent_service, patch("registry.utils.agent_validator.agent_validator") as mock_validator, ): mock_agent_service.get_agent_info = AsyncMock(return_value=None) mock_validation_result = MagicMock() mock_validation_result.is_valid = False mock_validation_result.errors = ["Invalid agent URL"] mock_validation_result.warnings = [] mock_validator.validate_agent_card = AsyncMock(return_value=mock_validation_result) # Act response = test_app.post("/agents/register", json=request_data) # Assert assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert "validation failed" in response.json()["detail"]["message"].lower() @pytest.mark.asyncio async def test_register_agent_no_permission(self, test_app_limited): """Test agent registration fails without permission (403).""" # Arrange request_data = { "name": "unauthorized-agent", "description": "Agent without permission", "url": "http://localhost:9000/unauthorized", "version": "1.0", "tags": "test", "supportedProtocol": "a2a", } # Act response = test_app_limited.post("/agents/register", json=request_data) # Assert assert response.status_code == status.HTTP_403_FORBIDDEN assert "permission" in response.json()["detail"].lower() @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestListAgents: """Tests for GET /agents endpoint.""" @pytest.mark.asyncio async def test_list_agents_success(self, test_app_admin, mock_admin_context, sample_agent_card): """Test successful agent listing (admin user, no filters = fast path).""" # Arrange - mock_admin_context has is_admin=True and no field filters, # so the route uses the fast path (get_agents_paginated) with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agents_paginated = AsyncMock( return_value=([sample_agent_card], 1) ) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) # Act response = test_app_admin.get("/agents") # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert "agents" in data assert "total_count" in data assert data["total_count"] == 1 assert len(data["agents"]) == 1 assert data["limit"] == 20 assert data["offset"] == 0 assert data["has_next"] is False @pytest.mark.asyncio async def test_list_agents_enabled_only_filter(self, test_app, mock_user_context): """Test listing only enabled agents.""" # Arrange enabled_agent = AgentCardFactory(path="/agents/enabled", is_enabled=True) disabled_agent = AgentCardFactory(path="/agents/disabled", is_enabled=False) with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock( return_value=[enabled_agent, disabled_agent] ) mock_agent_service.is_agent_enabled = AsyncMock(side_effect=lambda path: path == "/agents/enabled") # Act response = test_app.get("/agents?enabled_only=true") # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_count"] == 1 assert data["agents"][0]["path"] == "/agents/enabled" assert data["limit"] == 20 assert data["offset"] == 0 assert data["has_next"] is False @pytest.mark.asyncio async def test_list_agents_visibility_filter(self, test_app, mock_admin_context): """Test filtering agents by visibility.""" # Arrange public_agent = AgentCardFactory(visibility="public", path="/agents/public") internal_agent = AgentCardFactory(visibility="internal", path="/agents/internal") with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock( return_value=[public_agent, internal_agent] ) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) # Act response = test_app.get("/agents?visibility=public") # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_count"] == 1 assert data["agents"][0]["path"] == "/agents/public" assert data["limit"] == 20 assert data["offset"] == 0 assert data["has_next"] is False @pytest.mark.asyncio async def test_list_agents_query_search(self, test_app, mock_user_context): """Test searching agents by query string.""" # Arrange data_agent = AgentCardFactory( name="data-processor", description="Process data efficiently", tags=["data", "processing"], path="/agents/data-processor", ) image_agent = AgentCardFactory( name="image-processor", description="Process images", tags=["image", "processing"], path="/agents/image-processor", ) with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock(return_value=[data_agent, image_agent]) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) # Act response = test_app.get("/agents?query=data") # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_count"] == 1 assert data["agents"][0]["name"] == "data-processor" assert data["limit"] == 20 assert data["offset"] == 0 assert data["has_next"] is False # --- Metadata keyword search tests (issue #775) --- @pytest.mark.asyncio async def test_list_agents_query_matches_metadata_value(self, test_app, mock_user_context): """Query matches a value in agent metadata.""" agent_with_meta = AgentCardFactory( name="generic-agent", description="A generic agent", tags=["general"], path="/agents/generic-agent", metadata={"team": "finance", "region": "us-east-1"}, ) agent_without_meta = AgentCardFactory( name="other-agent", description="Another agent", tags=["other"], path="/agents/other-agent", metadata={}, ) with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock( return_value=[agent_with_meta, agent_without_meta] ) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) response = test_app.get("/agents?query=finance") assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_count"] == 1 assert data["agents"][0]["name"] == "generic-agent" @pytest.mark.asyncio async def test_list_agents_query_matches_metadata_key(self, test_app, mock_user_context): """Query matches a key name in agent metadata.""" agent = AgentCardFactory( name="generic-agent", description="A generic agent", tags=[], path="/agents/generic-agent", metadata={"department": "engineering"}, ) with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock(return_value=[agent]) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) response = test_app.get("/agents?query=department") assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_count"] == 1 @pytest.mark.asyncio async def test_list_agents_query_matches_metadata_list_item(self, test_app, mock_user_context): """Query matches an item inside a metadata list value.""" agent = AgentCardFactory( name="polyglot-agent", description="A polyglot agent", tags=[], path="/agents/polyglot-agent", metadata={"languages": ["python", "golang", "rust"]}, ) with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock(return_value=[agent]) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) response = test_app.get("/agents?query=golang") assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_count"] == 1 @pytest.mark.asyncio async def test_list_agents_query_no_match_in_metadata(self, test_app, mock_user_context): """Query that does not match name, description, tags, skills, or metadata returns nothing.""" agent = AgentCardFactory( name="agent-a", description="Description A", tags=["tag-a"], path="/agents/agent-a", metadata={"team": "alpha"}, ) with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock(return_value=[agent]) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) response = test_app.get("/agents?query=nonexistent") assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_count"] == 0 @pytest.mark.asyncio async def test_list_agents_empty_metadata_no_error(self, test_app, mock_user_context): """Agent with empty metadata does not cause errors during search.""" agent = AgentCardFactory( name="minimal-agent", description="Minimal", tags=[], path="/agents/minimal-agent", metadata={}, ) with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock(return_value=[agent]) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) response = test_app.get("/agents?query=minimal") assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_count"] == 1 # --- Pagination: Validation tests --- @pytest.mark.asyncio async def test_list_agents_limit_exceeds_max_rejected(self, test_app, mock_user_context): """limit=501 must be rejected with 422.""" response = test_app.get("/agents?limit=501") assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @pytest.mark.asyncio async def test_list_agents_limit_zero_rejected(self, test_app, mock_user_context): """limit=0 must be rejected with 422.""" response = test_app.get("/agents?limit=0") assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @pytest.mark.asyncio async def test_list_agents_negative_offset_rejected(self, test_app, mock_user_context): """offset=-1 must be rejected with 422.""" response = test_app.get("/agents?offset=-1") assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY # --- Pagination: Fast path tests (unrestricted user, no field filters) --- @pytest.mark.asyncio async def test_list_agents_fast_path_with_limit_offset(self, test_app_admin, mock_admin_context): """Admin user with limit/offset uses DB-level pagination.""" agents = [AgentCardFactory(path=f"/agents/agent-{i}") for i in range(5)] with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agents_paginated = AsyncMock(return_value=(agents[2:4], 5)) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) response = test_app_admin.get("/agents?limit=2&offset=2") assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data["agents"]) == 2 assert data["total_count"] == 5 assert data["limit"] == 2 assert data["offset"] == 2 assert data["has_next"] is True mock_agent_service.get_agents_paginated.assert_called_once_with(skip=2, limit=2) @pytest.mark.asyncio async def test_list_agents_fast_path_has_next_false(self, test_app_admin, mock_admin_context): """Fast path: has_next is false when all agents fit in one page.""" agents = [AgentCardFactory(path=f"/agents/agent-{i}") for i in range(3)] with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agents_paginated = AsyncMock(return_value=(agents, 3)) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) response = test_app_admin.get("/agents?limit=20") assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data["agents"]) == 3 assert data["total_count"] == 3 assert data["has_next"] is False @pytest.mark.asyncio async def test_list_agents_fast_path_offset_beyond_total(self, test_app_admin, mock_admin_context): """Fast path: offset beyond total returns empty list.""" with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agents_paginated = AsyncMock(return_value=([], 3)) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) response = test_app_admin.get("/agents?offset=100") assert response.status_code == status.HTTP_200_OK data = response.json() assert data["agents"] == [] assert data["total_count"] == 3 assert data["offset"] == 100 assert data["has_next"] is False # --- Pagination: Fallback path tests (unrestricted + field filters) --- @pytest.mark.asyncio async def test_list_agents_fallback_with_query_filter(self, test_app, mock_user_context): """Unrestricted user with query filter falls back to full fetch + slice.""" agents = [ AgentCardFactory( name="data-agent", description="Processes data", path="/agents/data", tags=["data"], visibility="public", ), AgentCardFactory( name="image-agent", description="Processes images", path="/agents/image", tags=["image"], visibility="public", ), ] with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock(return_value=agents) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) response = test_app.get("/agents?query=data&limit=10") assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_count"] == 1 assert len(data["agents"]) == 1 assert data["agents"][0]["name"] == "data-agent" assert data["limit"] == 10 assert data["offset"] == 0 # Fallback path used get_all_agents, not get_agents_paginated mock_agent_service.get_all_agents.assert_called_once() # --- Pagination: Fallback path tests (restricted user) --- @pytest.mark.asyncio async def test_list_agents_restricted_user_pagination(self, test_app_limited): """Restricted user uses fallback path with full fetch + access filter + slice.""" agents = [ AgentCardFactory(path="/test-agent", visibility="public"), AgentCardFactory(path="/other-agent", visibility="public"), AgentCardFactory(path="/third-agent", visibility="public"), ] with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock(return_value=agents) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) response = test_app_limited.get("/agents?limit=5") assert response.status_code == status.HTTP_200_OK data = response.json() # Limited user only has access to /test-agent assert data["total_count"] == 1 assert len(data["agents"]) == 1 assert data["agents"][0]["path"] == "/test-agent" assert data["limit"] == 5 assert data["offset"] == 0 assert data["has_next"] is False @pytest.mark.asyncio async def test_list_agents_restricted_user_offset_slicing(self, test_app_limited): """Restricted user with offset correctly slices accessible agents.""" # Create multiple agents the limited user can access agents = [ AgentCardFactory(path="/test-agent", visibility="public"), AgentCardFactory(path="/other-agent", visibility="public"), ] with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock(return_value=agents) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) # Limited user can only see /test-agent, offset=1 gives empty response = test_app_limited.get("/agents?limit=5&offset=1") assert response.status_code == status.HTTP_200_OK data = response.json() assert data["total_count"] == 1 assert data["agents"] == [] assert data["offset"] == 1 assert data["has_next"] is False @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestCheckAgentHealth: """Tests for POST /agents/{path:path}/health endpoint.""" @pytest.mark.asyncio async def test_check_agent_health_healthy(self, test_app, mock_user_context, sample_agent_card): """Test health check returns healthy status.""" # Arrange with ( patch("registry.api.agent_routes.agent_service") as mock_agent_service, patch("httpx.AsyncClient") as mock_httpx_client, ): mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) # Mock httpx response mock_response = MagicMock() mock_response.status_code = 200 mock_client_instance = AsyncMock() mock_client_instance.__aenter__.return_value.get = AsyncMock(return_value=mock_response) mock_httpx_client.return_value = mock_client_instance # Act response = test_app.post("/agents/test-agent/health") # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["status"] == "healthy" assert data["status_code"] == 200 assert "response_time_ms" in data @pytest.mark.asyncio async def test_check_agent_health_unhealthy( self, test_app, mock_user_context, sample_agent_card ): """Test health check returns unhealthy status.""" # Arrange import httpx with ( patch("registry.api.agent_routes.agent_service") as mock_agent_service, patch("httpx.AsyncClient") as mock_httpx_client, ): mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) # Mock httpx timeout for both GET (health URLs) and HEAD (fallback) mock_client_instance = AsyncMock() mock_client_instance.__aenter__.return_value.get = AsyncMock( side_effect=httpx.TimeoutException("Timeout") ) mock_client_instance.__aenter__.return_value.head = AsyncMock( side_effect=httpx.TimeoutException("Timeout") ) mock_httpx_client.return_value = mock_client_instance # Act response = test_app.post("/agents/test-agent/health") # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["status"] == "unhealthy" assert "timed out" in data["detail"].lower() @pytest.mark.asyncio async def test_check_agent_health_not_found(self, test_app, mock_user_context): """Test health check on non-existent agent (404).""" # Arrange with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=None) # Act response = test_app.post("/agents/nonexistent/health") # Assert assert response.status_code == status.HTTP_404_NOT_FOUND @pytest.mark.asyncio async def test_check_agent_health_disabled_agent( self, test_app, mock_user_context, sample_agent_card ): """Test health check on disabled agent (400).""" # Arrange with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) mock_agent_service.is_agent_enabled = AsyncMock(return_value=False) # Act response = test_app.post("/agents/test-agent/health") # Assert assert response.status_code == status.HTTP_400_BAD_REQUEST assert "disabled" in response.json()["detail"].lower() @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestRateAgent: """Tests for POST /agents/{path:path}/rate endpoint.""" @pytest.mark.asyncio async def test_rate_agent_success(self, test_app, mock_user_context, sample_agent_card): """Test successful agent rating.""" # Arrange rating_request = {"rating": 5} with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) mock_agent_service.update_rating = AsyncMock(return_value=4.7) # Act response = test_app.post("/agents/test-agent/rate", json=rating_request) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["message"] == "Rating added successfully" assert data["average_rating"] == 4.7 @pytest.mark.asyncio async def test_rate_agent_invalid_rating(self, test_app, mock_user_context, sample_agent_card): """Test rating agent with invalid rating value (400).""" # Arrange rating_request = {"rating": 10} with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) mock_agent_service.update_rating = AsyncMock( side_effect=ValueError("Rating must be between 1 and 5") ) # Act response = test_app.post("/agents/test-agent/rate", json=rating_request) # Assert assert response.status_code == status.HTTP_400_BAD_REQUEST @pytest.mark.asyncio async def test_rate_agent_not_found(self, test_app, mock_user_context): """Test rating non-existent agent (404).""" # Arrange rating_request = {"rating": 5} with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=None) # Act response = test_app.post("/agents/nonexistent/rate", json=rating_request) # Assert assert response.status_code == status.HTTP_404_NOT_FOUND @pytest.mark.asyncio async def test_rate_agent_no_access( self, test_app, mock_limited_user_context, sample_internal_agent_card ): """Test rating agent without access (403).""" # Arrange rating_request = {"rating": 5} # Update agent to be owned by different user sample_internal_agent_card.registered_by = "differentuser" with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=sample_internal_agent_card) # Act response = test_app.post("/agents/private-agent/rate", json=rating_request) # Assert assert response.status_code == status.HTTP_403_FORBIDDEN @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestGetAgentRating: """Tests for GET /agents/{path:path}/rating endpoint.""" @pytest.mark.asyncio async def test_get_agent_rating_success(self, test_app, mock_user_context, sample_agent_card): """Test successfully retrieving agent rating.""" # Arrange with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) # Act response = test_app.get("/agents/test-agent/rating") # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert "num_stars" in data assert "rating_details" in data assert data["num_stars"] == sample_agent_card.num_stars @pytest.mark.asyncio async def test_get_agent_rating_not_found(self, test_app, mock_user_context): """Test getting rating for non-existent agent (404).""" # Arrange with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=None) # Act response = test_app.get("/agents/nonexistent/rating") # Assert assert response.status_code == status.HTTP_404_NOT_FOUND @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestToggleAgent: """Tests for POST /agents/{path:path}/toggle endpoint.""" @pytest.mark.asyncio async def test_toggle_agent_enable_success( self, test_app, mock_user_context, sample_agent_card ): """Test successfully enabling an agent.""" # Arrange with ( patch("registry.api.agent_routes.agent_service") as mock_agent_service, patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ), patch("registry.search.service.faiss_service") as mock_faiss, ): mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) mock_agent_service.toggle_agent = AsyncMock(return_value=True) mock_faiss.add_or_update_entity = AsyncMock() # Act response = test_app.post("/agents/test-agent/toggle?enabled=true") # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert "enabled" in data["message"].lower() assert data["is_enabled"] is True @pytest.mark.asyncio async def test_toggle_agent_no_permission( self, test_app, mock_limited_user_context, sample_agent_card ): """Test toggling agent without permission (403).""" # Arrange with ( patch("registry.api.agent_routes.agent_service") as mock_agent_service, patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=False ), ): mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) # Act response = test_app.post("/agents/test-agent/toggle?enabled=true") # Assert assert response.status_code == status.HTTP_403_FORBIDDEN @pytest.mark.asyncio async def test_toggle_agent_not_found(self, test_app, mock_user_context): """Test toggling non-existent agent (404).""" # Arrange with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=None) # Act response = test_app.post("/agents/nonexistent/toggle?enabled=true") # Assert assert response.status_code == status.HTTP_404_NOT_FOUND @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestGetAgent: """Tests for GET /agents/{path:path} endpoint.""" @pytest.mark.asyncio async def test_get_agent_success(self, test_app, mock_user_context, sample_agent_card): """Test successfully retrieving an agent.""" # Arrange with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) # Act response = test_app.get("/agents/test-agent") # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["name"] == sample_agent_card.name assert data["path"] == sample_agent_card.path @pytest.mark.asyncio async def test_get_agent_not_found(self, test_app, mock_user_context): """Test getting non-existent agent (404).""" # Arrange with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=None) # Act response = test_app.get("/agents/nonexistent") # Assert assert response.status_code == status.HTTP_404_NOT_FOUND @pytest.mark.asyncio async def test_get_agent_no_access( self, test_app, mock_limited_user_context, sample_internal_agent_card ): """Test getting agent without access (403).""" # Arrange sample_internal_agent_card.registered_by = "differentuser" with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=sample_internal_agent_card) # Act response = test_app.get("/agents/private-agent") # Assert assert response.status_code == status.HTTP_403_FORBIDDEN @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestUpdateAgent: """Tests for PUT /agents/{path:path} endpoint.""" @pytest.mark.asyncio async def test_update_agent_success(self, test_app, mock_user_context, sample_agent_card): """Test successfully updating an agent.""" # Arrange update_data = { "name": "updated-agent", "description": "Updated description", "url": "http://localhost:9000/updated-agent", "version": "2.0", "tags": "updated,test", "supportedProtocol": "a2a", } with ( patch("registry.api.agent_routes.agent_service") as mock_agent_service, patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ), patch("registry.utils.agent_validator.agent_validator") as mock_validator, patch("registry.search.service.faiss_service") as mock_faiss, ): mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) mock_agent_service.update_agent = AsyncMock(return_value=True) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) mock_validation_result = MagicMock() mock_validation_result.is_valid = True mock_validator.validate_agent_card = AsyncMock(return_value=mock_validation_result) mock_faiss.add_or_update_entity = AsyncMock() # Act response = test_app.put("/agents/test-agent", json=update_data) # Assert assert response.status_code == status.HTTP_200_OK @pytest.mark.asyncio async def test_update_agent_not_owner(self, test_app, mock_user_context): """Test updating agent as non-owner (403).""" # Arrange other_user_agent = AgentCardFactory( path="/agents/other-agent", registered_by="otheruser", ) update_data = { "name": "updated-agent", "url": "http://localhost:9000/updated", "version": "2.0", "tags": "test", "supportedProtocol": "a2a", } with ( patch("registry.api.agent_routes.agent_service") as mock_agent_service, patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ), ): mock_agent_service.get_agent_info = AsyncMock(return_value=other_user_agent) # Act response = test_app.put("/agents/other-agent", json=update_data) # Assert assert response.status_code == status.HTTP_403_FORBIDDEN assert "only update agents you registered" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_update_agent_validation_failure( self, test_app, mock_user_context, sample_agent_card ): """Test updating agent with validation failure (422).""" # Arrange update_data = { "name": "invalid-agent", "url": "invalid-url", "version": "2.0", "tags": "test", } with ( patch("registry.api.agent_routes.agent_service") as mock_agent_service, patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ), patch("registry.utils.agent_validator.agent_validator") as mock_validator, ): mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) mock_validation_result = MagicMock() mock_validation_result.is_valid = False mock_validation_result.errors = ["Invalid URL format"] mock_validator.validate_agent_card = AsyncMock(return_value=mock_validation_result) # Act response = test_app.put("/agents/test-agent", json=update_data) # Assert assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestDeleteAgent: """Tests for DELETE /agents/{path:path} endpoint.""" @pytest.mark.asyncio async def test_delete_agent_success(self, test_app, mock_user_context, sample_agent_card): """Test successfully deleting an agent.""" # Arrange with ( patch("registry.api.agent_routes.agent_service") as mock_agent_service, patch("registry.search.service.faiss_service") as mock_faiss, ): mock_agent_service.get_agent_info = AsyncMock(return_value=sample_agent_card) mock_agent_service.remove_agent = AsyncMock(return_value=True) mock_faiss.remove_entity = AsyncMock() # Act response = test_app.delete("/agents/test-agent") # Assert assert response.status_code == status.HTTP_204_NO_CONTENT @pytest.mark.asyncio async def test_delete_agent_not_owner(self, test_app, mock_user_context): """Test deleting agent as non-owner without delete_agent permission (403).""" # Arrange other_user_agent = AgentCardFactory( path="/agents/other-agent", registered_by="otheruser", ) with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=other_user_agent) # Act response = test_app.delete("/agents/other-agent") # Assert assert response.status_code == status.HTTP_403_FORBIDDEN # Updated error message includes delete_agent permission option assert "delete_agent permission" in response.json()["detail"].lower() @pytest.mark.asyncio async def test_delete_agent_not_found(self, test_app, mock_user_context): """Test deleting non-existent agent (404).""" # Arrange with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_agent_info = AsyncMock(return_value=None) # Act response = test_app.delete("/agents/nonexistent") # Assert assert response.status_code == status.HTTP_404_NOT_FOUND @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestDiscoverAgentsBySkills: """Tests for POST /agents/discover endpoint.""" @pytest.mark.asyncio @pytest.mark.skip( reason="Source code bug: agent_routes.py line 930 accesses agent.streaming but AgentCard " "has no 'streaming' attribute. Should use agent.capabilities.get('streaming', False). " "See .scratchpad/fixes/registry/fix-agent-streaming-attribute.md" ) async def test_discover_agents_by_skills_success(self, test_app, mock_user_context): """Test successful agent discovery by skills.""" # Arrange agent_with_skill = AgentCardFactory( path="/agents/data-agent", skills=[ SkillFactory(id="data-retrieval", name="Data Retrieval"), ], is_enabled=True, visibility="public", ) # FastAPI expects multiple body params as a single JSON object with keys matching param names request_body = { "skills": ["data-retrieval"], } with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock(return_value=[agent_with_skill]) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) # Act - skills sent as body object, max_results as query param response = test_app.post("/agents/discover?max_results=10", json=request_body) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert "agents" in data assert len(data["agents"]) == 1 assert "relevance_score" in data["agents"][0] @pytest.mark.asyncio async def test_discover_agents_by_skills_no_skills_provided(self, test_app, mock_user_context): """Test discovery fails when no skills provided (400).""" # Arrange request_data = { "skills": [], "max_results": 10, } with patch("registry.api.agent_routes.nginx_proxied_auth", return_value=mock_user_context): # Act response = test_app.post("/agents/discover", json=request_data) # Assert assert response.status_code == status.HTTP_400_BAD_REQUEST assert "skill" in response.json()["detail"].lower() @pytest.mark.asyncio @pytest.mark.skip( reason="Source code bug: agent_routes.py line 930 accesses agent.streaming but AgentCard " "has no 'streaming' attribute. Should use agent.capabilities.get('streaming', False). " "See .scratchpad/fixes/registry/fix-agent-streaming-attribute.md" ) async def test_discover_agents_by_skills_with_tag_filtering(self, test_app, mock_user_context): """Test discovery with tag filtering.""" # Arrange agent_with_tags = AgentCardFactory( path="/agents/data-agent", skills=[SkillFactory(id="data-retrieval", name="Data Retrieval")], tags=["production", "data"], is_enabled=True, visibility="public", ) agent_without_tags = AgentCardFactory( path="/agents/other-agent", skills=[SkillFactory(id="data-retrieval", name="Data Retrieval")], tags=["test"], is_enabled=True, visibility="public", ) # Both skills and tags are body parameters, max_results is query param request_body = { "skills": ["data-retrieval"], "tags": ["production"], } with patch("registry.api.agent_routes.agent_service") as mock_agent_service: mock_agent_service.get_all_agents = AsyncMock( return_value=[agent_with_tags, agent_without_tags] ) mock_agent_service.is_agent_enabled = AsyncMock(return_value=True) # Act response = test_app.post("/agents/discover?max_results=10", json=request_body) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() # Both agents have matching skills so both should be returned assert len(data["agents"]) == 2 # Agent with production tag should have higher relevance assert data["agents"][0]["path"] == "/agents/data-agent" @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestDiscoverAgentsSemantic: """Tests for POST /agents/discover/semantic endpoint.""" @pytest.mark.asyncio async def test_discover_agents_semantic_success(self, test_app, mock_user_context): """Test successful semantic agent discovery.""" # Arrange agent = AgentCardFactory(path="/agents/test-agent", visibility="public") # query is a body parameter (str type in POST = body) request_body = "find data processing agents" mock_search_results = [ { "path": "/agents/test-agent", "relevance_score": 0.85, } ] # Patch faiss_service where it's dynamically imported in the route function with ( patch("registry.api.agent_routes.agent_service") as mock_agent_service, patch("registry.search.service.faiss_service") as mock_faiss, ): mock_agent_service.get_all_agents = AsyncMock(return_value=[agent]) mock_faiss.search_entities = AsyncMock(return_value=mock_search_results) # Act - query sent as body string, max_results as query param response = test_app.post( "/agents/discover/semantic?max_results=10", content=request_body, headers={"Content-Type": "text/plain"}, ) # Assert - check either success or expected error handling # The endpoint might not accept plain text, so check the status if response.status_code == status.HTTP_200_OK: data = response.json() assert "agents" in data else: # If content-type mismatch, this test documents the behavior assert response.status_code in [ status.HTTP_422_UNPROCESSABLE_ENTITY, status.HTTP_400_BAD_REQUEST, ] @pytest.mark.asyncio async def test_discover_agents_semantic_empty_query(self, test_app, mock_user_context): """Test semantic discovery fails with empty query (400).""" # Arrange - send empty string as body request_body = "" # Act - The endpoint should reject empty query response = test_app.post( "/agents/discover/semantic?max_results=10", content=request_body, headers={"Content-Type": "text/plain"}, ) # Assert - empty query should fail with 400 or 422 assert response.status_code in [ status.HTTP_400_BAD_REQUEST, status.HTTP_422_UNPROCESSABLE_ENTITY, ] # ============================================================================= # RATING REQUEST MODEL TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.agents class TestRatingRequestModel: """Tests for RatingRequest Pydantic model.""" def test_rating_request_valid(self): """Test valid RatingRequest creation.""" # Arrange & Act request = RatingRequest(rating=5) # Assert assert request.rating == 5 def test_rating_request_invalid_type(self): """Test RatingRequest with invalid type.""" # Arrange & Act & Assert with pytest.raises(ValidationError): RatingRequest(rating="invalid") ================================================ FILE: tests/unit/api/test_config_export.py ================================================ """Unit tests for configuration export functions. Tests from LLD section 7.1 — validates export format correctness for env, JSON, and tfvars outputs. """ import json from registry.api.config_routes import ( _export_as_env, _export_as_json, _export_as_tfvars, ) class TestConfigExport: """Export function unit tests (Requirements 3.3, 3.4, 3.6, 3.7, 8.1).""" def test_export_env_masks_sensitive(self): """Verify _export_as_env(include_sensitive=False) masks sensitive values.""" output = _export_as_env(include_sensitive=False) assert "SENSITIVE_VALUE_MASKED" in output # Sensitive fields should be commented out, not exposed assert "# SECRET_KEY=" in output def test_export_env_includes_sensitive_when_requested(self): """Verify _export_as_env(include_sensitive=True) does not mask.""" output = _export_as_env(include_sensitive=True) assert "SENSITIVE_VALUE_MASKED" not in output def test_export_json_valid_json(self): """Verify _export_as_json produces valid JSON with required keys.""" output = _export_as_json(include_sensitive=False) parsed = json.loads(output) assert "_metadata" in parsed assert "configuration" in parsed assert "exported_at" in parsed["_metadata"] assert "registry_mode" in parsed["_metadata"] assert "includes_sensitive" in parsed["_metadata"] def test_export_tfvars_valid_syntax(self): """Verify _export_as_tfvars has no Python literals (None, True).""" output = _export_as_tfvars(include_sensitive=False) for line in output.splitlines(): stripped = line.strip() # Skip comments and empty lines if stripped.startswith("#") or not stripped: continue # Should not contain Python-style True/False/None assert "None" not in stripped, f"Found Python 'None' in: {stripped}" assert "True" not in stripped, f"Found Python 'True' in: {stripped}" assert "False" not in stripped, f"Found Python 'False' in: {stripped}" ================================================ FILE: tests/unit/api/test_federation_export_routes.py ================================================ """ Unit tests for Federation Export API endpoints. Tests the visibility-based access control, incremental sync, pagination, and authentication requirements for federation endpoints. """ from typing import ( Any, ) from unittest.mock import ( Mock, patch, ) import pytest from fastapi import status from fastapi.testclient import TestClient from registry.api import federation_export_routes from registry.main import app from registry.services.agent_service import agent_service from registry.services.server_service import server_service @pytest.fixture def mock_federation_auth(): """Mock nginx_proxied_auth for federation peer with federation-service scope.""" def _mock_auth( request=None, session=None, x_user=None, x_username=None, x_scopes=None, x_auth_method=None ): return { "username": "peer-registry-1", "groups": ["engineering", "finance"], "scopes": ["federation-service", "mcp-servers-restricted/read"], "auth_method": "oauth2", "provider": "keycloak", "accessible_servers": [], "accessible_services": ["all"], "can_modify_servers": False, "is_admin": False, } return _mock_auth @pytest.fixture def mock_federation_auth_no_groups(): """Mock nginx_proxied_auth for federation peer with no groups.""" def _mock_auth( request=None, session=None, x_user=None, x_username=None, x_scopes=None, x_auth_method=None ): return { "username": "peer-registry-public", "groups": [], "scopes": ["federation-service"], "auth_method": "oauth2", "provider": "keycloak", "accessible_servers": [], "accessible_services": ["all"], "can_modify_servers": False, "is_admin": False, } return _mock_auth @pytest.fixture def mock_federation_auth_missing_scope(): """Mock nginx_proxied_auth for peer WITHOUT federation-service scope.""" def _mock_auth( request=None, session=None, x_user=None, x_username=None, x_scopes=None, x_auth_method=None ): return { "username": "unauthorized-peer", "groups": ["engineering"], "scopes": ["mcp-servers-restricted/read"], "auth_method": "oauth2", "provider": "keycloak", "accessible_servers": [], "accessible_services": ["all"], "can_modify_servers": False, "is_admin": False, } return _mock_auth @pytest.fixture def sample_server_public() -> dict[str, Any]: """Create a public server for testing.""" return { "path": "/public-server", "name": "Public Server", "description": "Public server available to all", "visibility": "public", "allowed_groups": [], "sync_metadata": { "sync_generation": 10, "last_synced_at": "2024-01-15T10:30:00Z", }, } @pytest.fixture def sample_server_group_restricted() -> dict[str, Any]: """Create a group-restricted server for testing.""" return { "path": "/finance-server", "name": "Finance Server", "description": "Finance team only", "visibility": "group-restricted", "allowed_groups": ["finance"], "sync_metadata": { "sync_generation": 15, "last_synced_at": "2024-01-15T10:30:00Z", }, } @pytest.fixture def sample_server_internal() -> dict[str, Any]: """Create an internal server that should never be exported.""" return { "path": "/internal-server", "name": "Internal Server", "description": "Internal only, never exported", "visibility": "internal", "allowed_groups": [], "sync_metadata": { "sync_generation": 20, "last_synced_at": "2024-01-15T10:30:00Z", }, } @pytest.fixture def sample_agent_public() -> dict[str, Any]: """Create a public agent for testing.""" return { "path": "/agents/public-agent", "name": "Public Agent", "description": "Public agent available to all", "visibility": "public", "allowed_groups": [], "sync_metadata": { "sync_generation": 5, "last_synced_at": "2024-01-15T10:30:00Z", }, } @pytest.fixture def sample_agent_group_restricted() -> dict[str, Any]: """Create a group-restricted agent for testing.""" return { "path": "/agents/engineering-agent", "name": "Engineering Agent", "description": "Engineering team only", "visibility": "group-restricted", "allowed_groups": ["engineering"], "sync_metadata": { "sync_generation": 8, "last_synced_at": "2024-01-15T10:30:00Z", }, } @pytest.mark.unit class TestFederationHealth: """Test suite for GET /api/federation/health endpoint.""" def test_health_returns_200(self) -> None: """Test health endpoint returns 200 when registry is healthy (2.SC9).""" client = TestClient(app) response = client.get("/api/federation/health") assert response.status_code == status.HTTP_200_OK data = response.json() assert data["status"] == "healthy" assert "federation_api_version" in data assert "registry_id" in data def test_health_no_auth_required(self) -> None: """Test health endpoint does NOT require authentication.""" # Health endpoint should work without any auth client = TestClient(app) response = client.get("/api/federation/health") assert response.status_code == status.HTTP_200_OK @pytest.mark.unit class TestFederationAuthRequirements: """Test suite for federation authentication requirements.""" def test_export_servers_requires_auth(self) -> None: """Test unauthenticated requests to /api/federation/servers return 401 (2.SC1).""" from fastapi import HTTPException from registry.auth.dependencies import nginx_proxied_auth def _mock_no_auth( request=None, session=None, x_user=None, x_username=None, x_scopes=None, x_auth_method=None, ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required" ) app.dependency_overrides[nginx_proxied_auth] = _mock_no_auth client = TestClient(app) response = client.get("/api/federation/servers") assert response.status_code == status.HTTP_401_UNAUTHORIZED app.dependency_overrides.clear() def test_export_agents_requires_auth(self) -> None: """Test unauthenticated requests to /api/federation/agents return 401 (2.SC1).""" from fastapi import HTTPException from registry.auth.dependencies import nginx_proxied_auth def _mock_no_auth( request=None, session=None, x_user=None, x_username=None, x_scopes=None, x_auth_method=None, ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required" ) app.dependency_overrides[nginx_proxied_auth] = _mock_no_auth client = TestClient(app) response = client.get("/api/federation/agents") assert response.status_code == status.HTTP_401_UNAUTHORIZED app.dependency_overrides.clear() def test_missing_federation_scope_returns_403( self, mock_federation_auth_missing_scope: Any, ) -> None: """Test requests without federation-service scope return 403 (2.SC2).""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth_missing_scope client = TestClient(app) response = client.get("/api/federation/servers") assert response.status_code == status.HTTP_403_FORBIDDEN assert "federation-service" in response.json()["detail"] app.dependency_overrides.clear() @pytest.mark.unit class TestVisibilityFiltering: """Test suite for visibility-based filtering logic.""" def test_public_items_returned_to_all_peers( self, mock_federation_auth_no_groups: Any, sample_server_public: dict[str, Any], ) -> None: """Test visibility=public items are returned to peers with no groups (2.SC3).""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth_no_groups # Mock server service to return public server servers_dict = {sample_server_public["path"]: sample_server_public} with ( patch.object( server_service, "get_all_servers", return_value=servers_dict, ), patch.object( server_service, "is_service_enabled", return_value=True, ), ): client = TestClient(app) response = client.get("/api/federation/servers") assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data["items"]) == 1 assert data["items"][0]["path"] == "/public-server" app.dependency_overrides.clear() def test_group_restricted_returned_if_peer_in_group( self, mock_federation_auth: Any, sample_server_group_restricted: dict[str, Any], ) -> None: """Test group-restricted items returned only if peer is in allowed_groups (2.SC4).""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth # Mock auth returns groups: ["engineering", "finance"] # Server has allowed_groups: ["finance"] servers_dict = {sample_server_group_restricted["path"]: sample_server_group_restricted} with ( patch.object( server_service, "get_all_servers", return_value=servers_dict, ), patch.object( server_service, "is_service_enabled", return_value=True, ), ): client = TestClient(app) response = client.get("/api/federation/servers") assert response.status_code == status.HTTP_200_OK data = response.json() # Should be returned because peer is in "finance" group assert len(data["items"]) == 1 assert data["items"][0]["path"] == "/finance-server" app.dependency_overrides.clear() def test_group_restricted_not_returned_if_peer_not_in_group( self, mock_federation_auth_no_groups: Any, sample_server_group_restricted: dict[str, Any], ) -> None: """Test group-restricted items NOT returned if peer is not in allowed_groups (2.SC4).""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth_no_groups # Mock auth returns groups: [] # Server has allowed_groups: ["finance"] servers_dict = {sample_server_group_restricted["path"]: sample_server_group_restricted} with ( patch.object( server_service, "get_all_servers", return_value=servers_dict, ), patch.object( server_service, "is_service_enabled", return_value=True, ), ): client = TestClient(app) response = client.get("/api/federation/servers") assert response.status_code == status.HTTP_200_OK data = response.json() # Should NOT be returned because peer is not in "finance" group assert len(data["items"]) == 0 app.dependency_overrides.clear() def test_internal_items_never_returned( self, mock_federation_auth: Any, sample_server_internal: dict[str, Any], ) -> None: """Test visibility=internal items are NEVER returned (2.SC5).""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth servers_dict = {sample_server_internal["path"]: sample_server_internal} with ( patch.object( server_service, "get_all_servers", return_value=servers_dict, ), patch.object( server_service, "is_service_enabled", return_value=True, ), ): client = TestClient(app) response = client.get("/api/federation/servers") assert response.status_code == status.HTTP_200_OK data = response.json() # Internal items should NEVER be returned assert len(data["items"]) == 0 app.dependency_overrides.clear() def test_mixed_visibility_filtering( self, mock_federation_auth: Any, sample_server_public: dict[str, Any], sample_server_group_restricted: dict[str, Any], sample_server_internal: dict[str, Any], ) -> None: """Test filtering with mixed visibility items.""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth # Peer has groups: ["engineering", "finance"] servers_dict = { sample_server_public["path"]: sample_server_public, sample_server_group_restricted["path"]: sample_server_group_restricted, sample_server_internal["path"]: sample_server_internal, } with ( patch.object( server_service, "get_all_servers", return_value=servers_dict, ), patch.object( server_service, "is_service_enabled", return_value=True, ), ): client = TestClient(app) response = client.get("/api/federation/servers") assert response.status_code == status.HTTP_200_OK data = response.json() # Should return public + group-restricted (peer in finance group) # Should NOT return internal assert len(data["items"]) == 2 paths = [item["path"] for item in data["items"]] assert "/public-server" in paths assert "/finance-server" in paths assert "/internal-server" not in paths app.dependency_overrides.clear() @pytest.mark.unit class TestIncrementalSync: """Test suite for incremental sync with generation numbers.""" def test_since_generation_filters_items( self, mock_federation_auth: Any, sample_server_public: dict[str, Any], ) -> None: """Test since_generation param returns only items with generation > param value (2.SC6).""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth # Server has sync_generation: 10 servers_dict = {sample_server_public["path"]: sample_server_public} with ( patch.object( server_service, "get_all_servers", return_value=servers_dict, ), patch.object( server_service, "is_service_enabled", return_value=True, ), ): client = TestClient(app) # Request with since_generation=5 (should return server with gen 10) response = client.get("/api/federation/servers?since_generation=5") assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data["items"]) == 1 # Request with since_generation=10 (should NOT return server with gen 10) response = client.get("/api/federation/servers?since_generation=10") assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data["items"]) == 0 # Request with since_generation=15 (should NOT return server with gen 10) response = client.get("/api/federation/servers?since_generation=15") assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data["items"]) == 0 app.dependency_overrides.clear() def test_since_generation_zero_returns_all( self, mock_federation_auth: Any, sample_server_public: dict[str, Any], ) -> None: """Test since_generation=0 returns all items (2.SC6).""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth servers_dict = {sample_server_public["path"]: sample_server_public} with ( patch.object( server_service, "get_all_servers", return_value=servers_dict, ), patch.object( server_service, "is_service_enabled", return_value=True, ), ): client = TestClient(app) response = client.get("/api/federation/servers?since_generation=0") assert response.status_code == status.HTTP_200_OK data = response.json() # Should return all items assert len(data["items"]) == 1 app.dependency_overrides.clear() def test_response_includes_sync_generation( self, mock_federation_auth: Any, sample_server_public: dict[str, Any], ) -> None: """Test response includes sync_generation for incremental sync (2.SC8).""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth servers_dict = {sample_server_public["path"]: sample_server_public} with ( patch.object( server_service, "get_all_servers", return_value=servers_dict, ), patch.object( server_service, "is_service_enabled", return_value=True, ), ): client = TestClient(app) response = client.get("/api/federation/servers") assert response.status_code == status.HTTP_200_OK data = response.json() # Response must include sync_generation assert "sync_generation" in data assert isinstance(data["sync_generation"], int) app.dependency_overrides.clear() @pytest.mark.unit class TestPagination: """Test suite for pagination functionality.""" def test_pagination_limit_offset( self, mock_federation_auth: Any, ) -> None: """Test pagination works correctly with limit and offset (2.SC7).""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth # Create multiple servers for pagination testing servers_dict = {} for i in range(5): servers_dict[f"/server-{i}"] = { "path": f"/server-{i}", "name": f"Server {i}", "visibility": "public", "allowed_groups": [], } with ( patch.object( server_service, "get_all_servers", return_value=servers_dict, ), patch.object( server_service, "is_service_enabled", return_value=True, ), ): client = TestClient(app) # Test limit response = client.get("/api/federation/servers?limit=2") assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data["items"]) == 2 assert data["has_more"] is True # Test offset response = client.get("/api/federation/servers?limit=2&offset=2") assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data["items"]) == 2 assert data["has_more"] is True # Test last page response = client.get("/api/federation/servers?limit=2&offset=4") assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data["items"]) == 1 assert data["has_more"] is False app.dependency_overrides.clear() def test_limit_exceeds_max( self, mock_federation_auth: Any, ) -> None: """Test limit parameter is capped at max 1000.""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth with patch.object( server_service, "get_all_servers", return_value={}, ): client = TestClient(app) # Requesting limit=2000 should be rejected by validation response = client.get("/api/federation/servers?limit=2000") assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY app.dependency_overrides.clear() def test_pagination_metadata( self, mock_federation_auth: Any, sample_server_public: dict[str, Any], ) -> None: """Test pagination metadata in response.""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth servers_dict = {sample_server_public["path"]: sample_server_public} with ( patch.object( server_service, "get_all_servers", return_value=servers_dict, ), patch.object( server_service, "is_service_enabled", return_value=True, ), ): client = TestClient(app) response = client.get("/api/federation/servers") assert response.status_code == status.HTTP_200_OK data = response.json() # Response must include pagination metadata assert "total_count" in data assert "has_more" in data assert data["total_count"] == 1 assert data["has_more"] is False app.dependency_overrides.clear() @pytest.mark.unit class TestEmptyRegistry: """Test suite for empty registry edge case.""" def test_empty_registry_returns_empty_list( self, mock_federation_auth: Any, ) -> None: """Test empty registry returns empty list, not error (2.SC11).""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth with patch.object( server_service, "get_all_servers", return_value={}, ): client = TestClient(app) response = client.get("/api/federation/servers") assert response.status_code == status.HTTP_200_OK data = response.json() assert data["items"] == [] assert data["total_count"] == 0 assert data["has_more"] is False app.dependency_overrides.clear() def test_empty_agents_returns_empty_list( self, mock_federation_auth: Any, ) -> None: """Test empty agents registry returns empty list, not error (2.SC11).""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth with patch.object( agent_service, "get_all_agents", return_value=[], ): client = TestClient(app) response = client.get("/api/federation/agents") assert response.status_code == status.HTTP_200_OK data = response.json() assert data["items"] == [] assert data["total_count"] == 0 assert data["has_more"] is False app.dependency_overrides.clear() @pytest.mark.unit class TestAgentsEndpoint: """Test suite for GET /api/federation/agents endpoint.""" def test_export_agents_success( self, mock_federation_auth: Any, sample_agent_public: dict[str, Any], ) -> None: """Test exporting agents with proper visibility filtering.""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth # Create mock agent objects (agents are objects, not dicts) mock_agent = Mock() mock_agent.path = sample_agent_public["path"] mock_agent.name = sample_agent_public["name"] mock_agent.visibility = sample_agent_public["visibility"] mock_agent.allowed_groups = sample_agent_public["allowed_groups"] mock_agent.sync_metadata = sample_agent_public["sync_metadata"] mock_agent.model_dump = Mock(return_value=sample_agent_public) with ( patch.object( agent_service, "get_all_agents", return_value=[mock_agent], ), patch.object( agent_service, "get_all_agent_states", return_value={"/agents/public-agent": True}, ), ): client = TestClient(app) response = client.get("/api/federation/agents") assert response.status_code == status.HTTP_200_OK data = response.json() assert len(data["items"]) == 1 assert data["items"][0]["path"] == "/agents/public-agent" app.dependency_overrides.clear() def test_export_agents_visibility_filtering( self, mock_federation_auth: Any, sample_agent_public: dict[str, Any], sample_agent_group_restricted: dict[str, Any], ) -> None: """Test agents visibility filtering works correctly.""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth # Create mock agent objects mock_agent_public = Mock() mock_agent_public.path = sample_agent_public["path"] mock_agent_public.visibility = sample_agent_public["visibility"] mock_agent_public.allowed_groups = sample_agent_public["allowed_groups"] mock_agent_public.sync_metadata = sample_agent_public["sync_metadata"] mock_agent_public.model_dump = Mock(return_value=sample_agent_public) mock_agent_restricted = Mock() mock_agent_restricted.path = sample_agent_group_restricted["path"] mock_agent_restricted.visibility = sample_agent_group_restricted["visibility"] mock_agent_restricted.allowed_groups = sample_agent_group_restricted["allowed_groups"] mock_agent_restricted.sync_metadata = sample_agent_group_restricted["sync_metadata"] mock_agent_restricted.model_dump = Mock(return_value=sample_agent_group_restricted) # Peer has groups: ["engineering", "finance"] with ( patch.object( agent_service, "get_all_agents", return_value=[mock_agent_public, mock_agent_restricted], ), patch.object( agent_service, "get_all_agent_states", return_value={ "/agents/public-agent": True, "/agents/engineering-agent": True, }, ), ): client = TestClient(app) response = client.get("/api/federation/agents") assert response.status_code == status.HTTP_200_OK data = response.json() # Should return both: public + engineering-restricted (peer in engineering group) assert len(data["items"]) == 2 paths = [item["path"] for item in data["items"]] assert "/agents/public-agent" in paths assert "/agents/engineering-agent" in paths app.dependency_overrides.clear() @pytest.mark.unit class TestHelperFunctions: """Test suite for internal helper functions.""" def test_get_item_attr_dict(self) -> None: """Test _get_item_attr() with dict input.""" item = {"name": "test", "value": 42} assert federation_export_routes._get_item_attr(item, "name") == "test" assert federation_export_routes._get_item_attr(item, "value") == 42 assert federation_export_routes._get_item_attr(item, "missing", "default") == "default" def test_get_item_attr_object(self) -> None: """Test _get_item_attr() with object input.""" mock_obj = Mock(spec=["name", "value"]) mock_obj.name = "test" mock_obj.value = 42 assert federation_export_routes._get_item_attr(mock_obj, "name") == "test" assert federation_export_routes._get_item_attr(mock_obj, "value") == 42 assert federation_export_routes._get_item_attr(mock_obj, "missing", "default") == "default" def test_filter_by_visibility_public_only(self) -> None: """Test _filter_by_visibility() returns only public items to peers with no groups.""" items = [ {"visibility": "public", "path": "/public"}, { "visibility": "group-restricted", "allowed_groups": ["finance"], "path": "/restricted", }, {"visibility": "internal", "path": "/internal"}, ] filtered = federation_export_routes._filter_by_visibility(items, []) assert len(filtered) == 1 assert filtered[0]["path"] == "/public" def test_filter_by_visibility_group_match(self) -> None: """Test _filter_by_visibility() returns group-restricted if peer in group.""" items = [ {"visibility": "public", "path": "/public"}, {"visibility": "group-restricted", "allowed_groups": ["finance"], "path": "/finance"}, { "visibility": "group-restricted", "allowed_groups": ["engineering"], "path": "/engineering", }, ] filtered = federation_export_routes._filter_by_visibility(items, ["finance"]) assert len(filtered) == 2 paths = [item["path"] for item in filtered] assert "/public" in paths assert "/finance" in paths assert "/engineering" not in paths def test_filter_by_visibility_multiple_groups(self) -> None: """Test peer with multiple groups gets union of allowed items.""" items = [ {"visibility": "group-restricted", "allowed_groups": ["finance"], "path": "/finance"}, { "visibility": "group-restricted", "allowed_groups": ["engineering"], "path": "/engineering", }, {"visibility": "group-restricted", "allowed_groups": ["hr"], "path": "/hr"}, ] filtered = federation_export_routes._filter_by_visibility(items, ["finance", "engineering"]) assert len(filtered) == 2 paths = [item["path"] for item in filtered] assert "/finance" in paths assert "/engineering" in paths assert "/hr" not in paths def test_filter_by_visibility_empty_allowed_groups(self) -> None: """Test group-restricted with empty allowed_groups returns to no one.""" items = [ {"visibility": "group-restricted", "allowed_groups": [], "path": "/restricted"}, ] # Even if peer has groups, empty allowed_groups means no match filtered = federation_export_routes._filter_by_visibility(items, ["finance", "engineering"]) assert len(filtered) == 0 def test_filter_by_visibility_no_visibility_field(self) -> None: """Test items with no visibility field default to public (backwards compatibility).""" items = [ {"path": "/no-visibility"}, ] filtered = federation_export_routes._filter_by_visibility(items, []) # Should default to public and be exported (backwards compatibility) assert len(filtered) == 1 assert filtered[0]["path"] == "/no-visibility" def test_filter_by_generation_filters_correctly(self) -> None: """Test _filter_by_generation() filters items correctly.""" items = [ {"path": "/item1", "sync_metadata": {"sync_generation": 5}}, {"path": "/item2", "sync_metadata": {"sync_generation": 10}}, {"path": "/item3", "sync_metadata": {"sync_generation": 15}}, ] # since_generation=10 should return only items with generation > 10 filtered = federation_export_routes._filter_by_generation(items, 10) assert len(filtered) == 1 assert filtered[0]["path"] == "/item3" def test_filter_by_generation_none_returns_all(self) -> None: """Test _filter_by_generation() with None returns all items.""" items = [ {"path": "/item1", "sync_metadata": {"sync_generation": 5}}, {"path": "/item2", "sync_metadata": {"sync_generation": 10}}, ] filtered = federation_export_routes._filter_by_generation(items, None) assert len(filtered) == 2 def test_filter_by_generation_missing_metadata(self) -> None: """Test _filter_by_generation() includes items without sync_metadata. Items without sync_metadata are local items that have never been synced - they should always be included as they're "new" to the peer. """ items = [ {"path": "/item1"}, # No sync_metadata - local item {"path": "/item2", "sync_metadata": {"sync_generation": 10}}, ] # Items without sync_metadata are always included (local items) filtered = federation_export_routes._filter_by_generation(items, 0) # Both should be returned: item1 (local) and item2 (generation 10 > 0) assert len(filtered) == 2 paths = [item["path"] for item in filtered] assert "/item1" in paths assert "/item2" in paths def test_item_to_dict_dict(self) -> None: """Test _item_to_dict() with dict input.""" item = {"path": "/test", "name": "Test"} result = federation_export_routes._item_to_dict(item) assert result == item def test_item_to_dict_pydantic(self) -> None: """Test _item_to_dict() with Pydantic model.""" mock_model = Mock() mock_model.model_dump = Mock(return_value={"path": "/test", "name": "Test"}) result = federation_export_routes._item_to_dict(mock_model) assert result == {"path": "/test", "name": "Test"} mock_model.model_dump.assert_called_once() def test_paginate_first_page(self) -> None: """Test _paginate() returns first page correctly.""" items = [f"item{i}" for i in range(10)] paginated, has_more = federation_export_routes._paginate(items, limit=3, offset=0) assert len(paginated) == 3 assert paginated == ["item0", "item1", "item2"] assert has_more is True def test_paginate_middle_page(self) -> None: """Test _paginate() returns middle page correctly.""" items = [f"item{i}" for i in range(10)] paginated, has_more = federation_export_routes._paginate(items, limit=3, offset=3) assert len(paginated) == 3 assert paginated == ["item3", "item4", "item5"] assert has_more is True def test_paginate_last_page(self) -> None: """Test _paginate() returns last page correctly.""" items = [f"item{i}" for i in range(10)] paginated, has_more = federation_export_routes._paginate(items, limit=3, offset=9) assert len(paginated) == 1 assert paginated == ["item9"] assert has_more is False def test_check_federation_scope_valid(self) -> None: """Test _check_federation_scope() passes with valid scope.""" user_context = { "username": "test-peer", "scopes": ["federation-service", "other-scope"], } # Should not raise exception federation_export_routes._check_federation_scope(user_context) def test_check_federation_scope_invalid(self) -> None: """Test _check_federation_scope() raises 403 without scope.""" from fastapi import HTTPException user_context = { "username": "test-peer", "scopes": ["other-scope"], } with pytest.raises(HTTPException) as exc_info: federation_export_routes._check_federation_scope(user_context) assert exc_info.value.status_code == status.HTTP_403_FORBIDDEN assert "federation-service" in str(exc_info.value.detail) @pytest.mark.unit class TestDisabledItemsFiltering: """Test suite for filtering disabled servers and agents.""" def test_disabled_servers_not_exported( self, mock_federation_auth: Any, sample_server_public: dict[str, Any], ) -> None: """Test disabled servers are never exported.""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth servers_dict = {sample_server_public["path"]: sample_server_public} with ( patch.object( server_service, "get_all_servers", return_value=servers_dict, ), patch.object( server_service, "is_service_enabled", return_value=False, # Server is disabled ), ): client = TestClient(app) response = client.get("/api/federation/servers") assert response.status_code == status.HTTP_200_OK data = response.json() # Disabled server should not be exported assert len(data["items"]) == 0 app.dependency_overrides.clear() def test_disabled_agents_not_exported( self, mock_federation_auth: Any, sample_agent_public: dict[str, Any], ) -> None: """Test disabled agents are never exported.""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth mock_agent = Mock() mock_agent.path = sample_agent_public["path"] mock_agent.visibility = sample_agent_public["visibility"] mock_agent.allowed_groups = sample_agent_public["allowed_groups"] with ( patch.object( agent_service, "get_all_agents", return_value=[mock_agent], ), patch.object( agent_service, "get_all_agent_states", return_value={"/agents/public-agent": False}, ), ): client = TestClient(app) response = client.get("/api/federation/agents") assert response.status_code == status.HTTP_200_OK data = response.json() # Disabled agent should not be exported assert len(data["items"]) == 0 app.dependency_overrides.clear() @pytest.mark.unit class TestChainPrevention: """Test suite for chain prevention (A->B->C scenario). When registry B syncs items from registry A, those items should NOT be re-exported from B to registry C. This prevents federation chains and ensures items only come from their original source. """ def test_is_federated_item_with_dict(self) -> None: """Test _is_federated_item() detects federated dict items.""" # Item synced from another peer federated_item = { "path": "/peer-a/server1", "sync_metadata": { "is_federated": True, "source_peer_id": "peer-a", }, } assert federation_export_routes._is_federated_item(federated_item) is True def test_is_federated_item_with_object(self) -> None: """Test _is_federated_item() detects federated object items.""" mock_item = Mock() mock_item.sync_metadata = Mock() mock_item.sync_metadata.is_federated = True assert federation_export_routes._is_federated_item(mock_item) is True def test_is_federated_item_local_item(self) -> None: """Test _is_federated_item() returns False for local items.""" # Local item with no sync_metadata local_item = { "path": "/my-local-server", } assert federation_export_routes._is_federated_item(local_item) is False def test_is_federated_item_local_with_sync_metadata(self) -> None: """Test _is_federated_item() returns False for local items with sync_metadata.""" # Local item that has sync_metadata but is_federated is False local_item = { "path": "/my-local-server", "sync_metadata": { "is_federated": False, "sync_generation": 5, }, } assert federation_export_routes._is_federated_item(local_item) is False def test_is_federated_item_no_is_federated_field(self) -> None: """Test _is_federated_item() returns False when is_federated field missing.""" item = { "path": "/server", "sync_metadata": { "sync_generation": 10, }, } assert federation_export_routes._is_federated_item(item) is False def test_filter_by_visibility_excludes_federated_items(self) -> None: """Test _filter_by_visibility() excludes federated items (chain prevention).""" items = [ # Local public server - should be exported {"path": "/local-public", "visibility": "public"}, # Federated server from peer-a - should NOT be exported { "path": "/peer-a/server1", "visibility": "public", "sync_metadata": { "is_federated": True, "source_peer_id": "peer-a", }, }, # Another federated server - should NOT be exported { "path": "/peer-b/server2", "visibility": "public", "sync_metadata": { "is_federated": True, "source_peer_id": "peer-b", }, }, ] filtered = federation_export_routes._filter_by_visibility(items, []) # Only local item should be returned assert len(filtered) == 1 assert filtered[0]["path"] == "/local-public" def test_filter_by_visibility_mixed_local_and_federated(self) -> None: """Test filtering with mix of local and federated items.""" items = [ # Local public {"path": "/local-public", "visibility": "public"}, # Local group-restricted { "path": "/local-finance", "visibility": "group-restricted", "allowed_groups": ["finance"], }, # Local internal {"path": "/local-internal", "visibility": "internal"}, # Federated public { "path": "/peer-a/public", "visibility": "public", "sync_metadata": {"is_federated": True}, }, # Federated group-restricted { "path": "/peer-a/finance", "visibility": "group-restricted", "allowed_groups": ["finance"], "sync_metadata": {"is_federated": True}, }, ] # Peer has finance group filtered = federation_export_routes._filter_by_visibility(items, ["finance"]) # Should return local public + local finance # Should NOT return: local internal, any federated items assert len(filtered) == 2 paths = [item["path"] for item in filtered] assert "/local-public" in paths assert "/local-finance" in paths assert "/local-internal" not in paths assert "/peer-a/public" not in paths assert "/peer-a/finance" not in paths def test_export_servers_excludes_federated( self, mock_federation_auth: Any, ) -> None: """Test /api/federation/servers endpoint excludes federated servers.""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth servers_dict = { "/local-server": { "path": "/local-server", "name": "Local Server", "visibility": "public", }, "/peer-a/synced-server": { "path": "/peer-a/synced-server", "name": "Synced from Peer A", "visibility": "public", "sync_metadata": { "is_federated": True, "source_peer_id": "peer-a", }, }, } with ( patch.object( server_service, "get_all_servers", return_value=servers_dict, ), patch.object( server_service, "is_service_enabled", return_value=True, ), ): client = TestClient(app) response = client.get("/api/federation/servers") assert response.status_code == status.HTTP_200_OK data = response.json() # Only local server should be exported assert len(data["items"]) == 1 assert data["items"][0]["path"] == "/local-server" app.dependency_overrides.clear() def test_export_agents_excludes_federated( self, mock_federation_auth: Any, ) -> None: """Test /api/federation/agents endpoint excludes federated agents.""" from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = mock_federation_auth # Create mock agents local_agent = Mock() local_agent.path = "/agents/local-agent" local_agent.visibility = "public" local_agent.allowed_groups = [] local_agent.sync_metadata = None local_agent.model_dump = Mock( return_value={ "path": "/agents/local-agent", "visibility": "public", } ) federated_agent = Mock() federated_agent.path = "/agents/peer-a/synced-agent" federated_agent.visibility = "public" federated_agent.allowed_groups = [] federated_agent.sync_metadata = { "is_federated": True, "source_peer_id": "peer-a", } federated_agent.model_dump = Mock( return_value={ "path": "/agents/peer-a/synced-agent", "visibility": "public", "sync_metadata": { "is_federated": True, "source_peer_id": "peer-a", }, } ) with ( patch.object( agent_service, "get_all_agents", return_value=[local_agent, federated_agent], ), patch.object( agent_service, "get_all_agent_states", return_value={ "/agents/local-agent": True, "/agents/peer-a/synced-agent": True, }, ), ): client = TestClient(app) response = client.get("/api/federation/agents") assert response.status_code == status.HTTP_200_OK data = response.json() # Only local agent should be exported assert len(data["items"]) == 1 assert data["items"][0]["path"] == "/agents/local-agent" app.dependency_overrides.clear() ================================================ FILE: tests/unit/api/test_log_routes.py ================================================ """Unit tests for registry/api/log_routes.py - Application log retrieval API.""" import logging from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from registry.api import log_routes from registry.api.log_routes import router logger = logging.getLogger(__name__) @pytest.fixture(autouse=True) def _clear_rate_limit_cache(): """Reset rate limit cache between tests.""" log_routes._rate_limit_cache.clear() # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def mock_admin_context() -> dict[str, Any]: return { "username": "admin-user", "groups": ["mcp-registry-admin"], "scopes": ["mcp-registry-admin"], "auth_method": "session", "provider": "local", "is_admin": True, "accessible_servers": ["all"], "accessible_services": ["all"], "accessible_agents": ["all"], } @pytest.fixture def mock_non_admin_context() -> dict[str, Any]: return { "username": "regular-user", "groups": ["mcp-registry-user"], "scopes": ["read:servers"], "auth_method": "session", "provider": "local", "is_admin": False, "accessible_servers": ["all"], "accessible_services": ["all"], "accessible_agents": ["all"], } @pytest.fixture def mock_app_log_repo(): mock = AsyncMock() mock.query.return_value = ([], 0) mock.get_distinct_services.return_value = ["registry", "auth-server"] mock.get_distinct_hostnames.return_value = ["pod-abc123", "pod-def456"] return mock @pytest.fixture def sample_log_entries() -> list[dict[str, Any]]: return [ { "timestamp": datetime(2026, 4, 24, 10, 0, 0, tzinfo=UTC), "hostname": "pod-abc123", "service": "registry", "level": "INFO", "level_no": 20, "logger": "registry.main", "filename": "main.py", "lineno": 42, "process": 130, "message": "Server started successfully", }, { "timestamp": datetime(2026, 4, 24, 10, 0, 1, tzinfo=UTC), "hostname": "pod-abc123", "service": "registry", "level": "ERROR", "level_no": 40, "logger": "registry.api.server_routes", "filename": "server_routes.py", "lineno": 100, "process": 130, "message": "Failed to register server: timeout", }, ] @pytest.fixture def admin_client(mock_admin_context, mock_app_log_repo): app = FastAPI() app.include_router(router, prefix="/api") from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = lambda: mock_admin_context with patch( "registry.api.log_routes.get_app_log_repository", return_value=mock_app_log_repo, ): client = TestClient(app) yield client app.dependency_overrides.clear() @pytest.fixture def non_admin_client(mock_non_admin_context, mock_app_log_repo): app = FastAPI() app.include_router(router, prefix="/api") from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = lambda: mock_non_admin_context with patch( "registry.api.log_routes.get_app_log_repository", return_value=mock_app_log_repo, ): client = TestClient(app) yield client app.dependency_overrides.clear() @pytest.fixture def no_mongo_client(mock_admin_context): """Client where MongoDB is not available (file backend).""" app = FastAPI() app.include_router(router, prefix="/api") from registry.auth.dependencies import nginx_proxied_auth app.dependency_overrides[nginx_proxied_auth] = lambda: mock_admin_context with patch( "registry.api.log_routes.get_app_log_repository", return_value=None, ): client = TestClient(app) yield client app.dependency_overrides.clear() # ============================================================================= # ACCESS CONTROL TESTS # ============================================================================= class TestLogRoutesAccessControl: """Test admin-only access enforcement.""" def test_query_logs_requires_admin(self, non_admin_client): response = non_admin_client.get("/api/admin/logs") assert response.status_code == 403 assert "Admin access required" in response.json()["detail"] def test_export_logs_requires_admin(self, non_admin_client): response = non_admin_client.get("/api/admin/logs/export") assert response.status_code == 403 def test_metadata_requires_admin(self, non_admin_client): response = non_admin_client.get("/api/admin/logs/metadata") assert response.status_code == 403 def test_admin_can_query_logs(self, admin_client): response = admin_client.get("/api/admin/logs") assert response.status_code == 200 def test_admin_can_get_metadata(self, admin_client): response = admin_client.get("/api/admin/logs/metadata") assert response.status_code == 200 # ============================================================================= # QUERY LOGS TESTS # ============================================================================= class TestQueryLogs: """Test GET /api/admin/logs endpoint.""" def test_empty_response(self, admin_client): response = admin_client.get("/api/admin/logs") data = response.json() assert data["entries"] == [] assert data["total_count"] == 0 assert data["limit"] == 100 assert data["offset"] == 0 assert data["has_next"] is False def test_with_entries(self, admin_client, mock_app_log_repo, sample_log_entries): mock_app_log_repo.query.return_value = (sample_log_entries, 2) response = admin_client.get("/api/admin/logs") data = response.json() assert data["total_count"] == 2 assert len(data["entries"]) == 2 assert data["entries"][0]["service"] == "registry" assert data["entries"][0]["level"] == "INFO" def test_filter_by_service(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) response = admin_client.get("/api/admin/logs?service=auth-server") assert response.status_code == 200 mock_app_log_repo.query.assert_called_once() call_kwargs = mock_app_log_repo.query.call_args[1] assert call_kwargs["service"] == "auth-server" def test_filter_by_level(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) response = admin_client.get("/api/admin/logs?level=ERROR") assert response.status_code == 200 call_kwargs = mock_app_log_repo.query.call_args[1] assert call_kwargs["level_no"] == 40 def test_filter_by_hostname(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) response = admin_client.get("/api/admin/logs?hostname=pod-abc123") assert response.status_code == 200 call_kwargs = mock_app_log_repo.query.call_args[1] assert call_kwargs["hostname"] == "pod-abc123" def test_filter_by_time_range(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) response = admin_client.get( "/api/admin/logs?start=2026-04-24T00:00:00Z&end=2026-04-24T23:59:59Z" ) assert response.status_code == 200 call_kwargs = mock_app_log_repo.query.call_args[1] assert call_kwargs["start"] is not None assert call_kwargs["end"] is not None def test_search_in_message(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) response = admin_client.get("/api/admin/logs?search=timeout") assert response.status_code == 200 call_kwargs = mock_app_log_repo.query.call_args[1] assert call_kwargs["search"] == "timeout" def test_pagination_params(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) response = admin_client.get("/api/admin/logs?limit=50&offset=100") assert response.status_code == 200 call_kwargs = mock_app_log_repo.query.call_args[1] assert call_kwargs["limit"] == 50 assert call_kwargs["skip"] == 100 def test_has_next_true(self, admin_client, mock_app_log_repo, sample_log_entries): mock_app_log_repo.query.return_value = ([sample_log_entries[0]], 5) response = admin_client.get("/api/admin/logs?limit=1&offset=0") data = response.json() assert data["has_next"] is True assert data["total_count"] == 5 def test_has_next_false_at_end(self, admin_client, mock_app_log_repo, sample_log_entries): mock_app_log_repo.query.return_value = ([sample_log_entries[0]], 5) response = admin_client.get("/api/admin/logs?limit=1&offset=4") data = response.json() assert data["has_next"] is False def test_limit_validation_too_low(self, admin_client): response = admin_client.get("/api/admin/logs?limit=0") assert response.status_code == 422 def test_limit_validation_too_high(self, admin_client): response = admin_client.get("/api/admin/logs?limit=10001") assert response.status_code == 422 def test_offset_validation_negative(self, admin_client): response = admin_client.get("/api/admin/logs?offset=-1") assert response.status_code == 422 # ============================================================================= # EXPORT LOGS TESTS # ============================================================================= class TestExportLogs: """Test GET /api/admin/logs/export endpoint.""" def test_export_empty(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) response = admin_client.get("/api/admin/logs/export") assert response.status_code == 200 assert response.headers["content-type"].startswith("application/x-ndjson") assert response.text == "" def test_export_with_entries(self, admin_client, mock_app_log_repo, sample_log_entries): mock_app_log_repo.query.return_value = (sample_log_entries, 2) response = admin_client.get("/api/admin/logs/export") assert response.status_code == 200 lines = response.text.strip().split("\n") assert len(lines) == 2 def test_export_content_disposition(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) response = admin_client.get("/api/admin/logs/export") disposition = response.headers.get("content-disposition", "") assert "logs-all-" in disposition assert ".jsonl" in disposition def test_export_with_filters(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) response = admin_client.get("/api/admin/logs/export?service=registry&level=ERROR") assert response.status_code == 200 call_kwargs = mock_app_log_repo.query.call_args[1] assert call_kwargs["service"] == "registry" assert call_kwargs["level_no"] == 40 def test_export_limit_validation(self, admin_client): response = admin_client.get("/api/admin/logs/export?limit=50001") assert response.status_code == 422 # ============================================================================= # METADATA TESTS # ============================================================================= class TestLogMetadata: """Test GET /api/admin/logs/metadata endpoint.""" def test_metadata_returns_services_and_hostnames(self, admin_client, mock_app_log_repo): response = admin_client.get("/api/admin/logs/metadata") data = response.json() assert "registry" in data["services"] assert "auth-server" in data["services"] assert "pod-abc123" in data["hostnames"] assert "pod-def456" in data["hostnames"] assert "INFO" in data["levels"] assert "ERROR" in data["levels"] # ============================================================================= # NO MONGODB BACKEND TESTS # ============================================================================= class TestNoMongoDBBackend: """Test behavior when MongoDB backend is not available.""" def test_query_returns_503(self, no_mongo_client): response = no_mongo_client.get("/api/admin/logs") assert response.status_code == 503 assert "not available" in response.json()["detail"] def test_export_returns_503(self, no_mongo_client): response = no_mongo_client.get("/api/admin/logs/export") assert response.status_code == 503 def test_metadata_returns_503(self, no_mongo_client): response = no_mongo_client.get("/api/admin/logs/metadata") assert response.status_code == 503 # ============================================================================= # RATE LIMITING TESTS # ============================================================================= class TestRateLimiting: """Test per-user rate limiting on log API endpoints.""" def test_rate_limit_exceeded(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) for _ in range(10): response = admin_client.get("/api/admin/logs") assert response.status_code == 200 response = admin_client.get("/api/admin/logs") assert response.status_code == 429 assert "Rate limit exceeded" in response.json()["detail"] def test_rate_limit_applies_to_export(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) for _ in range(10): admin_client.get("/api/admin/logs/export") response = admin_client.get("/api/admin/logs/export") assert response.status_code == 429 # ============================================================================= # SEARCH SANITIZATION TESTS # ============================================================================= class TestSearchSanitization: """Test that regex metacharacters in search are properly escaped.""" def test_regex_metacharacters_escaped(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) response = admin_client.get("/api/admin/logs?search=error.*timeout") assert response.status_code == 200 call_kwargs = mock_app_log_repo.query.call_args[1] assert call_kwargs["search"] == r"error\.\*timeout" def test_search_truncated_at_max_length(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) long_search = "a" * 300 response = admin_client.get(f"/api/admin/logs?search={long_search}") assert response.status_code == 200 call_kwargs = mock_app_log_repo.query.call_args[1] assert len(call_kwargs["search"]) == 200 def test_empty_search_returns_none(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) response = admin_client.get("/api/admin/logs") assert response.status_code == 200 call_kwargs = mock_app_log_repo.query.call_args[1] assert call_kwargs["search"] is None def test_level_no_mapping(self, admin_client, mock_app_log_repo): mock_app_log_repo.query.return_value = ([], 0) for level, expected_no in [ ("DEBUG", 10), ("INFO", 20), ("WARNING", 30), ("ERROR", 40), ("CRITICAL", 50), ]: log_routes._rate_limit_cache.clear() response = admin_client.get(f"/api/admin/logs?level={level}") assert response.status_code == 200 call_kwargs = mock_app_log_repo.query.call_args[1] assert call_kwargs["level_no"] == expected_no, f"{level} should map to {expected_no}" ================================================ FILE: tests/unit/api/test_m2m_management_routes.py ================================================ """Unit tests for registry/api/m2m_management_routes.py (issue #851). Tests the direct M2M client registration endpoints: - POST /api/iam/m2m-clients - GET /api/iam/m2m-clients - GET /api/iam/m2m-clients/{client_id} - PATCH /api/iam/m2m-clients/{client_id} - DELETE /api/iam/m2m-clients/{client_id} """ import logging from datetime import datetime from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient from registry.schemas.idp_m2m_client import MANUAL_PROVIDER, IdPM2MClient from registry.services.m2m_management_service import ( M2MClientConflict, M2MClientImmutable, M2MClientNotFound, ) logger = logging.getLogger(__name__) @pytest.fixture def admin_user_context() -> dict[str, Any]: return { "username": "admin", "is_admin": True, "groups": ["mcp-registry-admin"], "scopes": ["mcp-servers-unrestricted/read"], "accessible_servers": ["all"], "accessible_services": ["all"], "accessible_agents": ["all"], "ui_permissions": {"register_service": ["all"]}, "auth_method": "session", } @pytest.fixture def regular_user_context() -> dict[str, Any]: return { "username": "user", "is_admin": False, "groups": ["test-group"], "scopes": [], "accessible_servers": [], "accessible_services": [], "accessible_agents": [], "ui_permissions": {}, "auth_method": "session", } @pytest.fixture def sample_client() -> IdPM2MClient: now = datetime.utcnow() return IdPM2MClient( client_id="test-client-id", name="Test Client", description="desc", groups=["group-a"], enabled=True, provider=MANUAL_PROVIDER, idp_app_id=None, created_by="admin", created_at=now, updated_at=now, ) def _override_auth(user_context: dict | None) -> None: """Override the nginx_proxied_auth FastAPI dependency.""" from registry.auth.dependencies import nginx_proxied_auth from registry.main import app def _override() -> dict | None: return user_context app.dependency_overrides[nginx_proxied_auth] = _override @pytest.fixture def mock_service() -> MagicMock: service = MagicMock() service.create = AsyncMock() service.list_paged = AsyncMock() service.get = AsyncMock() service.patch = AsyncMock() service.delete = AsyncMock() return service @pytest.fixture def client_admin(mock_settings, admin_user_context, mock_service): from registry.main import app _override_auth(admin_user_context) with patch( "registry.api.m2m_management_routes._get_service", new=AsyncMock(return_value=mock_service), ): client = TestClient(app, cookies={"mcp_gateway_session": "test-session"}) yield client, mock_service app.dependency_overrides.clear() @pytest.fixture def client_regular(mock_settings, regular_user_context, mock_service): from registry.main import app _override_auth(regular_user_context) with patch( "registry.api.m2m_management_routes._get_service", new=AsyncMock(return_value=mock_service), ): client = TestClient(app, cookies={"mcp_gateway_session": "test-session"}) yield client, mock_service app.dependency_overrides.clear() @pytest.fixture def client_anon(mock_settings, mock_service): from registry.main import app _override_auth(None) with patch( "registry.api.m2m_management_routes._get_service", new=AsyncMock(return_value=mock_service), ): client = TestClient(app, cookies={"mcp_gateway_session": "test-session"}) yield client, mock_service app.dependency_overrides.clear() @pytest.mark.unit @pytest.mark.api class TestCreateM2MClient: """Tests for POST /api/iam/m2m-clients.""" def test_unauthenticated_returns_401(self, client_anon): client, _ = client_anon response = client.post( "/api/iam/m2m-clients", json={"client_id": "abc", "client_name": "x"}, ) assert response.status_code == 401 def test_non_admin_returns_403(self, client_regular): client, _ = client_regular response = client.post( "/api/iam/m2m-clients", json={"client_id": "abc", "client_name": "x"}, ) assert response.status_code == 403 def test_happy_path_returns_201( self, client_admin, sample_client, ): client, service = client_admin service.create.return_value = sample_client response = client.post( "/api/iam/m2m-clients", json={ "client_id": "test-client-id", "client_name": "Test Client", "groups": ["group-a"], "description": "desc", }, ) assert response.status_code == 201 body = response.json() assert body["client_id"] == "test-client-id" assert body["provider"] == MANUAL_PROVIDER service.create.assert_awaited_once() create_kwargs = service.create.await_args.kwargs assert create_kwargs["created_by"] == "admin" def test_conflict_returns_409(self, client_admin): client, service = client_admin service.create.side_effect = M2MClientConflict("dup") response = client.post( "/api/iam/m2m-clients", json={"client_id": "dup", "client_name": "x"}, ) assert response.status_code == 409 def test_invalid_client_id_returns_422(self, client_admin): client, _ = client_admin response = client.post( "/api/iam/m2m-clients", json={"client_id": "bad id with space", "client_name": "x"}, ) assert response.status_code == 422 @pytest.mark.unit @pytest.mark.api class TestListM2MClients: """Tests for GET /api/iam/m2m-clients.""" def test_unauthenticated_returns_401(self, client_anon): client, _ = client_anon response = client.get("/api/iam/m2m-clients") assert response.status_code == 401 def test_returns_paginated_envelope( self, client_admin, sample_client, ): client, service = client_admin service.list_paged.return_value = ([sample_client], 1) response = client.get("/api/iam/m2m-clients") assert response.status_code == 200 body = response.json() assert body["total"] == 1 assert body["limit"] == 500 assert body["skip"] == 0 assert len(body["items"]) == 1 assert body["items"][0]["client_id"] == "test-client-id" def test_passes_provider_filter(self, client_admin): client, service = client_admin service.list_paged.return_value = ([], 0) client.get("/api/iam/m2m-clients?provider=manual") kwargs = service.list_paged.await_args.kwargs assert kwargs["provider"] == "manual" def test_enforces_limit_cap(self, client_admin): client, service = client_admin service.list_paged.return_value = ([], 0) response = client.get("/api/iam/m2m-clients?limit=5000") assert response.status_code == 422 # exceeds le=1000 def test_passes_skip_and_limit(self, client_admin): client, service = client_admin service.list_paged.return_value = ([], 0) client.get("/api/iam/m2m-clients?limit=25&skip=10") kwargs = service.list_paged.await_args.kwargs assert kwargs["limit"] == 25 assert kwargs["skip"] == 10 @pytest.mark.unit @pytest.mark.api class TestGetM2MClient: """Tests for GET /api/iam/m2m-clients/{client_id}.""" def test_unauthenticated_returns_401(self, client_anon): client, _ = client_anon response = client.get("/api/iam/m2m-clients/x") assert response.status_code == 401 def test_returns_200_on_found( self, client_admin, sample_client, ): client, service = client_admin service.get.return_value = sample_client response = client.get("/api/iam/m2m-clients/test-client-id") assert response.status_code == 200 assert response.json()["client_id"] == "test-client-id" def test_returns_404_on_missing(self, client_admin): client, service = client_admin service.get.side_effect = M2MClientNotFound("missing") response = client.get("/api/iam/m2m-clients/missing") assert response.status_code == 404 @pytest.mark.unit @pytest.mark.api class TestPatchM2MClient: """Tests for PATCH /api/iam/m2m-clients/{client_id}.""" def test_non_admin_returns_403(self, client_regular): client, _ = client_regular response = client.patch( "/api/iam/m2m-clients/x", json={"groups": ["g1"]}, ) assert response.status_code == 403 def test_happy_path_returns_200( self, client_admin, sample_client, ): client, service = client_admin service.patch.return_value = sample_client response = client.patch( "/api/iam/m2m-clients/test-client-id", json={"groups": ["new-group"]}, ) assert response.status_code == 200 def test_not_found_returns_404(self, client_admin): client, service = client_admin service.patch.side_effect = M2MClientNotFound("missing") response = client.patch( "/api/iam/m2m-clients/missing", json={"groups": ["g1"]}, ) assert response.status_code == 404 def test_immutable_returns_403(self, client_admin): client, service = client_admin service.patch.side_effect = M2MClientImmutable("sync-id") response = client.patch( "/api/iam/m2m-clients/sync-id", json={"groups": ["g1"]}, ) assert response.status_code == 403 @pytest.mark.unit @pytest.mark.api class TestDeleteM2MClient: """Tests for DELETE /api/iam/m2m-clients/{client_id}.""" def test_non_admin_returns_403(self, client_regular): client, _ = client_regular response = client.delete("/api/iam/m2m-clients/x") assert response.status_code == 403 def test_happy_path_returns_204(self, client_admin): client, service = client_admin service.delete.return_value = None response = client.delete("/api/iam/m2m-clients/test-client-id") assert response.status_code == 204 def test_not_found_returns_404(self, client_admin): client, service = client_admin service.delete.side_effect = M2MClientNotFound("missing") response = client.delete("/api/iam/m2m-clients/missing") assert response.status_code == 404 def test_immutable_returns_403(self, client_admin): client, service = client_admin service.delete.side_effect = M2MClientImmutable("sync-id") response = client.delete("/api/iam/m2m-clients/sync-id") assert response.status_code == 403 ================================================ FILE: tests/unit/api/test_management_routes.py ================================================ """ Unit tests for registry/api/management_routes.py Tests the IAM-related management endpoints including: - GET /management/iam/users - List users from identity provider - POST /management/iam/groups - Create group in IdP and MongoDB - DELETE /management/iam/groups/{group_name} - Delete group from IdP and MongoDB - GET /management/iam/groups - List groups from identity provider """ import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient logger = logging.getLogger(__name__) # ============================================================================= # AUTH MOCK FIXTURES # ============================================================================= @pytest.fixture def admin_user_context() -> dict[str, Any]: """Create admin user context.""" return { "username": "admin", "is_admin": True, "groups": ["mcp-registry-admin"], "scopes": ["mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute"], "accessible_servers": ["all"], "accessible_services": ["all"], "accessible_agents": ["all"], "ui_permissions": { "list_service": ["all"], "toggle_service": ["all"], "register_service": ["all"], "view_tools": ["all"], "refresh_service": ["all"], "modify_service": ["all"], }, "auth_method": "session", } @pytest.fixture def regular_user_context() -> dict[str, Any]: """Create regular (non-admin) user context.""" return { "username": "testuser", "is_admin": False, "groups": ["test-group"], "scopes": ["test-server/read"], "accessible_servers": ["test-server"], "accessible_services": ["test-server"], "accessible_agents": ["test-agent"], "ui_permissions": { "list_service": ["test-server"], "view_tools": ["test-server"], }, "auth_method": "session", } @pytest.fixture def mock_auth_admin(admin_user_context, mock_settings): """ Mock authentication dependencies with admin user. Note: depends on mock_settings to ensure environment is set up before importing app. """ from registry.auth.dependencies import nginx_proxied_auth from registry.main import app def mock_nginx_proxied_auth_override(): return admin_user_context app.dependency_overrides[nginx_proxied_auth] = mock_nginx_proxied_auth_override yield admin_user_context app.dependency_overrides.clear() @pytest.fixture def mock_auth_regular(regular_user_context, mock_settings): """ Mock authentication dependencies with regular user. Note: depends on mock_settings to ensure environment is set up before importing app. """ from registry.auth.dependencies import nginx_proxied_auth from registry.main import app def mock_nginx_proxied_auth_override(): return regular_user_context app.dependency_overrides[nginx_proxied_auth] = mock_nginx_proxied_auth_override yield regular_user_context app.dependency_overrides.clear() # ============================================================================= # IAM MANAGER MOCK FIXTURES # ============================================================================= @pytest.fixture def mock_iam_manager(): """Create a mock IAM manager for testing.""" mock = MagicMock() mock.list_users = AsyncMock(return_value=[]) mock.list_groups = AsyncMock(return_value=[]) mock.create_group = AsyncMock( return_value={ "id": "test-group-id", "name": "test-group", "path": "/test-group", "attributes": None, } ) mock.delete_group = AsyncMock(return_value=True) mock.create_human_user = AsyncMock( return_value={ "id": "test-user-id", "username": "testuser", "email": "test@example.com", "firstName": "Test", "lastName": "User", "enabled": True, "groups": ["test-group"], } ) mock.delete_user = AsyncMock(return_value=True) mock.create_service_account = AsyncMock( return_value={ "client_id": "test-client", "client_secret": "test-secret", "groups": ["test-group"], } ) return mock # ============================================================================= # TEST CLIENT FIXTURES # ============================================================================= @pytest.fixture def test_client_admin(mock_settings, mock_auth_admin, mock_iam_manager): """Create FastAPI test client with admin auth and IAM manager mocked.""" with patch( "registry.api.management_routes.get_iam_manager", return_value=mock_iam_manager, ): from registry.main import app client = TestClient(app, cookies={"mcp_gateway_session": "test-session"}) yield client, mock_iam_manager @pytest.fixture def test_client_regular(mock_settings, mock_auth_regular, mock_iam_manager): """Create FastAPI test client with regular user auth and IAM manager mocked.""" with patch( "registry.api.management_routes.get_iam_manager", return_value=mock_iam_manager, ): from registry.main import app client = TestClient(app, cookies={"mcp_gateway_session": "test-session"}) yield client, mock_iam_manager # ============================================================================= # TEST GET /management/iam/users - List Users # ============================================================================= @pytest.mark.unit @pytest.mark.api class TestManagementListUsers: """Tests for GET /management/iam/users endpoint.""" def test_list_users_success(self, test_client_admin): """Test successful listing of users.""" # Arrange client, mock_iam = test_client_admin mock_iam.list_users.return_value = [ { "id": "user-1", "username": "user1", "email": "user1@example.com", "firstName": "User", "lastName": "One", "enabled": True, "groups": ["group-a"], }, { "id": "user-2", "username": "user2", "email": "user2@example.com", "firstName": "User", "lastName": "Two", "enabled": False, "groups": [], }, ] # Act response = client.get("/api/management/iam/users") # Assert assert response.status_code == 200 data = response.json() assert "users" in data assert "total" in data assert data["total"] == 2 assert len(data["users"]) == 2 assert data["users"][0]["username"] == "user1" assert data["users"][1]["username"] == "user2" mock_iam.list_users.assert_called_once_with(search=None, max_results=500) def test_list_users_with_search(self, test_client_admin): """Test listing users with search parameter.""" # Arrange client, mock_iam = test_client_admin mock_iam.list_users.return_value = [ { "id": "user-1", "username": "john", "email": "john@example.com", "firstName": "John", "lastName": "Doe", "enabled": True, "groups": [], }, ] # Act response = client.get("/api/management/iam/users?search=john&limit=100") # Assert assert response.status_code == 200 data = response.json() assert data["total"] == 1 mock_iam.list_users.assert_called_once_with(search="john", max_results=100) def test_list_users_requires_admin(self, test_client_regular): """Test that listing users requires admin permissions.""" # Arrange client, _ = test_client_regular # Act response = client.get("/api/management/iam/users") # Assert assert response.status_code == 403 assert "Administrator permissions" in response.json()["detail"] def test_list_users_iam_error(self, test_client_admin): """Test error handling when IAM manager fails.""" # Arrange client, mock_iam = test_client_admin mock_iam.list_users.side_effect = Exception("Connection refused") # Act response = client.get("/api/management/iam/users") # Assert assert response.status_code == 502 assert "Connection refused" in response.json()["detail"] # ============================================================================= # TEST GET /management/iam/groups - List Groups # ============================================================================= @pytest.mark.unit @pytest.mark.api class TestManagementListGroups: """Tests for GET /management/iam/groups endpoint.""" def test_list_groups_success(self, test_client_admin): """Test successful listing of groups.""" # Arrange client, mock_iam = test_client_admin mock_iam.list_groups.return_value = [ { "id": "group-1", "name": "developers", "path": "/developers", "attributes": {"department": ["engineering"]}, }, { "id": "group-2", "name": "admins", "path": "/admins", "attributes": None, }, ] # Act response = client.get("/api/management/iam/groups") # Assert assert response.status_code == 200 data = response.json() assert "groups" in data assert "total" in data assert data["total"] == 2 assert len(data["groups"]) == 2 assert data["groups"][0]["name"] == "developers" assert data["groups"][1]["name"] == "admins" mock_iam.list_groups.assert_called_once() def test_list_groups_returns_group_summary(self, test_client_admin): """Test that groups are returned as GroupSummary objects.""" # Arrange client, mock_iam = test_client_admin mock_iam.list_groups.return_value = [ { "id": "test-id", "name": "test-group", "path": "/test-group", "attributes": {"key": ["value"]}, }, ] # Act response = client.get("/api/management/iam/groups") # Assert assert response.status_code == 200 data = response.json() group = data["groups"][0] assert "id" in group assert "name" in group assert "path" in group assert "attributes" in group assert group["id"] == "test-id" assert group["name"] == "test-group" assert group["path"] == "/test-group" def test_list_groups_requires_admin(self, test_client_regular): """Test that listing groups requires admin permissions.""" # Arrange client, _ = test_client_regular # Act response = client.get("/api/management/iam/groups") # Assert assert response.status_code == 403 assert "Administrator permissions" in response.json()["detail"] def test_list_groups_iam_error(self, test_client_admin): """Test error handling when IAM manager fails.""" # Arrange client, mock_iam = test_client_admin mock_iam.list_groups.side_effect = Exception("Keycloak unavailable") # Act response = client.get("/api/management/iam/groups") # Assert assert response.status_code == 502 assert "Unable to list IAM groups" in response.json()["detail"] # ============================================================================= # TEST POST /management/iam/groups - Create Group # ============================================================================= @pytest.mark.unit @pytest.mark.api class TestManagementCreateGroup: """Tests for POST /management/iam/groups endpoint.""" def test_create_group_success_keycloak(self, test_client_admin): """Test successful group creation with Keycloak provider.""" # Arrange client, mock_iam = test_client_admin mock_iam.create_group.return_value = { "id": "new-group-id", "name": "new-group", "path": "/new-group", "attributes": None, } with ( patch( "registry.api.management_routes.scope_service.import_group", new_callable=AsyncMock, return_value=True, ) as mock_import_group, patch("registry.api.management_routes.AUTH_PROVIDER", "keycloak"), ): # Act response = client.post( "/api/management/iam/groups", json={ "name": "new-group", "description": "A new test group", "scope_config": {"create_in_idp": True}, }, ) # Assert assert response.status_code == 200 data = response.json() assert data["id"] == "new-group-id" assert data["name"] == "new-group" mock_iam.create_group.assert_called_once_with( group_name="new-group", description="A new test group" ) # Keycloak uses group name in group_mappings mock_import_group.assert_called_once_with( scope_name="new-group", description="A new test group", group_mappings=["new-group"], server_access=[], ui_permissions={}, agent_access=[], ) def test_create_group_success_entra(self, test_client_admin): """Test successful group creation with Entra ID provider.""" # Arrange client, mock_iam = test_client_admin entra_group_id = "12345678-1234-1234-1234-123456789abc" mock_iam.create_group.return_value = { "id": entra_group_id, "name": "new-group", "path": "/new-group", "attributes": None, } with ( patch( "registry.api.management_routes.scope_service.import_group", new_callable=AsyncMock, return_value=True, ) as mock_import_group, patch("registry.api.management_routes.AUTH_PROVIDER", "entra"), ): # Act response = client.post( "/api/management/iam/groups", json={ "name": "new-group", "description": "Entra test group", "scope_config": {"create_in_idp": True}, }, ) # Assert assert response.status_code == 200 data = response.json() assert data["id"] == entra_group_id assert data["name"] == "new-group" mock_iam.create_group.assert_called_once_with( group_name="new-group", description="Entra test group" ) # Entra ID uses Object ID (GUID) in group_mappings mock_import_group.assert_called_once_with( scope_name="new-group", description="Entra test group", group_mappings=[entra_group_id], server_access=[], ui_permissions={}, agent_access=[], ) def test_create_group_requires_admin(self, test_client_regular): """Test that creating groups requires admin permissions.""" # Arrange client, _ = test_client_regular # Act response = client.post( "/api/management/iam/groups", json={"name": "new-group"}, ) # Assert assert response.status_code == 403 assert "Administrator permissions" in response.json()["detail"] def test_create_group_already_exists(self, test_client_admin): """Test error handling when group already exists.""" # Arrange client, mock_iam = test_client_admin mock_iam.create_group.side_effect = Exception("Group 'existing-group' already exists") # Act response = client.post( "/api/management/iam/groups", json={ "name": "existing-group", "scope_config": {"create_in_idp": True}, }, ) # Assert assert response.status_code == 400 assert "already exists" in response.json()["detail"] def test_create_group_iam_error(self, test_client_admin): """Test error handling when IAM manager fails.""" # Arrange client, mock_iam = test_client_admin mock_iam.create_group.side_effect = Exception("IAM service unavailable") # Act response = client.post( "/api/management/iam/groups", json={ "name": "new-group", "scope_config": {"create_in_idp": True}, }, ) # Assert assert response.status_code == 502 assert "IAM service unavailable" in response.json()["detail"] def test_create_group_scope_import_failure_logs_warning(self, test_client_admin): """Test that scope import failure is logged but doesn't fail the request.""" # Arrange client, mock_iam = test_client_admin mock_iam.create_group.return_value = { "id": "group-id", "name": "partial-group", "path": "/partial-group", "attributes": None, } with ( patch( "registry.api.management_routes.scope_service.import_group", new_callable=AsyncMock, return_value=False, ), patch("registry.api.management_routes.AUTH_PROVIDER", "keycloak"), ): # Act response = client.post( "/api/management/iam/groups", json={ "name": "partial-group", "scope_config": {"create_in_idp": True}, }, ) # Assert - should still succeed (IdP creation succeeded) assert response.status_code == 200 data = response.json() assert data["name"] == "partial-group" def test_create_group_without_description(self, test_client_admin): """Test group creation without description uses empty string.""" # Arrange client, mock_iam = test_client_admin mock_iam.create_group.return_value = { "id": "group-id", "name": "minimal-group", "path": "/minimal-group", "attributes": None, } with ( patch( "registry.api.management_routes.scope_service.import_group", new_callable=AsyncMock, return_value=True, ) as mock_import_group, patch("registry.api.management_routes.AUTH_PROVIDER", "keycloak"), ): # Act response = client.post( "/api/management/iam/groups", json={ "name": "minimal-group", "scope_config": {"create_in_idp": True}, }, ) # Assert assert response.status_code == 200 mock_iam.create_group.assert_called_once_with( group_name="minimal-group", description="" ) mock_import_group.assert_called_once_with( scope_name="minimal-group", description="", group_mappings=["minimal-group"], server_access=[], ui_permissions={}, agent_access=[], ) # ============================================================================= # TEST POST /management/iam/groups - Create Group with create_in_idp flag # ============================================================================= @pytest.mark.unit @pytest.mark.api class TestManagementCreateGroupCreateInIdp: """Tests for create_in_idp flag handling in group creation.""" def test_create_group_with_create_in_idp_false(self, test_client_admin): """When create_in_idp is False, group should only be created in MongoDB.""" # Arrange client, mock_iam = test_client_admin with ( patch( "registry.api.management_routes.scope_service.import_group", new_callable=AsyncMock, return_value=True, ) as mock_import_group, patch("registry.api.management_routes.AUTH_PROVIDER", "entra"), ): # Act response = client.post( "/api/management/iam/groups", json={ "name": "local-only-group", "description": "Local only group", "scope_config": {"create_in_idp": False}, }, ) # Assert assert response.status_code == 200 data = response.json() assert data["name"] == "local-only-group" # IdP create_group should NOT have been called mock_iam.create_group.assert_not_called() # MongoDB scope should still be created with group name as mapping mock_import_group.assert_called_once_with( scope_name="local-only-group", description="Local only group", group_mappings=["local-only-group"], server_access=[], ui_permissions={}, agent_access=[], ) def test_create_group_with_create_in_idp_true(self, test_client_admin): """When create_in_idp is True, group should be created in both IdP and MongoDB.""" # Arrange client, mock_iam = test_client_admin entra_group_id = "12345678-1234-1234-1234-123456789abc" mock_iam.create_group.return_value = { "id": entra_group_id, "name": "idp-group", "path": "/idp-group", "attributes": None, } with ( patch( "registry.api.management_routes.scope_service.import_group", new_callable=AsyncMock, return_value=True, ) as mock_import_group, patch("registry.api.management_routes.AUTH_PROVIDER", "entra"), ): # Act response = client.post( "/api/management/iam/groups", json={ "name": "idp-group", "description": "IdP group", "scope_config": {"create_in_idp": True}, }, ) # Assert assert response.status_code == 200 data = response.json() assert data["id"] == entra_group_id # IdP create_group SHOULD have been called mock_iam.create_group.assert_called_once_with( group_name="idp-group", description="IdP group", ) # MongoDB scope created with Entra Object ID as mapping mock_import_group.assert_called_once_with( scope_name="idp-group", description="IdP group", group_mappings=[entra_group_id], server_access=[], ui_permissions={}, agent_access=[], ) def test_create_group_default_does_not_create_in_idp(self, test_client_admin): """When create_in_idp not in scope_config, default to NOT creating in IdP.""" # Arrange client, mock_iam = test_client_admin with ( patch( "registry.api.management_routes.scope_service.import_group", new_callable=AsyncMock, return_value=True, ) as mock_import_group, patch("registry.api.management_routes.AUTH_PROVIDER", "keycloak"), ): # Act response = client.post( "/api/management/iam/groups", json={"name": "default-group"}, ) # Assert assert response.status_code == 200 data = response.json() assert data["name"] == "default-group" # IdP create_group should NOT be called (default is False) mock_iam.create_group.assert_not_called() # MongoDB scope should still be created with group name as mapping mock_import_group.assert_called_once_with( scope_name="default-group", description="", group_mappings=["default-group"], server_access=[], ui_permissions={}, agent_access=[], ) # ============================================================================= # TEST DELETE /management/iam/groups/{group_name} - Delete Group (with local-only) # ============================================================================= @pytest.mark.unit @pytest.mark.api class TestManagementDeleteGroupLocalOnly: """Tests for deleting groups that only exist in MongoDB (local-only).""" def test_delete_local_only_group_succeeds(self, test_client_admin): """Delete succeeds when group only exists in MongoDB (IdP returns not found).""" # Arrange client, mock_iam = test_client_admin mock_iam.delete_group.side_effect = Exception("Group 'local-group' not found") with patch( "registry.api.management_routes.scope_service.delete_group", new_callable=AsyncMock, return_value=True, ) as mock_delete_scope: # Act response = client.delete("/api/management/iam/groups/local-group") # Assert - should succeed because IdP "not found" is handled gracefully assert response.status_code == 200 data = response.json() assert data["name"] == "local-group" # MongoDB deletion should still proceed mock_delete_scope.assert_called_once_with( group_name="local-group", remove_from_mappings=True ) # ============================================================================= # TEST DELETE /management/iam/groups/{group_name} - Delete Group # ============================================================================= @pytest.mark.unit @pytest.mark.api class TestManagementDeleteGroup: """Tests for DELETE /management/iam/groups/{group_name} endpoint.""" def test_delete_group_success(self, test_client_admin): """Test successful group deletion.""" # Arrange client, mock_iam = test_client_admin mock_iam.delete_group.return_value = True with patch( "registry.api.management_routes.scope_service.delete_group", new_callable=AsyncMock, return_value=True, ) as mock_delete_scope: # Act response = client.delete("/api/management/iam/groups/test-group") # Assert assert response.status_code == 200 data = response.json() assert data["name"] == "test-group" assert data["deleted"] is True mock_iam.delete_group.assert_called_once_with(group_name="test-group") mock_delete_scope.assert_called_once_with( group_name="test-group", remove_from_mappings=True ) def test_delete_group_requires_admin(self, test_client_regular): """Test that deleting groups requires admin permissions.""" # Arrange client, _ = test_client_regular # Act response = client.delete("/api/management/iam/groups/test-group") # Assert assert response.status_code == 403 assert "Administrator permissions" in response.json()["detail"] def test_delete_group_not_found_in_idp_still_deletes_from_mongodb(self, test_client_admin): """Test that IdP 'not found' is handled gracefully (local-only group delete).""" # Arrange client, mock_iam = test_client_admin mock_iam.delete_group.side_effect = Exception("Group 'nonexistent' not found") with patch( "registry.api.management_routes.scope_service.delete_group", new_callable=AsyncMock, return_value=True, ) as mock_delete_scope: # Act response = client.delete("/api/management/iam/groups/nonexistent") # Assert - should succeed because IdP "not found" is handled gracefully assert response.status_code == 200 data = response.json() assert data["name"] == "nonexistent" # MongoDB deletion should still proceed mock_delete_scope.assert_called_once_with( group_name="nonexistent", remove_from_mappings=True ) def test_delete_group_iam_error(self, test_client_admin): """Test error handling when IAM manager fails.""" # Arrange client, mock_iam = test_client_admin mock_iam.delete_group.side_effect = Exception("IAM service error") # Act response = client.delete("/api/management/iam/groups/test-group") # Assert assert response.status_code == 502 assert "IAM service error" in response.json()["detail"] def test_delete_group_scope_deletion_failure_logs_warning(self, test_client_admin): """Test that scope deletion failure is logged but doesn't fail the request.""" # Arrange client, mock_iam = test_client_admin mock_iam.delete_group.return_value = True with patch( "registry.api.management_routes.scope_service.delete_group", new_callable=AsyncMock, return_value=False, ): # Act response = client.delete("/api/management/iam/groups/partial-delete") # Assert - should still succeed (IdP deletion succeeded) assert response.status_code == 200 data = response.json() assert data["name"] == "partial-delete" assert data["deleted"] is True # ============================================================================= # TEST HELPER FUNCTIONS # ============================================================================= @pytest.mark.unit @pytest.mark.api class TestManagementHelpers: """Tests for management routes helper functions.""" def test_translate_iam_error_already_exists(self): """Test error translation for 'already exists' errors.""" from registry.api.management_routes import _translate_iam_error exc = Exception("Group 'test' already exists in Keycloak") http_exc = _translate_iam_error(exc) assert http_exc.status_code == 400 def test_translate_iam_error_not_found(self): """Test error translation for 'not found' errors.""" from registry.api.management_routes import _translate_iam_error exc = Exception("User not found in identity provider") http_exc = _translate_iam_error(exc) assert http_exc.status_code == 400 def test_translate_iam_error_generic(self): """Test error translation for generic errors.""" from registry.api.management_routes import _translate_iam_error exc = Exception("Connection timeout to Keycloak") http_exc = _translate_iam_error(exc) assert http_exc.status_code == 502 def test_require_admin_passes_for_admin(self, admin_user_context): """Test _require_admin passes for admin users.""" from registry.api.management_routes import _require_admin # Should not raise _require_admin(admin_user_context) def test_require_admin_raises_for_non_admin(self, regular_user_context): """Test _require_admin raises HTTPException for non-admin users.""" from fastapi import HTTPException from registry.api.management_routes import _require_admin with pytest.raises(HTTPException) as exc_info: _require_admin(regular_user_context) assert exc_info.value.status_code == 403 assert "Administrator permissions" in exc_info.value.detail ================================================ FILE: tests/unit/api/test_peer_management_routes.py ================================================ """ Unit tests for registry/api/peer_management_routes.py Tests the peer management endpoints including: - PATCH /api/peers/{peer_id}/token - Update federation token """ import logging from typing import Any from unittest.mock import AsyncMock, patch import pytest from fastapi import status from fastapi.testclient import TestClient from registry.schemas.peer_federation_schema import PeerRegistryConfig logger = logging.getLogger(__name__) # ============================================================================= # AUTH MOCK FIXTURES # ============================================================================= @pytest.fixture def admin_user_context() -> dict[str, Any]: """Create admin user context with peer management permissions.""" return { "username": "admin", "is_admin": True, "groups": ["mcp-registry-admin"], "scopes": ["mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute"], "accessible_servers": ["all"], "accessible_services": ["all"], "accessible_agents": ["all"], "ui_permissions": { "list_service": ["all"], "toggle_service": ["all"], "register_service": ["all"], "view_tools": ["all"], "refresh_service": ["all"], "modify_service": ["all"], }, "auth_method": "session", } @pytest.fixture def non_admin_user_context() -> dict[str, Any]: """Create non-admin user context without peer management permissions.""" return { "username": "regular_user", "is_admin": False, "groups": ["mcp-users"], "scopes": [], "accessible_servers": [], "accessible_services": [], "accessible_agents": [], "ui_permissions": {}, "auth_method": "session", } @pytest.fixture def mock_auth_admin(admin_user_context): """Mock authentication dependencies with admin user.""" from registry.auth.dependencies import nginx_proxied_auth from registry.main import app def mock_nginx_proxied_auth_override(): return admin_user_context app.dependency_overrides[nginx_proxied_auth] = mock_nginx_proxied_auth_override yield admin_user_context app.dependency_overrides.clear() @pytest.fixture def mock_auth_regular(non_admin_user_context): """Mock authentication dependencies with regular user.""" from registry.auth.dependencies import nginx_proxied_auth from registry.main import app def mock_nginx_proxied_auth_override(): return non_admin_user_context app.dependency_overrides[nginx_proxied_auth] = mock_nginx_proxied_auth_override yield non_admin_user_context app.dependency_overrides.clear() # ============================================================================= # MOCK SERVICE FIXTURES # ============================================================================= @pytest.fixture def mock_peer_federation_service(): """Create mock peer federation service.""" mock = AsyncMock() mock.get_peer_by_id = AsyncMock() mock.update_peer = AsyncMock() return mock @pytest.fixture def sample_peer_config(): """Sample peer config for testing.""" return PeerRegistryConfig( peer_id="test-peer", name="Test Peer Registry", endpoint="https://peer.example.com", enabled=True, sync_mode="all", sync_interval_minutes=60, federation_token="original-token-abc123", ) # ============================================================================= # PATCH /api/peers/{peer_id}/token Tests # ============================================================================= @pytest.mark.unit class TestUpdatePeerToken: """Tests for PATCH /api/peers/{peer_id}/token endpoint.""" @pytest.mark.asyncio async def test_update_peer_token_success( self, mock_auth_admin, mock_peer_federation_service, sample_peer_config, ): """Test successfully updating peer federation token.""" # Arrange from registry.main import app client = TestClient(app) # Mock service to return updated peer updated_peer = sample_peer_config.model_copy() updated_peer.federation_token = "new-token-xyz789" mock_peer_federation_service.get_peer_by_id.return_value = sample_peer_config mock_peer_federation_service.update_peer.return_value = updated_peer with patch( "registry.api.peer_management_routes.get_peer_federation_service", return_value=mock_peer_federation_service, ): # Act response = client.patch( f"/api/peers/{sample_peer_config.peer_id}/token", json={"federation_token": "new-token-xyz789"}, ) # Assert assert response.status_code == status.HTTP_200_OK data = response.json() assert data["message"] == "Federation token updated successfully" assert data["peer_id"] == sample_peer_config.peer_id # Verify service was called correctly mock_peer_federation_service.update_peer.assert_called_once_with( sample_peer_config.peer_id, {"federation_token": "new-token-xyz789"}, ) @pytest.mark.asyncio async def test_update_peer_token_not_found( self, mock_auth_admin, mock_peer_federation_service, ): """Test updating token for non-existent peer returns 404.""" # Arrange from registry.main import app client = TestClient(app) # Mock service to raise ValueError for non-existent peer mock_peer_federation_service.get_peer_by_id.return_value = None mock_peer_federation_service.update_peer.side_effect = ValueError( "Peer not found: nonexistent-peer" ) with patch( "registry.api.peer_management_routes.get_peer_federation_service", return_value=mock_peer_federation_service, ): # Act response = client.patch( "/api/peers/nonexistent-peer/token", json={"federation_token": "new-token"}, ) # Assert assert response.status_code == status.HTTP_404_NOT_FOUND @pytest.mark.asyncio async def test_update_peer_token_requires_admin( self, mock_auth_regular, mock_peer_federation_service, ): """Test that updating peer token requires admin permissions.""" # Arrange from registry.main import app client = TestClient(app) with patch( "registry.api.peer_management_routes.get_peer_federation_service", return_value=mock_peer_federation_service, ): # Act response = client.patch( "/api/peers/test-peer/token", json={"federation_token": "new-token"}, ) # Assert assert response.status_code == status.HTTP_403_FORBIDDEN # Verify service was not called mock_peer_federation_service.update_peer.assert_not_called() @pytest.mark.asyncio async def test_update_peer_token_missing_token_field( self, mock_auth_admin, mock_peer_federation_service, ): """Test that request without federation_token field returns 422.""" # Arrange from registry.main import app client = TestClient(app) with patch( "registry.api.peer_management_routes.get_peer_federation_service", return_value=mock_peer_federation_service, ): # Act - send empty body response = client.patch( "/api/peers/test-peer/token", json={}, ) # Assert assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY # Verify service was not called mock_peer_federation_service.update_peer.assert_not_called() @pytest.mark.asyncio async def test_update_peer_token_empty_token_value( self, mock_auth_admin, mock_peer_federation_service, sample_peer_config, ): """Test that empty token value is accepted (clears token).""" # Arrange from registry.main import app client = TestClient(app) # Mock service to return updated peer with cleared token updated_peer = sample_peer_config.model_copy() updated_peer.federation_token = None mock_peer_federation_service.get_peer_by_id.return_value = sample_peer_config mock_peer_federation_service.update_peer.return_value = updated_peer with patch( "registry.api.peer_management_routes.get_peer_federation_service", return_value=mock_peer_federation_service, ): # Act - send empty string token response = client.patch( f"/api/peers/{sample_peer_config.peer_id}/token", json={"federation_token": ""}, ) # Assert assert response.status_code == status.HTTP_200_OK # Verify service was called with empty string mock_peer_federation_service.update_peer.assert_called_once_with( sample_peer_config.peer_id, {"federation_token": ""}, ) @pytest.mark.asyncio async def test_update_peer_token_internal_error( self, mock_auth_admin, mock_peer_federation_service, ): """Test that internal errors return 400 with error message.""" # Arrange from registry.main import app client = TestClient(app) # Mock service to raise generic ValueError during update mock_peer_federation_service.get_peer_by_id.return_value = None mock_peer_federation_service.update_peer.side_effect = ValueError("Internal database error") with patch( "registry.api.peer_management_routes.get_peer_federation_service", return_value=mock_peer_federation_service, ): # Act response = client.patch( "/api/peers/test-peer/token", json={"federation_token": "new-token"}, ) # Assert assert response.status_code == status.HTTP_400_BAD_REQUEST ================================================ FILE: tests/unit/api/test_search_routes.py ================================================ """ Unit tests for registry/api/search_routes.py Tests all components of the semantic search API including: - Pydantic model validation - User access control helper functions - Semantic search endpoint with various scenarios - Error handling and edge cases """ import logging from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest from fastapi import HTTPException, Request from pydantic import ValidationError from registry.api.search_routes import ( AgentSearchResult, MatchingToolResult, SemanticSearchRequest, SemanticSearchResponse, ServerSearchResult, ToolSearchResult, _user_can_access_agent, _user_can_access_server, semantic_search, ) from tests.fixtures.factories import AgentCardFactory logger = logging.getLogger(__name__) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def mock_http_request(): """Mock HTTP request for testing.""" mock_request = Mock(spec=Request) mock_request.state = Mock() return mock_request @pytest.fixture def mock_search_repo(): """Mock search repository for testing.""" mock = AsyncMock() yield mock @pytest.fixture def mock_server_service(): """Mock server service for testing.""" with patch("registry.api.search_routes.server_service") as mock: yield mock @pytest.fixture def mock_agent_service(): """Mock agent service for testing.""" with patch("registry.api.search_routes.agent_service") as mock: yield mock @pytest.fixture(autouse=True) def mock_server_and_agent_service_db_calls(): """Mock server_service and agent_service to avoid MongoDB connections in unit tests. This is an autouse fixture that automatically patches both services for ALL tests in this file to prevent slow MongoDB connection attempts. """ # Mock get_server_info method to return server info based on path async def get_server_info(path: str): # Return mock server info for known paths if "currenttime" in path or "Time Server" in path: return { "path": path, "server_name": "Time Server", "description": "Time utilities", "tags": ["time"], "num_tools": 1, } elif "restricted" in path: return { "path": path, "server_name": "restricted", "description": "Restricted server", "tags": [], "num_tools": 0, } elif "mcpgw" in path: return { "path": path, "server_name": "mcpgw", "description": "MCP Gateway", "tags": ["gateway"], "num_tools": 5, } return None # Mock get_agent_info method to return agent info based on path async def get_agent_info(path: str): # Return mock agent card for known paths from tests.fixtures.factories import AgentCardFactory if "code-reviewer" in path: return AgentCardFactory(path=path, name="code-reviewer", visibility="public") elif "test-agent" in path: return AgentCardFactory(path=path, name="test-agent", visibility="public") elif "data-analyst" in path: return AgentCardFactory(path=path, name="data-analyst", visibility="public") return None # Patch both service methods with ( patch( "registry.api.search_routes.server_service.get_server_info", new=AsyncMock(side_effect=get_server_info), ), patch( "registry.api.search_routes.agent_service.get_agent_info", new=AsyncMock(side_effect=get_agent_info), ), ): yield @pytest.fixture def admin_user_context() -> dict[str, Any]: """Create admin user context for testing.""" return { "username": "admin", "is_admin": True, "groups": ["mcp-registry-admin"], "scopes": ["mcp-servers-unrestricted/read"], "accessible_servers": ["*"], "accessible_agents": ["all"], } @pytest.fixture def regular_user_context() -> dict[str, Any]: """Create regular user context with specific access.""" return { "username": "regular_user", "is_admin": False, "groups": ["registry-users-lob1"], "scopes": ["registry-users-lob1"], "accessible_servers": ["currenttime", "mcpgw"], "accessible_agents": ["/agents/code-reviewer", "/agents/test-agent"], } @pytest.fixture def restricted_user_context() -> dict[str, Any]: """Create user context with no access.""" return { "username": "restricted_user", "is_admin": False, "groups": [], "scopes": [], "accessible_servers": [], "accessible_agents": [], } @pytest.fixture def user_with_all_servers_context() -> dict[str, Any]: """Create user context with 'all' access to servers.""" return { "username": "all_servers_user", "is_admin": False, "groups": ["registry-users"], "scopes": ["registry-users"], "accessible_servers": ["all"], "accessible_agents": [], } @pytest.fixture def sample_faiss_search_results() -> dict[str, list[dict[str, Any]]]: """Create sample FAISS search results.""" return { "servers": [ { "path": "/servers/currenttime", "server_name": "currenttime", "description": "Get current time in various timezones", "tags": ["time", "timezone"], "num_tools": 1, "is_enabled": True, "relevance_score": 0.95, "match_context": "time timezone utilities", "matching_tools": [ { "tool_name": "get_current_time", "description": "Get current time for timezone", "relevance_score": 0.92, "match_context": "current time timezone", } ], }, { "path": "/servers/weather", "server_name": "weather", "description": "Get weather information", "tags": ["weather", "forecast"], "num_tools": 2, "is_enabled": True, "relevance_score": 0.75, "match_context": "weather data", "matching_tools": [], }, ], "tools": [ { "server_path": "/servers/currenttime", "server_name": "currenttime", "tool_name": "get_current_time", "description": "Get current time for timezone", "relevance_score": 0.92, "match_context": "current time timezone", }, { "server_path": "/servers/weather", "server_name": "weather", "tool_name": "get_forecast", "description": "Get weather forecast", "relevance_score": 0.85, "match_context": "weather forecast data", }, ], "agents": [ { "path": "/agents/code-reviewer", "relevance_score": 0.88, "match_context": "code review analysis", "agent_card": { "name": "code-reviewer", "description": "Review code for best practices", "tags": ["code", "review"], "skills": [{"name": "Code Review"}], "visibility": "public", "is_enabled": True, }, }, { "path": "/agents/test-agent", "relevance_score": 0.82, "match_context": "test automation", "agent_card": { "name": "test-agent", "description": "Test automation agent", "tags": ["test", "automation"], "skills": [{"name": "Test Generation"}], "visibility": "public", "is_enabled": True, }, }, ], } # ============================================================================= # TEST: Pydantic Model Validation # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.search class TestPydanticModels: """Tests for Pydantic model validation.""" def test_matching_tool_result_valid(self): """Test MatchingToolResult with valid data.""" # Arrange & Act tool = MatchingToolResult( tool_name="test_tool", description="A test tool", relevance_score=0.85, match_context="test context", ) # Assert assert tool.tool_name == "test_tool" assert tool.description == "A test tool" assert tool.relevance_score == 0.85 assert tool.match_context == "test context" def test_matching_tool_result_defaults(self): """Test MatchingToolResult with default values.""" # Arrange & Act tool = MatchingToolResult(tool_name="test_tool") # Assert assert tool.tool_name == "test_tool" assert tool.description is None assert tool.relevance_score == 0.0 assert tool.match_context is None def test_matching_tool_result_score_validation(self): """Test MatchingToolResult score must be between 0 and 1.""" # Act & Assert - score too high with pytest.raises(ValidationError) as exc_info: MatchingToolResult(tool_name="test", relevance_score=1.5) assert "relevance_score" in str(exc_info.value) # Act & Assert - negative score with pytest.raises(ValidationError) as exc_info: MatchingToolResult(tool_name="test", relevance_score=-0.1) assert "relevance_score" in str(exc_info.value) def test_server_search_result_valid(self): """Test ServerSearchResult with valid data.""" # Arrange & Act server = ServerSearchResult( path="/servers/test", server_name="test-server", description="Test server", tags=["test"], num_tools=5, is_enabled=True, relevance_score=0.9, match_context="test context", matching_tools=[MatchingToolResult(tool_name="tool1", relevance_score=0.8)], ) # Assert assert server.path == "/servers/test" assert server.server_name == "test-server" assert server.num_tools == 5 assert len(server.matching_tools) == 1 def test_server_search_result_defaults(self): """Test ServerSearchResult with default values.""" # Arrange & Act server = ServerSearchResult( path="/servers/test", server_name="test-server", relevance_score=0.9, ) # Assert assert server.tags == [] assert server.num_tools == 0 assert server.is_enabled is False assert server.matching_tools == [] def test_tool_search_result_valid(self): """Test ToolSearchResult with valid data.""" # Arrange & Act tool = ToolSearchResult( server_path="/servers/test", server_name="test-server", tool_name="test_tool", description="Test tool", relevance_score=0.85, match_context="test context", ) # Assert assert tool.server_path == "/servers/test" assert tool.server_name == "test-server" assert tool.tool_name == "test_tool" def test_agent_search_result_valid(self): """Test AgentSearchResult with valid data.""" # Arrange & Act agent = AgentSearchResult( path="/agents/test", relevance_score=0.88, match_context="test context", agent_card={ "name": "test-agent", "description": "Test agent", "tags": ["test"], "skills": [{"name": "skill1"}, {"name": "skill2"}], "trust_level": "verified", "visibility": "public", "is_enabled": True, }, ) # Assert assert agent.path == "/agents/test" assert agent.agent_card["name"] == "test-agent" assert len(agent.agent_card["skills"]) == 2 def test_agent_search_result_defaults(self): """Test AgentSearchResult with default values.""" # Arrange & Act agent = AgentSearchResult( path="/agents/test", relevance_score=0.8, agent_card={ "name": "test-agent", "tags": [], "skills": [], "is_enabled": False, }, ) # Assert assert agent.agent_card["tags"] == [] assert agent.agent_card["skills"] == [] assert agent.agent_card["is_enabled"] is False def test_semantic_search_request_valid(self): """Test SemanticSearchRequest with valid data.""" # Arrange & Act request = SemanticSearchRequest( query="test query", entity_types=["mcp_server", "tool"], max_results=20, ) # Assert assert request.query == "test query" assert len(request.entity_types) == 2 assert request.max_results == 20 def test_semantic_search_request_defaults(self): """Test SemanticSearchRequest with default values.""" # Arrange & Act request = SemanticSearchRequest(query="test query") # Assert assert request.query == "test query" assert request.entity_types is None assert request.max_results == 10 def test_semantic_search_request_query_length_validation(self): """Test SemanticSearchRequest query length constraints.""" # Act & Assert - empty query is allowed (for tag-only searches) req = SemanticSearchRequest(query="", tags=["production"]) assert req.query == "" assert req.tags == ["production"] # Act & Assert - query too long with pytest.raises(ValidationError) as exc_info: SemanticSearchRequest(query="x" * 513) assert "query" in str(exc_info.value) def test_semantic_search_request_max_results_validation(self): """Test SemanticSearchRequest max_results constraints.""" # Act & Assert - max_results too low with pytest.raises(ValidationError) as exc_info: SemanticSearchRequest(query="test", max_results=0) assert "max_results" in str(exc_info.value) # Act & Assert - max_results too high with pytest.raises(ValidationError) as exc_info: SemanticSearchRequest(query="test", max_results=51) assert "max_results" in str(exc_info.value) def test_semantic_search_request_entity_types_validation(self): """Test SemanticSearchRequest entity_types must be valid.""" # Act & Assert - invalid entity type with pytest.raises(ValidationError) as exc_info: SemanticSearchRequest(query="test", entity_types=["invalid_type"]) assert "entity_types" in str(exc_info.value) def test_semantic_search_response_valid(self): """Test SemanticSearchResponse with valid data.""" # Arrange & Act response = SemanticSearchResponse( query="test query", servers=[ ServerSearchResult( path="/servers/test", server_name="test", relevance_score=0.9, ) ], tools=[], agents=[], total_servers=1, total_tools=0, total_agents=0, ) # Assert assert response.query == "test query" assert len(response.servers) == 1 assert response.total_servers == 1 def test_semantic_search_response_defaults(self): """Test SemanticSearchResponse with default values.""" # Arrange & Act response = SemanticSearchResponse(query="test query") # Assert assert response.servers == [] assert response.tools == [] assert response.agents == [] assert response.total_servers == 0 assert response.total_tools == 0 assert response.total_agents == 0 # ============================================================================= # TEST: _user_can_access_server Helper Function # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.search class TestUserCanAccessServer: """Tests for _user_can_access_server helper function.""" @pytest.mark.asyncio async def test_admin_user_can_access_any_server(self): """Test admin user can access any server.""" # Arrange user_context = {"is_admin": True} # Act result = await _user_can_access_server("/servers/test", "test-server", user_context) # Assert assert result is True @pytest.mark.asyncio async def test_user_with_all_accessible_servers(self): """Test user with 'all' in accessible_servers can access any server.""" # Arrange user_context = { "is_admin": False, "accessible_servers": ["all"], } # Act result = await _user_can_access_server("/servers/test", "test-server", user_context) # Assert assert result is True @pytest.mark.asyncio async def test_user_with_no_accessible_servers(self): """Test user with empty accessible_servers cannot access.""" # Arrange user_context = { "is_admin": False, "accessible_servers": [], } # Act result = await _user_can_access_server("/servers/test", "test-server", user_context) # Assert assert result is False @pytest.mark.asyncio async def test_user_with_none_accessible_servers(self): """Test user with None accessible_servers cannot access.""" # Arrange user_context = { "is_admin": False, "accessible_servers": None, } # Act result = await _user_can_access_server("/servers/test", "test-server", user_context) # Assert assert result is False @pytest.mark.asyncio async def test_user_can_access_via_server_service(self, mock_server_service): """Test user can access via server_service path validation.""" # Arrange mock_server_service.user_can_access_server_path = AsyncMock(return_value=True) user_context = { "is_admin": False, "accessible_servers": ["server1"], } # Act result = await _user_can_access_server("/servers/server1", "server1", user_context) # Assert assert result is True mock_server_service.user_can_access_server_path.assert_called_once_with( "/servers/server1", ["server1"] ) @pytest.mark.asyncio async def test_user_can_access_via_technical_name(self, mock_server_service): """Test user can access via technical name match.""" # Arrange # Note: technical_name is extracted as path.strip("/") which gives # "servers/currenttime", not "currenttime". Need server_service to handle. mock_server_service.user_can_access_server_path = AsyncMock(return_value=True) user_context = { "is_admin": False, "accessible_servers": ["currenttime"], } # Act result = await _user_can_access_server("/servers/currenttime", "Time Server", user_context) # Assert assert result is True @pytest.mark.asyncio async def test_user_can_access_via_server_name(self): """Test user can access via server name match.""" # Arrange user_context = { "is_admin": False, "accessible_servers": ["Time Server"], } # Act result = await _user_can_access_server("/servers/currenttime", "Time Server", user_context) # Assert assert result is True @pytest.mark.asyncio async def test_user_cannot_access_unlisted_server(self): """Test user cannot access server not in accessible list.""" # Arrange user_context = { "is_admin": False, "accessible_servers": ["server1", "server2"], } # Act result = await _user_can_access_server("/servers/server3", "server3", user_context) # Assert assert result is False @pytest.mark.asyncio async def test_server_service_exception_fallback_to_name_check(self, mock_server_service): """Test fallback to name check when server_service raises exception.""" # Arrange mock_server_service.user_can_access_server_path = AsyncMock( side_effect=Exception("Service error") ) user_context = { "is_admin": False, "accessible_servers": ["test-server"], } # Act result = await _user_can_access_server("/servers/test", "test-server", user_context) # Assert assert result is True # ============================================================================= # TEST: _user_can_access_agent Helper Function # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.search class TestUserCanAccessAgent: """Tests for _user_can_access_agent helper function.""" @pytest.mark.asyncio async def test_admin_user_can_access_any_agent(self, mock_agent_service): """Test admin user can access any agent.""" # Arrange mock_agent = AgentCardFactory(visibility="internal") mock_agent_service.get_agent_info = AsyncMock(return_value=mock_agent) user_context = {"is_admin": True} # Act result = await _user_can_access_agent("/agents/test", user_context) # Assert assert result is True @pytest.mark.asyncio async def test_user_without_agent_in_accessible_list(self, mock_agent_service): """Test user cannot access agent not in accessible_agents list.""" # Arrange user_context = { "is_admin": False, "accessible_agents": ["/agents/other"], } # Act result = await _user_can_access_agent("/agents/test", user_context) # Assert assert result is False @pytest.mark.asyncio async def test_user_with_all_can_access_public_agent(self, mock_agent_service): """Test user with 'all' can access public agents.""" # Arrange mock_agent = AgentCardFactory(visibility="public") mock_agent_service.get_agent_info = AsyncMock(return_value=mock_agent) user_context = { "is_admin": False, "accessible_agents": ["all"], } # Act result = await _user_can_access_agent("/agents/test", user_context) # Assert assert result is True @pytest.mark.asyncio async def test_agent_not_found_returns_false(self, mock_agent_service): """Test returns False when agent not found.""" # Arrange mock_agent_service.get_agent_info = AsyncMock(return_value=None) user_context = { "is_admin": False, "accessible_agents": ["all"], } # Act result = await _user_can_access_agent("/agents/test", user_context) # Assert assert result is False @pytest.mark.asyncio async def test_public_agent_accessible_to_authorized_user(self, mock_agent_service): """Test public agent is accessible to user in accessible list.""" # Arrange mock_agent = AgentCardFactory(visibility="public") mock_agent_service.get_agent_info = AsyncMock(return_value=mock_agent) user_context = { "is_admin": False, "accessible_agents": ["/agents/test"], } # Act result = await _user_can_access_agent("/agents/test", user_context) # Assert assert result is True @pytest.mark.asyncio async def test_internal_agent_accessible_to_owner(self, mock_agent_service): """Test internal agent is accessible to owner.""" # Arrange mock_agent = AgentCardFactory(visibility="internal", registered_by="testuser") mock_agent_service.get_agent_info = AsyncMock(return_value=mock_agent) user_context = { "is_admin": False, "username": "testuser", "accessible_agents": ["/agents/test"], } # Act result = await _user_can_access_agent("/agents/test", user_context) # Assert assert result is True @pytest.mark.asyncio async def test_internal_agent_not_accessible_to_others(self, mock_agent_service): """Test internal agent is not accessible to non-owners.""" # Arrange mock_agent = AgentCardFactory(visibility="internal", registered_by="owner") mock_agent_service.get_agent_info = AsyncMock(return_value=mock_agent) user_context = { "is_admin": False, "username": "otheruser", "accessible_agents": ["/agents/test"], } # Act result = await _user_can_access_agent("/agents/test", user_context) # Assert assert result is False @pytest.mark.asyncio async def test_group_restricted_agent_accessible_to_group_member(self, mock_agent_service): """Test group-restricted agent is accessible to group members.""" # Arrange mock_agent = AgentCardFactory( visibility="group-restricted", allowed_groups=["group1", "group2"], ) mock_agent_service.get_agent_info = AsyncMock(return_value=mock_agent) user_context = { "is_admin": False, "groups": ["group1", "group3"], "accessible_agents": ["/agents/test"], } # Act result = await _user_can_access_agent("/agents/test", user_context) # Assert assert result is True @pytest.mark.asyncio async def test_group_restricted_agent_not_accessible_to_non_member(self, mock_agent_service): """Test group-restricted agent is not accessible to non-members.""" # Arrange mock_agent = AgentCardFactory( visibility="group-restricted", allowed_groups=["group1", "group2"], ) mock_agent_service.get_agent_info = AsyncMock(return_value=mock_agent) user_context = { "is_admin": False, "groups": ["group3"], "accessible_agents": ["/agents/test"], } # Act result = await _user_can_access_agent("/agents/test", user_context) # Assert assert result is False @pytest.mark.asyncio async def test_unknown_visibility_returns_false(self, mock_agent_service): """Test unknown visibility type returns False.""" # Arrange # Note: AgentCard validates visibility, so we use a Mock instead mock_agent = Mock() mock_agent.visibility = "unknown" mock_agent_service.get_agent_info = AsyncMock(return_value=mock_agent) user_context = { "is_admin": False, "accessible_agents": ["/agents/test"], } # Act result = await _user_can_access_agent("/agents/test", user_context) # Assert assert result is False # ============================================================================= # TEST: semantic_search Endpoint - Success Cases # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.search class TestSemanticSearchSuccess: """Tests for successful semantic search endpoint operations.""" @pytest.mark.asyncio async def test_semantic_search_admin_sees_all_results( self, mock_http_request, mock_search_repo, mock_agent_service, admin_user_context, sample_faiss_search_results, ): """Test admin user sees all search results.""" # Arrange mock_search_repo.search = AsyncMock(return_value=sample_faiss_search_results) mock_agent_service.get_agent_info.side_effect = lambda path: AgentCardFactory( path=path, name=path.split("/")[-1], visibility="public", ) request = SemanticSearchRequest(query="test query", max_results=10) # Mock agent_service.get_agent_info to be async async def get_agent_side_effect(path): return AgentCardFactory( path=path, name=path.split("/")[-1], visibility="public", ) mock_agent_service.get_agent_info = AsyncMock(side_effect=get_agent_side_effect) # Act response = await semantic_search( mock_http_request, request, admin_user_context, mock_search_repo ) # Assert assert response.query == "test query" assert len(response.servers) == 2 assert len(response.tools) == 2 assert len(response.agents) == 2 assert response.total_servers == 2 assert response.total_tools == 2 assert response.total_agents == 2 @pytest.mark.asyncio async def test_semantic_search_filters_by_server_access( self, mock_http_request, mock_search_repo, mock_agent_service, regular_user_context, sample_faiss_search_results, ): """Test search filters servers by user access.""" # Arrange mock_search_repo.search = AsyncMock(return_value=sample_faiss_search_results) async def get_agent_side_effect(path): return AgentCardFactory(path=path, visibility="public") mock_agent_service.get_agent_info = AsyncMock(side_effect=get_agent_side_effect) request = SemanticSearchRequest(query="test query") # Act response = await semantic_search( mock_http_request, request, regular_user_context, mock_search_repo ) # Assert # User has access to "currenttime" but not "weather" assert len(response.servers) == 1 assert response.servers[0].server_name == "currenttime" assert len(response.tools) == 1 assert response.tools[0].server_name == "currenttime" @pytest.mark.asyncio async def test_semantic_search_filters_by_agent_access( self, mock_http_request, mock_search_repo, mock_agent_service, regular_user_context, sample_faiss_search_results, ): """Test search filters agents by user access.""" # Arrange mock_search_repo.search = AsyncMock(return_value=sample_faiss_search_results) # Create mock agents with proper model_dump method def create_mock_agent(path, name, visibility): agent = AgentCardFactory( path=path, name=name, visibility=visibility, ) return agent async def get_agent_info_side_effect(path): if path == "/agents/code-reviewer": return create_mock_agent(path, "code-reviewer", "public") elif path == "/agents/test-agent": return create_mock_agent(path, "test-agent", "public") return None mock_agent_service.get_agent_info = AsyncMock(side_effect=get_agent_info_side_effect) request = SemanticSearchRequest(query="test query") # Act response = await semantic_search( mock_http_request, request, regular_user_context, mock_search_repo ) # Assert # User has access to both agents assert len(response.agents) == 2 @pytest.mark.asyncio async def test_semantic_search_restricted_user_sees_nothing( self, mock_http_request, mock_search_repo, restricted_user_context, sample_faiss_search_results, ): """Test restricted user sees no results.""" # Arrange mock_search_repo.search = AsyncMock(return_value=sample_faiss_search_results) request = SemanticSearchRequest(query="test query") # Act response = await semantic_search( mock_http_request, request, restricted_user_context, mock_search_repo ) # Assert assert len(response.servers) == 0 assert len(response.tools) == 0 assert len(response.agents) == 0 assert response.total_servers == 0 assert response.total_tools == 0 assert response.total_agents == 0 @pytest.mark.asyncio async def test_semantic_search_empty_results( self, mock_http_request, mock_search_repo, admin_user_context ): """Test search with no results.""" # Arrange mock_search_repo.search = AsyncMock(return_value={"servers": [], "tools": [], "agents": []}) request = SemanticSearchRequest(query="nonexistent") # Act response = await semantic_search( mock_http_request, request, admin_user_context, mock_search_repo ) # Assert assert response.query == "nonexistent" assert len(response.servers) == 0 assert len(response.tools) == 0 assert len(response.agents) == 0 @pytest.mark.asyncio async def test_semantic_search_with_entity_type_filter( self, mock_http_request, mock_search_repo, admin_user_context, sample_faiss_search_results, ): """Test search with entity type filtering.""" # Arrange mock_search_repo.search = AsyncMock(return_value=sample_faiss_search_results) request = SemanticSearchRequest(query="test query", entity_types=["mcp_server"]) # Act await semantic_search(mock_http_request, request, admin_user_context, mock_search_repo) # Assert mock_search_repo.search.assert_called_once_with( query="test query", entity_types=["mcp_server"], max_results=10, include_draft=False, include_deprecated=False, include_disabled=False, ) @pytest.mark.asyncio async def test_semantic_search_with_custom_max_results( self, mock_http_request, mock_search_repo, admin_user_context, sample_faiss_search_results, ): """Test search with custom max_results.""" # Arrange mock_search_repo.search = AsyncMock(return_value=sample_faiss_search_results) request = SemanticSearchRequest(query="test query", max_results=25) # Act await semantic_search(mock_http_request, request, admin_user_context, mock_search_repo) # Assert mock_search_repo.search.assert_called_once_with( query="test query", entity_types=None, max_results=25, include_draft=False, include_deprecated=False, include_disabled=False, ) @pytest.mark.asyncio async def test_semantic_search_strips_query( self, mock_http_request, mock_search_repo, admin_user_context ): """Test search strips whitespace from query.""" # Arrange mock_search_repo.search = AsyncMock(return_value={"servers": [], "tools": [], "agents": []}) request = SemanticSearchRequest(query=" test query ") # Act response = await semantic_search( mock_http_request, request, admin_user_context, mock_search_repo ) # Assert assert response.query == "test query" @pytest.mark.asyncio async def test_semantic_search_server_with_matching_tools( self, mock_http_request, mock_search_repo, admin_user_context, sample_faiss_search_results, ): """Test server result includes matching tools.""" # Arrange mock_search_repo.search = AsyncMock(return_value=sample_faiss_search_results) request = SemanticSearchRequest(query="time") # Act response = await semantic_search( mock_http_request, request, admin_user_context, mock_search_repo ) # Assert currenttime_server = next(s for s in response.servers if s.server_name == "currenttime") assert len(currenttime_server.matching_tools) == 1 assert currenttime_server.matching_tools[0].tool_name == "get_current_time" # ============================================================================= # TEST: semantic_search Endpoint - Error Handling # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.search class TestSemanticSearchErrorHandling: """Tests for semantic search error handling.""" @pytest.mark.asyncio async def test_semantic_search_value_error_returns_400( self, mock_http_request, mock_search_repo, admin_user_context ): """Test ValueError from search service returns 400.""" # Arrange mock_search_repo.search = AsyncMock(side_effect=ValueError("Invalid search parameters")) request = SemanticSearchRequest(query="test") # Act & Assert with pytest.raises(HTTPException) as exc_info: await semantic_search(mock_http_request, request, admin_user_context, mock_search_repo) assert exc_info.value.status_code == 400 assert "Invalid search parameters" in exc_info.value.detail @pytest.mark.asyncio async def test_semantic_search_runtime_error_returns_503( self, mock_http_request, mock_search_repo, admin_user_context ): """Test RuntimeError from search service returns 503.""" # Arrange mock_search_repo.search = AsyncMock(side_effect=RuntimeError("Search index not available")) request = SemanticSearchRequest(query="test") # Act & Assert with pytest.raises(HTTPException) as exc_info: await semantic_search(mock_http_request, request, admin_user_context, mock_search_repo) assert exc_info.value.status_code == 503 assert "temporarily unavailable" in exc_info.value.detail.lower() @pytest.mark.asyncio async def test_semantic_search_handles_missing_agent_gracefully( self, mock_http_request, mock_search_repo, mock_agent_service, admin_user_context, ): """Test search handles missing agent gracefully.""" # Arrange faiss_results = { "servers": [], "tools": [], "agents": [ { "path": "/agents/missing", "relevance_score": 0.8, "agent_card": { "name": "missing-agent", "visibility": "public", }, } ], } mock_search_repo.search = AsyncMock(return_value=faiss_results) mock_agent_service.get_agent_info = AsyncMock(return_value=None) request = SemanticSearchRequest(query="test") # Act response = await semantic_search( mock_http_request, request, admin_user_context, mock_search_repo ) # Assert # Note: Current implementation uses fallback data from FAISS results # even when agent_service.get_agent_info returns None, so agent is included assert len(response.agents) == 1 assert response.agents[0].agent_card["name"] == "missing-agent" @pytest.mark.asyncio async def test_semantic_search_handles_agent_without_path( self, mock_http_request, mock_search_repo, admin_user_context, ): """Test search handles agent result without path.""" # Arrange faiss_results = { "servers": [], "tools": [], "agents": [ { "path": "", "agent_name": "no-path-agent", "relevance_score": 0.8, } ], } mock_search_repo.search = AsyncMock(return_value=faiss_results) request = SemanticSearchRequest(query="test") # Act response = await semantic_search( mock_http_request, request, admin_user_context, mock_search_repo ) # Assert # Agent should be filtered out since it has no path assert len(response.agents) == 0 # ============================================================================= # TEST: semantic_search Endpoint - Agent Field Extraction # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.search class TestSemanticSearchAgentFieldExtraction: """Tests for agent field extraction in search results.""" @pytest.mark.asyncio async def test_semantic_search_extracts_agent_fields_from_card( self, mock_http_request, mock_search_repo, mock_agent_service, admin_user_context, ): """Test agent fields are extracted from agent card.""" # Arrange # Create a mock with proper model_dump method mock_agent = Mock() mock_agent.model_dump.return_value = { "name": "Test Agent", "description": "Test description", "tags": ["tag1", "tag2"], "skills": [{"name": "Skill 1"}, {"name": "Skill 2"}], "trust_level": "verified", "visibility": "public", "is_enabled": True, } mock_agent_service.get_agent_info = AsyncMock(return_value=mock_agent) faiss_results = { "servers": [], "tools": [], "agents": [ { "path": "/agents/test", "relevance_score": 0.9, "match_context": "test context", "agent_card": {"name": "old-name", "visibility": "public"}, } ], } mock_search_repo.search = AsyncMock(return_value=faiss_results) request = SemanticSearchRequest(query="test") # Act response = await semantic_search( mock_http_request, request, admin_user_context, mock_search_repo ) # Assert assert len(response.agents) == 1 agent = response.agents[0] # Agent card data comes from the mock agent service's model_dump assert agent.agent_card["name"] == "Test Agent" assert agent.agent_card["description"] == "Test description" assert agent.agent_card["tags"] == ["tag1", "tag2"] assert agent.agent_card["skills"] == [{"name": "Skill 1"}, {"name": "Skill 2"}] assert agent.agent_card["trust_level"] == "verified" assert agent.agent_card["visibility"] == "public" assert agent.agent_card["is_enabled"] is True @pytest.mark.asyncio async def test_semantic_search_handles_skills_as_strings( self, mock_http_request, mock_search_repo, mock_agent_service, admin_user_context, ): """Test agent skills are handled when they are strings.""" # Arrange mock_agent = Mock() mock_agent.model_dump.return_value = { "name": "Test Agent", "description": "Test", "tags": [], "skills": ["Skill 1", "Skill 2"], # Skills as strings "trust_level": "unverified", "visibility": "public", "is_enabled": True, } mock_agent_service.get_agent_info = AsyncMock(return_value=mock_agent) faiss_results = { "servers": [], "tools": [], "agents": [ { "path": "/agents/test", "relevance_score": 0.9, "agent_card": {"name": "Test Agent", "visibility": "public"}, } ], } mock_search_repo.search = AsyncMock(return_value=faiss_results) request = SemanticSearchRequest(query="test") # Act response = await semantic_search( mock_http_request, request, admin_user_context, mock_search_repo ) # Assert assert len(response.agents) == 1 assert response.agents[0].agent_card["skills"] == ["Skill 1", "Skill 2"] @pytest.mark.asyncio async def test_semantic_search_fallback_to_faiss_agent_data( self, mock_http_request, mock_search_repo, mock_agent_service, admin_user_context, ): """Test fallback to search data when agent card not found.""" # Arrange mock_agent_service.get_agent_info = AsyncMock(return_value=None) faiss_results = { "servers": [], "tools": [], "agents": [ { "path": "/agents/test", "relevance_score": 0.9, "agent_card": { "name": "Test Agent", "description": "From search", "tags": ["from_card"], "skills": [{"name": "Card Skill"}], "visibility": "public", }, } ], } mock_search_repo.search = AsyncMock(return_value=faiss_results) request = SemanticSearchRequest(query="test") # Act response = await semantic_search( mock_http_request, request, admin_user_context, mock_search_repo ) # Assert assert len(response.agents) == 1 agent = response.agents[0] # Should use fallback data from agent_card in FAISS results assert agent.agent_card["tags"] == ["from_card"] assert agent.agent_card["skills"] == [{"name": "Card Skill"}] @pytest.mark.asyncio async def test_semantic_search_preserves_skills_structure( self, mock_http_request, mock_search_repo, mock_agent_service, admin_user_context, ): """Test skills structure is preserved in agent_card.""" # Arrange mock_agent = Mock() mock_agent.model_dump.return_value = { "name": "Test Agent", "description": "Test", "tags": [], "skills": [{"name": "Skill 1"}, {"name": None}, {"name": "Skill 2"}], "trust_level": "unverified", "visibility": "public", "is_enabled": True, } mock_agent_service.get_agent_info = AsyncMock(return_value=mock_agent) faiss_results = { "servers": [], "tools": [], "agents": [ { "path": "/agents/test", "relevance_score": 0.9, "agent_card": {"name": "Test Agent", "visibility": "public"}, } ], } mock_search_repo.search = AsyncMock(return_value=faiss_results) request = SemanticSearchRequest(query="test") # Act response = await semantic_search( mock_http_request, request, admin_user_context, mock_search_repo ) # Assert assert len(response.agents) == 1 # Skills structure is preserved in agent_card assert response.agents[0].agent_card["skills"] == [ {"name": "Skill 1"}, {"name": None}, {"name": "Skill 2"}, ] # ============================================================================= # TEST: Integration-style Tests # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.search class TestSemanticSearchIntegration: """Integration-style tests for semantic search.""" @pytest.mark.asyncio async def test_semantic_search_full_workflow( self, mock_http_request, mock_search_repo, mock_agent_service, regular_user_context, ): """Test complete search workflow with mixed results and filtering.""" # Arrange faiss_results = { "servers": [ { "path": "/servers/currenttime", "server_name": "currenttime", "description": "Time utilities", "tags": ["time"], "num_tools": 1, "is_enabled": True, "relevance_score": 0.95, "match_context": "time", "matching_tools": [ { "tool_name": "get_time", "description": "Get time", "relevance_score": 0.9, } ], }, { "path": "/servers/restricted", "server_name": "restricted", "description": "Restricted server", "tags": [], "num_tools": 0, "is_enabled": True, "relevance_score": 0.8, "match_context": "restricted", "matching_tools": [], }, ], "tools": [ { "server_path": "/servers/currenttime", "server_name": "currenttime", "tool_name": "get_time", "description": "Get time", "relevance_score": 0.9, }, { "server_path": "/servers/restricted", "server_name": "restricted", "tool_name": "restricted_tool", "description": "Restricted", "relevance_score": 0.85, }, ], "agents": [ { "path": "/agents/code-reviewer", "relevance_score": 0.88, "agent_card": {"name": "code-reviewer", "visibility": "public"}, }, { "path": "/agents/restricted-agent", "relevance_score": 0.82, "agent_card": {"name": "restricted-agent", "visibility": "private"}, }, ], } mock_search_repo.search = AsyncMock(return_value=faiss_results) def create_mock_agent(path, name, visibility, registered_by="testuser"): agent = AgentCardFactory( path=path, name=name, visibility=visibility, registered_by=registered_by, ) return agent async def get_agent_side_effect(path): if path == "/agents/code-reviewer": return create_mock_agent(path, "code-reviewer", "public") elif path == "/agents/restricted-agent": return create_mock_agent(path, "restricted-agent", "private", "otheruser") return None mock_agent_service.get_agent_info = AsyncMock(side_effect=get_agent_side_effect) request = SemanticSearchRequest(query="test query") # Act response = await semantic_search( mock_http_request, request, regular_user_context, mock_search_repo ) # Assert # User has access to "currenttime" but not "restricted" assert len(response.servers) == 1 assert response.servers[0].server_name == "currenttime" # Tools filtered by server access assert len(response.tools) == 1 assert response.tools[0].server_name == "currenttime" # User has access to "/agents/code-reviewer" but not private agent assert len(response.agents) == 1 assert response.agents[0].agent_card["name"] == "code-reviewer" # Totals should match filtered results assert response.total_servers == 1 assert response.total_tools == 1 assert response.total_agents == 1 ================================================ FILE: tests/unit/api/test_server_get_endpoint.py ================================================ """ Unit tests for GET /api/servers/{path} endpoint. Tests the single server retrieval endpoint including: - Successful retrieval for admin and regular users - 404 when server not found - 403 when user lacks access - Path normalization (with/without leading slash) - Credentials are never in the response - proxy_pass_url stripping behavior based on deployment mode - Audit logging """ import logging from typing import Any from unittest.mock import ( AsyncMock, MagicMock, patch, ) import pytest from fastapi.testclient import TestClient logger = logging.getLogger(__name__) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def admin_user_context() -> dict[str, Any]: """Create admin user context.""" return { "username": "admin", "is_admin": True, "groups": ["mcp-registry-admin"], "scopes": ["mcp-servers-unrestricted/read"], "accessible_servers": ["all"], "accessible_services": ["all"], "accessible_agents": ["all"], "ui_permissions": { "list_service": ["all"], "toggle_service": ["all"], "register_service": ["all"], "view_tools": ["all"], "refresh_service": ["all"], "modify_service": ["all"], }, "auth_method": "session", } @pytest.fixture def regular_user_context() -> dict[str, Any]: """Create regular (non-admin) user context.""" return { "username": "testuser", "is_admin": False, "groups": ["test-group"], "scopes": ["test-server/read"], "accessible_servers": ["test-server"], "accessible_services": ["test-server"], "accessible_agents": ["test-agent"], "ui_permissions": { "list_service": ["test-server"], "view_tools": ["test-server"], }, "auth_method": "session", } @pytest.fixture def sample_server_info() -> dict[str, Any]: """Create sample server info dict as returned by server_service.get_server_info().""" return { "server_name": "Test Server", "description": "A test MCP server", "path": "/test-server", "proxy_pass_url": "http://internal-backend:8080", "tags": ["test", "demo"], "num_tools": 2, "tool_list": [ { "name": "get_weather", "description": "Get weather data", "inputSchema": {"type": "object"}, }, { "name": "search_docs", "description": "Search documents", "inputSchema": {"type": "object"}, }, ], "is_enabled": True, "health_status": "healthy", "transport": "sse", "supported_transports": ["sse", "streamable-http"], "version": "v1.0.0", "versions": [{"version": "v1.0.0", "status": "active", "is_default": True}], "license": "Apache-2.0", "registered_at": "2026-04-01T00:00:00Z", "registered_by": "admin", } @pytest.fixture def mock_server_service(): """Mock server_service dependency.""" mock_service = MagicMock() mock_service.get_server_info = AsyncMock(return_value=None) mock_service.get_all_servers = AsyncMock(return_value={}) mock_service.get_all_servers_with_permissions = AsyncMock(return_value={}) mock_service.is_service_enabled = AsyncMock(return_value=True) mock_service.toggle_service = AsyncMock(return_value=True) mock_service.register_server = AsyncMock( return_value={ "success": True, "message": "Server registered successfully", "is_new_version": False, } ) mock_service.update_server = AsyncMock(return_value=True) mock_service.remove_server = AsyncMock(return_value=True) mock_service.get_enabled_services = AsyncMock(return_value=[]) mock_service.user_can_access_server_path = AsyncMock(return_value=True) return mock_service @pytest.fixture def _mock_auth_admin(admin_user_context, mock_settings): """Mock authentication dependencies with admin user.""" from registry.auth.dependencies import ( enhanced_auth, nginx_proxied_auth, ) from registry.main import app def mock_enhanced_auth_override(): return admin_user_context def mock_nginx_proxied_auth_override(): return admin_user_context app.dependency_overrides[enhanced_auth] = mock_enhanced_auth_override app.dependency_overrides[nginx_proxied_auth] = mock_nginx_proxied_auth_override yield admin_user_context app.dependency_overrides.clear() @pytest.fixture def _mock_auth_regular(regular_user_context, mock_settings): """Mock authentication dependencies with regular user.""" from registry.auth.dependencies import ( enhanced_auth, nginx_proxied_auth, ) from registry.main import app def mock_enhanced_auth_override(): return regular_user_context def mock_nginx_proxied_auth_override(): return regular_user_context app.dependency_overrides[enhanced_auth] = mock_enhanced_auth_override app.dependency_overrides[nginx_proxied_auth] = mock_nginx_proxied_auth_override yield regular_user_context app.dependency_overrides.clear() def _create_test_client( mock_server_service: MagicMock, user_context: dict[str, Any], ) -> TestClient: """Create a FastAPI test client with mocked services. Args: mock_server_service: Mocked server service user_context: User context for auth Returns: TestClient instance """ def mock_enhanced_auth_func(session=None): return user_context with ( patch("registry.api.server_routes.server_service", mock_server_service), patch("registry.search.service.faiss_service", MagicMock()), patch("registry.health.service.health_service", MagicMock()), patch("registry.core.nginx_service.nginx_service", MagicMock()), patch("registry.api.server_routes.security_scanner_service", MagicMock()), patch("registry.utils.scopes_manager.update_server_scopes", new_callable=AsyncMock), patch("registry.api.server_routes.enhanced_auth", mock_enhanced_auth_func), ): from registry.auth.csrf import verify_csrf_token_flexible from registry.main import app app.dependency_overrides[verify_csrf_token_flexible] = lambda: None client = TestClient(app, cookies={"mcp_gateway_session": "test-session"}) yield client app.dependency_overrides.pop(verify_csrf_token_flexible, None) @pytest.fixture def test_client_admin( mock_settings, mock_server_service, _mock_auth_admin, admin_user_context, ): """Create test client with admin auth.""" yield from _create_test_client(mock_server_service, admin_user_context) @pytest.fixture def test_client_regular( mock_settings, mock_server_service, _mock_auth_regular, regular_user_context, ): """Create test client with regular user auth.""" yield from _create_test_client(mock_server_service, regular_user_context) # ============================================================================= # TESTS: GET /api/servers/{path} # ============================================================================= class TestGetServer: """Tests for GET /api/servers/{path} endpoint.""" def test_get_server_success_admin( self, test_client_admin, mock_server_service, sample_server_info, ): """Test successful server retrieval as admin.""" mock_server_service.get_server_info.return_value = sample_server_info response = test_client_admin.get("/api/servers/test-server") assert response.status_code == 200 data = response.json() assert data["server_name"] == "Test Server" assert data["path"] == "/test-server" assert data["description"] == "A test MCP server" assert data["num_tools"] == 2 assert len(data["tool_list"]) == 2 assert data["is_enabled"] is True def test_get_server_success_regular_user( self, test_client_regular, mock_server_service, sample_server_info, ): """Test successful server retrieval as regular user.""" mock_server_service.get_server_info.return_value = sample_server_info mock_server_service.user_can_access_server_path.return_value = True response = test_client_regular.get("/api/servers/test-server") assert response.status_code == 200 data = response.json() assert data["server_name"] == "Test Server" def test_get_server_not_found( self, test_client_admin, mock_server_service, ): """Test 404 when server does not exist.""" mock_server_service.get_server_info.return_value = None response = test_client_admin.get("/api/servers/nonexistent-server") assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() def test_get_server_forbidden( self, test_client_regular, mock_server_service, sample_server_info, ): """Test 403 when user lacks access to the server.""" mock_server_service.get_server_info.return_value = sample_server_info mock_server_service.user_can_access_server_path.return_value = False response = test_client_regular.get("/api/servers/test-server") assert response.status_code == 403 assert "access" in response.json()["detail"].lower() def test_get_server_admin_bypasses_access_check( self, test_client_admin, mock_server_service, sample_server_info, ): """Test that admin users bypass the access control check.""" mock_server_service.get_server_info.return_value = sample_server_info response = test_client_admin.get("/api/servers/test-server") assert response.status_code == 200 # user_can_access_server_path should NOT be called for admin mock_server_service.user_can_access_server_path.assert_not_called() def test_get_server_path_normalization_no_slash( self, test_client_admin, mock_server_service, sample_server_info, ): """Test that paths without leading slash are normalized.""" mock_server_service.get_server_info.return_value = sample_server_info response = test_client_admin.get("/api/servers/my-server") assert response.status_code == 200 # Verify get_server_info was called with normalized path (leading slash) mock_server_service.get_server_info.assert_called_once_with("/my-server") def test_get_server_credentials_stripped( self, test_client_admin, mock_server_service, ): """Test that credentials are never included in the response.""" server_info = { "server_name": "Test Server", "path": "/test-server", "description": "Test", "is_enabled": True, } mock_server_service.get_server_info.return_value = server_info response = test_client_admin.get("/api/servers/test-server") assert response.status_code == 200 data = response.json() assert "auth_credential_encrypted" not in data assert "auth_credential" not in data def test_get_server_includes_tools( self, test_client_admin, mock_server_service, sample_server_info, ): """Test that the response includes tool_list.""" mock_server_service.get_server_info.return_value = sample_server_info response = test_client_admin.get("/api/servers/test-server") assert response.status_code == 200 data = response.json() assert "tool_list" in data assert len(data["tool_list"]) == 2 assert data["tool_list"][0]["name"] == "get_weather" def test_get_server_includes_versions( self, test_client_admin, mock_server_service, sample_server_info, ): """Test that the response includes versions for multi-version servers.""" mock_server_service.get_server_info.return_value = sample_server_info response = test_client_admin.get("/api/servers/test-server") assert response.status_code == 200 data = response.json() assert "versions" in data assert len(data["versions"]) == 1 assert data["versions"][0]["version"] == "v1.0.0" def test_get_server_proxy_pass_url_stripped_for_non_admin_with_gateway( self, test_client_regular, mock_server_service, mock_settings, sample_server_info, ): """Test proxy_pass_url is stripped for non-admin users in with-gateway mode.""" from registry.core.config import DeploymentMode mock_settings.deployment_mode = DeploymentMode.WITH_GATEWAY # Use a copy so dict.pop in the endpoint doesn't affect other tests mock_server_service.get_server_info.return_value = dict(sample_server_info) mock_server_service.user_can_access_server_path.return_value = True response = test_client_regular.get("/api/servers/test-server") assert response.status_code == 200 data = response.json() assert "proxy_pass_url" not in data def test_get_server_proxy_pass_url_kept_for_non_admin_registry_only( self, test_client_regular, mock_server_service, sample_server_info, ): """Test proxy_pass_url is kept for non-admin users in registry-only mode.""" from registry.core.config import DeploymentMode # Use a copy so dict.pop in the endpoint doesn't affect other tests mock_server_service.get_server_info.return_value = dict(sample_server_info) mock_server_service.user_can_access_server_path.return_value = True # Patch deployment_mode at the module level where the endpoint reads it with patch( "registry.api.server_routes.settings.deployment_mode", DeploymentMode.REGISTRY_ONLY, ): response = test_client_regular.get("/api/servers/test-server") assert response.status_code == 200 data = response.json() assert "proxy_pass_url" in data assert data["proxy_pass_url"] == "http://internal-backend:8080" def test_get_server_proxy_pass_url_kept_for_admin( self, test_client_admin, mock_server_service, sample_server_info, ): """Test proxy_pass_url is always kept for admin users.""" mock_server_service.get_server_info.return_value = sample_server_info response = test_client_admin.get("/api/servers/test-server") assert response.status_code == 200 data = response.json() assert "proxy_pass_url" in data assert data["proxy_pass_url"] == "http://internal-backend:8080" def test_get_server_audit_logged( self, test_client_admin, mock_server_service, sample_server_info, ): """Test that the read action is audit logged.""" mock_server_service.get_server_info.return_value = sample_server_info with patch("registry.api.server_routes.set_audit_action") as mock_audit: response = test_client_admin.get("/api/servers/test-server") assert response.status_code == 200 mock_audit.assert_called_once() call_args = mock_audit.call_args assert call_args[0][1] == "read" assert call_args[0][2] == "server" ================================================ FILE: tests/unit/api/test_server_routes.py ================================================ """ Unit tests for registry/api/server_routes.py Tests the main server routes including: - GET / - Main dashboard - GET /servers - JSON API for servers list - POST /toggle/{service_path:path} - Toggle service on/off - POST /register - Register new service - POST /internal/register - Internal registration with JWT Bearer Auth - POST /internal/remove - Internal removal with JWT Bearer Auth """ import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.templating import Jinja2Templates from fastapi.testclient import TestClient from registry.auth.internal import generate_internal_token logger = logging.getLogger(__name__) # ============================================================================= # AUTH MOCK FIXTURES (Following test_search_integration.py pattern) # ============================================================================= @pytest.fixture def admin_user_context() -> dict[str, Any]: """Create admin user context.""" return { "username": "admin", "is_admin": True, "groups": ["mcp-registry-admin"], "scopes": ["mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute"], "accessible_servers": ["all"], "accessible_services": ["all"], "accessible_agents": ["all"], "ui_permissions": { "list_service": ["all"], "toggle_service": ["all"], "register_service": ["all"], "view_tools": ["all"], "refresh_service": ["all"], "modify_service": ["all"], }, "auth_method": "session", } @pytest.fixture def regular_user_context() -> dict[str, Any]: """Create regular (non-admin) user context.""" return { "username": "testuser", "is_admin": False, "groups": ["test-group"], "scopes": ["test-server/read"], "accessible_servers": ["test-server"], "accessible_services": ["test-server"], "accessible_agents": ["test-agent"], "ui_permissions": {"list_service": ["test-server"], "view_tools": ["test-server"]}, "auth_method": "session", } @pytest.fixture def mock_auth_admin(admin_user_context, mock_settings): """ Mock authentication dependencies with admin user. Following test_search_integration.py pattern. Note: depends on mock_settings to ensure environment is set up before importing app. """ from registry.auth.dependencies import enhanced_auth, nginx_proxied_auth from registry.main import app def mock_enhanced_auth_override(): return admin_user_context def mock_nginx_proxied_auth_override(): return admin_user_context # Override dependencies at the app level app.dependency_overrides[enhanced_auth] = mock_enhanced_auth_override app.dependency_overrides[nginx_proxied_auth] = mock_nginx_proxied_auth_override yield admin_user_context # Cleanup app.dependency_overrides.clear() @pytest.fixture def mock_auth_regular(regular_user_context, mock_settings): """ Mock authentication dependencies with regular user. Note: depends on mock_settings to ensure environment is set up before importing app. """ from registry.auth.dependencies import enhanced_auth, nginx_proxied_auth from registry.main import app def mock_enhanced_auth_override(): return regular_user_context def mock_nginx_proxied_auth_override(): return regular_user_context # Override dependencies at the app level app.dependency_overrides[enhanced_auth] = mock_enhanced_auth_override app.dependency_overrides[nginx_proxied_auth] = mock_nginx_proxied_auth_override yield regular_user_context # Cleanup app.dependency_overrides.clear() # ============================================================================= # SERVICE MOCK FIXTURES # ============================================================================= @pytest.fixture def mock_server_service(): """Mock server_service dependency.""" mock_service = MagicMock() mock_service.get_all_servers = AsyncMock(return_value={}) mock_service.get_all_servers_with_permissions = AsyncMock(return_value={}) mock_service.get_server_info = AsyncMock(return_value=None) mock_service.is_service_enabled = AsyncMock(return_value=True) mock_service.toggle_service = AsyncMock(return_value=True) # register_server now returns a dict with success, message, is_new_version mock_service.register_server = AsyncMock( return_value={ "success": True, "message": "Server registered successfully", "is_new_version": False, } ) mock_service.update_server = AsyncMock(return_value=True) mock_service.remove_server = AsyncMock(return_value=True) mock_service.get_enabled_services = AsyncMock(return_value=[]) mock_service.user_can_access_server_path = AsyncMock(return_value=True) return mock_service @pytest.fixture def mock_faiss_service(): """Mock faiss_service dependency.""" mock_service = MagicMock() mock_service.add_or_update_service = AsyncMock() mock_service.remove_service = AsyncMock() return mock_service @pytest.fixture def mock_health_service(): """Mock health_service dependency.""" mock_service = MagicMock() mock_service._get_service_health_data = MagicMock( return_value={"status": "healthy", "last_checked_iso": "2025-01-01T00:00:00Z"} ) mock_service.perform_immediate_health_check = AsyncMock(return_value=("healthy", None)) mock_service.broadcast_health_update = AsyncMock() return mock_service @pytest.fixture def mock_security_scanner_service(): """Mock security_scanner_service dependency.""" from registry.schemas.security import SecurityScanConfig, SecurityScanResult mock_service = MagicMock() # Return config with scanning disabled to avoid scan during registration mock_service.get_scan_config.return_value = SecurityScanConfig( enabled=False, scan_on_registration=False, block_unsafe_servers=False ) # If scan is called anyway, return a passing result mock_service.scan_server = AsyncMock( return_value=SecurityScanResult( server_url="http://localhost:9000/mcp", server_path="/test-server", scan_timestamp="2025-01-01T00:00:00Z", is_safe=True, critical_issues=0, high_severity=0, medium_severity=0, low_severity=0, analyzers_used=["yara"], raw_output={}, scan_failed=False, ) ) return mock_service @pytest.fixture def mock_nginx_service(): """Mock nginx_service dependency.""" mock_service = MagicMock() mock_service.generate_config_async = AsyncMock() return mock_service @pytest.fixture def mock_templates(): """Mock Jinja2 templates.""" mock = MagicMock(spec=Jinja2Templates) mock.TemplateResponse = MagicMock(return_value=MagicMock(status_code=200)) return mock @pytest.fixture def sample_server_info() -> dict[str, Any]: """Create sample server info for testing.""" return { "server_name": "test-server", "description": "A test server", "path": "/test-server", "proxy_pass_url": "http://localhost:8080", "tags": ["test", "demo"], "num_tools": 5, "license": "MIT", "tool_list": [ {"name": "test_tool", "description": "A test tool", "inputSchema": {"type": "object"}} ], } @pytest.fixture def test_client_admin( mock_settings, mock_server_service, mock_faiss_service, mock_health_service, mock_nginx_service, mock_security_scanner_service, mock_auth_admin, admin_user_context, ): """Create FastAPI test client with admin auth and all services mocked.""" # For /api/ route, enhanced_auth is called directly (not as dependency) def mock_enhanced_auth_func(session=None): return admin_user_context # Patch services - server_service is imported at module level, others are lazy imports # For module-level imports, patch where used: registry.api.server_routes.server_service # For lazy imports (inside functions), patch at definition: registry.search.service.faiss_service with ( patch("registry.api.server_routes.server_service", mock_server_service), patch("registry.search.service.faiss_service", mock_faiss_service), patch("registry.health.service.health_service", mock_health_service), patch("registry.core.nginx_service.nginx_service", mock_nginx_service), patch("registry.api.server_routes.security_scanner_service", mock_security_scanner_service), patch("registry.utils.scopes_manager.update_server_scopes", new_callable=AsyncMock), patch("registry.api.server_routes.enhanced_auth", mock_enhanced_auth_func), ): from registry.auth.csrf import verify_csrf_token_flexible from registry.main import app # Override CSRF verification for tests app.dependency_overrides[verify_csrf_token_flexible] = lambda: None # Create client with session cookie (uses the default cookie name mcp_gateway_session) client = TestClient(app, cookies={"mcp_gateway_session": "test-session"}) yield client app.dependency_overrides.pop(verify_csrf_token_flexible, None) @pytest.fixture def test_client_regular( mock_settings, mock_server_service, mock_faiss_service, mock_health_service, mock_nginx_service, mock_security_scanner_service, mock_auth_regular, regular_user_context, ): """Create FastAPI test client with regular user auth and all services mocked.""" # For /api/ route, enhanced_auth is called directly (not as dependency) def mock_enhanced_auth_func(session=None): return regular_user_context # Patch services - server_service is imported at module level, others are lazy imports with ( patch("registry.api.server_routes.server_service", mock_server_service), patch("registry.search.service.faiss_service", mock_faiss_service), patch("registry.health.service.health_service", mock_health_service), patch("registry.core.nginx_service.nginx_service", mock_nginx_service), patch("registry.api.server_routes.security_scanner_service", mock_security_scanner_service), patch("registry.utils.scopes_manager.update_server_scopes", new_callable=AsyncMock), patch("registry.api.server_routes.enhanced_auth", mock_enhanced_auth_func), ): from registry.auth.csrf import verify_csrf_token_flexible from registry.main import app # Override CSRF verification for tests app.dependency_overrides[verify_csrf_token_flexible] = lambda: None # Create client with session cookie (uses the default cookie name mcp_gateway_session) client = TestClient(app, cookies={"mcp_gateway_session": "test-session"}) yield client app.dependency_overrides.pop(verify_csrf_token_flexible, None) @pytest.fixture def test_client_no_auth( mock_settings, mock_server_service, mock_faiss_service, mock_health_service, mock_nginx_service, mock_security_scanner_service, ): """Create FastAPI test client without auth mocking.""" # Patch services - server_service is imported at module level, others are lazy imports with ( patch("registry.api.server_routes.server_service", mock_server_service), patch("registry.search.service.faiss_service", mock_faiss_service), patch("registry.health.service.health_service", mock_health_service), patch("registry.core.nginx_service.nginx_service", mock_nginx_service), patch("registry.api.server_routes.security_scanner_service", mock_security_scanner_service), patch("registry.utils.scopes_manager.update_server_scopes", new_callable=AsyncMock), ): from registry.main import app # Clear any leftover auth overrides app.dependency_overrides.clear() client = TestClient(app) yield client # ============================================================================= # TEST GET / - Main Dashboard # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.servers class TestRootDashboard: """Tests for GET / endpoint.""" def test_no_session_cookie_redirects_to_login(self, test_client_no_auth): """Test that missing session cookie redirects to login page.""" # Act response = test_client_no_auth.get("/api/", follow_redirects=False) # Assert - without auth, should redirect to login assert response.status_code == 302 assert response.headers["location"] == "/login" @pytest.mark.skip( reason="Root dashboard uses Cookie() parameter which requires complex session mocking. " "Business logic is tested via TestGetServersJSON.test_admin_gets_all_servers" ) def test_admin_sees_all_servers(self, test_client_admin, mock_server_service): """Test that admin user sees all servers.""" pass @pytest.mark.skip( reason="Root dashboard uses Cookie() parameter which requires complex session mocking. " "Business logic is tested via TestGetServersJSON.test_non_admin_gets_filtered_servers" ) def test_non_admin_sees_filtered_servers( self, test_client_regular, mock_server_service, regular_user_context ): """Test that non-admin user sees only accessible servers.""" pass @pytest.mark.skip( reason="Root dashboard uses Cookie() parameter which requires complex session mocking. " "Business logic is tested via TestGetServersJSON.test_search_query_filters_results" ) def test_search_query_filters_services(self, test_client_admin, mock_server_service): """Test that search query filters services by name, description, and tags.""" pass # ============================================================================= # TEST GET /servers - JSON API # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.servers class TestGetServersJSON: """Tests for GET /servers endpoint.""" def test_admin_gets_all_servers(self, test_client_admin, mock_server_service): """Test that admin user gets all servers via JSON API (fast path).""" # Arrange - admin with no filters uses fast path (get_servers_paginated) mock_server_service.get_servers_paginated = AsyncMock( return_value=( { "/server1": { "server_name": "Server 1", "description": "Test 1", "tags": [], "num_tools": 3, "license": "MIT", "proxy_pass_url": "http://localhost:8080", } }, 1, ) ) # Act response = test_client_admin.get("/api/servers") # Assert assert response.status_code == 200 data = response.json() assert "servers" in data assert len(data["servers"]) == 1 assert data["total_count"] == 1 assert data["limit"] == 20 assert data["offset"] == 0 assert data["has_next"] is False mock_server_service.get_servers_paginated.assert_called_once_with(skip=0, limit=20) def test_non_admin_gets_filtered_servers( self, test_client_regular, mock_server_service, regular_user_context ): """Test that non-admin user gets only accessible servers.""" # Arrange mock_server_service.get_all_servers_with_permissions.return_value = { "/test-server": { "server_name": "test-server", "description": "Test", "tags": [], "num_tools": 2, "license": "Apache-2.0", "proxy_pass_url": "http://localhost:9000", } } # Act response = test_client_regular.get("/api/servers") # Assert assert response.status_code == 200 data = response.json() assert "servers" in data assert len(data["servers"]) == 1 assert data["servers"][0]["display_name"] == "test-server" def test_search_query_filters_results(self, test_client_admin, mock_server_service): """Test that search query filters server results.""" # Arrange mock_server_service.get_all_servers.return_value = { "/server1": { "server_name": "Python Server", "description": "A Python-based server", "tags": ["python"], "num_tools": 3, "license": "MIT", "proxy_pass_url": "http://localhost:8080", }, "/server2": { "server_name": "Node Server", "description": "A Node.js-based server", "tags": ["nodejs"], "num_tools": 2, "license": "MIT", "proxy_pass_url": "http://localhost:8081", }, } # Act response = test_client_admin.get("/api/servers?query=python") # Assert assert response.status_code == 200 data = response.json() assert "servers" in data assert len(data["servers"]) == 1 assert "Python" in data["servers"][0]["display_name"] def test_returns_health_status( self, test_client_admin, mock_server_service, mock_health_service ): """Test that server list includes health status (fast path).""" # Arrange - admin with no filters uses fast path mock_server_service.get_servers_paginated = AsyncMock( return_value=( { "/server1": { "server_name": "Server 1", "description": "Test", "tags": [], "num_tools": 3, "license": "MIT", "proxy_pass_url": "http://localhost:8080", } }, 1, ) ) mock_health_service._get_service_health_data.return_value = { "status": "healthy", "last_checked_iso": "2025-01-01T12:00:00Z", } # Act response = test_client_admin.get("/api/servers") # Assert assert response.status_code == 200 data = response.json() assert data["servers"][0]["health_status"] == "healthy" assert data["servers"][0]["last_checked_iso"] == "2025-01-01T12:00:00Z" # ============================================================================= # TEST POST /toggle/{service_path:path} - Toggle Service # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.servers class TestToggleService: """Tests for POST /toggle/{service_path:path} endpoint.""" def test_toggle_service_on_success( self, test_client_admin, mock_server_service, mock_faiss_service, mock_nginx_service, mock_health_service, sample_server_info, ): """Test successful toggle service on.""" # Arrange mock_server_service.get_server_info.return_value = sample_server_info mock_server_service.toggle_service.return_value = True # Patch at the actual module location (imported inside functions) with patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ): # Act response = test_client_admin.post("/api/toggle/test-server", data={"enabled": "on"}) # Assert assert response.status_code == 200 data = response.json() assert data["new_enabled_state"] is True assert data["service_path"] == "/test-server" mock_server_service.toggle_service.assert_called_once_with("/test-server", True) mock_faiss_service.add_or_update_service.assert_called_once() mock_nginx_service.generate_config_async.assert_called_once() def test_toggle_service_off_success( self, test_client_admin, mock_server_service, mock_faiss_service, mock_nginx_service, sample_server_info, ): """Test successful toggle service off.""" # Arrange mock_server_service.get_server_info.return_value = sample_server_info mock_server_service.toggle_service.return_value = True # Patch at the actual module location (imported inside functions) with patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ): # Act response = test_client_admin.post("/api/toggle/test-server", data={"enabled": "off"}) # Assert assert response.status_code == 200 data = response.json() assert data["new_enabled_state"] is False assert data["status"] == "disabled" mock_server_service.toggle_service.assert_called_once_with("/test-server", False) def test_toggle_service_not_found(self, test_client_admin, mock_server_service): """Test toggle fails when service not found.""" # Arrange mock_server_service.get_server_info.return_value = None # Act response = test_client_admin.post("/api/toggle/nonexistent", data={"enabled": "on"}) # Assert assert response.status_code == 404 assert "not registered" in response.json()["detail"] @pytest.mark.skip( reason="Bug in server_routes.py: local variable 'status' shadows imported 'status' module" ) def test_toggle_service_no_permission( self, test_client_regular, mock_server_service, sample_server_info ): """Test toggle fails when user lacks toggle_service permission.""" # Arrange mock_server_service.get_server_info.return_value = sample_server_info with patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=False ): # Act response = test_client_regular.post("/api/toggle/test-server", data={"enabled": "on"}) # Assert assert response.status_code == 403 assert "permission" in response.json()["detail"].lower() @pytest.mark.skip( reason="Bug in server_routes.py: local variable 'status' shadows imported 'status' module" ) def test_toggle_service_no_server_access( self, test_client_regular, mock_server_service, sample_server_info ): """Test toggle fails when non-admin user lacks server access.""" # Arrange mock_server_service.get_server_info.return_value = sample_server_info mock_server_service.user_can_access_server_path.return_value = False with patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ): # Act response = test_client_regular.post("/api/toggle/test-server", data={"enabled": "on"}) # Assert assert response.status_code == 403 assert "access" in response.json()["detail"].lower() def test_toggle_service_performs_health_check_when_enabling( self, test_client_admin, mock_server_service, mock_health_service, sample_server_info ): """Test that enabling a service triggers immediate health check.""" # Arrange mock_server_service.get_server_info.return_value = sample_server_info mock_server_service.toggle_service.return_value = True mock_health_service.perform_immediate_health_check.return_value = ("healthy", None) with patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ): # Act response = test_client_admin.post("/api/toggle/test-server", data={"enabled": "on"}) # Assert assert response.status_code == 200 mock_health_service.perform_immediate_health_check.assert_called_once_with( "/test-server" ) mock_health_service.broadcast_health_update.assert_called_once_with("/test-server") # ============================================================================= # TEST POST /register - Register Service # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.servers class TestRegisterService: """Tests for POST /register endpoint.""" def test_register_service_success( self, test_client_admin, mock_server_service, mock_faiss_service, mock_nginx_service, mock_health_service, ): """Test successful service registration.""" # Arrange - register_server returns a dict now mock_server_service.register_server.return_value = { "success": True, "message": "Server registered successfully", "is_new_version": False, } with patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ): # Act response = test_client_admin.post( "/api/register", data={ "name": "New Server", "description": "A new test server", "path": "/new-server", "proxy_pass_url": "http://localhost:9000", "tags": "test, new", "num_tools": 5, "license": "MIT", }, ) # Assert assert response.status_code == 201 data = response.json() assert data["message"] == "Service registered successfully" assert data["service"]["server_name"] == "New Server" mock_server_service.register_server.assert_called_once() mock_faiss_service.add_or_update_service.assert_called_once() mock_nginx_service.generate_config_async.assert_called_once() def test_register_service_no_permission(self, test_client_regular, mock_server_service): """Test registration fails when user lacks register_service permission.""" # Arrange - regular user context already lacks register_service permission # Act response = test_client_regular.post( "/api/register", data={ "name": "New Server", "description": "Test", "path": "/new-server", "proxy_pass_url": "http://localhost:9000", }, ) # Assert assert response.status_code == 403 assert "permission" in response.json()["detail"].lower() def test_register_service_path_already_exists(self, test_client_admin, mock_server_service): """Test registration fails when path already exists with same version.""" # Arrange - register_server returns a dict now mock_server_service.register_server.return_value = { "success": False, "message": "Server already exists at path /existing-server with the same version", "is_new_version": False, } with patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ): # Act response = test_client_admin.post( "/api/register", data={ "name": "Duplicate Server", "description": "Test", "path": "/existing-server", "proxy_pass_url": "http://localhost:9000", }, ) # Assert - returns 409 Conflict with generic error (no internal details) assert response.status_code == 409 assert "registration failed" in response.json()["error"].lower() def test_register_service_normalizes_path(self, test_client_admin, mock_server_service): """Test that service path is normalized to start with /.""" # Arrange - register_server returns a dict now mock_server_service.register_server.return_value = { "success": True, "message": "Server registered successfully", "is_new_version": False, } with patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ): # Act response = test_client_admin.post( "/api/register", data={ "name": "New Server", "description": "Test", "path": "new-server", # Missing leading slash "proxy_pass_url": "http://localhost:9000", }, ) # Assert assert response.status_code == 201 # Verify path was normalized call_args = mock_server_service.register_server.call_args[0][0] assert call_args["path"] == "/new-server" # ============================================================================= # TEST POST /internal/register - Internal Registration # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.servers class TestInternalRegister: """Tests for POST /internal/register endpoint.""" def test_internal_register_success( self, test_client_no_auth, mock_server_service, mock_faiss_service, mock_nginx_service, mock_health_service, ): """Test successful internal registration with valid JWT Bearer token.""" # Arrange - register_server returns a dict now mock_server_service.register_server.return_value = { "success": True, "message": "Server registered successfully", "is_new_version": False, } with ( patch.dict("os.environ", {"SECRET_KEY": "testpass"}), patch("registry.utils.scopes_manager.update_server_scopes", new_callable=AsyncMock), ): token = generate_internal_token(subject="test-service", purpose="test") # Act response = test_client_no_auth.post( "/api/internal/register", data={ "name": "Internal Server", "description": "Registered internally", "path": "/internal-server", "proxy_pass_url": "http://localhost:9000", "tags": "internal", "num_tools": 3, }, headers={"Authorization": f"Bearer {token}"}, ) # Assert assert response.status_code == 201 data = response.json() assert data["message"] == "Service registered successfully" mock_server_service.register_server.assert_called_once() mock_faiss_service.add_or_update_service.assert_called_once() def test_internal_register_missing_auth_header(self, test_client_no_auth): """Test internal registration fails without Authorization header.""" # Act response = test_client_no_auth.post( "/api/internal/register", data={ "name": "Server", "description": "Test", "path": "/test", "proxy_pass_url": "http://localhost:9000", }, ) # Assert assert response.status_code == 401 assert "authorization" in response.json()["detail"].lower() def test_internal_register_invalid_token(self, test_client_no_auth, mock_server_service): """Test internal registration fails with a token signed by a different key.""" # Arrange - generate token with a different key than what the server expects with patch.dict("os.environ", {"SECRET_KEY": "wrong-secret-key"}): token = generate_internal_token(subject="test-service", purpose="test") with patch.dict("os.environ", {"SECRET_KEY": "correct-secret-key"}): # Act response = test_client_no_auth.post( "/api/internal/register", data={ "name": "Server", "description": "Test", "path": "/test", "proxy_pass_url": "http://localhost:9000", }, headers={"Authorization": f"Bearer {token}"}, ) # Assert assert response.status_code == 401 assert "Invalid token" in response.json()["detail"] def test_internal_register_secret_key_not_set(self, test_client_no_auth): """Test internal registration fails when SECRET_KEY is not set on server.""" # Arrange - generate a token with some key, but the server won't have SECRET_KEY set with patch.dict("os.environ", {"SECRET_KEY": "some-key"}): token = generate_internal_token(subject="test-service", purpose="test") # Ensure SECRET_KEY is not set in the server's environment with patch.dict("os.environ", {}, clear=True): # Act response = test_client_no_auth.post( "/api/internal/register", data={ "name": "Server", "description": "Test", "path": "/test", "proxy_pass_url": "http://localhost:9000", }, headers={"Authorization": f"Bearer {token}"}, ) # Assert assert response.status_code == 500 assert "Internal server configuration error" in response.json()["detail"] def test_internal_register_overwrite_existing_service( self, test_client_no_auth, mock_server_service, sample_server_info ): """Test internal registration can overwrite existing service.""" # Arrange mock_server_service.get_server_info.return_value = sample_server_info mock_server_service.update_server.return_value = True with ( patch.dict("os.environ", {"SECRET_KEY": "testpass"}), patch("registry.utils.scopes_manager.update_server_scopes", new_callable=AsyncMock), ): token = generate_internal_token(subject="test-service", purpose="test") # Act response = test_client_no_auth.post( "/api/internal/register", data={ "name": "Updated Server", "description": "Updated", "path": "/test-server", "proxy_pass_url": "http://localhost:9001", "overwrite": "true", }, headers={"Authorization": f"Bearer {token}"}, ) # Assert assert response.status_code == 201 mock_server_service.update_server.assert_called_once() def test_internal_register_no_overwrite_existing_service( self, test_client_no_auth, mock_server_service, sample_server_info ): """Test internal registration fails without overwrite flag for existing service.""" # Arrange mock_server_service.get_server_info.return_value = sample_server_info with patch.dict("os.environ", {"SECRET_KEY": "testpass"}): token = generate_internal_token(subject="test-service", purpose="test") # Act response = test_client_no_auth.post( "/api/internal/register", data={ "name": "Server", "description": "Test", "path": "/test-server", "proxy_pass_url": "http://localhost:9000", "overwrite": "false", }, headers={"Authorization": f"Bearer {token}"}, ) # Assert assert response.status_code == 409 assert "already exists" in response.json()["reason"].lower() def test_internal_register_auto_enables_service( self, test_client_no_auth, mock_server_service, mock_faiss_service, mock_nginx_service ): """Test that internal registration auto-enables the service.""" # Arrange - register_server returns a dict now mock_server_service.register_server.return_value = { "success": True, "message": "Server registered successfully", "is_new_version": False, } mock_server_service.toggle_service.return_value = True mock_server_service.is_service_enabled.return_value = True with ( patch.dict("os.environ", {"SECRET_KEY": "testpass"}), patch("registry.utils.scopes_manager.update_server_scopes", new_callable=AsyncMock), ): token = generate_internal_token(subject="test-service", purpose="test") # Act response = test_client_no_auth.post( "/api/internal/register", data={ "name": "Auto-Enabled Server", "description": "Test", "path": "/auto-enabled", "proxy_pass_url": "http://localhost:9000", }, headers={"Authorization": f"Bearer {token}"}, ) # Assert assert response.status_code == 201 mock_server_service.toggle_service.assert_called_once_with("/auto-enabled", True) # ============================================================================= # TEST POST /internal/remove - Internal Removal # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.servers class TestInternalRemove: """Tests for POST /internal/remove endpoint.""" def test_internal_remove_success( self, test_client_no_auth, mock_server_service, sample_server_info ): """Test successful internal service removal.""" # Arrange mock_server_service.get_server_info.return_value = sample_server_info mock_server_service.remove_server.return_value = True with patch.dict("os.environ", {"SECRET_KEY": "testpass"}): token = generate_internal_token(subject="test-service", purpose="test") # Act response = test_client_no_auth.post( "/api/internal/remove", data={"service_path": "/test-server"}, headers={"Authorization": f"Bearer {token}"}, ) # Assert assert response.status_code == 200 mock_server_service.remove_server.assert_called_once_with("/test-server") def test_internal_remove_service_not_found(self, test_client_no_auth, mock_server_service): """Test internal removal fails when service not found.""" # Arrange mock_server_service.get_server_info.return_value = None with patch.dict("os.environ", {"SECRET_KEY": "testpass"}): token = generate_internal_token(subject="test-service", purpose="test") # Act response = test_client_no_auth.post( "/api/internal/remove", data={"service_path": "/nonexistent"}, headers={"Authorization": f"Bearer {token}"}, ) # Assert assert response.status_code == 404 assert "not found" in response.json()["error"].lower() def test_internal_remove_missing_auth(self, test_client_no_auth): """Test internal removal requires authentication.""" # Act response = test_client_no_auth.post("/api/internal/remove", data={"service_path": "/test"}) # Assert assert response.status_code == 401 assert "authorization" in response.json()["detail"].lower() def test_internal_remove_normalizes_path( self, test_client_no_auth, mock_server_service, sample_server_info ): """Test that service path is normalized in removal.""" # Arrange mock_server_service.get_server_info.return_value = sample_server_info mock_server_service.remove_server.return_value = True with patch.dict("os.environ", {"SECRET_KEY": "testpass"}): token = generate_internal_token(subject="test-service", purpose="test") # Act response = test_client_no_auth.post( "/api/internal/remove", data={"service_path": "test-server"}, # Missing leading slash headers={"Authorization": f"Bearer {token}"}, ) # Assert assert response.status_code == 200 mock_server_service.remove_server.assert_called_once_with("/test-server") # ============================================================================= # ADDITIONAL HELPER TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.api @pytest.mark.servers class TestHelperFunctions: """Tests for helper functions and edge cases.""" def test_path_normalization_in_toggle( self, test_client_admin, mock_server_service, sample_server_info ): """Test that paths without leading slash are normalized in toggle endpoint.""" # Arrange mock_server_service.get_server_info.return_value = sample_server_info mock_server_service.toggle_service.return_value = True with patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ): # Act response = test_client_admin.post( "/api/toggle/test-server", # Path in URL data={"enabled": "on"}, ) # Assert assert response.status_code == 200 # Verify the path was normalized mock_server_service.get_server_info.assert_called_with("/test-server") def test_tags_parsing_in_register(self, test_client_admin, mock_server_service): """Test that tags are properly parsed from comma-separated string.""" # Arrange - register_server returns a dict now mock_server_service.register_server.return_value = { "success": True, "message": "Server registered successfully", "is_new_version": False, } with patch( "registry.auth.dependencies.user_has_ui_permission_for_service", return_value=True ): # Act response = test_client_admin.post( "/api/register", data={ "name": "Tagged Server", "description": "Test", "path": "/tagged", "proxy_pass_url": "http://localhost:9000", "tags": "tag1, tag2, tag3", # Comma-separated with spaces }, ) # Assert assert response.status_code == 201 call_args = mock_server_service.register_server.call_args[0][0] assert call_args["tags"] == ["tag1", "tag2", "tag3"] ================================================ FILE: tests/unit/api/test_skill_inline_content.py ================================================ """ Unit tests for inline content serving in the skill content endpoint. Tests the get_skill_content endpoint behavior when a skill has skill_md_content set (inline content) versus when it is None (fallback to URL fetch). """ import logging from typing import Any from unittest.mock import ( AsyncMock, MagicMock, patch, ) import pytest from fastapi.testclient import TestClient logger = logging.getLogger(__name__) # ============================================================================= # CONSTANTS # ============================================================================= INLINE_SKILL_PATH: str = "/skills/inline-test" INLINE_SKILL_NAME: str = "inline-test" INLINE_SKILL_DESCRIPTION: str = "A skill with inline content" INLINE_SKILL_MD_CONTENT: str = "# Inline Skill\n\nThis content is stored in the database." SKILL_MD_URL: str = "https://example.com/SKILL.md" SKILL_MD_RAW_URL: str = "https://raw.example.com/SKILL.md" URL_FETCHED_CONTENT: str = "# URL Skill\n\nThis content was fetched from a URL." # ============================================================================= # HELPERS # ============================================================================= def _make_mock_skill( path: str = INLINE_SKILL_PATH, name: str = INLINE_SKILL_NAME, description: str = INLINE_SKILL_DESCRIPTION, skill_md_content: str | None = None, skill_md_url: str = SKILL_MD_URL, skill_md_raw_url: str | None = SKILL_MD_RAW_URL, visibility: str = "public", owner: str = "testuser", ) -> MagicMock: """Create a mock SkillCard with configurable inline content. Args: path: Skill path name: Skill name description: Skill description skill_md_content: Inline SKILL.md content (None for URL fetch) skill_md_url: SKILL.md URL skill_md_raw_url: Raw SKILL.md URL visibility: Visibility setting owner: Skill owner Returns: MagicMock configured as a SkillCard """ mock = MagicMock() mock.path = path mock.name = name mock.description = description mock.skill_md_content = skill_md_content mock.skill_md_url = skill_md_url mock.skill_md_raw_url = skill_md_raw_url mock.visibility = visibility mock.owner = owner mock.allowed_groups = [] mock.tags = [] # Drift-detection guard in get_skill_content() reads .content_integrity; # MagicMock's auto-attributes are truthy, which would trigger 409 Conflict. # Tests that exercise drift behaviour should override this explicitly. mock.content_integrity = None mock.resource_manifest = None return mock def _make_admin_user_context() -> dict[str, Any]: """Create admin user context for authentication. Returns: Dictionary with admin user context """ return { "username": "admin", "is_admin": True, "groups": ["mcp-registry-admin"], "scopes": [], "accessible_servers": ["all"], "accessible_services": ["all"], "accessible_agents": ["all"], "auth_method": "session", } def _create_test_client_with_mocks( mock_skill_service: MagicMock, user_context: dict[str, Any], ) -> TestClient: """Create a FastAPI test client with mocked skill service and auth. Args: mock_skill_service: Mocked skill service user_context: User context for authentication Returns: TestClient instance (as a context manager generator) """ from registry.auth.dependencies import nginx_proxied_auth from registry.main import app app.dependency_overrides[nginx_proxied_auth] = lambda: user_context with ( patch( "registry.api.skill_routes.get_skill_service", return_value=mock_skill_service, ), patch("registry.search.service.faiss_service", MagicMock()), patch("registry.health.service.health_service", MagicMock()), patch("registry.core.nginx_service.nginx_service", MagicMock()), ): client = TestClient(app, cookies={"mcp_gateway_session": "test-session"}) yield client app.dependency_overrides.clear() # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def admin_user_context() -> dict[str, Any]: """Create admin user context.""" return _make_admin_user_context() @pytest.fixture def mock_skill_service() -> MagicMock: """Create a mock skill service. Returns: MagicMock configured as a skill service """ service = MagicMock() service.get_skill = AsyncMock(return_value=None) service.list_skills_for_user = AsyncMock(return_value=[]) return service @pytest.fixture def test_client( mock_settings, mock_skill_service, admin_user_context, ): """Create test client with admin auth and mocked skill service.""" yield from _create_test_client_with_mocks(mock_skill_service, admin_user_context) # ============================================================================= # TESTS # ============================================================================= @pytest.mark.unit class TestSkillInlineContent: """Tests for inline content serving in get_skill_content endpoint.""" def test_inline_content_returned_when_skill_md_content_set( self, test_client, mock_skill_service, ): """When a skill has skill_md_content set, the endpoint returns it directly.""" # Arrange mock_skill = _make_mock_skill(skill_md_content=INLINE_SKILL_MD_CONTENT) mock_skill_service.get_skill.return_value = mock_skill # Act response = test_client.get("/api/skills/inline-test/content") # Assert assert response.status_code == 200 data = response.json() assert data["content"] == INLINE_SKILL_MD_CONTENT assert data["source"] == "inline" assert data["path"] == INLINE_SKILL_PATH def test_inline_content_response_has_no_url_field( self, test_client, mock_skill_service, ): """When inline content is served, the response should not contain a url field.""" # Arrange mock_skill = _make_mock_skill(skill_md_content=INLINE_SKILL_MD_CONTENT) mock_skill_service.get_skill.return_value = mock_skill # Act response = test_client.get("/api/skills/inline-test/content") # Assert assert response.status_code == 200 data = response.json() assert "url" not in data def test_falls_through_to_url_fetch_when_skill_md_content_is_none( self, test_client, mock_skill_service, ): """When skill_md_content is None, the endpoint fetches from the URL.""" # Arrange mock_skill = _make_mock_skill(skill_md_content=None) mock_skill_service.get_skill.return_value = mock_skill # Mock the httpx fetch and SSRF check (avoids DNS resolution in tests) mock_response = MagicMock() mock_response.status_code = 200 mock_response.text = URL_FETCHED_CONTENT mock_response.url = SKILL_MD_RAW_URL mock_async_client = AsyncMock() mock_async_client.__aenter__ = AsyncMock(return_value=mock_async_client) mock_async_client.__aexit__ = AsyncMock(return_value=False) mock_async_client.get = AsyncMock(return_value=mock_response) with ( patch("registry.services.skill_service._is_safe_url", return_value=True), patch("httpx.AsyncClient", return_value=mock_async_client), ): # Act response = test_client.get("/api/skills/inline-test/content") # Assert assert response.status_code == 200 data = response.json() assert data["content"] == URL_FETCHED_CONTENT assert data["url"] == SKILL_MD_RAW_URL assert "source" not in data def test_falls_through_to_url_fetch_when_skill_md_content_is_empty_string( self, test_client, mock_skill_service, ): """When skill_md_content is an empty string (falsy), it falls through to URL fetch.""" # Arrange mock_skill = _make_mock_skill(skill_md_content="") mock_skill_service.get_skill.return_value = mock_skill # Mock the httpx fetch and SSRF check (avoids DNS resolution in tests) mock_response = MagicMock() mock_response.status_code = 200 mock_response.text = URL_FETCHED_CONTENT mock_response.url = SKILL_MD_RAW_URL mock_async_client = AsyncMock() mock_async_client.__aenter__ = AsyncMock(return_value=mock_async_client) mock_async_client.__aexit__ = AsyncMock(return_value=False) mock_async_client.get = AsyncMock(return_value=mock_response) with ( patch("registry.services.skill_service._is_safe_url", return_value=True), patch("httpx.AsyncClient", return_value=mock_async_client), ): # Act response = test_client.get("/api/skills/inline-test/content") # Assert assert response.status_code == 200 data = response.json() assert data["content"] == URL_FETCHED_CONTENT assert "source" not in data def test_inline_content_returns_404_when_skill_not_found( self, test_client, mock_skill_service, ): """When skill does not exist, the endpoint returns 404.""" # Arrange mock_skill_service.get_skill.return_value = None # Act response = test_client.get("/api/skills/nonexistent/content") # Assert assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() ================================================ FILE: tests/unit/api/test_wellknown_routes.py ================================================ """ Unit tests for registry/api/wellknown_routes.py Tests the well-known URL discovery endpoint including: - GET /.well-known/mcp-servers - MCP server discovery - Health status retrieval from health service - Status normalization for client consumption """ import logging from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient logger = logging.getLogger(__name__) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def mock_server_service(): """Mock server_service dependency.""" mock_service = MagicMock() mock_service.get_all_servers = AsyncMock(return_value={}) mock_service.is_service_enabled = AsyncMock(return_value=True) return mock_service @pytest.fixture def mock_health_service(): """Mock health_service dependency with server_health_status dict.""" mock_service = MagicMock() mock_service.server_health_status = {} return mock_service @pytest.fixture def sample_server_info() -> dict[str, Any]: """Create sample server information for testing.""" return { "path": "test-server", "server_name": "Test Server", "description": "A test MCP server", "transport": "streamable-http", "auth_type": "oauth", "auth_provider": "keycloak", "tool_list": [ {"name": "get_data", "description": "Get data from source"}, {"name": "process_data", "description": "Process data"}, ], "proxy_pass_url": "http://localhost:8000", "is_enabled": True, } # ============================================================================= # UNIT TESTS FOR _get_normalized_health_status # ============================================================================= class TestGetNormalizedHealthStatus: """Tests for the _get_normalized_health_status helper function.""" def test_healthy_status_normalized(self, mock_health_service, mock_settings): """Test that 'healthy' status is returned as 'healthy'.""" mock_health_service.server_health_status = {"test-server": "healthy"} with patch("registry.api.wellknown_routes.health_service", mock_health_service): from registry.api.wellknown_routes import _get_normalized_health_status result = _get_normalized_health_status("test-server") assert result == "healthy" def test_healthy_auth_expired_normalized_to_healthy(self, mock_health_service, mock_settings): """Test that 'healthy-auth-expired' is normalized to 'healthy'.""" mock_health_service.server_health_status = {"test-server": "healthy-auth-expired"} with patch("registry.api.wellknown_routes.health_service", mock_health_service): from registry.api.wellknown_routes import _get_normalized_health_status result = _get_normalized_health_status("test-server") assert result == "healthy" def test_unhealthy_timeout_normalized(self, mock_health_service, mock_settings): """Test that 'unhealthy: timeout' is normalized to 'unhealthy'.""" mock_health_service.server_health_status = {"test-server": "unhealthy: timeout"} with patch("registry.api.wellknown_routes.health_service", mock_health_service): from registry.api.wellknown_routes import _get_normalized_health_status result = _get_normalized_health_status("test-server") assert result == "unhealthy" def test_unhealthy_connection_error_normalized(self, mock_health_service, mock_settings): """Test that 'unhealthy: connection error' is normalized to 'unhealthy'.""" mock_health_service.server_health_status = {"test-server": "unhealthy: connection error"} with patch("registry.api.wellknown_routes.health_service", mock_health_service): from registry.api.wellknown_routes import _get_normalized_health_status result = _get_normalized_health_status("test-server") assert result == "unhealthy" def test_error_status_normalized_to_unhealthy(self, mock_health_service, mock_settings): """Test that error statuses are normalized to 'unhealthy'.""" mock_health_service.server_health_status = {"test-server": "error: ConnectionError"} with patch("registry.api.wellknown_routes.health_service", mock_health_service): from registry.api.wellknown_routes import _get_normalized_health_status result = _get_normalized_health_status("test-server") assert result == "unhealthy" def test_disabled_status_normalized(self, mock_health_service, mock_settings): """Test that 'disabled' status is returned as 'disabled'.""" mock_health_service.server_health_status = {"test-server": "disabled"} with patch("registry.api.wellknown_routes.health_service", mock_health_service): from registry.api.wellknown_routes import _get_normalized_health_status result = _get_normalized_health_status("test-server") assert result == "disabled" def test_checking_status_normalized_to_unknown(self, mock_health_service, mock_settings): """Test that 'checking' status is normalized to 'unknown'.""" mock_health_service.server_health_status = {"test-server": "checking"} with patch("registry.api.wellknown_routes.health_service", mock_health_service): from registry.api.wellknown_routes import _get_normalized_health_status result = _get_normalized_health_status("test-server") assert result == "unknown" def test_unknown_server_returns_unknown(self, mock_health_service, mock_settings): """Test that unknown servers return 'unknown' status.""" mock_health_service.server_health_status = {} with patch("registry.api.wellknown_routes.health_service", mock_health_service): from registry.api.wellknown_routes import _get_normalized_health_status result = _get_normalized_health_status("nonexistent-server") assert result == "unknown" # ============================================================================= # UNIT TESTS FOR _format_server_discovery # ============================================================================= class TestFormatServerDiscovery: """Tests for the _format_server_discovery function.""" def test_format_includes_health_status( self, mock_health_service, mock_settings, sample_server_info ): """Test that formatted server includes actual health status.""" mock_health_service.server_health_status = {"test-server": "healthy"} with patch("registry.api.wellknown_routes.health_service", mock_health_service): from registry.api.wellknown_routes import _format_server_discovery # Create a mock request mock_request = MagicMock() mock_request.headers = {"host": "localhost:7860"} mock_request.url.scheme = "http" result = _format_server_discovery(sample_server_info, mock_request) assert result["health_status"] == "healthy" assert result["name"] == "Test Server" assert result["description"] == "A test MCP server" def test_format_uses_unhealthy_status_from_health_service( self, mock_health_service, mock_settings, sample_server_info ): """Test that formatted server uses unhealthy status from health service.""" mock_health_service.server_health_status = {"test-server": "unhealthy: timeout"} with patch("registry.api.wellknown_routes.health_service", mock_health_service): from registry.api.wellknown_routes import _format_server_discovery mock_request = MagicMock() mock_request.headers = {"host": "localhost:7860"} mock_request.url.scheme = "http" result = _format_server_discovery(sample_server_info, mock_request) # Should be normalized to 'unhealthy' assert result["health_status"] == "unhealthy" def test_format_unknown_server_has_unknown_status(self, mock_health_service, mock_settings): """Test that servers not in health service have 'unknown' status.""" mock_health_service.server_health_status = {} server_info = { "path": "new-server", "server_name": "New Server", "description": "A new server", } with patch("registry.api.wellknown_routes.health_service", mock_health_service): from registry.api.wellknown_routes import _format_server_discovery mock_request = MagicMock() mock_request.headers = {"host": "localhost:7860"} mock_request.url.scheme = "http" result = _format_server_discovery(server_info, mock_request) assert result["health_status"] == "unknown" # ============================================================================= # INTEGRATION TESTS FOR GET /.well-known/mcp-servers # ============================================================================= class TestWellKnownMcpServersEndpoint: """Integration tests for the well-known MCP servers endpoint.""" def test_endpoint_returns_actual_health_status( self, mock_server_service, mock_health_service, mock_settings, sample_server_info, ): """Test that the endpoint returns actual health status, not hardcoded.""" # Set up mock data mock_server_service.get_all_servers = AsyncMock( return_value={"test-server": sample_server_info} ) mock_server_service.is_service_enabled = AsyncMock(return_value=True) mock_health_service.server_health_status = {"test-server": "unhealthy: connection error"} # Patch settings to enable discovery mock_settings.enable_wellknown_discovery = True mock_settings.wellknown_cache_ttl = 300 with ( patch("registry.api.wellknown_routes.server_service", mock_server_service), patch("registry.api.wellknown_routes.health_service", mock_health_service), patch("registry.api.wellknown_routes.settings", mock_settings), ): from fastapi import FastAPI from registry.api.wellknown_routes import router app = FastAPI() app.include_router(router, prefix="/.well-known") client = TestClient(app) response = client.get("/.well-known/mcp-servers") assert response.status_code == 200 data = response.json() assert len(data["servers"]) == 1 # Verify health_status is normalized from "unhealthy: connection error" to "unhealthy" assert data["servers"][0]["health_status"] == "unhealthy" def test_endpoint_returns_healthy_status( self, mock_server_service, mock_health_service, mock_settings, sample_server_info, ): """Test that healthy servers show as healthy.""" mock_server_service.get_all_servers = AsyncMock( return_value={"test-server": sample_server_info} ) mock_server_service.is_service_enabled = AsyncMock(return_value=True) mock_health_service.server_health_status = {"test-server": "healthy"} mock_settings.enable_wellknown_discovery = True mock_settings.wellknown_cache_ttl = 300 with ( patch("registry.api.wellknown_routes.server_service", mock_server_service), patch("registry.api.wellknown_routes.health_service", mock_health_service), patch("registry.api.wellknown_routes.settings", mock_settings), ): from fastapi import FastAPI from registry.api.wellknown_routes import router app = FastAPI() app.include_router(router, prefix="/.well-known") client = TestClient(app) response = client.get("/.well-known/mcp-servers") assert response.status_code == 200 data = response.json() assert data["servers"][0]["health_status"] == "healthy" def test_endpoint_returns_unknown_for_unchecked_servers( self, mock_server_service, mock_health_service, mock_settings, sample_server_info, ): """Test that servers not yet health-checked show as unknown.""" mock_server_service.get_all_servers = AsyncMock( return_value={"test-server": sample_server_info} ) mock_server_service.is_service_enabled = AsyncMock(return_value=True) # Empty health status dict means no health checks have run yet mock_health_service.server_health_status = {} mock_settings.enable_wellknown_discovery = True mock_settings.wellknown_cache_ttl = 300 with ( patch("registry.api.wellknown_routes.server_service", mock_server_service), patch("registry.api.wellknown_routes.health_service", mock_health_service), patch("registry.api.wellknown_routes.settings", mock_settings), ): from fastapi import FastAPI from registry.api.wellknown_routes import router app = FastAPI() app.include_router(router, prefix="/.well-known") client = TestClient(app) response = client.get("/.well-known/mcp-servers") assert response.status_code == 200 data = response.json() assert data["servers"][0]["health_status"] == "unknown" def test_multiple_servers_with_different_health_statuses( self, mock_server_service, mock_health_service, mock_settings, ): """Test that multiple servers show their individual health statuses.""" servers = { "healthy-server": { "path": "healthy-server", "server_name": "Healthy Server", "description": "A healthy server", }, "unhealthy-server": { "path": "unhealthy-server", "server_name": "Unhealthy Server", "description": "An unhealthy server", }, "unknown-server": { "path": "unknown-server", "server_name": "Unknown Server", "description": "A server with unknown status", }, } mock_server_service.get_all_servers = AsyncMock(return_value=servers) mock_server_service.is_service_enabled = AsyncMock(return_value=True) mock_health_service.server_health_status = { "healthy-server": "healthy", "unhealthy-server": "unhealthy: timeout", # unknown-server not in dict, should return "unknown" } mock_settings.enable_wellknown_discovery = True mock_settings.wellknown_cache_ttl = 300 with ( patch("registry.api.wellknown_routes.server_service", mock_server_service), patch("registry.api.wellknown_routes.health_service", mock_health_service), patch("registry.api.wellknown_routes.settings", mock_settings), ): from fastapi import FastAPI from registry.api.wellknown_routes import router app = FastAPI() app.include_router(router, prefix="/.well-known") client = TestClient(app) response = client.get("/.well-known/mcp-servers") assert response.status_code == 200 data = response.json() assert len(data["servers"]) == 3 # Create a dict for easier verification server_statuses = {s["name"]: s["health_status"] for s in data["servers"]} assert server_statuses["Healthy Server"] == "healthy" assert server_statuses["Unhealthy Server"] == "unhealthy" assert server_statuses["Unknown Server"] == "unknown" ================================================ FILE: tests/unit/audit/__init__.py ================================================ """Unit tests for the audit logging module.""" ================================================ FILE: tests/unit/audit/test_audit_composite_key.py ================================================ """ Unit tests for audit events composite key (request_id, log_type). Validates that both MCPServerAccessRecord and RegistryApiAccessRecord can coexist for the same request_id, and that the detail endpoint returns multiple events. Related: GitHub issue #527 """ from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException from pymongo.errors import DuplicateKeyError from registry.audit.models import ( Identity, MCPRequest, MCPResponse, MCPServer, MCPServerAccessRecord, RegistryApiAccessRecord, Request, Response, ) from registry.audit.routes import get_audit_event from registry.repositories.audit_repository import DocumentDBAuditRepository def _make_registry_record( request_id: str = "req-123", ) -> RegistryApiAccessRecord: """Create a test RegistryApiAccessRecord.""" return RegistryApiAccessRecord( timestamp=datetime.now(UTC), request_id=request_id, identity=Identity( username="testuser", auth_method="oauth2", credential_type="bearer_token", ), request=Request( method="POST", path="/cloudflare-docs/mcp", client_ip="127.0.0.1", ), response=Response( status_code=200, duration_ms=150.0, ), ) def _make_mcp_record( request_id: str = "req-123", ) -> MCPServerAccessRecord: """Create a test MCPServerAccessRecord.""" return MCPServerAccessRecord( timestamp=datetime.now(UTC), request_id=request_id, identity=Identity( username="testuser", auth_method="oauth2", credential_type="bearer_token", ), mcp_server=MCPServer( name="cloudflare-docs", path="/cloudflare-docs", proxy_target="http://localhost:8001", ), mcp_request=MCPRequest( method="tools/call", tool_name="search_cloudflare_documentation", ), mcp_response=MCPResponse( status="success", duration_ms=120.0, ), ) class TestCompositeKeyInsert: """Tests for composite unique key (request_id, log_type) insert behavior.""" async def test_both_record_types_insert_with_same_request_id(self): """Both MCPServerAccessRecord and RegistryApiAccessRecord can be inserted with the same request_id (different log_type values).""" mock_collection = AsyncMock() mock_collection.insert_one.return_value = MagicMock(inserted_id="new_id") with patch.object( DocumentDBAuditRepository, "_get_collection", return_value=mock_collection, ): repo = DocumentDBAuditRepository() repo._collection = mock_collection mcp_record = _make_mcp_record(request_id="req-123") result1 = await repo.insert(mcp_record) assert result1 is True registry_record = _make_registry_record(request_id="req-123") result2 = await repo.insert(registry_record) assert result2 is True assert mock_collection.insert_one.call_count == 2 async def test_true_duplicate_returns_true(self): """DuplicateKeyError is caught for true duplicates (same request_id AND same log_type).""" mock_collection = AsyncMock() mock_collection.insert_one.side_effect = DuplicateKeyError("duplicate key error") with patch.object( DocumentDBAuditRepository, "_get_collection", return_value=mock_collection, ): repo = DocumentDBAuditRepository() repo._collection = mock_collection result = await repo.insert(_make_registry_record()) assert result is True async def test_record_log_type_defaults_are_distinct(self): """Verify the two record types have distinct log_type defaults.""" mcp_record = _make_mcp_record() registry_record = _make_registry_record() assert mcp_record.log_type == "mcp_server_access" assert registry_record.log_type == "registry_api_access" assert mcp_record.log_type != registry_record.log_type class TestDetailEndpointMultipleEvents: """Tests for GET /events/{request_id} with composite key.""" async def test_returns_multiple_events(self): """Endpoint returns all events for a given request_id.""" mock_repo = AsyncMock() mock_repo.find.return_value = [ { "request_id": "req-123", "log_type": "mcp_server_access", }, { "request_id": "req-123", "log_type": "registry_api_access", }, ] with patch( "registry.audit.routes.get_audit_repository", return_value=mock_repo, ): response = await get_audit_event( request_id="req-123", user_context={"username": "admin"}, ) assert response["request_id"] == "req-123" assert len(response["events"]) == 2 async def test_filters_by_log_type(self): """Endpoint filters events by log_type query parameter.""" mock_repo = AsyncMock() mock_repo.find.return_value = [ { "request_id": "req-123", "log_type": "registry_api_access", }, ] with patch( "registry.audit.routes.get_audit_repository", return_value=mock_repo, ): response = await get_audit_event( request_id="req-123", user_context={"username": "admin"}, log_type="registry_api_access", ) assert len(response["events"]) == 1 mock_repo.find.assert_called_once_with( { "request_id": "req-123", "log_type": "registry_api_access", }, limit=10, ) async def test_returns_404_when_not_found(self): """Endpoint returns 404 for unknown request_id.""" mock_repo = AsyncMock() mock_repo.find.return_value = [] with patch( "registry.audit.routes.get_audit_repository", return_value=mock_repo, ): with pytest.raises(HTTPException) as exc_info: await get_audit_event( request_id="nonexistent", user_context={"username": "admin"}, ) assert exc_info.value.status_code == 404 async def test_without_log_type_queries_all(self): """Endpoint queries without log_type filter when not provided.""" mock_repo = AsyncMock() mock_repo.find.return_value = [ {"request_id": "req-123", "log_type": "mcp_server_access"}, ] with patch( "registry.audit.routes.get_audit_repository", return_value=mock_repo, ): await get_audit_event( request_id="req-123", user_context={"username": "admin"}, log_type=None, ) mock_repo.find.assert_called_once_with( {"request_id": "req-123"}, limit=10, ) ================================================ FILE: tests/unit/audit/test_audit_repository.py ================================================ """ Unit tests for Audit Repository. Validates: Requirements 6.1, 6.2 """ from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch from pymongo.errors import DuplicateKeyError from registry.audit.models import Identity, RegistryApiAccessRecord, Request, Response from registry.repositories.audit_repository import DocumentDBAuditRepository def make_test_record(request_id: str = "test-123") -> RegistryApiAccessRecord: """Create a test audit record.""" return RegistryApiAccessRecord( timestamp=datetime.now(UTC), request_id=request_id, identity=Identity( username="testuser", auth_method="oauth2", credential_type="bearer_token" ), request=Request(method="GET", path="/api/test", client_ip="127.0.0.1"), response=Response(status_code=200, duration_ms=50.5), ) class TestFind: """Tests for find() method.""" async def test_returns_list_of_events(self): """find() returns a list of audit events.""" mock_collection = MagicMock() mock_cursor = MagicMock() mock_cursor.sort = MagicMock(return_value=mock_cursor) mock_cursor.skip = MagicMock(return_value=mock_cursor) mock_cursor.limit = MagicMock(return_value=mock_cursor) test_docs = [{"request_id": "req-1"}, {"request_id": "req-2"}] async def async_iter(): for doc in test_docs: yield doc mock_cursor.__aiter__ = lambda self: async_iter() mock_collection.find = MagicMock(return_value=mock_cursor) with patch.object( DocumentDBAuditRepository, "_get_collection", new_callable=AsyncMock, return_value=mock_collection, ): repo = DocumentDBAuditRepository() repo._collection = mock_collection results = await repo.find({}) assert len(results) == 2 async def test_applies_pagination(self): """find() applies limit and offset.""" mock_collection = MagicMock() mock_cursor = MagicMock() mock_cursor.sort = MagicMock(return_value=mock_cursor) mock_cursor.skip = MagicMock(return_value=mock_cursor) mock_cursor.limit = MagicMock(return_value=mock_cursor) async def async_iter(): return yield mock_cursor.__aiter__ = lambda self: async_iter() mock_collection.find = MagicMock(return_value=mock_cursor) with patch.object( DocumentDBAuditRepository, "_get_collection", new_callable=AsyncMock, return_value=mock_collection, ): repo = DocumentDBAuditRepository() repo._collection = mock_collection await repo.find({}, limit=25, offset=50) mock_cursor.skip.assert_called_once_with(50) mock_cursor.limit.assert_called_once_with(25) class TestInsert: """Tests for insert() method.""" async def test_writes_record(self): """insert() writes the audit record to MongoDB.""" mock_collection = AsyncMock() mock_collection.insert_one.return_value = MagicMock(inserted_id="new_id") with patch.object( DocumentDBAuditRepository, "_get_collection", return_value=mock_collection ): repo = DocumentDBAuditRepository() repo._collection = mock_collection result = await repo.insert(make_test_record()) assert result is True mock_collection.insert_one.assert_called_once() async def test_returns_false_on_error(self): """insert() returns False when an error occurs.""" mock_collection = AsyncMock() mock_collection.insert_one.side_effect = Exception("Database error") with patch.object( DocumentDBAuditRepository, "_get_collection", return_value=mock_collection ): repo = DocumentDBAuditRepository() repo._collection = mock_collection result = await repo.insert(make_test_record()) assert result is False async def test_returns_true_on_duplicate_key(self): """insert() returns True when a duplicate audit event already exists.""" mock_collection = AsyncMock() mock_collection.insert_one.side_effect = DuplicateKeyError("duplicate key error") with patch.object( DocumentDBAuditRepository, "_get_collection", return_value=mock_collection ): repo = DocumentDBAuditRepository() repo._collection = mock_collection result = await repo.insert(make_test_record()) assert result is True ================================================ FILE: tests/unit/audit/test_filter_statistics.py ================================================ """ Unit tests for Audit Filter Options and Statistics endpoints. Tests the GET /audit/filter-options and GET /audit/statistics endpoints, plus the repository distinct() and aggregate() methods. Validates: Issue #572 """ from unittest.mock import AsyncMock, MagicMock, patch from registry.repositories.audit_repository import DocumentDBAuditRepository # ============================================================================= # Repository: distinct() method # ============================================================================= class TestDistinct: """Tests for DocumentDBAuditRepository.distinct() method.""" async def test_returns_sorted_distinct_values(self): """distinct() returns a sorted list of distinct string values.""" mock_collection = AsyncMock() mock_collection.distinct = AsyncMock(return_value=["charlie", "alice", "bob"]) with patch.object( DocumentDBAuditRepository, "_get_collection", new_callable=AsyncMock, return_value=mock_collection, ): repo = DocumentDBAuditRepository() repo._collection = mock_collection result = await repo.distinct("identity.username") assert result == ["alice", "bob", "charlie"] mock_collection.distinct.assert_called_once_with("identity.username", {}) async def test_filters_out_none_and_empty(self): """distinct() filters out None and empty string values.""" mock_collection = AsyncMock() mock_collection.distinct = AsyncMock(return_value=["admin", None, "", "user1"]) with patch.object( DocumentDBAuditRepository, "_get_collection", new_callable=AsyncMock, return_value=mock_collection, ): repo = DocumentDBAuditRepository() repo._collection = mock_collection result = await repo.distinct("identity.username") assert result == ["admin", "user1"] async def test_passes_query_filter(self): """distinct() passes the query filter to MongoDB.""" mock_collection = AsyncMock() mock_collection.distinct = AsyncMock(return_value=["admin"]) query = {"log_type": "registry_api_access"} with patch.object( DocumentDBAuditRepository, "_get_collection", new_callable=AsyncMock, return_value=mock_collection, ): repo = DocumentDBAuditRepository() repo._collection = mock_collection result = await repo.distinct("identity.username", query) mock_collection.distinct.assert_called_once_with("identity.username", query) assert result == ["admin"] async def test_returns_empty_on_error(self): """distinct() returns empty list on error.""" mock_collection = AsyncMock() mock_collection.distinct = AsyncMock(side_effect=Exception("DB error")) with patch.object( DocumentDBAuditRepository, "_get_collection", new_callable=AsyncMock, return_value=mock_collection, ): repo = DocumentDBAuditRepository() repo._collection = mock_collection result = await repo.distinct("identity.username") assert result == [] # ============================================================================= # Repository: aggregate() method # ============================================================================= class TestAggregate: """Tests for DocumentDBAuditRepository.aggregate() method.""" async def test_returns_aggregation_results(self): """aggregate() returns list of aggregation result docs.""" mock_collection = MagicMock() test_results = [ {"_id": "admin", "count": 100}, {"_id": "user1", "count": 50}, ] async def async_iter(): for doc in test_results: yield doc mock_collection.aggregate = MagicMock(return_value=async_iter()) with patch.object( DocumentDBAuditRepository, "_get_collection", new_callable=AsyncMock, return_value=mock_collection, ): repo = DocumentDBAuditRepository() repo._collection = mock_collection pipeline = [ {"$match": {"log_type": "registry_api_access"}}, {"$group": {"_id": "$identity.username", "count": {"$sum": 1}}}, ] result = await repo.aggregate(pipeline) assert len(result) == 2 assert result[0]["_id"] == "admin" assert result[0]["count"] == 100 async def test_returns_empty_list_on_no_results(self): """aggregate() returns empty list when no results.""" mock_collection = MagicMock() async def async_iter(): return yield mock_collection.aggregate = MagicMock(return_value=async_iter()) with patch.object( DocumentDBAuditRepository, "_get_collection", new_callable=AsyncMock, return_value=mock_collection, ): repo = DocumentDBAuditRepository() repo._collection = mock_collection result = await repo.aggregate([{"$match": {}}]) assert result == [] async def test_returns_empty_on_error(self): """aggregate() returns empty list on error.""" mock_collection = MagicMock() mock_collection.aggregate = MagicMock(side_effect=Exception("DB error")) with patch.object( DocumentDBAuditRepository, "_get_collection", new_callable=AsyncMock, return_value=mock_collection, ): repo = DocumentDBAuditRepository() repo._collection = mock_collection result = await repo.aggregate([{"$match": {}}]) assert result == [] # ============================================================================= # API Endpoint: GET /audit/filter-options # ============================================================================= class TestFilterOptionsEndpoint: """Tests for GET /api/audit/filter-options endpoint.""" async def test_returns_usernames_for_registry_stream(self): """Returns usernames for registry_api stream.""" mock_repo = MagicMock() mock_repo.distinct = AsyncMock(side_effect=lambda field, query: ["admin", "user1"]) with patch( "registry.audit.routes.get_audit_repository", return_value=mock_repo, ): from registry.audit.routes import get_filter_options result = await get_filter_options( user_context={"is_admin": True, "username": "admin"}, stream="registry_api", ) assert result.usernames == ["admin", "user1"] assert result.server_names == [] async def test_returns_usernames_and_servers_for_mcp_stream(self): """Returns both usernames and server names for mcp_access stream.""" mock_repo = MagicMock() async def mock_distinct(field, query): if field == "identity.username": return ["admin", "user1"] elif field == "mcp_server.name": return ["fininfo-server", "currenttime-server"] return [] mock_repo.distinct = AsyncMock(side_effect=mock_distinct) with patch( "registry.audit.routes.get_audit_repository", return_value=mock_repo, ): from registry.audit.routes import get_filter_options result = await get_filter_options( user_context={"is_admin": True, "username": "admin"}, stream="mcp_access", ) assert result.usernames == ["admin", "user1"] assert result.server_names == ["fininfo-server", "currenttime-server"] # ============================================================================= # API Endpoint: GET /audit/statistics # ============================================================================= class TestStatisticsEndpoint: """Tests for GET /api/audit/statistics endpoint.""" async def test_returns_statistics_for_registry_stream(self): """Returns aggregated statistics for registry_api stream.""" mock_repo = MagicMock() mock_repo.count = AsyncMock(return_value=500) # Top users top_users = [ {"_id": "admin", "count": 300}, {"_id": "user1", "count": 200}, ] # Top operations top_ops = [ {"_id": "list", "count": 250}, {"_id": "read", "count": 150}, ] # Timeline timeline = [ {"_id": "2026-02-27", "count": 200}, {"_id": "2026-02-28", "count": 300}, ] # Status distribution status_dist = [ {"_id": "2xx", "count": 450}, {"_id": "4xx", "count": 40}, {"_id": "5xx", "count": 10}, ] # Per-user activity breakdown user_activity = [ { "_id": "admin", "total": 300, "operations": [ {"name": "list", "count": 200}, {"name": "read", "count": 100}, ], }, { "_id": "user1", "total": 200, "operations": [{"name": "read", "count": 200}], }, ] # aggregate() is called 5 times for registry_api (no server aggregation) mock_repo.aggregate = AsyncMock( side_effect=[top_users, top_ops, timeline, status_dist, user_activity] ) with patch( "registry.audit.routes.get_audit_repository", return_value=mock_repo, ): from registry.audit.routes import get_statistics result = await get_statistics( user_context={"is_admin": True, "username": "admin"}, stream="registry_api", days=7, username=None, ) assert result.total_events == 500 assert len(result.top_users) == 2 assert result.top_users[0].name == "admin" assert result.top_users[0].count == 300 assert len(result.top_operations) == 2 assert len(result.activity_timeline) == 2 assert result.status_distribution.status_2xx == 450 assert result.status_distribution.status_4xx == 40 assert result.status_distribution.status_5xx == 10 assert result.top_servers == [] assert len(result.user_activity) == 2 assert result.user_activity[0].username == "admin" assert result.user_activity[0].total == 300 assert len(result.user_activity[0].operations) == 2 async def test_returns_statistics_for_mcp_stream(self): """Returns aggregated statistics for mcp_access stream including servers.""" mock_repo = MagicMock() mock_repo.count = AsyncMock(return_value=200) top_users = [{"_id": "admin", "count": 200}] top_ops = [{"_id": "tools/call", "count": 100}] timeline = [{"_id": "2026-02-28", "count": 200}] status_dist = [ {"_id": "success", "count": 180}, {"_id": "error", "count": 20}, ] # Per-user activity breakdown user_activity = [ { "_id": "admin", "total": 200, "operations": [{"name": "tools/call", "count": 100}], }, ] top_servers = [ {"_id": "fininfo-server", "count": 89}, {"_id": "currenttime-server", "count": 67}, ] # aggregate() is called 6 times for mcp_access (includes user_activity + server aggregation) mock_repo.aggregate = AsyncMock( side_effect=[top_users, top_ops, timeline, status_dist, user_activity, top_servers] ) with patch( "registry.audit.routes.get_audit_repository", return_value=mock_repo, ): from registry.audit.routes import get_statistics result = await get_statistics( user_context={"is_admin": True, "username": "admin"}, stream="mcp_access", days=7, username=None, ) assert result.total_events == 200 assert len(result.top_servers) == 2 assert result.top_servers[0].name == "fininfo-server" # MCP success -> status_2xx assert result.status_distribution.status_2xx == 180 # MCP error -> status_5xx assert result.status_distribution.status_5xx == 20 assert len(result.user_activity) == 1 assert result.user_activity[0].username == "admin" async def test_handles_empty_results(self): """Returns zero counts when no events exist.""" mock_repo = MagicMock() mock_repo.count = AsyncMock(return_value=0) mock_repo.aggregate = AsyncMock(return_value=[]) with patch( "registry.audit.routes.get_audit_repository", return_value=mock_repo, ): from registry.audit.routes import get_statistics result = await get_statistics( user_context={"is_admin": True, "username": "admin"}, stream="registry_api", days=7, username=None, ) assert result.total_events == 0 assert result.top_users == [] assert result.top_operations == [] assert result.activity_timeline == [] assert result.status_distribution.status_2xx == 0 assert result.status_distribution.status_4xx == 0 assert result.status_distribution.status_5xx == 0 assert result.user_activity == [] ================================================ FILE: tests/unit/audit/test_mcp_logger.py ================================================ """ Tests for MCP Logger functionality. Validates: Requirements 9.3, 9.5 """ import json import pytest from hypothesis import given, settings from hypothesis import strategies as st from registry.audit.mcp_logger import MCPLogger from registry.audit.models import Identity, MCPServer from registry.audit.service import AuditLogger class TestJSONRPCParsing: """Property 14: JSON-RPC parsing extracts method and tool name.""" @given( tool_name=st.text(min_size=1, max_size=50).filter(lambda x: x.strip()), jsonrpc_id=st.one_of(st.integers(), st.text(min_size=1, max_size=20)), ) @settings(max_examples=50) def test_tools_call_extracts_tool_name(self, tool_name: str, jsonrpc_id): """For tools/call requests, parse_jsonrpc_body extracts the tool_name.""" body = json.dumps( { "jsonrpc": "2.0", "method": "tools/call", "params": {"name": tool_name}, "id": jsonrpc_id, } ) result = MCPLogger(None).parse_jsonrpc_body(body) assert result["method"] == "tools/call" assert result["tool_name"] == tool_name @given( resource_uri=st.text(min_size=1, max_size=100).filter(lambda x: x.strip()), ) @settings(max_examples=50) def test_resources_read_extracts_uri(self, resource_uri: str): """For resources/read requests, parse_jsonrpc_body extracts the resource_uri.""" body = json.dumps( { "jsonrpc": "2.0", "method": "resources/read", "params": {"uri": resource_uri}, "id": 1, } ) result = MCPLogger(None).parse_jsonrpc_body(body) assert result["method"] == "resources/read" assert result["resource_uri"] == resource_uri def test_invalid_json_returns_unknown(self): """Invalid JSON returns method='unknown'.""" result = MCPLogger(None).parse_jsonrpc_body(b"not valid json") assert result["method"] == "unknown" assert result["jsonrpc_id"] == "" def test_empty_body_returns_unknown(self): """Empty body returns method='unknown'.""" result = MCPLogger(None).parse_jsonrpc_body(b"") assert result["method"] == "unknown" class TestLogMCPAccess: """Tests for log_mcp_access method.""" @pytest.mark.asyncio async def test_creates_audit_record(self): """log_mcp_access creates a complete audit record via MongoDB.""" from unittest.mock import AsyncMock # Create mock repository to capture the audit record mock_repository = AsyncMock() captured_records = [] async def capture_insert(record): captured_records.append(record) mock_repository.insert.side_effect = capture_insert # Create AuditLogger with MongoDB enabled audit_logger = AuditLogger( stream_name="mcp-server-access", mongodb_enabled=True, audit_repository=mock_repository, ) mcp_logger = MCPLogger(audit_logger) identity = Identity( username="test-user", auth_method="oauth2", credential_type="bearer_token", credential_hint="abc123xyz789", ) mcp_server = MCPServer( name="weather-server", path="/mcp/weather", proxy_target="http://localhost:8080", ) await mcp_logger.log_mcp_access( request_id="req-123", identity=identity, mcp_server=mcp_server, request_body=b'{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "get_weather"}, "id": 1}', response_status="success", duration_ms=150.5, mcp_session_id="session-456", ) await audit_logger.close() # Verify audit record was captured assert len(captured_records) == 1 record = captured_records[0] assert record.log_type == "mcp_server_access" assert record.mcp_request.method == "tools/call" assert record.mcp_request.tool_name == "get_weather" assert record.identity.credential_hint == "***xyz789" ================================================ FILE: tests/unit/audit/test_middleware.py ================================================ """ Unit tests for Audit Middleware. Validates: Requirements 4.1, 4.3 """ import tempfile from unittest.mock import MagicMock import pytest from registry.audit import AuditLogger, AuditMiddleware class MockRequest: """Mock FastAPI Request object.""" def __init__( self, path="/api/test", method="GET", headers=None, cookies=None, client_host="127.0.0.1" ): self.url = MagicMock() self.url.path = path self.method = method self._headers = headers or {} self._cookies = cookies or {} self.client = MagicMock() self.client.host = client_host self.state = MagicMock() self.state.user_context = None self.state.audit_action = None self.query_params = {} @property def headers(self): return self._headers @property def cookies(self): return self._cookies class TestShouldLog: """Tests for _should_log method - health check and static asset exclusion.""" def setup_method(self): self.tmpdir = tempfile.mkdtemp() self.audit_logger = AuditLogger(log_dir=self.tmpdir) self.mock_app = MagicMock() def test_logs_regular_api_paths(self): """Regular API paths should be logged.""" middleware = AuditMiddleware(self.mock_app, self.audit_logger) assert middleware._should_log("/api/servers") is True def test_excludes_health_checks_by_default(self): """Health check paths should NOT be logged by default.""" middleware = AuditMiddleware(self.mock_app, self.audit_logger) assert middleware._should_log("/health") is False assert middleware._should_log("/api/health") is False def test_logs_health_checks_when_enabled(self): """Health check paths should be logged when enabled.""" middleware = AuditMiddleware(self.mock_app, self.audit_logger, log_health_checks=True) assert middleware._should_log("/health") is True def test_excludes_static_assets_by_default(self): """Static asset paths should NOT be logged by default.""" middleware = AuditMiddleware(self.mock_app, self.audit_logger) assert middleware._should_log("/static/app.js") is False assert middleware._should_log("/favicon.ico") is False class TestCredentialDetection: """Tests for credential type and hint detection.""" def setup_method(self): self.tmpdir = tempfile.mkdtemp() self.audit_logger = AuditLogger(log_dir=self.tmpdir) self.middleware = AuditMiddleware(MagicMock(), self.audit_logger) def test_detects_session_cookie(self): """Session cookie should be detected.""" # Use the actual configured cookie name from settings request = MockRequest(cookies={"mcp_gateway_session": "abc123"}) assert self.middleware._get_credential_type(request) == "session_cookie" def test_detects_bearer_token(self): """Bearer token should be detected.""" request = MockRequest(headers={"Authorization": "Bearer eyJhbGciOiJIUzI1NiJ9..."}) assert self.middleware._get_credential_type(request) == "bearer_token" def test_detects_no_credential(self): """No credential should return 'none'.""" request = MockRequest() assert self.middleware._get_credential_type(request) == "none" class TestDispatch: """Tests for dispatch method.""" def setup_method(self): self.tmpdir = tempfile.mkdtemp() self.audit_logger = AuditLogger(log_dir=self.tmpdir) self.middleware = AuditMiddleware(MagicMock(), self.audit_logger) @pytest.mark.asyncio async def test_captures_request_response(self): """Dispatch captures request and response details.""" request = MockRequest(path="/api/servers", method="POST") request.state.user_context = {"username": "testuser", "auth_method": "oauth2"} response = MagicMock() response.status_code = 201 response.headers = {} logged_events = [] async def capture_log_event(record): logged_events.append(record) self.audit_logger.log_event = capture_log_event result = await self.middleware.dispatch(request, lambda r: self._async_return(response)) assert result == response assert len(logged_events) == 1 assert logged_events[0].request.method == "POST" assert logged_events[0].response.status_code == 201 @pytest.mark.asyncio async def test_skips_excluded_paths(self): """Dispatch skips logging for excluded paths.""" request = MockRequest(path="/health") response = MagicMock() response.status_code = 200 log_called = [] async def track_log(record): log_called.append(record) self.audit_logger.log_event = track_log await self.middleware.dispatch(request, lambda r: self._async_return(response)) assert len(log_called) == 0 async def _async_return(self, value): return value ================================================ FILE: tests/unit/audit/test_models_properties.py ================================================ """ Property-based tests for audit model masking and JSONL serialization. Validates: Requirements 2.1, 2.2, 2.3, 2.4, 3.1 """ import json from datetime import UTC from hypothesis import given, settings from hypothesis import strategies as st from registry.audit.models import ( SENSITIVE_QUERY_PARAMS, Identity, RegistryApiAccessRecord, Request, Response, mask_credential, ) class TestCredentialMasking: """Property 3: Credential masking consistency.""" @given(st.text(min_size=0, max_size=6)) @settings(max_examples=50) def test_short_credentials_masked_completely(self, credential: str): """Short credentials (<=6 chars) return '***'.""" assert mask_credential(credential) == "***" @given(st.text(min_size=7, max_size=100)) @settings(max_examples=50) def test_long_credentials_show_last_six(self, credential: str): """Long credentials return '***' + last 6 characters.""" result = mask_credential(credential) assert result == "***" + credential[-6:] assert len(result[3:]) <= 6 class TestSensitiveQueryParamMasking: """Property 4: Sensitive query parameter masking.""" @given( st.dictionaries( keys=st.sampled_from(list(SENSITIVE_QUERY_PARAMS)), values=st.text(min_size=1, max_size=50), min_size=1, max_size=3, ) ) @settings(max_examples=50) def test_sensitive_params_are_masked(self, sensitive_params: dict): """Query parameters with sensitive keys have their values masked.""" request = Request( method="GET", path="/api/test", query_params=sensitive_params, client_ip="127.0.0.1", ) for key, original_value in sensitive_params.items(): assert request.query_params[key] == mask_credential(str(original_value)) class TestJSONLFormatValidity: """Property 5: JSONL format validity.""" @given( st.builds( RegistryApiAccessRecord, timestamp=st.datetimes(timezones=st.just(UTC)), request_id=st.uuids().map(str), identity=st.builds( Identity, username=st.text(min_size=1, max_size=20).filter(lambda x: x.strip()), auth_method=st.sampled_from(["oauth2", "anonymous"]), credential_type=st.sampled_from(["bearer_token", "none"]), ), request=st.builds( Request, method=st.sampled_from(["GET", "POST"]), path=st.just("/api/test"), client_ip=st.just("127.0.0.1"), ), response=st.builds( Response, status_code=st.integers(min_value=200, max_value=500), duration_ms=st.floats(min_value=0.0, max_value=1000.0, allow_nan=False), ), ) ) @settings(max_examples=50) def test_audit_record_round_trip(self, record: RegistryApiAccessRecord): """Serializing and deserializing produces an equivalent object.""" json_str = record.model_dump_json() assert "\n" not in json_str # Single line parsed = json.loads(json_str) reconstructed = RegistryApiAccessRecord.model_validate(parsed) assert reconstructed.request_id == record.request_id ================================================ FILE: tests/unit/audit/test_routes.py ================================================ """ Unit tests for Audit API routes. Validates: Requirements 7.1, 7.2, 7.5, 7.6 """ from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException from hypothesis import given, settings from hypothesis import strategies as st from registry.audit.routes import _build_query, _generate_csv, _generate_jsonl, require_admin # ============================================================================= # Property 11: Admin-Only Audit API Access # ============================================================================= class TestAdminOnlyAccess: """Property 11: Admin-only audit API access.""" @given( st.fixed_dictionaries( { "username": st.text(min_size=1, max_size=20).filter(lambda x: x.strip()), "is_admin": st.just(False), } ) ) @settings(max_examples=50) def test_rejects_non_admin_users(self, user_context: dict): """require_admin raises 403 for any non-admin user.""" with pytest.raises(HTTPException) as exc_info: require_admin(user_context) assert exc_info.value.status_code == 403 def test_allows_admin_users(self): """require_admin allows admin users.""" user_context = {"username": "admin", "is_admin": True} result = require_admin(user_context) assert result["is_admin"] is True # ============================================================================= # Query Building # ============================================================================= class TestBuildQuery: """Tests for _build_query function.""" def test_stream_only(self): """Build query with only stream parameter.""" query = _build_query( stream="registry_api", from_time=None, to_time=None, username=None, operation=None, resource_type=None, resource_id=None, status_min=None, status_max=None, auth_decision=None, ) assert query == {"log_type": "registry_api_access"} def test_with_filters(self): """Build query with multiple filters.""" from_time = datetime(2025, 1, 1, tzinfo=UTC) query = _build_query( stream="registry_api", from_time=from_time, to_time=None, username="admin", operation="create", resource_type="server", resource_id=None, status_min=400, status_max=499, auth_decision=None, ) # Username uses case-insensitive regex for partial matching assert query["identity.username"]["$regex"] == "admin" assert query["identity.username"]["$options"] == "i" assert query["action.operation"] == "create" assert query["response.status_code"]["$gte"] == 400 # ============================================================================= # Export Format Generation # ============================================================================= class TestExportFormats: """Tests for export format generation.""" def test_generate_jsonl(self): """Generate JSONL from events.""" events = [{"request_id": "req-1"}, {"request_id": "req-2"}] result = list(_generate_jsonl(events)) assert len(result) == 2 assert all(line.endswith("\n") for line in result) def test_generate_csv(self): """Generate CSV from events.""" events = [ { "timestamp": datetime(2025, 1, 15, tzinfo=UTC), "request_id": "req-1", "identity": {"username": "admin"}, "request": {"method": "GET", "path": "/api/test"}, "response": {"status_code": 200, "duration_ms": 50.0}, "action": {"operation": "read", "resource_type": "server"}, } ] result = list(_generate_csv(events)) csv_content = result[0] assert "timestamp" in csv_content assert "req-1" in csv_content # ============================================================================= # API Endpoints # ============================================================================= class TestAuditEventsEndpoint: """Tests for GET /api/audit/events endpoint.""" async def test_returns_paginated_results(self): """GET /events returns paginated audit events.""" mock_repo = MagicMock() mock_repo.find = AsyncMock(return_value=[{"request_id": "req-1"}]) mock_repo.count = AsyncMock(return_value=1) with patch("registry.audit.routes.get_audit_repository", return_value=mock_repo): from registry.audit.routes import get_audit_events result = await get_audit_events( user_context={"is_admin": True}, stream="registry_api", from_time=None, to_time=None, username=None, operation=None, resource_type=None, resource_id=None, status_min=None, status_max=None, auth_decision=None, limit=50, offset=0, sort_order=-1, ) assert result.total == 1 assert len(result.events) == 1 class TestAuditEventDetailEndpoint: """Tests for GET /api/audit/events/{request_id} endpoint.""" async def test_returns_404_when_not_found(self): """GET /events/{request_id} returns 404 when event not found.""" mock_repo = MagicMock() mock_repo.find = AsyncMock(return_value=[]) with patch("registry.audit.routes.get_audit_repository", return_value=mock_repo): from registry.audit.routes import get_audit_event with pytest.raises(HTTPException) as exc_info: await get_audit_event( request_id="nonexistent", user_context={"is_admin": True}, log_type=None, ) assert exc_info.value.status_code == 404 ================================================ FILE: tests/unit/audit/test_service.py ================================================ """ Unit tests for AuditLogger service. Tests the MongoDB-only audit logging functionality. """ from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock from registry.audit import ( AuditLogger, Identity, RegistryApiAccessRecord, Request, Response, ) def make_test_record(request_id: str = "test-123") -> RegistryApiAccessRecord: """Create a test audit record.""" return RegistryApiAccessRecord( timestamp=datetime.now(UTC), request_id=request_id, identity=Identity( username="testuser", auth_method="oauth2", credential_type="bearer_token", ), request=Request( method="GET", path="/api/test", client_ip="127.0.0.1", ), response=Response( status_code=200, duration_ms=50.5, ), ) class TestAuditLoggerInit: """Tests for AuditLogger initialization.""" def test_init_with_mongodb_enabled(self): """Logger initializes correctly with MongoDB enabled.""" mock_repo = MagicMock() logger = AuditLogger( stream_name="test-stream", mongodb_enabled=True, audit_repository=mock_repo, ) assert logger.mongodb_enabled is True assert logger.is_open is True assert logger.stream_name == "test-stream" def test_init_with_mongodb_disabled(self): """Logger initializes correctly with MongoDB disabled.""" logger = AuditLogger( stream_name="test-stream", mongodb_enabled=False, ) assert logger.mongodb_enabled is False assert logger.is_open is False def test_deprecated_params_accepted(self): """Deprecated parameters are accepted for backward compatibility.""" logger = AuditLogger( log_dir="/tmp/test", rotation_hours=2, rotation_max_mb=50, local_retention_hours=48, stream_name="test-stream", ) # Should not raise, deprecated params are ignored assert logger.stream_name == "test-stream" class TestLogEvent: """Tests for log_event method.""" async def test_log_event_writes_to_mongodb(self): """Event is written to MongoDB when enabled.""" mock_repo = AsyncMock() logger = AuditLogger( stream_name="test-stream", mongodb_enabled=True, audit_repository=mock_repo, ) record = make_test_record() await logger.log_event(record) mock_repo.insert.assert_called_once_with(record) async def test_log_event_skipped_when_disabled(self): """Event is skipped when MongoDB is disabled.""" mock_repo = AsyncMock() logger = AuditLogger( stream_name="test-stream", mongodb_enabled=False, audit_repository=mock_repo, ) await logger.log_event(make_test_record()) mock_repo.insert.assert_not_called() async def test_log_event_handles_mongodb_error(self): """MongoDB errors are caught and logged, not raised.""" mock_repo = AsyncMock() mock_repo.insert.side_effect = Exception("MongoDB connection failed") logger = AuditLogger( stream_name="test-stream", mongodb_enabled=True, audit_repository=mock_repo, ) # Should not raise await logger.log_event(make_test_record()) async def test_multiple_events_logged(self): """Multiple events can be logged sequentially.""" mock_repo = AsyncMock() logger = AuditLogger( stream_name="test-stream", mongodb_enabled=True, audit_repository=mock_repo, ) for i in range(3): await logger.log_event(make_test_record(f"request-{i}")) assert mock_repo.insert.call_count == 3 class TestClose: """Tests for close method.""" async def test_close_is_safe(self): """Close method completes without error.""" logger = AuditLogger( stream_name="test-stream", mongodb_enabled=True, audit_repository=AsyncMock(), ) # Should not raise await logger.close() class TestProperties: """Tests for logger properties.""" def test_current_file_path_returns_none(self): """current_file_path returns None (no local files).""" logger = AuditLogger(stream_name="test-stream") assert logger.current_file_path is None def test_is_open_with_mongodb(self): """is_open returns True when MongoDB is enabled and repo is set.""" logger = AuditLogger( stream_name="test-stream", mongodb_enabled=True, audit_repository=MagicMock(), ) assert logger.is_open is True def test_is_open_without_mongodb(self): """is_open returns False when MongoDB is disabled.""" logger = AuditLogger( stream_name="test-stream", mongodb_enabled=False, ) assert logger.is_open is False def test_is_open_without_repo(self): """is_open returns False when MongoDB enabled but no repo.""" logger = AuditLogger( stream_name="test-stream", mongodb_enabled=True, audit_repository=None, ) assert logger.is_open is False ================================================ FILE: tests/unit/auth/__init__.py ================================================ """Authentication and authorization unit tests.""" ================================================ FILE: tests/unit/auth/test_csrf.py ================================================ """Tests for CSRF token validation with Bearer token bypass.""" import pytest from unittest.mock import AsyncMock, MagicMock from fastapi import HTTPException from registry.auth.csrf import ( generate_csrf_token, verify_csrf_token_flexible, ) def _make_request( cookies: dict | None = None, headers: dict | None = None, form_data: dict | None = None, ): """Create a mock Request object with optional cookies, headers, and form data.""" request = MagicMock() request.cookies = cookies or {} header_dict = headers or {} request.headers = MagicMock() request.headers.get = lambda key, default=None: header_dict.get(key, default) request.form = AsyncMock(return_value=form_data or {}) return request class TestVerifyCsrfTokenFlexibleBypass: """Tests for the session-cookie-based CSRF bypass.""" @pytest.mark.asyncio async def test_skip_csrf_when_no_session_cookie(self): """No session cookie means non-browser client, CSRF check is skipped.""" request = _make_request(cookies={}, headers={}) await verify_csrf_token_flexible(request) @pytest.mark.asyncio async def test_skip_csrf_for_bearer_token_client(self): """Bearer token client with no cookies should skip CSRF.""" request = _make_request( cookies={}, headers={"Authorization": "Bearer eyJhbGciOiJSUzI1NiJ9.test"}, ) await verify_csrf_token_flexible(request) class TestVerifyCsrfTokenFlexibleEnforcement: """Tests for CSRF enforcement when session cookie is present.""" @pytest.mark.asyncio async def test_reject_when_session_cookie_but_no_csrf_token(self): """Session cookie present but no CSRF token should return 403.""" request = _make_request( cookies={"mcp_gateway_session": "test-session"}, headers={}, ) with pytest.raises(HTTPException) as exc_info: await verify_csrf_token_flexible(request) assert exc_info.value.status_code == 403 assert "no token provided" in exc_info.value.detail @pytest.mark.asyncio async def test_reject_when_session_cookie_and_invalid_csrf_token(self): """Session cookie + invalid CSRF token should return 403.""" request = _make_request( cookies={"mcp_gateway_session": "test-session"}, headers={"X-CSRF-Token": "invalid-token-value"}, ) with pytest.raises(HTTPException) as exc_info: await verify_csrf_token_flexible(request) assert exc_info.value.status_code == 403 assert "invalid token" in exc_info.value.detail @pytest.mark.asyncio async def test_pass_when_session_cookie_and_valid_csrf_header(self): """Session cookie + valid CSRF token in header should pass.""" session_id = "test-session-id" csrf_token = generate_csrf_token(session_id) request = _make_request( cookies={"mcp_gateway_session": session_id}, headers={"X-CSRF-Token": csrf_token}, ) await verify_csrf_token_flexible(request) @pytest.mark.asyncio async def test_pass_when_session_cookie_and_valid_csrf_form(self): """Session cookie + valid CSRF token in form data should pass.""" session_id = "test-session-id" csrf_token = generate_csrf_token(session_id) request = _make_request( cookies={"mcp_gateway_session": session_id}, headers={}, form_data={"csrf_token": csrf_token}, ) await verify_csrf_token_flexible(request) @pytest.mark.asyncio async def test_header_token_takes_precedence_over_form(self): """X-CSRF-Token header should be checked before form data.""" session_id = "test-session-id" valid_token = generate_csrf_token(session_id) request = _make_request( cookies={"mcp_gateway_session": session_id}, headers={"X-CSRF-Token": valid_token}, form_data={"csrf_token": "wrong-token"}, ) await verify_csrf_token_flexible(request) ================================================ FILE: tests/unit/auth/test_dependencies.py ================================================ """ Unit tests for registry/auth/dependencies.py Tests all authentication dependencies including: - Session validation and extraction - User context building - Scope mapping - Permission checking - UI permissions - Server access control """ import logging from pathlib import Path from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest import yaml from fastapi import HTTPException, Request from itsdangerous import SignatureExpired, URLSafeTimedSerializer from registry.auth.dependencies import ( _user_is_admin, api_auth, create_session_cookie, enhanced_auth, get_accessible_agents_for_user, get_accessible_services_for_user, get_current_user, get_servers_for_scope, get_ui_permissions_for_user, get_user_accessible_servers, get_user_session_data, map_cognito_groups_to_scopes, nginx_proxied_auth, user_can_access_server, user_can_modify_servers, user_has_ui_permission_for_service, user_has_wildcard_access, web_auth, ) from tests.fixtures.mocks.mock_auth import MockSessionValidator logger = logging.getLogger(__name__) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def test_secret_key() -> str: """Secret key for session signing.""" return "test-secret-key-for-unit-tests" @pytest.fixture def mock_signer(test_secret_key: str, monkeypatch): """Mock URLSafeTimedSerializer for session signing.""" signer = URLSafeTimedSerializer(test_secret_key) # Patch the module-level signer monkeypatch.setattr("registry.auth.dependencies.signer", signer) return signer @pytest.fixture def sample_scopes_config() -> dict[str, Any]: """Sample scopes configuration for testing.""" return { "UI-Scopes": { "mcp-registry-admin": { "list_agents": ["all"], "get_agent": ["all"], "publish_agent": ["all"], "modify_agent": ["all"], "delete_agent": ["all"], "list_service": ["all"], "register_service": ["all"], "toggle_service": ["all"], }, "registry-admins": { "list_agents": ["all"], "get_agent": ["all"], "publish_agent": ["all"], "modify_agent": ["all"], "delete_agent": ["all"], "list_service": ["all"], "register_service": ["all"], "toggle_service": ["all"], }, "registry-users-lob1": { "list_agents": ["/code-reviewer", "/test-automation"], "get_agent": ["/code-reviewer", "/test-automation"], "list_service": ["currenttime", "mcpgw"], }, }, "group_mappings": { "mcp-registry-admin": [ "mcp-registry-admin", "mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute", ], "registry-admins": [ "registry-admins", "mcp-servers-unrestricted/read", "mcp-servers-unrestricted/execute", ], "registry-users-lob1": ["registry-users-lob1"], }, "mcp-servers-unrestricted/read": [ { "server": "*", "methods": ["initialize", "tools/list", "tools/call"], "tools": "*", } ], "mcp-servers-unrestricted/execute": [ { "server": "*", "methods": ["initialize", "GET", "POST", "PUT", "DELETE"], "tools": "*", } ], "registry-admins": [ { "server": "*", "methods": [ "initialize", "GET", "POST", "PUT", "DELETE", "tools/list", "tools/call", ], "tools": "*", } ], "registry-users-lob1": [ { "server": "currenttime", "methods": ["initialize", "tools/list"], "tools": ["current_time_by_timezone"], } ], } @pytest.fixture def mock_scopes_config(sample_scopes_config: dict[str, Any], monkeypatch): """Mock SCOPES_CONFIG global variable and scope repository.""" # Keep existing monkeypatch for backward compatibility monkeypatch.setattr("registry.auth.dependencies.SCOPES_CONFIG", sample_scopes_config) # Create mock repository mock_repo = AsyncMock() # Configure get_group_mappings based on sample config async def mock_get_group_mappings(group: str): group_mappings = sample_scopes_config.get("group_mappings", {}) return group_mappings.get(group, []) # Configure get_ui_scopes based on sample config async def mock_get_ui_scopes(scope: str): ui_scopes = sample_scopes_config.get("UI-Scopes", {}) return ui_scopes.get(scope, {}) # Configure get_server_scopes based on sample config async def mock_get_server_scopes(scope: str): # Check in the main config for scope definitions # The scope config is stored directly as a key in sample_scopes_config # Return the raw config (list of dicts), not extracted server names scope_config = sample_scopes_config.get(scope, []) if scope_config and isinstance(scope_config, list): return scope_config return [] mock_repo.get_group_mappings.side_effect = mock_get_group_mappings mock_repo.get_ui_scopes.side_effect = mock_get_ui_scopes mock_repo.get_server_scopes.side_effect = mock_get_server_scopes # Patch get_scope_repository to return our mock using patch context manager # Since it's imported locally in functions, we need to patch the import with patch("registry.repositories.factory.get_scope_repository", return_value=mock_repo): yield sample_scopes_config @pytest.fixture def mock_session_validator(test_secret_key: str): """Create a mock session validator.""" return MockSessionValidator(secret_key=test_secret_key) # ============================================================================= # TEST: get_current_user # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestGetCurrentUser: """Tests for get_current_user dependency.""" def test_get_current_user_with_valid_session(self, mock_signer: URLSafeTimedSerializer): """Test extracting user from valid session cookie.""" # Arrange session_data = {"username": "testuser"} session_cookie = mock_signer.dumps(session_data) # Act username = get_current_user(session=session_cookie) # Assert assert username == "testuser" def test_get_current_user_no_session_cookie(self): """Test that missing session cookie raises 401.""" # Act & Assert with pytest.raises(HTTPException) as exc_info: get_current_user(session=None) assert exc_info.value.status_code == 401 assert "Authentication required" in exc_info.value.detail def test_get_current_user_expired_session(self, mock_signer: URLSafeTimedSerializer): """Test that expired session raises 401.""" # Arrange session_data = {"username": "testuser"} session_cookie = mock_signer.dumps(session_data) # Mock signature expired exception with patch.object(mock_signer, "loads", side_effect=SignatureExpired("Expired")): # Act & Assert with pytest.raises(HTTPException) as exc_info: get_current_user(session=session_cookie) assert exc_info.value.status_code == 401 assert "expired" in exc_info.value.detail.lower() def test_get_current_user_invalid_signature(self, mock_signer: URLSafeTimedSerializer): """Test that invalid signature raises 401.""" # Arrange invalid_session = "invalid.session.cookie" # Act & Assert with pytest.raises(HTTPException) as exc_info: get_current_user(session=invalid_session) assert exc_info.value.status_code == 401 assert "Invalid session" in exc_info.value.detail def test_get_current_user_no_username_in_session(self, mock_signer: URLSafeTimedSerializer): """Test that session without username raises 401.""" # Arrange session_data = {"other_field": "value"} session_cookie = mock_signer.dumps(session_data) # Act & Assert with pytest.raises(HTTPException) as exc_info: get_current_user(session=session_cookie) assert exc_info.value.status_code == 401 # Note: The actual message is "Authentication failed" due to exception handling # in the code (the inner HTTPException is caught by outer except) assert ( "Authentication failed" in exc_info.value.detail or "Invalid session data" in exc_info.value.detail ) # ============================================================================= # TEST: get_user_session_data # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestGetUserSessionData: """Tests for get_user_session_data dependency.""" def test_get_session_data_traditional_user_rejected(self, mock_signer: URLSafeTimedSerializer): """Test that non-OAuth2 sessions are rejected.""" # Arrange session_data = { "username": "admin", "auth_method": "traditional", } session_cookie = mock_signer.dumps(session_data) # Act & Assert - traditional sessions should be rejected with pytest.raises(HTTPException) as exc_info: get_user_session_data(session=session_cookie) assert exc_info.value.status_code == 401 def test_get_session_data_oauth2_user(self, mock_signer: URLSafeTimedSerializer): """Test extracting session data for OAuth2 user.""" # Arrange session_data = { "username": "oauth_user", "auth_method": "oauth2", "groups": ["registry-users-lob1"], "provider": "cognito", } session_cookie = mock_signer.dumps(session_data) # Act result = get_user_session_data(session=session_cookie) # Assert assert result["username"] == "oauth_user" assert result["auth_method"] == "oauth2" assert result["groups"] == ["registry-users-lob1"] # OAuth2 users don't get default admin privileges assert "scopes" not in result or "mcp-registry-admin" not in result.get("scopes", []) def test_get_session_data_no_session(self): """Test that missing session raises 401.""" # Act & Assert with pytest.raises(HTTPException) as exc_info: get_user_session_data(session=None) assert exc_info.value.status_code == 401 assert "Authentication required" in exc_info.value.detail def test_get_session_data_expired(self, mock_signer: URLSafeTimedSerializer): """Test that expired session raises 401.""" # Arrange session_cookie = "some.session.cookie" with patch.object(mock_signer, "loads", side_effect=SignatureExpired("Expired")): # Act & Assert with pytest.raises(HTTPException) as exc_info: get_user_session_data(session=session_cookie) assert exc_info.value.status_code == 401 assert "expired" in exc_info.value.detail.lower() # ============================================================================= # TEST: load_scopes_config # ============================================================================= @pytest.mark.unit @pytest.mark.auth @pytest.mark.skip(reason="load_scopes_config function does not exist in dependencies.py") class TestLoadScopesConfig: """Tests for load_scopes_config function.""" def test_load_scopes_config_from_default_path(self, tmp_path: Path, monkeypatch): """Test loading scopes config from default path.""" # Arrange scopes_file = tmp_path / "auth_server" / "scopes.yml" scopes_file.parent.mkdir(parents=True) test_config = { "group_mappings": { "test-group": ["test-scope"], } } with open(scopes_file, "w") as f: yaml.safe_dump(test_config, f) # Set env var to point to our test file monkeypatch.setenv("SCOPES_CONFIG_PATH", str(scopes_file)) # Act config = load_scopes_config() # Assert assert "group_mappings" in config assert "test-group" in config["group_mappings"] def test_load_scopes_config_from_env_var(self, tmp_path: Path, monkeypatch): """Test loading scopes config from SCOPES_CONFIG_PATH env var.""" # Arrange scopes_file = tmp_path / "custom_scopes.yml" test_config = { "group_mappings": { "custom-group": ["custom-scope"], } } with open(scopes_file, "w") as f: yaml.safe_dump(test_config, f) monkeypatch.setenv("SCOPES_CONFIG_PATH", str(scopes_file)) # Act config = load_scopes_config() # Assert assert "group_mappings" in config assert "custom-group" in config["group_mappings"] def test_load_scopes_config_file_not_found(self, monkeypatch): """Test that missing scopes file returns empty dict.""" # Arrange monkeypatch.delenv("SCOPES_CONFIG_PATH", raising=False) # Mock Path to always return non-existent file with patch("registry.auth.dependencies.Path") as mock_path: mock_path.return_value.exists.return_value = False mock_path.return_value.parent.exists.return_value = True mock_path.return_value.parent.iterdir.return_value = [] # Act config = load_scopes_config() # Assert assert config == {} def test_load_scopes_config_yaml_error(self, tmp_path: Path, monkeypatch): """Test that YAML parsing error returns empty dict.""" # Arrange scopes_file = tmp_path / "invalid_scopes.yml" scopes_file.write_text("invalid: yaml: content: [") monkeypatch.setenv("SCOPES_CONFIG_PATH", str(scopes_file)) # Act config = load_scopes_config() # Assert assert config == {} # ============================================================================= # TEST: map_cognito_groups_to_scopes # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestMapCognitoGroupsToScopes: """Tests for map_cognito_groups_to_scopes function.""" @pytest.mark.asyncio async def test_map_admin_group(self, mock_scopes_config: dict[str, Any]): """Test mapping admin group to scopes.""" # Arrange groups = ["mcp-registry-admin"] # Act scopes = await map_cognito_groups_to_scopes(groups) # Assert assert "mcp-registry-admin" in scopes assert "mcp-servers-unrestricted/read" in scopes assert "mcp-servers-unrestricted/execute" in scopes @pytest.mark.asyncio async def test_map_lob1_group(self, mock_scopes_config: dict[str, Any]): """Test mapping LOB1 group to scopes.""" # Arrange groups = ["registry-users-lob1"] # Act scopes = await map_cognito_groups_to_scopes(groups) # Assert assert "registry-users-lob1" in scopes assert "mcp-registry-admin" not in scopes @pytest.mark.asyncio async def test_map_multiple_groups(self, mock_scopes_config: dict[str, Any]): """Test mapping multiple groups removes duplicates.""" # Arrange groups = ["mcp-registry-admin", "registry-users-lob1"] # Act scopes = await map_cognito_groups_to_scopes(groups) # Assert assert "mcp-registry-admin" in scopes assert "registry-users-lob1" in scopes # Verify no duplicates assert len(scopes) == len(set(scopes)) @pytest.mark.asyncio async def test_map_unknown_group(self, mock_scopes_config: dict[str, Any]): """Test mapping unknown group returns empty list.""" # Arrange groups = ["unknown-group"] # Act scopes = await map_cognito_groups_to_scopes(groups) # Assert assert scopes == [] @pytest.mark.asyncio async def test_map_empty_groups(self, mock_scopes_config: dict[str, Any]): """Test mapping empty groups list.""" # Arrange groups = [] # Act scopes = await map_cognito_groups_to_scopes(groups) # Assert assert scopes == [] # ============================================================================= # TEST: get_ui_permissions_for_user # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestGetUIPermissionsForUser: """Tests for get_ui_permissions_for_user function.""" @pytest.mark.asyncio async def test_admin_ui_permissions(self, mock_scopes_config: dict[str, Any]): """Test admin user gets all UI permissions.""" # Arrange user_scopes = ["mcp-registry-admin"] # Act permissions = await get_ui_permissions_for_user(user_scopes) # Assert assert "list_agents" in permissions assert "all" in permissions["list_agents"] assert "list_service" in permissions assert "all" in permissions["list_service"] @pytest.mark.asyncio async def test_lob1_ui_permissions(self, mock_scopes_config: dict[str, Any]): """Test LOB1 user gets restricted UI permissions.""" # Arrange user_scopes = ["registry-users-lob1"] # Act permissions = await get_ui_permissions_for_user(user_scopes) # Assert assert "list_agents" in permissions assert "/code-reviewer" in permissions["list_agents"] assert "/test-automation" in permissions["list_agents"] assert "all" not in permissions["list_agents"] @pytest.mark.asyncio async def test_no_scopes_no_permissions(self, mock_scopes_config: dict[str, Any]): """Test user with no scopes gets no permissions.""" # Arrange user_scopes = [] # Act permissions = await get_ui_permissions_for_user(user_scopes) # Assert assert permissions == {} @pytest.mark.asyncio async def test_unknown_scope_no_permissions(self, mock_scopes_config: dict[str, Any]): """Test unknown scope grants no permissions.""" # Arrange user_scopes = ["unknown-scope"] # Act permissions = await get_ui_permissions_for_user(user_scopes) # Assert assert permissions == {} # ============================================================================= # TEST: user_has_ui_permission_for_service # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestUserHasUIPermissionForService: """Tests for user_has_ui_permission_for_service function.""" def test_has_permission_for_all_services(self): """Test user with 'all' permission can access any service.""" # Arrange permissions = {"list_service": ["all"]} # Act & Assert assert user_has_ui_permission_for_service("list_service", "any_service", permissions) def test_has_permission_for_specific_service(self): """Test user with specific service permission.""" # Arrange permissions = {"list_service": ["currenttime", "mcpgw"]} # Act & Assert assert user_has_ui_permission_for_service("list_service", "currenttime", permissions) assert user_has_ui_permission_for_service("list_service", "mcpgw", permissions) def test_no_permission_for_service(self): """Test user without permission for service.""" # Arrange permissions = {"list_service": ["currenttime"]} # Act & Assert assert not user_has_ui_permission_for_service("list_service", "other_service", permissions) def test_permission_not_in_user_permissions(self): """Test permission type not in user's permissions.""" # Arrange permissions = {"list_service": ["currenttime"]} # Act & Assert assert not user_has_ui_permission_for_service( "register_service", "currenttime", permissions ) # ============================================================================= # TEST: get_accessible_services_for_user # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestGetAccessibleServicesForUser: """Tests for get_accessible_services_for_user function.""" def test_all_services_accessible(self): """Test user with 'all' can access all services.""" # Arrange permissions = {"list_service": ["all"]} # Act services = get_accessible_services_for_user(permissions) # Assert assert services == ["all"] def test_specific_services_accessible(self): """Test user with specific services.""" # Arrange permissions = {"list_service": ["currenttime", "mcpgw"]} # Act services = get_accessible_services_for_user(permissions) # Assert assert "currenttime" in services assert "mcpgw" in services def test_no_list_permission(self): """Test user without list_service permission.""" # Arrange permissions = {"other_permission": ["service1"]} # Act services = get_accessible_services_for_user(permissions) # Assert assert services == [] # ============================================================================= # TEST: get_accessible_agents_for_user # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestGetAccessibleAgentsForUser: """Tests for get_accessible_agents_for_user function.""" def test_all_agents_accessible(self): """Test user with 'all' can access all agents.""" # Arrange permissions = {"list_agents": ["all"]} # Act agents = get_accessible_agents_for_user(permissions) # Assert assert agents == ["all"] def test_specific_agents_accessible(self): """Test user with specific agents.""" # Arrange permissions = {"list_agents": ["/code-reviewer", "/test-automation"]} # Act agents = get_accessible_agents_for_user(permissions) # Assert assert "/code-reviewer" in agents assert "/test-automation" in agents def test_no_list_agents_permission(self): """Test user without list_agents permission.""" # Arrange permissions = {"other_permission": ["/agent1"]} # Act agents = get_accessible_agents_for_user(permissions) # Assert assert agents == [] # ============================================================================= # TEST: get_servers_for_scope # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestGetServersForScope: """Tests for get_servers_for_scope function.""" @pytest.mark.asyncio async def test_wildcard_scope_returns_wildcard(self, mock_scopes_config: dict[str, Any]): """Test wildcard scope returns wildcard server.""" # Act servers = await get_servers_for_scope("mcp-servers-unrestricted/read") # Assert assert "*" in servers @pytest.mark.asyncio async def test_specific_scope_returns_servers(self, mock_scopes_config: dict[str, Any]): """Test specific scope returns specific servers.""" # Act servers = await get_servers_for_scope("registry-users-lob1") # Assert assert "currenttime" in servers @pytest.mark.asyncio async def test_unknown_scope_returns_empty(self, mock_scopes_config: dict[str, Any]): """Test unknown scope returns empty list.""" # Act servers = await get_servers_for_scope("unknown-scope") # Assert assert servers == [] # ============================================================================= # TEST: user_has_wildcard_access # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestUserHasWildcardAccess: """Tests for user_has_wildcard_access function.""" @pytest.mark.asyncio async def test_admin_has_wildcard_access(self, mock_scopes_config: dict[str, Any]): """Test admin user has wildcard access.""" # Arrange scopes = ["mcp-servers-unrestricted/read"] # Act has_access = await user_has_wildcard_access(scopes) # Assert assert has_access is True @pytest.mark.asyncio async def test_restricted_user_no_wildcard_access(self, mock_scopes_config: dict[str, Any]): """Test restricted user has no wildcard access.""" # Arrange scopes = ["registry-users-lob1"] # Act has_access = await user_has_wildcard_access(scopes) # Assert assert has_access is False @pytest.mark.asyncio async def test_no_scopes_no_wildcard_access(self, mock_scopes_config: dict[str, Any]): """Test user with no scopes has no wildcard access.""" # Arrange scopes = [] # Act has_access = await user_has_wildcard_access(scopes) # Assert assert has_access is False # ============================================================================= # TEST: get_user_accessible_servers # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestGetUserAccessibleServers: """Tests for get_user_accessible_servers function.""" @pytest.mark.asyncio async def test_admin_access_all_servers(self, mock_scopes_config: dict[str, Any]): """Test admin user can access all servers (wildcard).""" # Arrange scopes = ["mcp-servers-unrestricted/read"] # Act servers = await get_user_accessible_servers(scopes) # Assert assert "*" in servers @pytest.mark.asyncio async def test_lob1_access_specific_servers(self, mock_scopes_config: dict[str, Any]): """Test LOB1 user can access specific servers.""" # Arrange scopes = ["registry-users-lob1"] # Act servers = await get_user_accessible_servers(scopes) # Assert assert "currenttime" in servers assert "*" not in servers @pytest.mark.asyncio async def test_multiple_scopes_combine_servers(self, mock_scopes_config: dict[str, Any]): """Test multiple scopes combine accessible servers.""" # Arrange scopes = [ "registry-users-lob1", "mcp-servers-unrestricted/read", ] # Act servers = await get_user_accessible_servers(scopes) # Assert assert "currenttime" in servers assert "*" in servers # ============================================================================= # TEST: user_can_modify_servers # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestUserCanModifyServers: """Tests for user_can_modify_servers function.""" def test_admin_can_modify(self): """Test admin group can modify servers.""" # Arrange groups = ["mcp-registry-admin"] scopes = ["mcp-servers-unrestricted/execute"] # Act can_modify = user_can_modify_servers(groups, scopes) # Assert assert can_modify is True def test_execute_scope_can_modify(self): """Test user with execute scope can modify.""" # Arrange groups = [] scopes = ["mcp-servers-unrestricted/execute"] # Act can_modify = user_can_modify_servers(groups, scopes) # Assert assert can_modify is True def test_read_only_cannot_modify(self): """Test read-only user cannot modify.""" # Arrange groups = ["registry-users-lob1"] scopes = ["registry-users-lob1"] # Act can_modify = user_can_modify_servers(groups, scopes) # Assert assert can_modify is False def test_any_execute_scope_can_modify(self): """Test any execute scope grants modify permission.""" # Arrange groups = [] scopes = ["some-scope/execute"] # Act can_modify = user_can_modify_servers(groups, scopes) # Assert assert can_modify is True # ============================================================================= # TEST: user_can_access_server # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestUserCanAccessServer: """Tests for user_can_access_server function.""" @pytest.mark.asyncio async def test_admin_can_access_any_server(self, mock_scopes_config: dict[str, Any]): """Test admin can access any server.""" # Arrange scopes = ["mcp-servers-unrestricted/read"] # Act & Assert # Admin has wildcard in accessible servers # Note: The implementation checks if server name is in accessible_servers list # For wildcard access, "*" is in the list, but specific server names won't match # This test documents current behavior - wildcard doesn't match arbitrary names # User needs to check for "*" in accessible_servers separately accessible_servers = await get_user_accessible_servers(scopes) assert "*" in accessible_servers # The function doesn't expand wildcard, so specific server check returns False # This is expected behavior - caller should check for "*" separately assert not await user_can_access_server("any-server", scopes) @pytest.mark.asyncio async def test_user_can_access_allowed_server(self, mock_scopes_config: dict[str, Any]): """Test user can access allowed server.""" # Arrange scopes = ["registry-users-lob1"] # Act & Assert assert await user_can_access_server("currenttime", scopes) @pytest.mark.asyncio async def test_user_cannot_access_disallowed_server(self, mock_scopes_config: dict[str, Any]): """Test user cannot access disallowed server.""" # Arrange scopes = ["registry-users-lob1"] # Act & Assert assert not await user_can_access_server("other-server", scopes) # ============================================================================= # TEST: create_session_cookie # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestCreateSessionCookie: """Tests for create_session_cookie function.""" def test_create_default_session(self, mock_signer: URLSafeTimedSerializer): """Test creating session cookie with default auth_method (oauth2).""" # Act session_cookie = create_session_cookie( username="testuser", ) # Assert assert session_cookie is not None # Validate we can decode it data = mock_signer.loads(session_cookie) assert data["username"] == "testuser" assert data["auth_method"] == "oauth2" assert data["provider"] == "local" def test_create_oauth2_session(self, mock_signer: URLSafeTimedSerializer): """Test creating OAuth2 session cookie.""" # Act session_cookie = create_session_cookie( username="oauth_user", auth_method="oauth2", provider="cognito" ) # Assert assert session_cookie is not None data = mock_signer.loads(session_cookie) assert data["username"] == "oauth_user" assert data["auth_method"] == "oauth2" assert data["provider"] == "cognito" # ============================================================================= # TEST: api_auth and web_auth # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestAuthWrappers: """Tests for api_auth and web_auth wrapper functions.""" def test_api_auth_calls_get_current_user(self, mock_signer: URLSafeTimedSerializer): """Test api_auth delegates to get_current_user.""" # Arrange session_data = {"username": "apiuser"} session_cookie = mock_signer.dumps(session_data) # Act username = api_auth(session=session_cookie) # Assert assert username == "apiuser" def test_web_auth_calls_get_current_user(self, mock_signer: URLSafeTimedSerializer): """Test web_auth delegates to get_current_user.""" # Arrange session_data = {"username": "webuser"} session_cookie = mock_signer.dumps(session_data) # Act username = web_auth(session=session_cookie) # Assert assert username == "webuser" # ============================================================================= # TEST: enhanced_auth # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestEnhancedAuth: """Tests for enhanced_auth dependency.""" @pytest.mark.asyncio async def test_enhanced_auth_traditional_user_rejected( self, mock_signer: URLSafeTimedSerializer, mock_scopes_config: dict[str, Any], ): """Test enhanced_auth rejects traditional (non-OAuth2) sessions.""" # Arrange session_data = { "username": "admin", "auth_method": "traditional", "provider": "local", } session_cookie = mock_signer.dumps(session_data) mock_request = Mock(spec=Request) mock_request.state = Mock() # Act & Assert - traditional sessions should be rejected with pytest.raises(HTTPException) as exc_info: await enhanced_auth(request=mock_request, session=session_cookie) assert exc_info.value.status_code == 401 @pytest.mark.asyncio async def test_enhanced_auth_oauth2_user( self, mock_signer: URLSafeTimedSerializer, mock_scopes_config: dict[str, Any], ): """Test enhanced_auth for OAuth2 user.""" # Arrange session_data = { "username": "oauth_user", "auth_method": "oauth2", "provider": "cognito", "groups": ["registry-users-lob1"], } session_cookie = mock_signer.dumps(session_data) mock_request = Mock(spec=Request) mock_request.state = Mock() # Act context = await enhanced_auth(request=mock_request, session=session_cookie) # Assert assert context["username"] == "oauth_user" assert context["auth_method"] == "oauth2" assert "registry-users-lob1" in context["groups"] assert context["can_modify_servers"] is False assert context["is_admin"] is False @pytest.mark.asyncio async def test_enhanced_auth_no_session(self): """Test enhanced_auth raises 401 without session.""" # Arrange mock_request = Mock(spec=Request) mock_request.state = Mock() # Act & Assert with pytest.raises(HTTPException) as exc_info: await enhanced_auth(request=mock_request, session=None) assert exc_info.value.status_code == 401 # ============================================================================= # TEST: nginx_proxied_auth # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestNginxProxiedAuth: """Tests for nginx_proxied_auth dependency.""" @pytest.mark.asyncio async def test_nginx_auth_with_headers(self, mock_scopes_config: dict[str, Any]): """Test nginx auth with X-User headers.""" # Arrange mock_request = Mock(spec=Request) mock_request.url.path = "/api/test" mock_request.method = "GET" mock_request.state = Mock() mock_request.headers = { "x-user": "nginx_user", "x-username": "nginx_user", "x-scopes": "mcp-servers-unrestricted/read mcp-servers-unrestricted/execute", "x-auth-method": "keycloak", } # Act context = await nginx_proxied_auth( request=mock_request, session=None, x_user="nginx_user", x_username="nginx_user", x_scopes="mcp-servers-unrestricted/read mcp-servers-unrestricted/execute", x_auth_method="keycloak", ) # Assert assert context["username"] == "nginx_user" assert context["auth_method"] == "keycloak" assert "mcp-servers-unrestricted/read" in context["scopes"] assert "mcp-registry-admin" in context["groups"] @pytest.mark.asyncio async def test_nginx_auth_fallback_to_session_oauth2( self, mock_signer: URLSafeTimedSerializer, mock_scopes_config: dict[str, Any], ): """Test nginx auth falls back to OAuth2 session cookie.""" # Arrange mock_request = Mock(spec=Request) mock_request.url.path = "/api/test" mock_request.method = "GET" mock_request.state = Mock() mock_request.headers = {} session_data = { "username": "session_user", "auth_method": "oauth2", "provider": "cognito", "groups": ["registry-admins"], } session_cookie = mock_signer.dumps(session_data) # Act context = await nginx_proxied_auth( request=mock_request, session=session_cookie, x_user=None, x_username=None, x_scopes=None, x_auth_method=None, x_client_id=None, ) # Assert assert context["username"] == "session_user" assert context["auth_method"] == "oauth2" @pytest.mark.asyncio async def test_nginx_auth_fallback_rejects_traditional_session( self, mock_signer: URLSafeTimedSerializer, mock_scopes_config: dict[str, Any], ): """Test nginx auth rejects traditional (non-OAuth2) session cookies.""" # Arrange mock_request = Mock(spec=Request) mock_request.url.path = "/api/test" mock_request.method = "GET" mock_request.state = Mock() mock_request.headers = {} session_data = { "username": "session_user", "auth_method": "traditional", } session_cookie = mock_signer.dumps(session_data) # Act & Assert - traditional sessions should be rejected with pytest.raises(HTTPException) as exc_info: await nginx_proxied_auth( request=mock_request, session=session_cookie, x_user=None, x_username=None, x_scopes=None, x_auth_method=None, x_client_id=None, ) assert exc_info.value.status_code == 401 @pytest.mark.asyncio async def test_nginx_auth_oauth2_user_without_admin_scopes( self, mock_scopes_config: dict[str, Any] ): """Test OAuth2 user without admin scopes gets user group.""" # Arrange mock_request = Mock(spec=Request) mock_request.url.path = "/api/test" mock_request.method = "GET" mock_request.state = Mock() mock_request.headers = {} # Act context = await nginx_proxied_auth( request=mock_request, session=None, x_user="oauth_user", x_username="oauth_user", x_scopes="registry-users-lob1", x_auth_method="cognito", ) # Assert assert context["username"] == "oauth_user" assert "mcp-registry-user" in context["groups"] assert "mcp-registry-admin" not in context["groups"] # ============================================================================= # TEST: Edge Cases and Error Handling # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestEdgeCases: """Tests for edge cases and error handling.""" def test_session_with_empty_username(self, mock_signer: URLSafeTimedSerializer): """Test session with empty string username.""" # Arrange session_data = {"username": ""} session_cookie = mock_signer.dumps(session_data) # Act & Assert with pytest.raises(HTTPException) as exc_info: get_current_user(session=session_cookie) assert exc_info.value.status_code == 401 @pytest.mark.asyncio async def test_scopes_deduplication(self, mock_scopes_config: dict[str, Any]): """Test that duplicate scopes are removed.""" # Arrange - create mock repository that returns duplicate scopes mock_repo = AsyncMock() async def mock_get_group_mappings_with_duplicates(group: str): if group == "test-group": return ["scope1", "scope2", "scope1"] return [] mock_repo.get_group_mappings.side_effect = mock_get_group_mappings_with_duplicates with patch("registry.repositories.factory.get_scope_repository", return_value=mock_repo): # Act scopes = await map_cognito_groups_to_scopes(["test-group"]) # Assert assert len(scopes) == len(set(scopes)) # No duplicates assert scopes.count("scope1") == 1 @pytest.mark.asyncio async def test_enhanced_auth_oauth2_no_groups( self, mock_signer: URLSafeTimedSerializer, mock_scopes_config: dict[str, Any], ): """Test OAuth2 user with no groups gets minimal permissions.""" # Arrange session_data = { "username": "no_groups_user", "auth_method": "oauth2", "groups": [], } session_cookie = mock_signer.dumps(session_data) mock_request = Mock(spec=Request) mock_request.state = Mock() # Act context = await enhanced_auth(request=mock_request, session=session_cookie) # Assert assert context["username"] == "no_groups_user" assert context["groups"] == [] assert context["scopes"] == [] assert context["can_modify_servers"] is False def test_ui_permissions_with_all_and_specific(self, mock_scopes_config: dict[str, Any]): """Test UI permissions handles 'all' with specific services.""" # Arrange - Create permissions with both 'all' and specific permissions = {"list_service": ["all", "currenttime"]} # Act & Assert assert user_has_ui_permission_for_service("list_service", "any_service", permissions) # ============================================================================= # NETWORK-TRUSTED AUTH METHOD TESTS # ============================================================================= class TestNetworkTrustedAuthMethod: """Tests for network-trusted auth method in nginx_proxied_auth (issue #357).""" @pytest.mark.asyncio async def test_network_trusted_with_admin_scopes_gets_admin( self, mock_scopes_config: dict[str, Any] ): """Test network-trusted auth method with admin scopes resolves to admin. After issue #779, network-trusted goes through the standard resolution path (hard-coded admin branch removed). The auth server now returns the full scope set including UI scope names (e.g. mcp-registry-admin) so the registry can derive admin status via _user_is_admin. """ # Arrange mock_request = Mock(spec=Request) mock_request.url.path = "/api/servers" mock_request.method = "GET" mock_request.headers = {} # Act: scopes now include the UI scope name for admin resolution context = await nginx_proxied_auth( request=mock_request, session=None, x_user="network-user", x_username="network-user", x_scopes="mcp-registry-admin mcp-servers-unrestricted/read mcp-servers-unrestricted/execute", x_auth_method="network-trusted", ) # Assert assert context["username"] == "network-user" assert context["auth_method"] == "network-trusted" assert "mcp-registry-admin" in context["groups"] assert "mcp-servers-unrestricted/read" in context["scopes"] assert "mcp-servers-unrestricted/execute" in context["scopes"] assert context["is_admin"] is True @pytest.mark.asyncio async def test_network_trusted_readonly_scopes_not_admin( self, mock_scopes_config: dict[str, Any] ): """Test network-trusted with read-only scopes does NOT get admin (issue #779).""" # Arrange mock_request = Mock(spec=Request) mock_request.url.path = "/api/servers" mock_request.method = "GET" mock_request.headers = {} # Act: read-only scopes only context = await nginx_proxied_auth( request=mock_request, session=None, x_user="monitoring-script", x_username="monitoring-script", x_scopes="registry-users-lob1", x_auth_method="network-trusted", ) # Assert assert context["username"] == "monitoring-script" assert context["is_admin"] is False # ============================================================================= # TEST: _user_is_admin (issue #663) # ============================================================================= @pytest.mark.unit @pytest.mark.auth class TestUserIsAdmin: """Tests for _user_is_admin function. Verifies that admin status is derived from mutating UI-Scopes actions (register_, modify_, toggle_, delete_, publish_, create_) with 'all' resources, NOT from server: '*' wildcard access. See GitHub issue #663. """ @pytest.mark.parametrize( "action", [ "register_service", "modify_service", "toggle_service", "delete_service", "publish_agent", "modify_agent", "delete_agent", "create_virtual_server", "modify_virtual_server", "delete_virtual_server", ], ) def test_admin_with_mutating_action_all(self, action: str): """User with any mutating action for [all] is admin.""" # Arrange ui_permissions = {action: ["all"], "list_service": ["all"]} # Act result = _user_is_admin(ui_permissions) # Assert assert result is True def test_not_admin_with_only_read_actions(self): """Consumer with only read-only permissions is not admin (issue #663 core fix).""" # Arrange ui_permissions = { "list_service": ["all"], "health_check_service": ["all"], "list_agents": ["all"], "get_agent": ["all"], "list_virtual_server": ["all"], } # Act result = _user_is_admin(ui_permissions) # Assert assert result is False def test_not_admin_with_specific_server_modify(self): """User with modify_service for specific servers only is not admin.""" # Arrange ui_permissions = {"modify_service": ["server1", "server2"]} # Act result = _user_is_admin(ui_permissions) # Assert assert result is False def test_not_admin_empty_permissions(self): """User with no UI permissions is not admin.""" # Arrange / Act result = _user_is_admin({}) # Assert assert result is False def test_full_admin_permissions_match_registry_admins_json(self): """Full admin role (matching scripts/registry-admins.json) is admin.""" # Arrange ui_permissions = { "list_agents": ["all"], "get_agent": ["all"], "publish_agent": ["all"], "modify_agent": ["all"], "delete_agent": ["all"], "list_service": ["all"], "register_service": ["all"], "health_check_service": ["all"], "toggle_service": ["all"], "modify_service": ["all"], "delete_service": ["all"], "list_virtual_server": ["all"], "create_virtual_server": ["all"], "modify_virtual_server": ["all"], "delete_virtual_server": ["all"], } # Act result = _user_is_admin(ui_permissions) # Assert assert result is True def test_consumer_with_wildcard_server_not_admin(self): """Issue #663: server: '*' in scopes should NOT trigger is_admin. A consumer role with server: '*' but only read-only UI-Scopes must not be treated as admin. """ # Arrange - consumer has only read-only UI-Scopes ui_permissions = { "list_service": ["all"], "health_check_service": ["all"], "list_agents": ["all"], "get_agent": ["all"], } # Act - even though the user's scopes contain server: '*', # _user_is_admin only checks ui_permissions, not server access result = _user_is_admin(ui_permissions) # Assert assert result is False @pytest.mark.parametrize( "action", [ "list_service", "get_agent", "health_check_service", "list_agents", "list_virtual_server", ], ) def test_read_only_actions_never_grant_admin(self, action: str): """Read-only actions with 'all' do not grant admin status.""" # Arrange ui_permissions = {action: ["all"]} # Act result = _user_is_admin(ui_permissions) # Assert assert result is False ================================================ FILE: tests/unit/cli/__init__.py ================================================ ================================================ FILE: tests/unit/cli/test_agentcore_cross_account.py ================================================ """Unit tests for cross-account support in AgentCore auto-registration. Tests the assume-role logic, multi-account iteration, account ID parsing, and that scanner/builder correctly use cross-account sessions. """ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest # --------------------------------------------------------------------------- # _parse_account_ids # --------------------------------------------------------------------------- class TestParseAccountIds: """Tests for _parse_account_ids helper.""" def test_empty_string_returns_empty_list(self): from cli.agentcore.sync import _parse_account_ids assert _parse_account_ids("") == [] def test_whitespace_only_returns_empty_list(self): from cli.agentcore.sync import _parse_account_ids assert _parse_account_ids(" ") == [] def test_single_account(self): from cli.agentcore.sync import _parse_account_ids assert _parse_account_ids("111122223333") == ["111122223333"] def test_multiple_accounts(self): from cli.agentcore.sync import _parse_account_ids result = _parse_account_ids("111122223333,444455556666,777788889999") assert result == ["111122223333", "444455556666", "777788889999"] def test_strips_whitespace(self): from cli.agentcore.sync import _parse_account_ids result = _parse_account_ids(" 111122223333 , 444455556666 ") assert result == ["111122223333", "444455556666"] def test_ignores_empty_entries(self): from cli.agentcore.sync import _parse_account_ids result = _parse_account_ids("111122223333,,444455556666,") assert result == ["111122223333", "444455556666"] # --------------------------------------------------------------------------- # _assume_role_session # --------------------------------------------------------------------------- class TestAssumeRoleSession: """Tests for _assume_role_session helper.""" @patch("boto3.client") @patch("boto3.Session") def test_assume_role_creates_session(self, mock_session_cls, mock_client_fn): from cli.agentcore.sync import _assume_role_session mock_sts = MagicMock() mock_client_fn.return_value = mock_sts mock_sts.assume_role.return_value = { "Credentials": { "AccessKeyId": "AKID", "SecretAccessKey": "SECRET", "SessionToken": "TOKEN", } } mock_session = MagicMock() mock_session_cls.return_value = mock_session result = _assume_role_session("111122223333", "MyRole", "us-east-2") mock_sts.assume_role.assert_called_once_with( RoleArn="arn:aws:iam::111122223333:role/MyRole", RoleSessionName="agentcore-sync-111122223333", DurationSeconds=3600, ) mock_session_cls.assert_called_once_with( aws_access_key_id="AKID", aws_secret_access_key="SECRET", aws_session_token="TOKEN", region_name="us-east-2", ) assert result == mock_session @patch("boto3.client") def test_assume_role_propagates_error(self, mock_client_fn): from botocore.exceptions import ClientError from cli.agentcore.sync import _assume_role_session mock_sts = MagicMock() mock_client_fn.return_value = mock_sts mock_sts.assume_role.side_effect = ClientError( {"Error": {"Code": "AccessDenied", "Message": "Not authorized"}}, "AssumeRole", ) with pytest.raises(ClientError): _assume_role_session("111122223333", "MyRole", "us-east-2") # --------------------------------------------------------------------------- # Scanner with cross-account session # --------------------------------------------------------------------------- class TestScannerCrossAccount: """Tests that AgentCoreScanner uses the provided session.""" @patch("cli.agentcore.discovery.boto3") def test_scanner_uses_session_client(self, mock_boto3): from cli.agentcore.discovery import AgentCoreScanner mock_session = MagicMock() mock_client = MagicMock() mock_session.client.return_value = mock_client scanner = AgentCoreScanner(region="us-east-2", timeout=5, session=mock_session) # Should use session.client, not boto3.client mock_session.client.assert_called_once() mock_boto3.client.assert_not_called() assert scanner.client == mock_client @patch("cli.agentcore.discovery.boto3") def test_scanner_without_session_uses_default(self, mock_boto3): from cli.agentcore.discovery import AgentCoreScanner mock_client = MagicMock() mock_boto3.client.return_value = mock_client scanner = AgentCoreScanner(region="us-east-2", timeout=5) mock_boto3.client.assert_called_once() assert scanner.client == mock_client # --------------------------------------------------------------------------- # RegistrationBuilder with cross-account session # --------------------------------------------------------------------------- class TestRegistrationBuilderCrossAccount: """Tests that RegistrationBuilder uses the provided session for STS.""" @patch("cli.agentcore.registration.boto3") def test_builder_uses_session_for_account_id(self, mock_boto3): from cli.agentcore.registration import RegistrationBuilder mock_session = MagicMock() mock_sts = MagicMock() mock_session.client.return_value = mock_sts mock_sts.get_caller_identity.return_value = {"Account": "999988887777"} builder = RegistrationBuilder(region="us-east-2", session=mock_session) mock_session.client.assert_called_once_with("sts") mock_boto3.client.assert_not_called() assert builder.account_id == "999988887777" @patch("cli.agentcore.registration.boto3") def test_builder_without_session_uses_default(self, mock_boto3): mock_sts = MagicMock() mock_boto3.client.return_value = mock_sts mock_sts.get_caller_identity.return_value = {"Account": "111122223333"} from cli.agentcore.registration import RegistrationBuilder builder = RegistrationBuilder(region="us-east-2") mock_boto3.client.assert_called_once_with("sts") assert builder.account_id == "111122223333" # --------------------------------------------------------------------------- # CLI argument parsing # --------------------------------------------------------------------------- class TestCLIAccountArgs: """Tests that --accounts and --assume-role-name are parsed correctly.""" def test_accounts_flag_parsed(self): from cli.agentcore.sync import build_parser parser = build_parser() args = parser.parse_args( [ "sync", "--accounts", "111122223333,444455556666", "--assume-role-name", "CrossAccountRole", ] ) assert args.accounts == "111122223333,444455556666" assert args.assume_role_name == "CrossAccountRole" def test_accounts_defaults_to_empty(self): from cli.agentcore.sync import build_parser parser = build_parser() args = parser.parse_args(["sync"]) # Default is empty string (or env var) assert hasattr(args, "accounts") def test_list_subcommand_has_accounts_flag(self): from cli.agentcore.sync import build_parser parser = build_parser() args = parser.parse_args( [ "list", "--accounts", "111122223333", ] ) assert args.accounts == "111122223333" def test_default_role_name(self): from cli.agentcore.sync import build_parser parser = build_parser() args = parser.parse_args(["sync"]) assert args.assume_role_name == "AgentCoreSyncRole" ================================================ FILE: tests/unit/cli/test_agentcore_discovery.py ================================================ """Unit tests for cli.agentcore.discovery — AgentCoreScanner. Tests pagination (multi-page nextToken handling), READY filtering, and error handling (AccessDeniedException, ThrottlingException). """ from __future__ import annotations from unittest.mock import MagicMock, patch import pytest from botocore.exceptions import ClientError # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- def _make_scanner(region: str = "us-east-1", timeout: int = 5): """Create an AgentCoreScanner with a mocked boto3 client.""" with patch("cli.agentcore.discovery.boto3") as mock_boto3: mock_client = MagicMock() mock_boto3.client.return_value = mock_client from cli.agentcore.discovery import AgentCoreScanner scanner = AgentCoreScanner(region=region, timeout=timeout) scanner.client = mock_client return scanner, mock_client # --------------------------------------------------------------------------- # Gateway pagination tests # --------------------------------------------------------------------------- class TestGatewayPagination: """Tests for scan_gateways() pagination via nextToken.""" def test_single_page_no_next_token(self): scanner, client = _make_scanner() client.list_gateways.return_value = { "items": [ {"gatewayId": "gw-1", "status": "READY"}, ], } client.get_gateway.return_value = { "gatewayId": "gw-1", "name": "Gateway One", "status": "READY", } client.list_gateway_targets.return_value = {"items": []} result = scanner.scan_gateways() assert len(result) == 1 assert result[0]["name"] == "Gateway One" client.list_gateways.assert_called_once() def test_multi_page_pagination(self): scanner, client = _make_scanner() client.list_gateways.side_effect = [ { "items": [{"gatewayId": "gw-1", "status": "READY"}], "nextToken": "page2", }, { "items": [{"gatewayId": "gw-2", "status": "READY"}], "nextToken": "page3", }, { "items": [{"gatewayId": "gw-3", "status": "READY"}], }, ] client.get_gateway.side_effect = [ {"gatewayId": "gw-1", "name": "GW1", "status": "READY"}, {"gatewayId": "gw-2", "name": "GW2", "status": "READY"}, {"gatewayId": "gw-3", "name": "GW3", "status": "READY"}, ] client.list_gateway_targets.return_value = {"items": []} result = scanner.scan_gateways() assert len(result) == 3 assert client.list_gateways.call_count == 3 # Verify nextToken was passed on subsequent calls calls = client.list_gateways.call_args_list assert calls[0] == ((), {}) assert calls[1] == ((), {"nextToken": "page2"}) assert calls[2] == ((), {"nextToken": "page3"}) def test_empty_response(self): scanner, client = _make_scanner() client.list_gateways.return_value = {"items": []} result = scanner.scan_gateways() assert len(result) == 0 # --------------------------------------------------------------------------- # Gateway READY filtering tests # --------------------------------------------------------------------------- class TestGatewayReadyFiltering: """Tests for READY status filtering in scan_gateways().""" def test_only_ready_gateways_returned(self): scanner, client = _make_scanner() client.list_gateways.return_value = { "items": [ {"gatewayId": "gw-ready", "status": "READY"}, {"gatewayId": "gw-creating", "status": "CREATING"}, {"gatewayId": "gw-failed", "status": "FAILED"}, {"gatewayId": "gw-deleting", "status": "DELETING"}, ], } client.get_gateway.return_value = { "gatewayId": "gw-ready", "name": "Ready GW", "status": "READY", } client.list_gateway_targets.return_value = {"items": []} result = scanner.scan_gateways() assert len(result) == 1 assert result[0]["gatewayId"] == "gw-ready" client.get_gateway.assert_called_once_with(gatewayIdentifier="gw-ready") def test_no_ready_gateways(self): scanner, client = _make_scanner() client.list_gateways.return_value = { "items": [ {"gatewayId": "gw-1", "status": "CREATING"}, {"gatewayId": "gw-2", "status": "FAILED"}, ], } result = scanner.scan_gateways() assert len(result) == 0 client.get_gateway.assert_not_called() # --------------------------------------------------------------------------- # Runtime pagination tests # --------------------------------------------------------------------------- class TestRuntimePagination: """Tests for scan_runtimes() pagination via nextToken.""" def test_single_page(self): scanner, client = _make_scanner() client.list_agent_runtimes.return_value = { "agentRuntimes": [ {"agentRuntimeId": "rt-1", "status": "READY"}, ], } client.get_agent_runtime.return_value = { "agentRuntimeId": "rt-1", "agentRuntimeName": "Runtime One", "status": "READY", } client.list_agent_runtime_endpoints.return_value = { "runtimeEndpoints": [], } result = scanner.scan_runtimes() assert len(result) == 1 assert result[0]["agentRuntimeName"] == "Runtime One" def test_multi_page_pagination(self): scanner, client = _make_scanner() client.list_agent_runtimes.side_effect = [ { "agentRuntimes": [{"agentRuntimeId": "rt-1", "status": "READY"}], "nextToken": "page2", }, { "agentRuntimes": [{"agentRuntimeId": "rt-2", "status": "READY"}], }, ] client.get_agent_runtime.side_effect = [ {"agentRuntimeId": "rt-1", "agentRuntimeName": "RT1", "status": "READY"}, {"agentRuntimeId": "rt-2", "agentRuntimeName": "RT2", "status": "READY"}, ] client.list_agent_runtime_endpoints.return_value = { "runtimeEndpoints": [], } result = scanner.scan_runtimes() assert len(result) == 2 assert client.list_agent_runtimes.call_count == 2 # --------------------------------------------------------------------------- # Runtime READY filtering tests # --------------------------------------------------------------------------- class TestRuntimeReadyFiltering: """Tests for READY status filtering in scan_runtimes().""" def test_only_ready_runtimes_returned(self): scanner, client = _make_scanner() client.list_agent_runtimes.return_value = { "agentRuntimes": [ {"agentRuntimeId": "rt-ready", "status": "READY"}, {"agentRuntimeId": "rt-creating", "status": "CREATING"}, {"agentRuntimeId": "rt-failed", "status": "FAILED"}, ], } client.get_agent_runtime.return_value = { "agentRuntimeId": "rt-ready", "agentRuntimeName": "Ready RT", "status": "READY", } client.list_agent_runtime_endpoints.return_value = { "runtimeEndpoints": [], } result = scanner.scan_runtimes() assert len(result) == 1 assert result[0]["agentRuntimeId"] == "rt-ready" # --------------------------------------------------------------------------- # Gateway target pagination tests # --------------------------------------------------------------------------- class TestGatewayTargetPagination: """Tests for _get_gateway_targets() pagination.""" def test_target_pagination(self): scanner, client = _make_scanner() client.list_gateway_targets.side_effect = [ { "items": [{"targetId": "t-1", "status": "READY"}], "nextToken": "tpage2", }, { "items": [{"targetId": "t-2", "status": "READY"}], }, ] client.get_gateway_target.side_effect = [ {"targetId": "t-1", "name": "Target1"}, {"targetId": "t-2", "name": "Target2"}, ] targets = scanner._get_gateway_targets("gw-1") assert len(targets) == 2 assert client.list_gateway_targets.call_count == 2 def test_target_ready_filtering(self): scanner, client = _make_scanner() client.list_gateway_targets.return_value = { "items": [ {"targetId": "t-ready", "status": "READY"}, {"targetId": "t-creating", "status": "CREATING"}, ], } client.get_gateway_target.return_value = { "targetId": "t-ready", "name": "Ready Target", } targets = scanner._get_gateway_targets("gw-1") assert len(targets) == 1 client.get_gateway_target.assert_called_once() # --------------------------------------------------------------------------- # Error handling tests (Task 4.4 — discovery portion) # --------------------------------------------------------------------------- class TestDiscoveryErrorHandling: """Tests for AWS API error handling in AgentCoreScanner.""" def test_access_denied_exception_propagates(self): scanner, client = _make_scanner() client.list_gateways.side_effect = ClientError( {"Error": {"Code": "AccessDeniedException", "Message": "Not authorized"}}, "ListGateways", ) with pytest.raises(ClientError) as exc_info: scanner.scan_gateways() assert "AccessDeniedException" in str(exc_info.value) def test_throttling_exception_propagates(self): scanner, client = _make_scanner() client.list_agent_runtimes.side_effect = ClientError( {"Error": {"Code": "ThrottlingException", "Message": "Rate exceeded"}}, "ListAgentRuntimes", ) with pytest.raises(ClientError) as exc_info: scanner.scan_runtimes() assert "ThrottlingException" in str(exc_info.value) ================================================ FILE: tests/unit/cli/test_agentcore_registration.py ================================================ """Unit tests for cli.agentcore.registration — RegistrationBuilder & SyncOrchestrator. Tests registration model building, idempotency checks, overwrite behavior, and error handling (registry 4xx/5xx, retry logic). """ from __future__ import annotations from unittest.mock import MagicMock, patch import requests # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- SAMPLE_GATEWAY = { "gatewayId": "gw-123", "gatewayArn": "arn:aws:bedrock:us-east-1:111122223333:gateway/gw-123", "gatewayUrl": "https://gw.example.com/mcp", "name": "Customer Support Gateway", "description": "Customer support MCP gateway", "status": "READY", "authorizerType": "CUSTOM_JWT", "authorizerConfiguration": { "customJWTAuthorizer": { "discoveryUrl": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_pnikLWYzO/.well-known/openid-configuration", "allowedClients": ["7kqi2l0n47mnfmhfapsf29ch4h"], } }, } SAMPLE_MCP_RUNTIME = { "agentRuntimeId": "rt-mcp-1", "agentRuntimeArn": "arn:aws:bedrock:us-east-1:111122223333:runtime/rt-mcp-1", "agentRuntimeName": "MCP Runtime", "description": "An MCP runtime", "status": "READY", "protocolConfiguration": {"serverProtocol": "MCP"}, } SAMPLE_HTTP_RUNTIME = { "agentRuntimeId": "rt-http-1", "agentRuntimeArn": "arn:aws:bedrock:us-east-1:111122223333:runtime/rt-http-1", "agentRuntimeName": "HTTP Agent", "description": "An HTTP agent", "status": "READY", "protocolConfiguration": {"serverProtocol": "HTTP"}, } SAMPLE_A2A_RUNTIME = { "agentRuntimeId": "rt-a2a-1", "agentRuntimeArn": "arn:aws:bedrock:us-east-1:111122223333:runtime/rt-a2a-1", "agentRuntimeName": "A2A Agent", "description": "An A2A agent", "status": "READY", "protocolConfiguration": {"serverProtocol": "A2A"}, } SAMPLE_MCP_TARGET = { "targetId": "t-mcp-1", "name": "MCP Target", "description": "An MCP server target", "status": "READY", "targetConfiguration": { "mcp": { "mcpServer": { "endpoint": "https://mcp-target.example.com/mcp", } } }, } SAMPLE_LAMBDA_TARGET = { "targetId": "t-lambda-1", "name": "Lambda Target", "status": "READY", "targetConfiguration": {"lambda": {"functionArn": "arn:aws:lambda:us-east-1:111:function:foo"}}, } def _make_builder(region: str = "us-east-1"): """Create a RegistrationBuilder with mocked STS.""" with patch("cli.agentcore.registration.boto3") as mock_boto3: mock_sts = MagicMock() mock_sts.get_caller_identity.return_value = {"Account": "111122223333"} mock_boto3.client.return_value = mock_sts from cli.agentcore.registration import RegistrationBuilder return RegistrationBuilder(region=region) # --------------------------------------------------------------------------- # Task 4.2 — Registration model building # --------------------------------------------------------------------------- class TestGatewayRegistration: """Tests for build_gateway_registration().""" def test_gateway_produces_mcp_server_registration(self): builder = _make_builder() reg = builder.build_gateway_registration(SAMPLE_GATEWAY) assert reg.service_path == "/customer-support-gateway" assert reg.name == "Customer Support Gateway" assert reg.mcp_endpoint == "https://gw.example.com/mcp" assert reg.auth_provider == "bedrock-agentcore" assert reg.auth_scheme == "bearer" assert reg.supported_transports == ["streamable-http"] assert "agentcore" in reg.tags assert "gateway" in reg.tags assert "auto-registered" in reg.tags assert reg.metadata["gateway_arn"] == SAMPLE_GATEWAY["gatewayArn"] assert reg.metadata["source"] == "agentcore-sync" assert reg.metadata["region"] == "us-east-1" assert reg.metadata["account_id"] == "111122223333" def test_gateway_iam_auth_scheme(self): builder = _make_builder() gw = {**SAMPLE_GATEWAY, "authorizerType": "AWS_IAM"} reg = builder.build_gateway_registration(gw) assert reg.auth_scheme == "bearer" def test_gateway_none_auth_scheme(self): builder = _make_builder() gw = {**SAMPLE_GATEWAY, "authorizerType": "NONE"} reg = builder.build_gateway_registration(gw) assert reg.auth_scheme == "none" class TestRuntimeMCPRegistration: """Tests for build_runtime_mcp_registration().""" def test_mcp_runtime_produces_mcp_server_registration(self): builder = _make_builder() reg = builder.build_runtime_mcp_registration(SAMPLE_MCP_RUNTIME) assert reg.service_path == "/mcp-runtime" assert reg.name == "MCP Runtime" assert "https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/" in reg.mcp_endpoint assert reg.mcp_endpoint.endswith("/invocations") assert reg.auth_provider == "bedrock-agentcore" assert reg.auth_scheme == "bearer" assert "mcp-server" in reg.tags assert "runtime" in reg.tags assert reg.metadata["server_protocol"] == "MCP" assert reg.metadata["runtime_arn"] == SAMPLE_MCP_RUNTIME["agentRuntimeArn"] class TestRuntimeAgentRegistration: """Tests for build_runtime_agent_registration().""" def test_http_runtime_produces_agent_registration(self): builder = _make_builder() reg = builder.build_runtime_agent_registration(SAMPLE_HTTP_RUNTIME) assert reg.name == "HTTP Agent" assert reg.version == "1.0.0" assert "https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/" in reg.url assert reg.url.endswith("/invocations") assert "agent" in reg.tags assert "runtime" in reg.tags assert "a2a" not in reg.tags assert reg.metadata["server_protocol"] == "HTTP" def test_a2a_runtime_produces_agent_registration(self): builder = _make_builder() reg = builder.build_runtime_agent_registration(SAMPLE_A2A_RUNTIME) assert reg.name == "A2A Agent" assert reg.version == "1.0.0" assert "a2a" in reg.tags assert reg.metadata["server_protocol"] == "A2A" class TestTargetRegistration: """Tests for build_target_registration().""" def test_mcp_target_produces_registration(self): builder = _make_builder() reg = builder.build_target_registration(SAMPLE_GATEWAY, SAMPLE_MCP_TARGET) assert reg is not None assert reg.service_path == "/customer-support-gateway-mcp-target" assert reg.mcp_endpoint == "https://mcp-target.example.com/mcp" assert "gateway-target" in reg.tags assert "mcp-server" in reg.tags def test_lambda_target_returns_none(self): builder = _make_builder() reg = builder.build_target_registration(SAMPLE_GATEWAY, SAMPLE_LAMBDA_TARGET) assert reg is None def test_target_no_endpoint_returns_none(self): builder = _make_builder() target = { "targetId": "t-1", "name": "No Endpoint", "targetConfiguration": {"mcp": {"mcpServer": {}}}, } reg = builder.build_target_registration(SAMPLE_GATEWAY, target) assert reg is None # --------------------------------------------------------------------------- # Task 4.3 — Idempotency check tests # --------------------------------------------------------------------------- def _make_orchestrator(dry_run=False, overwrite=False, include_mcp_targets=False): """Create a SyncOrchestrator with all dependencies mocked.""" with patch("cli.agentcore.registration.boto3") as mock_boto3: mock_sts = MagicMock() mock_sts.get_caller_identity.return_value = {"Account": "111122223333"} mock_boto3.client.return_value = mock_sts from cli.agentcore.registration import ( RegistrationBuilder, SyncOrchestrator, ) scanner = MagicMock() builder = RegistrationBuilder(region="us-east-1") registry = MagicMock() orch = SyncOrchestrator( scanner=scanner, builder=builder, registry_client=registry, dry_run=dry_run, overwrite=overwrite, include_mcp_targets=include_mcp_targets, manifest_path="/tmp/test_manifest.json", ) return orch, registry, scanner class TestIdempotency: """Tests for idempotent registration — skip existing, overwrite flag.""" def test_already_exists_without_overwrite_skips(self): orch, registry, scanner = _make_orchestrator(overwrite=False) registry.register_service.side_effect = Exception("already exists") scanner.scan_gateways.return_value = [SAMPLE_GATEWAY] orch.sync_gateways() assert len(orch.results) == 1 assert orch.results[0]["status"] == "skipped" assert "already registered" in orch.results[0]["message"].lower() def test_overwrite_sets_flag_on_registration(self): orch, registry, scanner = _make_orchestrator(overwrite=True) scanner.scan_gateways.return_value = [SAMPLE_GATEWAY] orch.sync_gateways() # The registration should have been called (not skipped) assert registry.register_service.called assert len(orch.results) == 1 assert orch.results[0]["status"] == "registered" def test_dry_run_does_not_call_registry(self): orch, registry, scanner = _make_orchestrator(dry_run=True) scanner.scan_gateways.return_value = [SAMPLE_GATEWAY] orch.sync_gateways() registry.register_service.assert_not_called() assert len(orch.results) == 1 assert orch.results[0]["status"] == "dry_run" def test_agent_conflict_without_overwrite_skips(self): orch, registry, scanner = _make_orchestrator(overwrite=False) resp = MagicMock() resp.status_code = 409 registry.register_agent.side_effect = requests.HTTPError(response=resp) scanner.scan_runtimes.return_value = [SAMPLE_HTTP_RUNTIME] orch.sync_runtimes() assert len(orch.results) == 1 assert orch.results[0]["status"] == "skipped" assert "already registered" in orch.results[0]["message"].lower() def test_agent_conflict_with_overwrite_calls_update(self): orch, registry, scanner = _make_orchestrator(overwrite=True) resp = MagicMock() resp.status_code = 409 registry.register_agent.side_effect = requests.HTTPError(response=resp) scanner.scan_runtimes.return_value = [SAMPLE_HTTP_RUNTIME] orch.sync_runtimes() assert registry.update_agent.called assert len(orch.results) == 1 assert orch.results[0]["status"] == "registered" assert "overwrite" in orch.results[0]["message"].lower() def test_agent_overwrite_update_failure_records_failed(self): orch, registry, scanner = _make_orchestrator(overwrite=True) resp = MagicMock() resp.status_code = 409 registry.register_agent.side_effect = requests.HTTPError(response=resp) registry.update_agent.side_effect = Exception("Update failed") scanner.scan_runtimes.return_value = [SAMPLE_HTTP_RUNTIME] orch.sync_runtimes() assert len(orch.results) == 1 assert orch.results[0]["status"] == "failed" # --------------------------------------------------------------------------- # Task 4.4 — Error handling tests (registration portion) # --------------------------------------------------------------------------- class TestRegistrationErrorHandling: """Tests for registry error handling and retry logic.""" def test_registry_error_records_failed_and_continues(self): orch, registry, scanner = _make_orchestrator() # First gateway fails, second succeeds gw1 = { **SAMPLE_GATEWAY, "gatewayId": "gw-fail", "name": "Fail GW", "gatewayArn": "arn:fail", "authorizerType": "NONE", } gw2 = { **SAMPLE_GATEWAY, "gatewayId": "gw-ok", "name": "OK GW", "gatewayArn": "arn:ok", "authorizerType": "NONE", } scanner.scan_gateways.return_value = [gw1, gw2] registry.register_service.side_effect = [ Exception("Internal Server Error"), None, ] orch.sync_gateways() assert len(orch.results) == 2 assert orch.results[0]["status"] == "failed" assert orch.results[1]["status"] == "registered" def test_invalid_url_skips_registration(self): orch, registry, scanner = _make_orchestrator() gw = {**SAMPLE_GATEWAY, "gatewayUrl": "http://insecure.example.com"} scanner.scan_gateways.return_value = [gw] orch.sync_gateways() registry.register_service.assert_not_called() assert len(orch.results) == 1 assert orch.results[0]["status"] == "skipped" assert "HTTPS" in orch.results[0]["message"] def test_empty_url_skips_registration(self): orch, registry, scanner = _make_orchestrator() gw = {**SAMPLE_GATEWAY, "gatewayUrl": ""} scanner.scan_gateways.return_value = [gw] orch.sync_gateways() registry.register_service.assert_not_called() assert orch.results[0]["status"] == "skipped" def test_runtime_mcp_registration_error_records_failed(self): orch, registry, scanner = _make_orchestrator() scanner.scan_runtimes.return_value = [SAMPLE_MCP_RUNTIME] registry.register_service.side_effect = Exception("500 Server Error") orch.sync_runtimes() assert len(orch.results) == 1 assert orch.results[0]["status"] == "failed" def test_runtime_agent_registration_error_records_failed(self): orch, registry, scanner = _make_orchestrator() scanner.scan_runtimes.return_value = [SAMPLE_HTTP_RUNTIME] registry.register_agent.side_effect = Exception("Connection refused") orch.sync_runtimes() assert len(orch.results) == 1 assert orch.results[0]["status"] == "failed" def test_include_mcp_targets_registers_targets(self): orch, registry, scanner = _make_orchestrator(include_mcp_targets=True) gw = {**SAMPLE_GATEWAY, "targets": [SAMPLE_MCP_TARGET], "authorizerType": "NONE"} scanner.scan_gateways.return_value = [gw] orch.sync_gateways() # Gateway + target = 2 results assert len(orch.results) == 2 assert registry.register_service.call_count == 2 # --------------------------------------------------------------------------- # OIDC metadata in gateway registration # --------------------------------------------------------------------------- class TestOIDCMetadata: """Tests for OIDC metadata enrichment in gateway registration.""" def test_custom_jwt_gateway_has_oidc_metadata(self): builder = _make_builder() reg = builder.build_gateway_registration(SAMPLE_GATEWAY) assert reg.metadata["discovery_url"] == ( "https://cognito-idp.us-east-1.amazonaws.com/" "us-east-1_pnikLWYzO/.well-known/openid-configuration" ) assert reg.metadata["allowed_clients"] == ["7kqi2l0n47mnfmhfapsf29ch4h"] assert reg.metadata["idp_vendor"] == "cognito" def test_none_auth_gateway_has_no_oidc_metadata(self): builder = _make_builder() gw = {**SAMPLE_GATEWAY, "authorizerType": "NONE", "authorizerConfiguration": {}} reg = builder.build_gateway_registration(gw) assert "discovery_url" not in reg.metadata assert "allowed_clients" not in reg.metadata assert "idp_vendor" not in reg.metadata def test_iam_auth_gateway_has_no_oidc_metadata(self): builder = _make_builder() gw = {**SAMPLE_GATEWAY, "authorizerType": "AWS_IAM", "authorizerConfiguration": {}} reg = builder.build_gateway_registration(gw) assert "discovery_url" not in reg.metadata # --------------------------------------------------------------------------- # IdP vendor detection # --------------------------------------------------------------------------- class TestDetectIdpVendor: """Tests for _detect_idp_vendor().""" def test_cognito_detection(self): from cli.agentcore.registration import _detect_idp_vendor assert ( _detect_idp_vendor( "https://cognito-idp.us-east-1.amazonaws.com/pool/.well-known/openid-configuration" ) == "cognito" ) def test_auth0_detection(self): from cli.agentcore.registration import _detect_idp_vendor assert ( _detect_idp_vendor("https://myorg.auth0.com/.well-known/openid-configuration") == "auth0" ) def test_okta_detection(self): from cli.agentcore.registration import _detect_idp_vendor assert ( _detect_idp_vendor("https://myorg.okta.com/.well-known/openid-configuration") == "okta" ) def test_entra_detection(self): from cli.agentcore.registration import _detect_idp_vendor assert ( _detect_idp_vendor( "https://login.microsoftonline.com/tenant/.well-known/openid-configuration" ) == "entra" ) def test_keycloak_detection(self): from cli.agentcore.registration import _detect_idp_vendor assert ( _detect_idp_vendor( "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration" ) == "keycloak" ) def test_unknown_detection(self): from cli.agentcore.registration import _detect_idp_vendor assert ( _detect_idp_vendor("https://custom-idp.example.com/.well-known/openid-configuration") == "unknown" ) # --------------------------------------------------------------------------- # Manifest collection and writing # --------------------------------------------------------------------------- class TestManifest: """Tests for manifest collection and writing.""" def test_custom_jwt_gateway_collects_manifest_entry(self): orch, registry, scanner = _make_orchestrator() scanner.scan_gateways.return_value = [SAMPLE_GATEWAY] orch.sync_gateways() assert len(orch._manifest_entries) == 1 entry = orch._manifest_entries[0] assert entry["server_path"] == "/customer-support-gateway" assert "cognito-idp" in entry["discovery_url"] assert entry["idp_vendor"] == "cognito" assert entry["allowed_clients"] == ["7kqi2l0n47mnfmhfapsf29ch4h"] def test_iam_gateway_no_manifest_entry(self): orch, registry, scanner = _make_orchestrator() gw = {**SAMPLE_GATEWAY, "authorizerType": "AWS_IAM", "authorizerConfiguration": {}} scanner.scan_gateways.return_value = [gw] orch.sync_gateways() assert len(orch._manifest_entries) == 0 def test_none_gateway_no_manifest_entry(self): orch, registry, scanner = _make_orchestrator() gw = {**SAMPLE_GATEWAY, "authorizerType": "NONE", "authorizerConfiguration": {}} scanner.scan_gateways.return_value = [gw] orch.sync_gateways() assert len(orch._manifest_entries) == 0 def test_dry_run_collects_manifest_entries(self): orch, registry, scanner = _make_orchestrator(dry_run=True) scanner.scan_gateways.return_value = [SAMPLE_GATEWAY] orch.sync_gateways() assert len(orch._manifest_entries) == 1 def test_write_manifest_creates_file(self, tmp_path): manifest_file = tmp_path / "manifest.json" orch, registry, scanner = _make_orchestrator() orch.manifest_path = str(manifest_file) scanner.scan_gateways.return_value = [SAMPLE_GATEWAY] orch.sync_gateways() orch.write_manifest() import json data = json.loads(manifest_file.read_text()) assert len(data) == 1 assert data[0]["idp_vendor"] == "cognito" def test_write_manifest_dry_run_skips(self, tmp_path): manifest_file = tmp_path / "manifest.json" orch, registry, scanner = _make_orchestrator(dry_run=True) orch.manifest_path = str(manifest_file) scanner.scan_gateways.return_value = [SAMPLE_GATEWAY] orch.sync_gateways() orch.write_manifest() assert not manifest_file.exists() def test_runtime_no_manifest_entry(self): orch, registry, scanner = _make_orchestrator() scanner.scan_runtimes.return_value = [SAMPLE_MCP_RUNTIME] orch.sync_runtimes() assert len(orch._manifest_entries) == 0 ================================================ FILE: tests/unit/cli/test_agentcore_token_refresher.py ================================================ """Unit tests for cli.agentcore.token_refresher. Tests IdP vendor detection, client secret resolution, OIDC discovery, token requests, registry updates, and end-to-end refresh_all flow. """ from __future__ import annotations import json import os from unittest.mock import MagicMock, patch import pytest import requests from cli.agentcore.token_refresher import ( _detect_idp_vendor, _get_client_secret, _get_cognito_client_secret, _get_token_endpoint, _load_registry_token, _read_manifest, _request_token, _trigger_security_scan, _update_registry_credential, refresh_all, ) # --------------------------------------------------------------------------- # _detect_idp_vendor # --------------------------------------------------------------------------- class TestDetectIdpVendor: """Tests for IdP vendor detection from discovery URL.""" def test_cognito(self): url = "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc/.well-known/openid-configuration" assert _detect_idp_vendor(url) == "cognito" def test_auth0(self): url = "https://myorg.auth0.com/.well-known/openid-configuration" assert _detect_idp_vendor(url) == "auth0" def test_okta(self): url = "https://myorg.okta.com/.well-known/openid-configuration" assert _detect_idp_vendor(url) == "okta" def test_entra(self): url = "https://login.microsoftonline.com/tenant-id/.well-known/openid-configuration" assert _detect_idp_vendor(url) == "entra" def test_keycloak(self): url = "https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration" assert _detect_idp_vendor(url) == "keycloak" def test_unknown(self): url = "https://custom-idp.example.com/.well-known/openid-configuration" assert _detect_idp_vendor(url) == "unknown" # --------------------------------------------------------------------------- # _read_manifest # --------------------------------------------------------------------------- class TestReadManifest: """Tests for manifest reading.""" def test_reads_valid_manifest(self, tmp_path): manifest = tmp_path / "manifest.json" entries = [{"server_path": "/test", "discovery_url": "https://example.com"}] manifest.write_text(json.dumps(entries)) result = _read_manifest(str(manifest)) assert len(result) == 1 assert result[0]["server_path"] == "/test" def test_raises_on_missing_file(self, tmp_path): with pytest.raises(FileNotFoundError): _read_manifest(str(tmp_path / "nonexistent.json")) def test_raises_on_invalid_json(self, tmp_path): manifest = tmp_path / "bad.json" manifest.write_text("not valid json{{{") with pytest.raises(ValueError, match="Invalid JSON"): _read_manifest(str(manifest)) def test_raises_on_non_array(self, tmp_path): manifest = tmp_path / "obj.json" manifest.write_text('{"not": "an array"}') with pytest.raises(ValueError, match="JSON array"): _read_manifest(str(manifest)) # --------------------------------------------------------------------------- # _get_cognito_client_secret # --------------------------------------------------------------------------- class TestGetCognitoClientSecret: """Tests for Cognito client secret auto-retrieval.""" @patch("cli.agentcore.token_refresher.boto3") def test_retrieves_secret(self, mock_boto3): mock_client = MagicMock() mock_boto3.client.return_value = mock_client mock_client.describe_user_pool_client.return_value = { "UserPoolClient": {"ClientSecret": "super-secret"} } discovery_url = ( "https://cognito-idp.us-east-1.amazonaws.com/" "us-east-1_pnikLWYzO/.well-known/openid-configuration" ) result = _get_cognito_client_secret(discovery_url, "my-client-id") assert result == "super-secret" mock_boto3.client.assert_called_once_with("cognito-idp", region_name="us-east-1") mock_client.describe_user_pool_client.assert_called_once_with( UserPoolId="us-east-1_pnikLWYzO", ClientId="my-client-id", ) @patch("cli.agentcore.token_refresher.boto3") def test_returns_none_on_error(self, mock_boto3): mock_boto3.client.side_effect = Exception("Access denied") result = _get_cognito_client_secret( "https://cognito-idp.us-east-1.amazonaws.com/pool/.well-known/openid-configuration", "client-id", ) assert result is None # --------------------------------------------------------------------------- # _get_client_secret # --------------------------------------------------------------------------- class TestGetClientSecret: """Tests for client secret resolution per IdP vendor.""" def test_per_client_env_var_takes_priority(self): env = {"OAUTH_CLIENT_SECRET_my-client-id": "from-env"} with patch.dict(os.environ, env): result = _get_client_secret( "cognito", "https://cognito-idp.example.com", "my-client-id" ) assert result == "from-env" @patch("cli.agentcore.token_refresher._get_cognito_client_secret") def test_cognito_delegates_to_auto_retrieval(self, mock_cognito): mock_cognito.return_value = "cognito-secret" result = _get_client_secret("cognito", "https://cognito-idp.example.com", "client-id") assert result == "cognito-secret" mock_cognito.assert_called_once() def test_auth0_reads_from_env(self): with patch.dict(os.environ, {"AUTH0_CLIENT_SECRET": "auth0-secret"}): result = _get_client_secret("auth0", "https://myorg.auth0.com", "client-id") assert result == "auth0-secret" def test_okta_reads_from_env(self): with patch.dict(os.environ, {"OKTA_CLIENT_SECRET": "okta-secret"}): result = _get_client_secret("okta", "https://myorg.okta.com", "client-id") assert result == "okta-secret" def test_entra_reads_from_env(self): with patch.dict(os.environ, {"ENTRA_CLIENT_SECRET": "entra-secret"}): result = _get_client_secret("entra", "https://login.microsoftonline.com", "client-id") assert result == "entra-secret" def test_missing_env_returns_none(self): with patch.dict(os.environ, {}, clear=True): result = _get_client_secret("auth0", "https://myorg.auth0.com", "client-id") assert result is None def test_unknown_vendor_returns_none(self): result = _get_client_secret("unknown", "https://custom.example.com", "client-id") assert result is None # --------------------------------------------------------------------------- # _get_token_endpoint # --------------------------------------------------------------------------- class TestGetTokenEndpoint: """Tests for OIDC discovery token endpoint extraction.""" @patch("cli.agentcore.token_refresher.requests.get") def test_extracts_token_endpoint(self, mock_get): mock_response = MagicMock() mock_response.json.return_value = { "token_endpoint": "https://auth.example.com/oauth2/token", "issuer": "https://auth.example.com", } mock_get.return_value = mock_response result = _get_token_endpoint("https://auth.example.com/.well-known/openid-configuration") assert result == "https://auth.example.com/oauth2/token" @patch("cli.agentcore.token_refresher.requests.get") def test_returns_none_on_error(self, mock_get): mock_get.side_effect = Exception("Connection refused") result = _get_token_endpoint("https://unreachable.example.com") assert result is None # --------------------------------------------------------------------------- # _request_token # --------------------------------------------------------------------------- class TestRequestToken: """Tests for OAuth2 client_credentials token request.""" @patch("cli.agentcore.token_refresher.requests.post") def test_successful_token_request(self, mock_post): mock_response = MagicMock() mock_response.json.return_value = {"access_token": "eyJtoken123"} mock_post.return_value = mock_response result = _request_token( "https://auth.example.com/oauth2/token", "client-id", "client-secret", ) assert result == "eyJtoken123" mock_post.assert_called_once() call_data = mock_post.call_args[1]["data"] assert call_data["grant_type"] == "client_credentials" assert call_data["client_id"] == "client-id" assert call_data["client_secret"] == "client-secret" @patch("cli.agentcore.token_refresher.requests.post") def test_returns_none_on_error(self, mock_post): mock_post.side_effect = Exception("401 Unauthorized") result = _request_token("https://auth.example.com/token", "id", "secret") assert result is None # --------------------------------------------------------------------------- # _update_registry_credential # --------------------------------------------------------------------------- class TestUpdateRegistryCredential: """Tests for PATCH auth_credential in registry.""" @patch("cli.agentcore.token_refresher.requests.patch") def test_successful_update(self, mock_patch): mock_response = MagicMock() mock_response.status_code = 200 mock_patch.return_value = mock_response result = _update_registry_credential( "https://registry.example.com", "registry-token", "/my-server", "eyJnewtoken", ) assert result is True mock_patch.assert_called_once() url = mock_patch.call_args[0][0] assert url == "https://registry.example.com/api/servers/my-server/auth-credential" @patch("cli.agentcore.token_refresher.requests.patch") def test_returns_false_on_error(self, mock_patch): mock_patch.side_effect = Exception("500 Server Error") result = _update_registry_credential( "https://registry.example.com", "token", "/server", "cred", ) assert result is False # --------------------------------------------------------------------------- # _load_registry_token # --------------------------------------------------------------------------- class TestLoadRegistryToken: """Tests for loading registry auth token from file.""" def test_loads_access_token(self, tmp_path): token_file = tmp_path / ".token" token_file.write_text(json.dumps({"access_token": "my-jwt-token"})) result = _load_registry_token(str(token_file)) assert result == "my-jwt-token" def test_loads_token_field(self, tmp_path): token_file = tmp_path / ".token" token_file.write_text(json.dumps({"token": "alt-token"})) result = _load_registry_token(str(token_file)) assert result == "alt-token" def test_raises_on_missing_file(self, tmp_path): with pytest.raises(FileNotFoundError): _load_registry_token(str(tmp_path / "missing.json")) def test_raises_on_missing_token_field(self, tmp_path): token_file = tmp_path / ".token" token_file.write_text(json.dumps({"other": "field"})) with pytest.raises(ValueError, match="No access_token or token"): _load_registry_token(str(token_file)) # --------------------------------------------------------------------------- # refresh_all (end-to-end) # --------------------------------------------------------------------------- class TestTriggerSecurityScan: """Tests for triggering security rescan after credential update.""" @patch("cli.agentcore.token_refresher.requests.post") def test_successful_scan(self, mock_post): mock_response = MagicMock() mock_response.json.return_value = { "is_safe": True, "critical_issues": 0, "high_severity": 0, } mock_post.return_value = mock_response result = _trigger_security_scan( "https://registry.example.com", "registry-token", "/my-server", ) assert result is True mock_post.assert_called_once() url = mock_post.call_args[0][0] assert url == "https://registry.example.com/api/servers/my-server/rescan" @patch("cli.agentcore.token_refresher.requests.post") def test_scan_with_findings(self, mock_post): mock_response = MagicMock() mock_response.json.return_value = { "is_safe": False, "critical_issues": 1, "high_severity": 2, } mock_post.return_value = mock_response result = _trigger_security_scan( "https://registry.example.com", "token", "/my-server", ) assert result is True @patch("cli.agentcore.token_refresher.requests.post") def test_scan_forbidden_returns_false(self, mock_post): mock_response = MagicMock() mock_response.status_code = 403 http_error = requests.exceptions.HTTPError(response=mock_response) mock_response.raise_for_status.side_effect = http_error mock_post.return_value = mock_response result = _trigger_security_scan( "https://registry.example.com", "non-admin-token", "/my-server", ) assert result is False @patch("cli.agentcore.token_refresher.requests.post") def test_scan_error_returns_false(self, mock_post): mock_post.side_effect = Exception("Connection refused") result = _trigger_security_scan( "https://registry.example.com", "token", "/my-server", ) assert result is False # --------------------------------------------------------------------------- # refresh_all (end-to-end) # --------------------------------------------------------------------------- class TestRefreshAll: """Tests for the end-to-end refresh_all flow.""" def _write_manifest(self, tmp_path, entries): manifest = tmp_path / "manifest.json" manifest.write_text(json.dumps(entries)) return str(manifest) @patch("cli.agentcore.token_refresher._update_registry_credential") @patch("cli.agentcore.token_refresher._request_token") @patch("cli.agentcore.token_refresher._get_token_endpoint") @patch("cli.agentcore.token_refresher._get_client_secret") def test_refresh_cognito_success( self, mock_secret, mock_endpoint, mock_token, mock_update, tmp_path ): mock_secret.return_value = "cognito-secret" mock_endpoint.return_value = "https://cognito.example.com/oauth2/token" mock_token.return_value = "eyJnewtoken" mock_update.return_value = True manifest_path = self._write_manifest( tmp_path, [ { "server_path": "/my-gw", "gateway_arn": "arn:aws:bedrock:us-east-1:123:gateway/gw-1", "discovery_url": "https://cognito-idp.us-east-1.amazonaws.com/pool/.well-known/openid-configuration", "allowed_clients": ["client-1"], "idp_vendor": "cognito", } ], ) summary = refresh_all( manifest_path, "https://registry.example.com", "reg-token", run_scan=False, ) assert summary["success"] == 1 assert summary["failed"] == 0 assert summary["skipped"] == 0 mock_update.assert_called_once_with( "https://registry.example.com", "reg-token", "/my-gw", "eyJnewtoken" ) @patch("cli.agentcore.token_refresher._update_registry_credential") @patch("cli.agentcore.token_refresher._request_token") @patch("cli.agentcore.token_refresher._get_token_endpoint") @patch("cli.agentcore.token_refresher._get_client_secret") def test_refresh_mixed_idps( self, mock_secret, mock_endpoint, mock_token, mock_update, tmp_path ): mock_secret.side_effect = ["cognito-secret", "auth0-secret", None] mock_endpoint.return_value = "https://example.com/token" mock_token.return_value = "eyJtoken" mock_update.return_value = True entries = [ { "server_path": "/gw-cognito", "gateway_arn": "arn:1", "discovery_url": "https://cognito-idp.example.com", "allowed_clients": ["c1"], "idp_vendor": "cognito", }, { "server_path": "/gw-auth0", "gateway_arn": "arn:2", "discovery_url": "https://myorg.auth0.com", "allowed_clients": ["c2"], "idp_vendor": "auth0", }, { "server_path": "/gw-unknown", "gateway_arn": "arn:3", "discovery_url": "https://custom.example.com", "allowed_clients": ["c3"], "idp_vendor": "unknown", }, ] manifest_path = self._write_manifest(tmp_path, entries) summary = refresh_all( manifest_path, "https://registry.example.com", "reg-token", run_scan=False, ) assert summary["success"] == 2 assert summary["skipped"] == 1 assert summary["total"] == 3 @patch("cli.agentcore.token_refresher._get_client_secret") def test_refresh_skips_no_allowed_clients(self, mock_secret, tmp_path): manifest_path = self._write_manifest( tmp_path, [ { "server_path": "/no-clients", "gateway_arn": "arn:1", "discovery_url": "https://example.com", "allowed_clients": [], "idp_vendor": "cognito", } ], ) summary = refresh_all( manifest_path, "https://registry.example.com", "token", run_scan=False, ) assert summary["skipped"] == 1 mock_secret.assert_not_called() @patch("cli.agentcore.token_refresher._update_registry_credential") @patch("cli.agentcore.token_refresher._request_token") @patch("cli.agentcore.token_refresher._get_token_endpoint") @patch("cli.agentcore.token_refresher._get_client_secret") def test_refresh_writes_timestamps( self, mock_secret, mock_endpoint, mock_token, mock_update, tmp_path ): mock_secret.return_value = "secret" mock_endpoint.return_value = "https://example.com/token" mock_token.return_value = "eyJtoken" mock_update.return_value = True manifest_path = self._write_manifest( tmp_path, [ { "server_path": "/gw", "gateway_arn": "arn:1", "discovery_url": "https://cognito-idp.example.com", "allowed_clients": ["c1"], "idp_vendor": "cognito", } ], ) refresh_all( manifest_path, "https://registry.example.com", "token", run_scan=False, ) updated = json.loads((tmp_path / "manifest.json").read_text()) assert "last_refreshed" in updated[0] @patch("cli.agentcore.token_refresher._trigger_security_scan") @patch("cli.agentcore.token_refresher._update_registry_credential") @patch("cli.agentcore.token_refresher._request_token") @patch("cli.agentcore.token_refresher._get_token_endpoint") @patch("cli.agentcore.token_refresher._get_client_secret") def test_refresh_triggers_scan_after_update( self, mock_secret, mock_endpoint, mock_token, mock_update, mock_scan, tmp_path ): mock_secret.return_value = "secret" mock_endpoint.return_value = "https://example.com/token" mock_token.return_value = "eyJtoken" mock_update.return_value = True mock_scan.return_value = True manifest_path = self._write_manifest( tmp_path, [ { "server_path": "/gw", "gateway_arn": "arn:1", "discovery_url": "https://cognito-idp.example.com", "allowed_clients": ["c1"], "idp_vendor": "cognito", } ], ) summary = refresh_all( manifest_path, "https://registry.example.com", "token", run_scan=True, ) assert summary["success"] == 1 assert summary["scans_triggered"] == 1 assert summary["scans_failed"] == 0 mock_scan.assert_called_once_with("https://registry.example.com", "token", "/gw") @patch("cli.agentcore.token_refresher._trigger_security_scan") @patch("cli.agentcore.token_refresher._update_registry_credential") @patch("cli.agentcore.token_refresher._request_token") @patch("cli.agentcore.token_refresher._get_token_endpoint") @patch("cli.agentcore.token_refresher._get_client_secret") def test_refresh_no_scan_when_disabled( self, mock_secret, mock_endpoint, mock_token, mock_update, mock_scan, tmp_path ): mock_secret.return_value = "secret" mock_endpoint.return_value = "https://example.com/token" mock_token.return_value = "eyJtoken" mock_update.return_value = True manifest_path = self._write_manifest( tmp_path, [ { "server_path": "/gw", "gateway_arn": "arn:1", "discovery_url": "https://cognito-idp.example.com", "allowed_clients": ["c1"], "idp_vendor": "cognito", } ], ) summary = refresh_all( manifest_path, "https://registry.example.com", "token", run_scan=False, ) assert summary["success"] == 1 assert "scans_triggered" not in summary mock_scan.assert_not_called() @patch("cli.agentcore.token_refresher._trigger_security_scan") @patch("cli.agentcore.token_refresher._update_registry_credential") @patch("cli.agentcore.token_refresher._request_token") @patch("cli.agentcore.token_refresher._get_token_endpoint") @patch("cli.agentcore.token_refresher._get_client_secret") def test_refresh_scan_failure_tracked( self, mock_secret, mock_endpoint, mock_token, mock_update, mock_scan, tmp_path ): mock_secret.return_value = "secret" mock_endpoint.return_value = "https://example.com/token" mock_token.return_value = "eyJtoken" mock_update.return_value = True mock_scan.return_value = False manifest_path = self._write_manifest( tmp_path, [ { "server_path": "/gw", "gateway_arn": "arn:1", "discovery_url": "https://cognito-idp.example.com", "allowed_clients": ["c1"], "idp_vendor": "cognito", } ], ) summary = refresh_all( manifest_path, "https://registry.example.com", "token", run_scan=True, ) assert summary["success"] == 1 assert summary["scans_triggered"] == 0 assert summary["scans_failed"] == 1 @patch("cli.agentcore.token_refresher._trigger_security_scan") @patch("cli.agentcore.token_refresher._update_registry_credential") @patch("cli.agentcore.token_refresher._request_token") @patch("cli.agentcore.token_refresher._get_token_endpoint") @patch("cli.agentcore.token_refresher._get_client_secret") def test_refresh_no_scan_on_failed_update( self, mock_secret, mock_endpoint, mock_token, mock_update, mock_scan, tmp_path ): mock_secret.return_value = "secret" mock_endpoint.return_value = "https://example.com/token" mock_token.return_value = "eyJtoken" mock_update.return_value = False manifest_path = self._write_manifest( tmp_path, [ { "server_path": "/gw", "gateway_arn": "arn:1", "discovery_url": "https://cognito-idp.example.com", "allowed_clients": ["c1"], "idp_vendor": "cognito", } ], ) summary = refresh_all( manifest_path, "https://registry.example.com", "token", run_scan=True, ) assert summary["failed"] == 1 assert summary["scans_triggered"] == 0 mock_scan.assert_not_called() ================================================ FILE: tests/unit/conftest.py ================================================ """ Conftest for unit tests. Provides fixtures specific to unit tests. """ import logging from unittest.mock import AsyncMock, MagicMock import pytest logger = logging.getLogger(__name__) @pytest.fixture def mock_faiss_service(): """ Create a mock FAISS service for testing. Returns: Mock FAISS service with common methods """ service = MagicMock() service.add_server = MagicMock() service.remove_server = MagicMock() service.search = MagicMock(return_value=[]) service.get_index_size = MagicMock(return_value=0) return service @pytest.fixture def mock_embeddings_client(): """ Create a mock embeddings client for testing. Returns: Mock embeddings client """ from tests.fixtures.mocks.mock_embeddings import MockEmbeddingsClient return MockEmbeddingsClient() @pytest.fixture def mock_http_client(): """ Create a mock HTTP client for testing. Returns: Mock HTTP client """ from tests.fixtures.mocks.mock_http import MockAsyncClient return MockAsyncClient() @pytest.fixture def mock_mcp_client(): """ Create a mock MCP client for testing. Returns: Mock MCP client with common methods """ client = AsyncMock() client.connect = AsyncMock() client.disconnect = AsyncMock() client.list_tools = AsyncMock(return_value=[]) client.call_tool = AsyncMock(return_value={}) return client ================================================ FILE: tests/unit/core/__init__.py ================================================ """Core infrastructure unit tests.""" ================================================ FILE: tests/unit/core/test_config.py ================================================ """ Unit tests for registry.core.config module. This module tests the Settings class and its configuration management, including default values, environment variable loading, path resolution, and computed properties. """ from pathlib import Path from unittest.mock import MagicMock, patch import pytest from registry.core.config import Settings # ============================================================================= # TEST CLASS: Settings Instantiation and Defaults # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsInstantiation: """Test Settings class instantiation and default values.""" def test_settings_default_values(self, monkeypatch, tmp_path) -> None: """Test Settings instantiation with default values.""" # Arrange - Clear environment variables and disable .env file loading monkeypatch.delenv("AUTH_SERVER_URL", raising=False) monkeypatch.delenv("SECRET_KEY", raising=False) # Change to temp directory to prevent .env file loading monkeypatch.chdir(tmp_path) # Act settings = Settings() # Assert - Auth settings assert settings.session_cookie_name == "mcp_gateway_session" assert settings.session_max_age_seconds == 60 * 60 * 8 # 8 hours assert settings.session_cookie_secure is False assert settings.session_cookie_domain is None assert settings.auth_server_url == "http://localhost:8888" assert settings.auth_server_external_url == "http://localhost:8888" def test_settings_embeddings_default_values(self) -> None: """Test embeddings-related default values.""" # Act settings = Settings() # Assert - Embeddings settings assert settings.embeddings_provider == "sentence-transformers" assert settings.embeddings_model_name == "all-MiniLM-L6-v2" assert settings.embeddings_model_dimensions == 384 assert settings.embeddings_api_key is None assert settings.embeddings_secret_key is None assert settings.embeddings_api_base is None assert settings.embeddings_aws_region == "us-east-1" def test_settings_health_check_defaults(self) -> None: """Test health check default values.""" # Act settings = Settings() # Assert assert settings.health_check_interval_seconds == 300 # 5 minutes assert settings.health_check_timeout_seconds == 2 def test_settings_websocket_defaults(self) -> None: """Test WebSocket performance default values.""" # Act settings = Settings() # Assert assert settings.max_websocket_connections == 100 assert settings.websocket_send_timeout_seconds == 2.0 assert settings.websocket_broadcast_interval_ms == 10 assert settings.websocket_max_batch_size == 20 assert settings.websocket_cache_ttl_seconds == 1 def test_settings_wellknown_defaults(self) -> None: """Test well-known discovery default values.""" # Act settings = Settings() # Assert assert settings.enable_wellknown_discovery is True assert settings.wellknown_cache_ttl == 300 # 5 minutes def test_settings_container_paths_defaults(self) -> None: """Test container path default values.""" # Act settings = Settings() # Assert assert settings.container_app_dir == Path("/app") assert settings.container_registry_dir == Path("/app/registry") assert settings.container_log_dir == Path("/app/logs") def test_settings_secret_key_auto_generation(self, monkeypatch, tmp_path) -> None: """Test that secret_key is auto-generated when not provided.""" # Arrange - Clear SECRET_KEY env var and disable .env file loading monkeypatch.delenv("SECRET_KEY", raising=False) monkeypatch.chdir(tmp_path) # Act settings = Settings() # Assert assert settings.secret_key != "" assert len(settings.secret_key) == 64 # 32 bytes hex = 64 chars assert all(c in "0123456789abcdef" for c in settings.secret_key) def test_settings_secret_key_not_overridden(self) -> None: """Test that provided secret_key is not overridden.""" # Arrange custom_key = "my-custom-secret-key-12345" # Act settings = Settings(secret_key=custom_key) # Assert assert settings.secret_key == custom_key def test_settings_with_custom_values(self) -> None: """Test Settings instantiation with custom values.""" # Arrange custom_values = { "secret_key": "test-secret", "session_cookie_name": "test_cookie", "session_max_age_seconds": 3600, "embeddings_provider": "litellm", "embeddings_model_name": "text-embedding-3-small", "embeddings_model_dimensions": 1024, "health_check_interval_seconds": 600, } # Act settings = Settings(**custom_values) # Assert assert settings.secret_key == custom_values["secret_key"] assert settings.session_cookie_name == custom_values["session_cookie_name"] assert settings.session_max_age_seconds == custom_values["session_max_age_seconds"] assert settings.embeddings_provider == custom_values["embeddings_provider"] assert settings.embeddings_model_name == custom_values["embeddings_model_name"] assert settings.embeddings_model_dimensions == custom_values["embeddings_model_dimensions"] assert ( settings.health_check_interval_seconds == custom_values["health_check_interval_seconds"] ) # ============================================================================= # TEST CLASS: Environment Variable Loading # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsEnvironmentVariables: """Test Settings loading from environment variables.""" def test_settings_load_from_env_auth(self, monkeypatch) -> None: """Test loading auth settings from environment variables.""" # Arrange monkeypatch.setenv("SECRET_KEY", "env-secret-key") monkeypatch.setenv("SESSION_COOKIE_NAME", "env_session") # Act settings = Settings() # Assert assert settings.secret_key == "env-secret-key" assert settings.session_cookie_name == "env_session" def test_settings_load_from_env_embeddings(self, monkeypatch) -> None: """Test loading embeddings settings from environment variables.""" # Arrange monkeypatch.setenv("EMBEDDINGS_PROVIDER", "litellm") monkeypatch.setenv("EMBEDDINGS_MODEL_NAME", "bedrock/amazon.titan-embed-text-v2:0") monkeypatch.setenv("EMBEDDINGS_MODEL_DIMENSIONS", "1024") monkeypatch.setenv("EMBEDDINGS_API_KEY", "test-api-key") monkeypatch.setenv("EMBEDDINGS_AWS_REGION", "us-west-2") # Act settings = Settings() # Assert assert settings.embeddings_provider == "litellm" assert settings.embeddings_model_name == "bedrock/amazon.titan-embed-text-v2:0" assert settings.embeddings_model_dimensions == 1024 assert settings.embeddings_api_key == "test-api-key" assert settings.embeddings_aws_region == "us-west-2" def test_settings_load_from_env_health_check(self, monkeypatch) -> None: """Test loading health check settings from environment variables.""" # Arrange monkeypatch.setenv("HEALTH_CHECK_INTERVAL_SECONDS", "600") monkeypatch.setenv("HEALTH_CHECK_TIMEOUT_SECONDS", "5") # Act settings = Settings() # Assert assert settings.health_check_interval_seconds == 600 assert settings.health_check_timeout_seconds == 5 def test_settings_load_from_env_websocket(self, monkeypatch) -> None: """Test loading WebSocket settings from environment variables.""" # Arrange monkeypatch.setenv("MAX_WEBSOCKET_CONNECTIONS", "200") monkeypatch.setenv("WEBSOCKET_SEND_TIMEOUT_SECONDS", "5.0") monkeypatch.setenv("WEBSOCKET_BROADCAST_INTERVAL_MS", "20") # Act settings = Settings() # Assert assert settings.max_websocket_connections == 200 assert settings.websocket_send_timeout_seconds == 5.0 assert settings.websocket_broadcast_interval_ms == 20 def test_settings_env_case_insensitive(self, monkeypatch) -> None: """Test that environment variables are case-insensitive.""" # Arrange - using lowercase env var names monkeypatch.setenv("session_cookie_name", "lowercase_session") monkeypatch.setenv("AUTH_SERVER_URL", "http://uppercase:8888") # Act settings = Settings() # Assert assert settings.session_cookie_name == "lowercase_session" assert settings.auth_server_url == "http://uppercase:8888" def test_settings_extra_env_ignored(self, monkeypatch) -> None: """Test that extra environment variables are ignored.""" # Arrange monkeypatch.setenv("UNKNOWN_VARIABLE", "some_value") monkeypatch.setenv("ANOTHER_UNKNOWN", "another_value") # Act - Should not raise an error settings = Settings() # Assert assert not hasattr(settings, "unknown_variable") assert not hasattr(settings, "another_unknown") def test_settings_optional_fields_none(self) -> None: """Test that optional fields can be None.""" # Act settings = Settings() # Assert - Optional fields should be None by default assert settings.embeddings_api_key is None assert settings.embeddings_secret_key is None assert settings.embeddings_api_base is None assert settings.session_cookie_domain is None # ============================================================================= # TEST CLASS: Path Properties - Local Development # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsPathsLocalDev: """Test path properties in local development mode.""" @patch("registry.core.config.Path") def test_is_local_dev_true(self, mock_path_class) -> None: """Test is_local_dev property when /app does not exist.""" # Arrange mock_app_path = MagicMock() mock_app_path.exists.return_value = False mock_path_class.return_value = mock_app_path # Act settings = Settings() # Assert assert settings.is_local_dev is True @patch("registry.core.config.Path") def test_is_local_dev_false(self, mock_path_class) -> None: """Test is_local_dev property when /app exists.""" # Arrange mock_app_path = MagicMock() mock_app_path.exists.return_value = True mock_path_class.return_value = mock_app_path # Act settings = Settings() # Assert assert settings.is_local_dev is False @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: True)) def test_servers_dir_local_dev(self, mock_is_local_dev) -> None: """Test servers_dir property in local development mode.""" # Arrange settings = Settings() # Act result = settings.servers_dir # Assert expected = Path.cwd() / "registry" / "servers" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: True)) def test_static_dir_local_dev(self, mock_is_local_dev) -> None: """Test static_dir property in local development mode.""" # Arrange settings = Settings() # Act result = settings.static_dir # Assert expected = Path.cwd() / "registry" / "static" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: True)) def test_templates_dir_local_dev(self, mock_is_local_dev) -> None: """Test templates_dir property in local development mode.""" # Arrange settings = Settings() # Act result = settings.templates_dir # Assert expected = Path.cwd() / "registry" / "templates" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: True)) def test_log_dir_local_dev(self, mock_is_local_dev) -> None: """Test log_dir property in local development mode.""" # Arrange settings = Settings() # Act result = settings.log_dir # Assert expected = Path.cwd() / "logs" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: True)) def test_log_file_path_local_dev(self, mock_is_local_dev) -> None: """Test log_file_path property in local development mode.""" # Arrange settings = Settings() # Act result = settings.log_file_path # Assert expected = Path.cwd() / "logs" / "registry.log" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: True)) def test_dotenv_path_local_dev(self, mock_is_local_dev) -> None: """Test dotenv_path property in local development mode.""" # Arrange settings = Settings() # Act result = settings.dotenv_path # Assert expected = Path.cwd() / ".env" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: True)) def test_agents_dir_local_dev(self, mock_is_local_dev) -> None: """Test agents_dir property in local development mode.""" # Arrange settings = Settings() # Act result = settings.agents_dir # Assert expected = Path.cwd() / "registry" / "agents" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: True)) def test_embeddings_model_dir_local_dev(self, mock_is_local_dev) -> None: """Test embeddings_model_dir property in local development mode.""" # Arrange settings = Settings(embeddings_model_name="test-model") # Act result = settings.embeddings_model_dir # Assert expected = Path.cwd() / "registry" / "models" / "test-model" assert result == expected # ============================================================================= # TEST CLASS: Path Properties - Container Mode # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsPathsContainer: """Test path properties in container/production mode.""" @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: False)) def test_servers_dir_container(self, mock_is_local_dev) -> None: """Test servers_dir property in container mode.""" # Arrange settings = Settings() # Act result = settings.servers_dir # Assert expected = Path("/app/registry") / "servers" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: False)) def test_static_dir_container(self, mock_is_local_dev) -> None: """Test static_dir property in container mode.""" # Arrange settings = Settings() # Act result = settings.static_dir # Assert expected = Path("/app/registry") / "static" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: False)) def test_templates_dir_container(self, mock_is_local_dev) -> None: """Test templates_dir property in container mode.""" # Arrange settings = Settings() # Act result = settings.templates_dir # Assert expected = Path("/app/registry") / "templates" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: False)) def test_log_dir_container(self, mock_is_local_dev) -> None: """Test log_dir property in container mode.""" # Arrange settings = Settings() # Act result = settings.log_dir # Assert expected = Path("/app/logs") assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: False)) def test_log_file_path_container(self, mock_is_local_dev) -> None: """Test log_file_path property in container mode.""" # Arrange settings = Settings() # Act result = settings.log_file_path # Assert expected = Path("/app/logs") / "registry.log" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: False)) def test_dotenv_path_container(self, mock_is_local_dev) -> None: """Test dotenv_path property in container mode.""" # Arrange settings = Settings() # Act result = settings.dotenv_path # Assert expected = Path("/app/registry") / ".env" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: False)) def test_agents_dir_container(self, mock_is_local_dev) -> None: """Test agents_dir property in container mode.""" # Arrange settings = Settings() # Act result = settings.agents_dir # Assert expected = Path("/app/registry") / "agents" assert result == expected @patch.object(Settings, "is_local_dev", new_callable=lambda: property(lambda self: False)) def test_embeddings_model_dir_container(self, mock_is_local_dev) -> None: """Test embeddings_model_dir property in container mode.""" # Arrange settings = Settings(embeddings_model_name="test-model") # Act result = settings.embeddings_model_dir # Assert expected = Path("/app/registry") / "models" / "test-model" assert result == expected # ============================================================================= # TEST CLASS: Fixed Path Properties # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsFixedPaths: """Test path properties that don't depend on is_local_dev.""" def test_nginx_config_path(self) -> None: """Test nginx_config_path property.""" # Arrange settings = Settings() # Act result = settings.nginx_config_path # Assert assert result == Path("/etc/nginx/conf.d/nginx_rev_proxy.conf") @patch.object( Settings, "servers_dir", new_callable=lambda: property(lambda self: Path("/test/servers")) ) def test_state_file_path(self, mock_servers_dir) -> None: """Test state_file_path property.""" # Arrange settings = Settings() # Act result = settings.state_file_path # Assert expected = Path("/test/servers") / "server_state.json" assert result == expected @patch.object( Settings, "servers_dir", new_callable=lambda: property(lambda self: Path("/test/servers")) ) def test_faiss_index_path(self, mock_servers_dir) -> None: """Test faiss_index_path property.""" # Arrange settings = Settings() # Act result = settings.faiss_index_path # Assert expected = Path("/test/servers") / "service_index.faiss" assert result == expected @patch.object( Settings, "servers_dir", new_callable=lambda: property(lambda self: Path("/test/servers")) ) def test_faiss_metadata_path(self, mock_servers_dir) -> None: """Test faiss_metadata_path property.""" # Arrange settings = Settings() # Act result = settings.faiss_metadata_path # Assert expected = Path("/test/servers") / "service_index_metadata.json" assert result == expected @patch.object( Settings, "agents_dir", new_callable=lambda: property(lambda self: Path("/test/agents")) ) def test_agent_state_file_path(self, mock_agents_dir) -> None: """Test agent_state_file_path property.""" # Arrange settings = Settings() # Act result = settings.agent_state_file_path # Assert expected = Path("/test/agents") / "agent_state.json" assert result == expected # ============================================================================= # TEST CLASS: Embeddings Provider Configuration # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsEmbeddingsProviders: """Test embeddings provider configurations.""" def test_sentence_transformers_provider(self) -> None: """Test sentence-transformers provider configuration.""" # Act settings = Settings( embeddings_provider="sentence-transformers", embeddings_model_name="all-MiniLM-L6-v2", embeddings_model_dimensions=384, ) # Assert assert settings.embeddings_provider == "sentence-transformers" assert settings.embeddings_model_name == "all-MiniLM-L6-v2" assert settings.embeddings_model_dimensions == 384 assert settings.embeddings_api_key is None assert settings.embeddings_secret_key is None assert settings.embeddings_api_base is None def test_litellm_provider_with_api_key(self) -> None: """Test litellm provider configuration with API key.""" # Act settings = Settings( embeddings_provider="litellm", embeddings_model_name="text-embedding-3-small", embeddings_model_dimensions=1536, embeddings_api_key="test-api-key", embeddings_api_base="https://api.openai.com/v1", ) # Assert assert settings.embeddings_provider == "litellm" assert settings.embeddings_model_name == "text-embedding-3-small" assert settings.embeddings_model_dimensions == 1536 assert settings.embeddings_api_key == "test-api-key" assert settings.embeddings_api_base == "https://api.openai.com/v1" def test_litellm_provider_bedrock(self) -> None: """Test litellm provider configuration for Amazon Bedrock.""" # Act settings = Settings( embeddings_provider="litellm", embeddings_model_name="bedrock/amazon.titan-embed-text-v2:0", embeddings_model_dimensions=1024, embeddings_aws_region="us-west-2", ) # Assert assert settings.embeddings_provider == "litellm" assert settings.embeddings_model_name == "bedrock/amazon.titan-embed-text-v2:0" assert settings.embeddings_model_dimensions == 1024 assert settings.embeddings_aws_region == "us-west-2" # API key should be None for Bedrock (uses AWS credentials) assert settings.embeddings_api_key is None # ============================================================================= # TEST CLASS: Settings Model Configuration # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsModelConfig: """Test Pydantic model configuration.""" def test_settings_extra_fields_ignored(self) -> None: """Test that extra fields are ignored per model config.""" # Act - Should not raise an error settings = Settings( unknown_field="should_be_ignored", another_unknown=123, ) # Assert assert not hasattr(settings, "unknown_field") assert not hasattr(settings, "another_unknown") def test_settings_preserves_field_names(self) -> None: """Test that constructor uses exact field names.""" # Act settings = Settings( session_cookie_name="test_cookie", auth_server_url="http://test:8888", ) # Assert assert settings.session_cookie_name == "test_cookie" assert settings.auth_server_url == "http://test:8888" # ============================================================================= # TEST CLASS: Integration with Test Fixtures # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsWithFixtures: """Test Settings class with pytest fixtures.""" def test_test_settings_fixture(self, test_settings: Settings) -> None: """Test that test_settings fixture provides valid Settings.""" # Assert assert isinstance(test_settings, Settings) assert test_settings.secret_key == "test-secret-key-for-testing-only" def test_test_settings_paths_are_temp(self, test_settings: Settings, tmp_path: Path) -> None: """Test that test_settings uses temporary paths.""" # Assert - paths should be within tmp_path or be Path objects assert isinstance(test_settings.servers_dir, Path) assert isinstance(test_settings.agents_dir, Path) assert isinstance(test_settings.embeddings_model_dir, Path) assert isinstance(test_settings.log_dir, Path) # ============================================================================= # TEST CLASS: Secret Key Generation # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsSecretKeyGeneration: """Test secret key generation logic.""" def test_secret_key_generated_when_empty_string(self) -> None: """Test that secret key is generated when provided as empty string.""" # Act settings = Settings(secret_key="") # Assert assert settings.secret_key != "" assert len(settings.secret_key) == 64 def test_secret_key_different_on_each_instantiation(self) -> None: """Test that generated secret keys are different for each instance.""" # Act settings1 = Settings(secret_key="") settings2 = Settings(secret_key="") # Assert assert settings1.secret_key != settings2.secret_key def test_secret_key_is_hex_string(self) -> None: """Test that generated secret key is a valid hex string.""" # Act settings = Settings(secret_key="") # Assert # Should be 64 character hex string (32 bytes) assert len(settings.secret_key) == 64 try: bytes.fromhex(settings.secret_key) is_valid_hex = True except ValueError: is_valid_hex = False assert is_valid_hex # ============================================================================= # TEST CLASS: Session Cookie Configuration # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsSessionCookie: """Test session cookie configuration.""" def test_session_cookie_secure_false_by_default(self) -> None: """Test that session_cookie_secure is False by default.""" # Act settings = Settings() # Assert assert settings.session_cookie_secure is False def test_session_cookie_secure_can_be_enabled(self, monkeypatch) -> None: """Test that session_cookie_secure can be enabled via env var.""" # Arrange monkeypatch.setenv("SESSION_COOKIE_SECURE", "true") # Act settings = Settings() # Assert assert settings.session_cookie_secure is True def test_session_cookie_domain_none_by_default(self) -> None: """Test that session_cookie_domain is None by default.""" # Act settings = Settings() # Assert assert settings.session_cookie_domain is None def test_session_cookie_domain_can_be_set(self, monkeypatch) -> None: """Test that session_cookie_domain can be set via env var.""" # Arrange monkeypatch.setenv("SESSION_COOKIE_DOMAIN", ".example.com") # Act settings = Settings() # Assert assert settings.session_cookie_domain == ".example.com" def test_session_max_age_default(self) -> None: """Test that session_max_age_seconds has correct default.""" # Act settings = Settings() # Assert assert settings.session_max_age_seconds == 28800 # 8 hours in seconds # ============================================================================= # TEST CLASS: Auth Server URLs # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsAuthServerUrls: """Test auth server URL configuration.""" def test_auth_server_urls_default_to_localhost(self, monkeypatch, tmp_path) -> None: """Test that auth server URLs default to localhost.""" # Arrange - Clear AUTH_SERVER_URL env vars and disable .env file loading monkeypatch.delenv("AUTH_SERVER_URL", raising=False) monkeypatch.delenv("AUTH_SERVER_EXTERNAL_URL", raising=False) monkeypatch.chdir(tmp_path) # Act settings = Settings() # Assert assert settings.auth_server_url == "http://localhost:8888" assert settings.auth_server_external_url == "http://localhost:8888" def test_auth_server_urls_can_differ(self, monkeypatch) -> None: """Test that internal and external auth URLs can be different.""" # Arrange monkeypatch.setenv("AUTH_SERVER_URL", "http://auth-internal:8888") monkeypatch.setenv("AUTH_SERVER_EXTERNAL_URL", "https://auth.example.com") # Act settings = Settings() # Assert assert settings.auth_server_url == "http://auth-internal:8888" assert settings.auth_server_external_url == "https://auth.example.com" # ============================================================================= # TEST CLASS: Settings Tab Visibility Feature Flags # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsTabVisibilityFeatureFlags: """Test SHOW_*_TAB + REGISTRY_MODE precedence in get_config() response.""" @pytest.mark.asyncio async def test_settings_tab_defaults_match_current_behavior(self): """All defaults (true) produce same features as REGISTRY_MODE=full.""" from registry.api.config_routes import get_config result = await get_config() features = result["features"] assert features["mcp_servers"] is True assert features["agents"] is True assert features["skills"] is True assert features["virtual_servers"] is True @pytest.mark.asyncio async def test_settings_tab_show_false_hides_feature(self, monkeypatch, tmp_path): """Setting SHOW_AGENTS_TAB=false hides the tab even with REGISTRY_MODE=full.""" monkeypatch.chdir(tmp_path) monkeypatch.setenv("SHOW_AGENTS_TAB", "false") new_settings = Settings() with patch("registry.api.config_routes.settings", new_settings): from registry.api.config_routes import get_config result = await get_config() assert result["features"]["agents"] is False assert result["features"]["mcp_servers"] is True @pytest.mark.asyncio async def test_settings_tab_mode_disables_feature_regardless(self, monkeypatch, tmp_path): """REGISTRY_MODE=mcp-servers-only hides agents even if SHOW_AGENTS_TAB=true.""" monkeypatch.chdir(tmp_path) monkeypatch.setenv("REGISTRY_MODE", "mcp-servers-only") monkeypatch.setenv("SHOW_AGENTS_TAB", "true") new_settings = Settings() with patch("registry.api.config_routes.settings", new_settings): from registry.api.config_routes import get_config result = await get_config() assert result["features"]["agents"] is False assert result["features"]["mcp_servers"] is True @pytest.mark.asyncio async def test_settings_tab_virtual_servers_key_present(self): """virtual_servers key is present in features dict.""" from registry.api.config_routes import get_config result = await get_config() assert "virtual_servers" in result["features"] assert result["features"]["virtual_servers"] is True @pytest.mark.asyncio async def test_settings_tab_virtual_servers_false(self, monkeypatch, tmp_path): """SHOW_VIRTUAL_SERVERS_TAB=false hides virtual servers.""" monkeypatch.chdir(tmp_path) monkeypatch.setenv("SHOW_VIRTUAL_SERVERS_TAB", "false") new_settings = Settings() with patch("registry.api.config_routes.settings", new_settings): from registry.api.config_routes import get_config result = await get_config() assert result["features"]["virtual_servers"] is False @pytest.mark.asyncio async def test_settings_tab_virtual_servers_hidden_by_mode(self, monkeypatch, tmp_path): """REGISTRY_MODE=agents-only hides virtual servers even if SHOW_VIRTUAL_SERVERS_TAB=true.""" monkeypatch.chdir(tmp_path) monkeypatch.setenv("REGISTRY_MODE", "agents-only") monkeypatch.setenv("SHOW_VIRTUAL_SERVERS_TAB", "true") new_settings = Settings() with patch("registry.api.config_routes.settings", new_settings): from registry.api.config_routes import get_config result = await get_config() assert result["features"]["virtual_servers"] is False assert result["features"]["agents"] is True # ============================================================================= # TEST CLASS: Settings Tab Visibility Startup Warnings # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestSettingsTabVisibilityStartupWarnings: """Test log_tab_visibility_warnings() logs correctly.""" def test_settings_tab_warning_for_ineffective_override(self, monkeypatch, tmp_path, caplog): """Warning logged when SHOW_AGENTS_TAB=true but mode disables agents.""" monkeypatch.delenv("SHOW_AGENTS_TAB", raising=False) monkeypatch.setenv("REGISTRY_MODE", "mcp-servers-only") monkeypatch.chdir(tmp_path) import logging from registry.core.config import log_tab_visibility_warnings s = Settings() with caplog.at_level(logging.WARNING): log_tab_visibility_warnings(s) assert any( "SHOW_AGENTS_TAB" in msg and "mcp-servers-only" in msg for msg in caplog.messages ) def test_settings_tab_no_warning_when_consistent(self, monkeypatch, tmp_path, caplog): """No warning when all SHOW_*_TAB are consistent with REGISTRY_MODE=full.""" monkeypatch.setenv("REGISTRY_MODE", "full") monkeypatch.chdir(tmp_path) import logging from registry.core.config import log_tab_visibility_warnings s = Settings() with caplog.at_level(logging.WARNING): log_tab_visibility_warnings(s) assert not any("SHOW_" in msg for msg in caplog.messages) ================================================ FILE: tests/unit/core/test_endpoint_utils.py ================================================ """ Unit tests for registry.core.endpoint_utils module. This module tests the endpoint URL resolution utilities, including custom endpoint support and backward compatibility with default /mcp and /sse suffixes. """ import pytest from registry.core.endpoint_utils import ( _url_contains_transport_path, get_endpoint_url, get_endpoint_url_from_server_info, ) # ============================================================================= # TEST CLASS: URL Contains Transport Path Detection # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestUrlContainsTransportPath: """Test _url_contains_transport_path helper function.""" def test_url_ending_with_mcp(self) -> None: """URL ending with /mcp should be detected.""" assert _url_contains_transport_path("http://server.com/mcp") is True def test_url_ending_with_sse(self) -> None: """URL ending with /sse should be detected.""" assert _url_contains_transport_path("http://server.com/sse") is True def test_url_with_mcp_in_path(self) -> None: """URL with /mcp/ in path should be detected.""" assert _url_contains_transport_path("http://server.com/mcp/v1") is True def test_url_with_sse_in_path(self) -> None: """URL with /sse/ in path should be detected.""" assert _url_contains_transport_path("http://server.com/sse/v1") is True def test_url_without_transport_path(self) -> None: """URL without transport path should not be detected.""" assert _url_contains_transport_path("http://server.com/api") is False def test_url_with_custom_path(self) -> None: """URL with custom path should not be detected.""" assert _url_contains_transport_path("http://server.com/use-case") is False # ============================================================================= # TEST CLASS: get_endpoint_url for Streamable HTTP # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestGetEndpointUrlStreamableHttp: """Test get_endpoint_url function for streamable-http transport.""" def test_explicit_mcp_endpoint_takes_priority(self) -> None: """Explicit mcp_endpoint should be used when provided.""" result = get_endpoint_url( proxy_pass_url="http://server.com/api", transport_type="streamable-http", mcp_endpoint="http://custom.server.com/use-case", ) assert result == "http://custom.server.com/use-case" def test_explicit_mcp_endpoint_preserves_trailing_slash(self) -> None: """Explicit mcp_endpoint should preserve trailing slash (changed behavior).""" result = get_endpoint_url( proxy_pass_url="http://server.com/api", transport_type="streamable-http", mcp_endpoint="http://custom.server.com/use-case/", ) # Changed: Now preserves trailing slash for servers that require it assert result == "http://custom.server.com/use-case/" def test_url_with_mcp_used_as_is(self) -> None: """URL already containing /mcp should be used as-is.""" result = get_endpoint_url( proxy_pass_url="http://server.com/mcp", transport_type="streamable-http", ) assert result == "http://server.com/mcp" def test_url_with_mcp_in_path_used_as_is(self) -> None: """URL with /mcp/ in path should be used as-is.""" result = get_endpoint_url( proxy_pass_url="http://server.com/mcp/v1", transport_type="streamable-http", ) assert result == "http://server.com/mcp/v1" def test_plain_url_gets_mcp_appended(self) -> None: """Plain URL without transport path should get /mcp appended.""" result = get_endpoint_url( proxy_pass_url="http://server.com/api", transport_type="streamable-http", ) assert result == "http://server.com/api/mcp" def test_url_with_trailing_slash_handled(self) -> None: """URL with trailing slash should be handled correctly.""" result = get_endpoint_url( proxy_pass_url="http://server.com/api/", transport_type="streamable-http", ) assert result == "http://server.com/api/mcp" def test_default_transport_is_streamable_http(self) -> None: """Default transport type should be streamable-http.""" result = get_endpoint_url( proxy_pass_url="http://server.com/api", ) assert result == "http://server.com/api/mcp" # ============================================================================= # TEST CLASS: get_endpoint_url for SSE # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestGetEndpointUrlSse: """Test get_endpoint_url function for SSE transport.""" def test_explicit_sse_endpoint_takes_priority(self) -> None: """Explicit sse_endpoint should be used when provided.""" result = get_endpoint_url( proxy_pass_url="http://server.com/api", transport_type="sse", sse_endpoint="http://custom.server.com/events", ) assert result == "http://custom.server.com/events" def test_explicit_sse_endpoint_preserves_trailing_slash(self) -> None: """Explicit sse_endpoint should preserve trailing slash (changed behavior).""" result = get_endpoint_url( proxy_pass_url="http://server.com/api", transport_type="sse", sse_endpoint="http://custom.server.com/events/", ) # Changed: Now preserves trailing slash for servers that require it assert result == "http://custom.server.com/events/" def test_url_with_sse_used_as_is(self) -> None: """URL already ending with /sse should be used as-is.""" result = get_endpoint_url( proxy_pass_url="http://server.com/sse", transport_type="sse", ) assert result == "http://server.com/sse" def test_url_with_sse_in_path_used_as_is(self) -> None: """URL with /sse/ in path should be used as-is.""" result = get_endpoint_url( proxy_pass_url="http://server.com/sse/v1", transport_type="sse", ) assert result == "http://server.com/sse/v1" def test_plain_url_gets_sse_appended(self) -> None: """Plain URL without transport path should get /sse appended.""" result = get_endpoint_url( proxy_pass_url="http://server.com/api", transport_type="sse", ) assert result == "http://server.com/api/sse" def test_url_with_trailing_slash_handled(self) -> None: """URL with trailing slash should be handled correctly.""" result = get_endpoint_url( proxy_pass_url="http://server.com/api/", transport_type="sse", ) assert result == "http://server.com/api/sse" # ============================================================================= # TEST CLASS: get_endpoint_url_from_server_info # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestGetEndpointUrlFromServerInfo: """Test get_endpoint_url_from_server_info function.""" def test_extracts_proxy_pass_url(self) -> None: """Should extract proxy_pass_url from server_info.""" server_info = {"proxy_pass_url": "http://server.com/api"} result = get_endpoint_url_from_server_info(server_info) assert result == "http://server.com/api/mcp" def test_uses_mcp_endpoint_from_server_info(self) -> None: """Should use mcp_endpoint when present in server_info.""" server_info = { "proxy_pass_url": "http://server.com/api", "mcp_endpoint": "http://custom.server.com/use-case", } result = get_endpoint_url_from_server_info(server_info, transport_type="streamable-http") assert result == "http://custom.server.com/use-case" def test_uses_sse_endpoint_from_server_info(self) -> None: """Should use sse_endpoint when present in server_info.""" server_info = { "proxy_pass_url": "http://server.com/api", "sse_endpoint": "http://custom.server.com/events", } result = get_endpoint_url_from_server_info(server_info, transport_type="sse") assert result == "http://custom.server.com/events" def test_raises_on_missing_proxy_pass_url(self) -> None: """Should raise ValueError if proxy_pass_url is missing.""" server_info = {"server_name": "test"} with pytest.raises(ValueError, match="proxy_pass_url"): get_endpoint_url_from_server_info(server_info) def test_handles_none_endpoint_fields(self) -> None: """Should handle None values for endpoint fields.""" server_info = { "proxy_pass_url": "http://server.com/api", "mcp_endpoint": None, "sse_endpoint": None, } result = get_endpoint_url_from_server_info(server_info) assert result == "http://server.com/api/mcp" # ============================================================================= # TEST CLASS: Real-World Scenarios # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestRealWorldScenarios: """Test real-world scenarios mentioned in the issue.""" def test_custom_use_case_endpoint(self) -> None: """Test custom endpoint like mcp.myorg.com/use-case.""" server_info = { "proxy_pass_url": "http://mcp.myorg.com/use-case", "mcp_endpoint": "http://mcp.myorg.com/use-case", } result = get_endpoint_url_from_server_info(server_info) assert result == "http://mcp.myorg.com/use-case" def test_multiple_servers_same_host(self) -> None: """Test multiple MCP servers on same host with different paths.""" server1 = { "proxy_pass_url": "http://myorg.com/mcp-1", "mcp_endpoint": "http://myorg.com/mcp-1", } server2 = { "proxy_pass_url": "http://myorg.com/mcp-2", "mcp_endpoint": "http://myorg.com/mcp-2", } result1 = get_endpoint_url_from_server_info(server1) result2 = get_endpoint_url_from_server_info(server2) assert result1 == "http://myorg.com/mcp-1" assert result2 == "http://myorg.com/mcp-2" def test_backward_compatibility_no_explicit_endpoint(self) -> None: """Test backward compatibility when no explicit endpoint is set.""" server_info = { "proxy_pass_url": "http://server.com/api", } result = get_endpoint_url_from_server_info(server_info) assert result == "http://server.com/api/mcp" def test_backward_compatibility_url_already_has_mcp(self) -> None: """Test backward compatibility when URL already has /mcp.""" server_info = { "proxy_pass_url": "http://server.com/api/mcp", } result = get_endpoint_url_from_server_info(server_info) assert result == "http://server.com/api/mcp" # ============================================================================= # TEST CLASS: Trailing Slash Preservation (Issue #539 Fix) # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestTrailingSlashPreservation: """Test trailing slash preservation for servers that require it (e.g., Hydrata).""" def test_hydrata_url_with_trailing_slash(self) -> None: """Hydrata URL with /mcp/ should preserve trailing slash.""" result = get_endpoint_url( proxy_pass_url="https://hydrata.com/mcp/", transport_type="streamable-http", ) # Critical fix: Preserve trailing slash to avoid 301 redirect → 405 error assert result == "https://hydrata.com/mcp/" def test_url_with_mcp_and_trailing_slash(self) -> None: """URL ending with /mcp/ should preserve trailing slash.""" result = get_endpoint_url( proxy_pass_url="https://example.com/mcp/", transport_type="streamable-http", ) assert result == "https://example.com/mcp/" def test_url_with_sse_and_trailing_slash(self) -> None: """URL ending with /sse/ should preserve trailing slash.""" result = get_endpoint_url( proxy_pass_url="https://example.com/sse/", transport_type="sse", ) assert result == "https://example.com/sse/" def test_url_with_mcp_in_middle_and_trailing_slash(self) -> None: """URL with /mcp/ in middle and trailing slash should preserve it.""" result = get_endpoint_url( proxy_pass_url="https://example.com/mcp/v1/", transport_type="streamable-http", ) assert result == "https://example.com/mcp/v1/" def test_url_without_transport_still_strips_slash(self) -> None: """URL without transport path should still strip trailing slash before appending.""" result = get_endpoint_url( proxy_pass_url="http://server.com/api/", transport_type="streamable-http", ) assert result == "http://server.com/api/mcp" # ============================================================================= # TEST CLASS: Production URL Patterns (All 4 Patterns) # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestProductionUrlPatterns: """Test all 4 URL patterns found in production database.""" def test_pattern1_no_transport_no_slash(self) -> None: """Pattern 1: No transport, no slash (1 server in prod).""" # Example: http://localhost:3000 result = get_endpoint_url("http://localhost:3000") assert result == "http://localhost:3000/mcp" def test_pattern2_no_transport_with_slash(self) -> None: """Pattern 2: No transport, has slash (7 servers in prod).""" # Examples: http://mcpgw-server:8003/, http://currenttime-server:8000/ result1 = get_endpoint_url("http://mcpgw-server:8003/") result2 = get_endpoint_url("http://currenttime-server:8000/") assert result1 == "http://mcpgw-server:8003/mcp" assert result2 == "http://currenttime-server:8000/mcp" def test_pattern3_has_transport_no_slash(self) -> None: """Pattern 3: Has transport, no slash (10 servers in prod).""" # Examples: https://docs.mcp.cloudflare.com/mcp, https://mcp.context7.com/mcp result1 = get_endpoint_url("https://docs.mcp.cloudflare.com/mcp") result2 = get_endpoint_url("https://mcp.context7.com/mcp") result3 = get_endpoint_url("https://mcp.cloudflare.com/mcp") assert result1 == "https://docs.mcp.cloudflare.com/mcp" assert result2 == "https://mcp.context7.com/mcp" assert result3 == "https://mcp.cloudflare.com/mcp" def test_pattern4_has_transport_with_slash(self) -> None: """Pattern 4: Has transport, has slash (1 server in prod - Hydrata).""" # Example: https://hydrata.com/mcp/ result = get_endpoint_url("https://hydrata.com/mcp/") # This is the ONLY pattern with changed behavior (fix for issue #539) assert result == "https://hydrata.com/mcp/" def test_all_patterns_except_pattern4_unchanged(self) -> None: """Verify patterns 1-3 have identical behavior to old code.""" # Pattern 1 assert get_endpoint_url("http://localhost:3000") == "http://localhost:3000/mcp" # Pattern 2 assert get_endpoint_url("http://mcpgw-server:8003/") == "http://mcpgw-server:8003/mcp" # Pattern 3 assert ( get_endpoint_url("https://docs.mcp.cloudflare.com/mcp") == "https://docs.mcp.cloudflare.com/mcp" ) # Only Pattern 4 has different behavior (this is the fix) assert get_endpoint_url("https://hydrata.com/mcp/") == "https://hydrata.com/mcp/" # ============================================================================= # TEST CLASS: Regression Prevention # ============================================================================= @pytest.mark.unit @pytest.mark.core class TestRegressionPrevention: """Tests to prevent regression of the Hydrata 301/405 bug.""" def test_prevents_301_redirect_issue(self) -> None: """Ensure URLs with trailing slash don't cause 301 redirects. Background: When POSTing to https://hydrata.com/mcp (no slash), Hydrata redirects with 301 to https://hydrata.com/mcp/ (with slash). HTTP clients change POST to GET on 301 redirects, causing 405 errors. Fix: Preserve trailing slash so we POST directly to the correct URL. """ url_with_slash = "https://hydrata.com/mcp/" result = get_endpoint_url(url_with_slash) # Should return URL with slash to avoid redirect assert result == "https://hydrata.com/mcp/" assert not result.endswith("/mcp") # Should NOT strip the slash def test_explicit_endpoint_with_slash_preserved(self) -> None: """Explicit endpoints with trailing slash should be preserved.""" result = get_endpoint_url( proxy_pass_url="http://localhost:3000", mcp_endpoint="https://hydrata.com/mcp/", ) assert result == "https://hydrata.com/mcp/" def test_cloudflare_docs_url_unchanged(self) -> None: """Cloudflare docs URL without slash should remain unchanged.""" result = get_endpoint_url("https://docs.mcp.cloudflare.com/mcp") assert result == "https://docs.mcp.cloudflare.com/mcp" def test_context7_url_unchanged(self) -> None: """Context7 URL without slash should remain unchanged.""" result = get_endpoint_url("https://mcp.context7.com/mcp") assert result == "https://mcp.context7.com/mcp" ================================================ FILE: tests/unit/core/test_mcp_client.py ================================================ """ Unit tests for registry/core/mcp_client.py Tests the MCPClientService for tool discovery and server connections. """ import contextlib from unittest.mock import AsyncMock, MagicMock, patch import pytest from registry.core.mcp_client import ( MCPClientService, _build_headers_for_server, _extract_tool_details, _get_tools_sse, _get_tools_streamable_http, detect_server_transport, detect_server_transport_aware, get_tools_from_server_with_server_info, get_tools_from_server_with_transport, mcp_client_service, normalize_sse_endpoint_url, normalize_sse_endpoint_url_for_request, ) # ============================================================================= # TEST FIXTURES # ============================================================================= @pytest.fixture def mock_server_info(): """Create mock server info.""" return { "server_name": "test-server", "supported_transports": ["streamable-http"], "headers": [{"X-Custom-Header": "custom-value"}], "tags": [], } @pytest.fixture def mock_tools_response(): """Create mock tools response from MCP server.""" mock_tool = MagicMock() mock_tool.name = "test_tool" mock_tool.description = """Test tool for testing. Args: param1: First parameter param2: Second parameter Returns: Result of the operation Raises: ValueError: If parameters are invalid """ mock_tool.inputSchema = { "type": "object", "properties": { "param1": {"type": "string"}, "param2": {"type": "integer"}, }, } mock_response = MagicMock() mock_response.tools = [mock_tool] return mock_response @pytest.fixture def mock_client_session(): """Create mock MCP ClientSession.""" session = AsyncMock() session.initialize = AsyncMock() session.list_tools = AsyncMock() return session # ============================================================================= # NORMALIZE_SSE_ENDPOINT_URL TESTS # ============================================================================= @pytest.mark.unit def test_normalize_sse_endpoint_url_with_mount_path(): """Test normalizing SSE endpoint URL with mount path.""" url = "/fininfo/messages/?session_id=123" result = normalize_sse_endpoint_url(url) assert result == "/messages/?session_id=123" @pytest.mark.unit def test_normalize_sse_endpoint_url_without_mount_path(): """Test normalizing SSE endpoint URL without mount path.""" url = "/messages/?session_id=123" result = normalize_sse_endpoint_url(url) assert result == "/messages/?session_id=123" @pytest.mark.unit def test_normalize_sse_endpoint_url_empty(): """Test normalizing empty SSE endpoint URL.""" result = normalize_sse_endpoint_url("") assert result == "" @pytest.mark.unit def test_normalize_sse_endpoint_url_complex_path(): """Test normalizing complex SSE endpoint URL.""" url = "/currenttime/messages/?session_id=abc-123¶m=value" result = normalize_sse_endpoint_url(url) assert result == "/messages/?session_id=abc-123¶m=value" # ============================================================================= # NORMALIZE_SSE_ENDPOINT_URL_FOR_REQUEST TESTS # ============================================================================= @pytest.mark.unit def test_normalize_sse_endpoint_url_for_request_with_mount(): """Test normalizing request URL with mount path.""" url = "http://localhost:8000/currenttime/messages/?session_id=123" result = normalize_sse_endpoint_url_for_request(url) assert result == "http://localhost:8000/messages/?session_id=123" @pytest.mark.unit def test_normalize_sse_endpoint_url_for_request_without_mount(): """Test normalizing request URL without mount path.""" url = "http://localhost:8000/messages/?session_id=123" result = normalize_sse_endpoint_url_for_request(url) assert result == "http://localhost:8000/messages/?session_id=123" @pytest.mark.unit def test_normalize_sse_endpoint_url_for_request_api_path(): """Test normalizing request URL with common API path.""" url = "http://localhost:8000/api/messages/?session_id=123" result = normalize_sse_endpoint_url_for_request(url) # Should not normalize 'api' as mount path assert result == "http://localhost:8000/api/messages/?session_id=123" @pytest.mark.unit def test_normalize_sse_endpoint_url_for_request_no_messages(): """Test normalizing request URL without /messages/ path.""" url = "http://localhost:8000/api/data" result = normalize_sse_endpoint_url_for_request(url) assert result == "http://localhost:8000/api/data" # ============================================================================= # BUILD_HEADERS_FOR_SERVER TESTS # ============================================================================= @pytest.mark.unit def test_build_headers_for_server_with_custom_headers(): """Test building headers with custom server headers.""" server_info = { "headers": [ {"X-Custom-1": "value1"}, {"X-Custom-2": "value2"}, ] } headers = _build_headers_for_server(server_info) assert "Accept" in headers assert "Content-Type" in headers assert headers["X-Custom-1"] == "value1" assert headers["X-Custom-2"] == "value2" @pytest.mark.unit def test_build_headers_for_server_no_custom_headers(): """Test building headers without custom server headers.""" headers = _build_headers_for_server(None) assert "Accept" in headers assert "Content-Type" in headers assert headers["Accept"] == "application/json, text/event-stream" @pytest.mark.unit def test_build_headers_for_server_empty_headers(): """Test building headers with empty headers list.""" server_info = {"headers": []} headers = _build_headers_for_server(server_info) assert "Accept" in headers assert "Content-Type" in headers # ============================================================================= # DETECT_SERVER_TRANSPORT TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_detect_server_transport_explicit_sse(): """Test detecting transport when URL has /sse endpoint.""" url = "http://localhost:8000/sse" result = await detect_server_transport(url) assert result == "sse" @pytest.mark.unit @pytest.mark.asyncio async def test_detect_server_transport_explicit_mcp(): """Test detecting transport when URL has /mcp endpoint.""" url = "http://localhost:8000/mcp" result = await detect_server_transport(url) assert result == "streamable-http" @pytest.mark.unit @pytest.mark.asyncio async def test_detect_server_transport_streamable_http_success(): """Test detecting transport with successful streamable-http connection.""" url = "http://localhost:8000" with patch("registry.core.mcp_client.streamablehttp_client") as mock_client: mock_client.return_value.__aenter__.return_value = MagicMock() result = await detect_server_transport(url) assert result == "streamable-http" @pytest.mark.unit @pytest.mark.asyncio async def test_detect_server_transport_sse_fallback(): """Test detecting transport with SSE fallback.""" url = "http://localhost:8000" with patch("registry.core.mcp_client.streamablehttp_client") as mock_streamable: mock_streamable.side_effect = Exception("Connection failed") with patch("registry.core.mcp_client.sse_client") as mock_sse: mock_sse.return_value.__aenter__.return_value = MagicMock() result = await detect_server_transport(url) assert result == "sse" @pytest.mark.unit @pytest.mark.asyncio async def test_detect_server_transport_default(): """Test detecting transport defaults to streamable-http.""" url = "http://localhost:8000" with patch("registry.core.mcp_client.streamablehttp_client") as mock_streamable: mock_streamable.side_effect = Exception("Connection failed") with patch("registry.core.mcp_client.sse_client") as mock_sse: mock_sse.side_effect = Exception("Connection failed") result = await detect_server_transport(url) assert result == "streamable-http" # ============================================================================= # DETECT_SERVER_TRANSPORT_AWARE TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_detect_server_transport_aware_with_config(): """Test transport detection using server configuration.""" url = "http://localhost:8000" server_info = {"supported_transports": ["sse"]} result = await detect_server_transport_aware(url, server_info) assert result == "sse" @pytest.mark.unit @pytest.mark.asyncio async def test_detect_server_transport_aware_prefer_streamable(): """Test transport detection prefers streamable-http.""" url = "http://localhost:8000" server_info = {"supported_transports": ["sse", "streamable-http"]} result = await detect_server_transport_aware(url, server_info) assert result == "streamable-http" @pytest.mark.unit @pytest.mark.asyncio async def test_detect_server_transport_aware_explicit_url(): """Test transport detection with explicit URL endpoint.""" url = "http://localhost:8000/sse" server_info = {"supported_transports": ["streamable-http"]} result = await detect_server_transport_aware(url, server_info) # URL takes precedence assert result == "sse" @pytest.mark.unit @pytest.mark.asyncio async def test_detect_server_transport_aware_no_config(): """Test transport detection without server config.""" url = "http://localhost:8000" with patch("registry.core.mcp_client.detect_server_transport", return_value="streamable-http"): result = await detect_server_transport_aware(url, None) assert result == "streamable-http" # ============================================================================= # EXTRACT_TOOL_DETAILS TESTS # ============================================================================= @pytest.mark.unit def test_extract_tool_details(mock_tools_response): """Test extracting tool details from MCP response.""" result = _extract_tool_details(mock_tools_response) assert len(result) == 1 assert result[0]["name"] == "test_tool" assert "parsed_description" in result[0] assert result[0]["parsed_description"]["main"] == "Test tool for testing." assert "param1" in result[0]["parsed_description"]["args"] assert "schema" in result[0] # Verify raw description is also stored assert "description" in result[0] assert "Test tool for testing" in result[0]["description"] @pytest.mark.unit def test_extract_tool_details_no_description(): """Test extracting tool details with no description.""" mock_tool = MagicMock() mock_tool.name = "simple_tool" mock_tool.description = None mock_tool.__doc__ = None # MagicMock has its own __doc__; clear it mock_tool.inputSchema = {} mock_response = MagicMock() mock_response.tools = [mock_tool] result = _extract_tool_details(mock_response) assert len(result) == 1 assert result[0]["name"] == "simple_tool" assert result[0]["parsed_description"]["main"] == "No description available." @pytest.mark.unit def test_extract_tool_details_empty_response(): """Test extracting tool details from empty response.""" mock_response = MagicMock() mock_response.tools = [] result = _extract_tool_details(mock_response) assert len(result) == 0 @pytest.mark.unit def test_extract_tool_details_complex_docstring(): """Test extracting tool details with complex docstring.""" mock_tool = MagicMock() mock_tool.name = "complex_tool" mock_tool.description = """ Main description line 1. Main description line 2. Args: arg1: Description of arg1 arg2: Description of arg2 Returns: Description of return value Raises: ValueError: When something goes wrong TypeError: When type is incorrect """ mock_tool.inputSchema = {} mock_response = MagicMock() mock_response.tools = [mock_tool] result = _extract_tool_details(mock_response) assert len(result) == 1 parsed = result[0]["parsed_description"] assert "Main description" in parsed["main"] assert "arg1" in parsed["args"] assert "return value" in parsed["returns"] assert "ValueError" in parsed["raises"] # ============================================================================= # GET_TOOLS_STREAMABLE_HTTP TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_streamable_http_success(mock_server_info, mock_tools_response): """Test getting tools via streamable-http successfully.""" url = "http://localhost:8000/mcp" mock_session = AsyncMock() mock_session.initialize = AsyncMock() mock_session.list_tools = AsyncMock(return_value=mock_tools_response) with patch("registry.core.mcp_client.streamablehttp_client") as mock_client: mock_client.return_value.__aenter__.return_value = (MagicMock(), MagicMock(), MagicMock()) with patch("registry.core.mcp_client.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__.return_value = mock_session result = await _get_tools_streamable_http(url, mock_server_info) assert result is not None assert len(result) == 1 assert result[0]["name"] == "test_tool" @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_streamable_http_timeout(): """Test getting tools via streamable-http with timeout.""" url = "http://localhost:8000/mcp" mock_session = AsyncMock() mock_session.initialize = AsyncMock(side_effect=TimeoutError()) with patch("registry.core.mcp_client.streamablehttp_client") as mock_client: mock_client.return_value.__aenter__.return_value = (MagicMock(), MagicMock(), MagicMock()) with patch("registry.core.mcp_client.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__.return_value = mock_session result = await _get_tools_streamable_http(url, None) assert result is None @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_streamable_http_anthropic_registry(): """Test getting tools from Anthropic registry server.""" url = "http://localhost:8000/mcp" server_info = { "tags": ["anthropic-registry"], "headers": [], } mock_session = AsyncMock() mock_session.initialize = AsyncMock() mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[])) with patch("registry.core.mcp_client.streamablehttp_client") as mock_client: # Capture the URL passed to streamablehttp_client captured_urls = [] @contextlib.asynccontextmanager async def mock_cm(*args, **kwargs): captured_urls.append(kwargs.get("url")) yield (MagicMock(), MagicMock(), MagicMock()) mock_client.side_effect = mock_cm with patch("registry.core.mcp_client.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__.return_value = mock_session await _get_tools_streamable_http(url, server_info) # Verify instance_id parameter was added assert len(captured_urls) > 0 assert any("instance_id=default" in u for u in captured_urls) @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_streamable_http_fallback_endpoints(): """Test getting tools trying multiple endpoints.""" url = "http://localhost:8000" mock_session = AsyncMock() mock_session.initialize = AsyncMock() mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[])) call_count = 0 def mock_client_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: # First attempt fails raise Exception("Connection failed") else: # Second attempt succeeds return (MagicMock(), MagicMock(), MagicMock()) with patch("registry.core.mcp_client.streamablehttp_client") as mock_client: mock_client.return_value.__aenter__.side_effect = mock_client_side_effect with patch("registry.core.mcp_client.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__.return_value = mock_session await _get_tools_streamable_http(url, None) # Should try /mcp/ first, then / (root) assert call_count == 2 # ============================================================================= # GET_TOOLS_SSE TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_sse_success(mock_tools_response): """Test getting tools via SSE successfully.""" url = "http://localhost:8000/sse" mock_session = AsyncMock() mock_session.initialize = AsyncMock() mock_session.list_tools = AsyncMock(return_value=mock_tools_response) with patch("registry.core.mcp_client.sse_client") as mock_client: mock_client.return_value.__aenter__.return_value = (MagicMock(), MagicMock()) with patch("registry.core.mcp_client.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__.return_value = mock_session result = await _get_tools_sse(url, None) assert result is not None assert len(result) == 1 @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_sse_timeout(): """Test getting tools via SSE with timeout.""" url = "http://localhost:8000/sse" mock_session = AsyncMock() mock_session.initialize = AsyncMock(side_effect=TimeoutError()) with patch("registry.core.mcp_client.sse_client") as mock_client: mock_client.return_value.__aenter__.return_value = (MagicMock(), MagicMock()) with patch("registry.core.mcp_client.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__.return_value = mock_session result = await _get_tools_sse(url, None) assert result is None @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_sse_connection_error(): """Test getting tools via SSE with connection error.""" url = "http://localhost:8000/sse" with patch("registry.core.mcp_client.sse_client") as mock_client: mock_client.return_value.__aenter__.side_effect = Exception("Connection failed") result = await _get_tools_sse(url, None) assert result is None @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_sse_url_normalization(): """Test getting tools via SSE with URL normalization.""" url = "http://localhost:8000" mock_session = AsyncMock() mock_session.initialize = AsyncMock() mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[])) captured_url = None @contextlib.asynccontextmanager async def mock_cm(url_arg, *args, **kwargs): nonlocal captured_url captured_url = url_arg yield (MagicMock(), MagicMock()) with patch("registry.core.mcp_client.sse_client") as mock_client: mock_client.side_effect = mock_cm with patch("registry.core.mcp_client.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__.return_value = mock_session await _get_tools_sse(url, None) # Should append /sse to URL assert captured_url is not None assert captured_url.endswith("/sse") # ============================================================================= # GET_TOOLS_FROM_SERVER_WITH_TRANSPORT TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_from_server_with_transport_auto(): """Test getting tools with auto transport detection.""" url = "http://localhost:8000" with patch("registry.core.mcp_client.detect_server_transport", return_value="streamable-http"): with patch("registry.core.mcp_client._get_tools_streamable_http", return_value=[]): result = await get_tools_from_server_with_transport(url, "auto") assert result == [] @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_from_server_with_transport_streamable_http(): """Test getting tools with explicit streamable-http transport.""" url = "http://localhost:8000" with patch("registry.core.mcp_client._get_tools_streamable_http", return_value=[]) as mock_get: result = await get_tools_from_server_with_transport(url, "streamable-http") mock_get.assert_awaited_once() assert result == [] @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_from_server_with_transport_sse(): """Test getting tools with explicit SSE transport.""" url = "http://localhost:8000" with patch("registry.core.mcp_client._get_tools_sse", return_value=[]) as mock_get: result = await get_tools_from_server_with_transport(url, "sse") mock_get.assert_awaited_once() assert result == [] @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_from_server_with_transport_unsupported(): """Test getting tools with unsupported transport.""" url = "http://localhost:8000" result = await get_tools_from_server_with_transport(url, "invalid-transport") assert result is None @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_from_server_with_transport_empty_url(): """Test getting tools with empty URL.""" result = await get_tools_from_server_with_transport("", "auto") assert result is None # ============================================================================= # GET_TOOLS_FROM_SERVER_WITH_SERVER_INFO TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_from_server_with_server_info_success(mock_server_info): """Test getting tools with server info successfully.""" url = "http://localhost:8000" with patch( "registry.core.mcp_client.detect_server_transport_aware", return_value="streamable-http" ): with patch( "registry.core.mcp_client._get_tools_streamable_http", return_value=[] ) as mock_get: result = await get_tools_from_server_with_server_info(url, mock_server_info) mock_get.assert_awaited_once() assert result == [] @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_from_server_with_server_info_empty_url(): """Test getting tools with server info but empty URL.""" result = await get_tools_from_server_with_server_info("", {"supported_transports": ["sse"]}) assert result is None @pytest.mark.unit @pytest.mark.asyncio async def test_get_tools_from_server_with_server_info_exception(): """Test getting tools with server info when exception occurs in detect_server_transport_aware. Note: Due to a bug in mcp_client.py, exceptions from detect_server_transport_aware are not caught (it's called before the try block). See: .scratchpad/fixes/registry/fix-mcp-client-exception-handling.md This test verifies the actual behavior (exception propagates). When the bug is fixed, this test should expect result == None instead. """ url = "http://localhost:8000" with patch( "registry.core.mcp_client.detect_server_transport_aware", side_effect=Exception("Test error"), ): # Actual behavior: exception propagates (not caught) # Expected behavior (when bug is fixed): should return None with pytest.raises(Exception, match="Test error"): await get_tools_from_server_with_server_info(url, None) # ============================================================================= # MCPCLIENTSERVICE TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_mcp_client_service_wrapper(mock_server_info): """Test MCPClientService wrapper method.""" service = MCPClientService() url = "http://localhost:8000" with patch( "registry.core.mcp_client.get_tools_from_server_with_server_info", return_value=[{"name": "tool1"}], ) as mock_get: result = await service.get_tools_from_server_with_server_info(url, mock_server_info) mock_get.assert_awaited_once_with(url, mock_server_info) assert len(result) == 1 assert result[0]["name"] == "tool1" @pytest.mark.unit def test_mcp_client_service_global_instance(): """Test that global mcp_client_service instance exists.""" assert mcp_client_service is not None assert isinstance(mcp_client_service, MCPClientService) # ============================================================================= # INTEGRATION-STYLE TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_full_tool_discovery_flow_streamable_http(mock_server_info, mock_tools_response): """Test complete tool discovery flow for streamable-http.""" url = "http://localhost:8000" mock_session = AsyncMock() mock_session.initialize = AsyncMock() mock_session.list_tools = AsyncMock(return_value=mock_tools_response) with patch("registry.core.mcp_client.streamablehttp_client") as mock_client: mock_client.return_value.__aenter__.return_value = (MagicMock(), MagicMock(), MagicMock()) with patch("registry.core.mcp_client.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__.return_value = mock_session # Full flow: detect transport -> get tools with patch( "registry.core.mcp_client.detect_server_transport_aware", return_value="streamable-http", ): result = await get_tools_from_server_with_server_info(url, mock_server_info) assert result is not None assert len(result) == 1 assert result[0]["name"] == "test_tool" assert "parsed_description" in result[0] @pytest.mark.unit @pytest.mark.asyncio async def test_full_tool_discovery_flow_sse(mock_tools_response): """Test complete tool discovery flow for SSE.""" url = "http://localhost:8000" server_info = {"supported_transports": ["sse"], "headers": []} mock_session = AsyncMock() mock_session.initialize = AsyncMock() mock_session.list_tools = AsyncMock(return_value=mock_tools_response) with patch("registry.core.mcp_client.sse_client") as mock_client: mock_client.return_value.__aenter__.return_value = (MagicMock(), MagicMock()) with patch("registry.core.mcp_client.ClientSession") as mock_session_class: mock_session_class.return_value.__aenter__.return_value = mock_session # Full flow: detect transport -> get tools with patch( "registry.core.mcp_client.detect_server_transport_aware", return_value="sse" ): result = await get_tools_from_server_with_server_info(url, server_info) assert result is not None assert len(result) == 1 ================================================ FILE: tests/unit/core/test_nginx_service.py ================================================ """ Unit tests for registry/core/nginx_service.py Tests the NginxConfigService for configuration generation and reload. """ import asyncio from unittest.mock import AsyncMock, MagicMock, mock_open, patch from urllib.parse import urlparse import httpx import pytest from registry.constants import HealthStatus from registry.core.nginx_service import NginxConfigService # ============================================================================= # TEST FIXTURES # ============================================================================= @pytest.fixture def nginx_service(): """Create a NginxConfigService instance.""" with patch("registry.core.nginx_service.Path") as mock_path_class: # Mock SSL certificate existence checks mock_ssl_cert = MagicMock() mock_ssl_cert.exists.return_value = False mock_ssl_key = MagicMock() mock_ssl_key.exists.return_value = False # Mock template path existence mock_template = MagicMock() mock_template.exists.return_value = True mock_path_class.return_value = mock_template # Mock settings.nginx_updates_enabled to True for testing with patch("registry.core.nginx_service.settings") as mock_settings: mock_settings.nginx_updates_enabled = True mock_settings.deployment_mode = MagicMock() mock_settings.deployment_mode.value = "with-gateway" mock_settings.nginx_config_path = "/etc/nginx/conf.d/nginx_rev_proxy.conf" service = NginxConfigService() yield service @pytest.fixture def sample_servers(): """Create sample server configuration.""" return { "/test-server": { "server_name": "test-server", "proxy_pass_url": "http://localhost:8000/mcp", "supported_transports": ["streamable-http"], "headers": [{"X-Custom-Header": "value"}], }, "/test-server-2": { "server_name": "test-server-2", "proxy_pass_url": "https://external.example.com/sse", "supported_transports": ["sse"], }, } @pytest.fixture def mock_health_service(): """Create mock health service.""" mock_service = MagicMock() mock_service.server_health_status = {} return mock_service # ============================================================================= # INITIALIZATION TESTS # ============================================================================= @pytest.mark.unit def test_nginx_service_init_http_only(): """Test NginxConfigService initialization with HTTP-only template.""" with patch("registry.core.nginx_service.Path") as mock_path_class: # Mock SSL certificates as not existing mock_ssl_cert = MagicMock() mock_ssl_cert.exists.return_value = False mock_ssl_key = MagicMock() mock_ssl_key.exists.return_value = False # Mock template paths - return Path-like mocks that stringify correctly mock_http_only_template = MagicMock() mock_http_only_template.exists.return_value = True mock_http_only_template.__str__ = MagicMock(return_value="/templates/nginx_http_only.conf") def path_side_effect(path_str): if "fullchain.pem" in str(path_str): return mock_ssl_cert elif "privkey.pem" in str(path_str): return mock_ssl_key elif "http_only" in str(path_str).lower(): return mock_http_only_template else: # For any other path (like http_and_https), return non-existent mock = MagicMock() mock.exists.return_value = False return mock mock_path_class.side_effect = path_side_effect service = NginxConfigService() # Should use HTTP-only template assert "http_only" in str(service.nginx_template_path).lower() @pytest.mark.unit def test_nginx_service_init_http_and_https(): """Test NginxConfigService initialization with HTTPS template.""" with patch("registry.core.nginx_service.Path") as mock_path_class: # Mock SSL certificates as existing mock_ssl_cert = MagicMock() mock_ssl_cert.exists.return_value = True mock_ssl_key = MagicMock() mock_ssl_key.exists.return_value = True # Mock template path with proper string representation mock_https_template = MagicMock() mock_https_template.exists.return_value = True mock_https_template.__str__ = MagicMock(return_value="/templates/nginx_http_and_https.conf") def path_side_effect(path_str): if "fullchain.pem" in str(path_str): return mock_ssl_cert elif "privkey.pem" in str(path_str): return mock_ssl_key elif "http_and_https" in str(path_str).lower(): return mock_https_template else: mock = MagicMock() mock.exists.return_value = False return mock mock_path_class.side_effect = path_side_effect service = NginxConfigService() # Should use HTTP+HTTPS template assert "http_and_https" in str(service.nginx_template_path).lower() # ============================================================================= # GET_ADDITIONAL_SERVER_NAMES TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_get_additional_server_names_from_env(nginx_service): """Test getting additional server names from environment variable.""" with patch.dict("os.environ", {"GATEWAY_ADDITIONAL_SERVER_NAMES": "custom.example.com"}): result = await nginx_service.get_additional_server_names() assert result == "custom.example.com" @pytest.mark.unit @pytest.mark.asyncio async def test_get_additional_server_names_ec2_metadata(nginx_service): """Test getting additional server names from EC2 metadata.""" with patch.dict("os.environ", {}, clear=True): mock_client = AsyncMock() # Mock token response mock_token_response = MagicMock() mock_token_response.status_code = 200 mock_token_response.text = "test-token" # Mock IP response mock_ip_response = MagicMock() mock_ip_response.status_code = 200 mock_ip_response.text = "10.0.1.100" mock_client.put.return_value = mock_token_response mock_client.get.return_value = mock_ip_response with patch("httpx.AsyncClient") as mock_async_client: mock_async_client.return_value.__aenter__.return_value = mock_client result = await nginx_service.get_additional_server_names() assert result == "10.0.1.100" @pytest.mark.unit @pytest.mark.asyncio async def test_get_additional_server_names_ecs_metadata(nginx_service): """Test getting additional server names from ECS metadata.""" with patch.dict("os.environ", {"ECS_CONTAINER_METADATA_URI": "http://169.254.170.2/v4/test"}): mock_client = AsyncMock() mock_response = MagicMock() mock_response.status_code = 200 mock_response.text = '{"Networks": [{"IPv4Addresses": ["172.17.0.5"]}]}' mock_client.get.return_value = mock_response with patch("httpx.AsyncClient") as mock_async_client: mock_async_client.return_value.__aenter__.return_value = mock_client result = await nginx_service.get_additional_server_names() assert result == "172.17.0.5" @pytest.mark.unit @pytest.mark.asyncio async def test_get_additional_server_names_pod_ip(nginx_service): """Test getting additional server names from Kubernetes POD_IP.""" # Mock httpx to fail (simulating no EC2/ECS metadata available) mock_client = AsyncMock() mock_client.put.side_effect = httpx.ConnectTimeout("Connection timed out") mock_client.get.side_effect = httpx.ConnectTimeout("Connection timed out") with patch.dict("os.environ", {"POD_IP": "192.168.1.50"}, clear=False): # Clear metadata-related env vars with patch.dict("os.environ", {"ECS_CONTAINER_METADATA_URI": ""}, clear=False): with patch("httpx.AsyncClient") as mock_async_client: mock_async_client.return_value.__aenter__.return_value = mock_client result = await nginx_service.get_additional_server_names() assert result == "192.168.1.50" @pytest.mark.unit @pytest.mark.asyncio async def test_get_additional_server_names_hostname_command(nginx_service): """Test getting additional server names from hostname command.""" with patch.dict("os.environ", {}, clear=True): mock_result = MagicMock() mock_result.returncode = 0 mock_result.stdout = "10.1.1.1 192.168.1.1 " with patch("subprocess.run", return_value=mock_result): with patch("httpx.AsyncClient") as mock_client: # Mock EC2 metadata failure mock_client.return_value.__aenter__.return_value.put.side_effect = ( httpx.ConnectError("No connection") ) result = await nginx_service.get_additional_server_names() assert result == "10.1.1.1" @pytest.mark.unit @pytest.mark.asyncio async def test_get_additional_server_names_fallback_empty(nginx_service): """Test getting additional server names with no available sources.""" with patch.dict("os.environ", {}, clear=True): with patch("httpx.AsyncClient") as mock_client: # Mock EC2 metadata failure mock_client.return_value.__aenter__.return_value.put.side_effect = httpx.ConnectError( "No connection" ) with patch("subprocess.run") as mock_subprocess: # Mock hostname command failure mock_subprocess.side_effect = Exception("Command failed") result = await nginx_service.get_additional_server_names() assert result == "" # ============================================================================= # GENERATE_CONFIG TESTS # ============================================================================= @pytest.mark.unit def test_generate_config_from_async_context(nginx_service): """Test that generate_config logs error when called from async context.""" async def async_test(): result = nginx_service.generate_config({}) assert result is False asyncio.run(async_test()) @pytest.mark.unit @pytest.mark.asyncio async def test_generate_config_async_success(nginx_service, sample_servers, mock_health_service): """Test successful configuration generation.""" template_content = """ server { listen 80; server_name localhost {{ADDITIONAL_SERVER_NAMES}}; {{LOCATION_BLOCKS}} } """ with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): with patch("builtins.open", mock_open(read_data=template_content)): with patch("registry.health.service.health_service", mock_health_service): # Mark servers as healthy mock_health_service.server_health_status = { "/test-server": HealthStatus.HEALTHY, "/test-server-2": HealthStatus.HEALTHY, } with patch.object( nginx_service, "get_additional_server_names", return_value="10.0.0.1" ): with patch.object(nginx_service, "reload_nginx", return_value=True): env_values = { "AUTH_PROVIDER": "keycloak", "KEYCLOAK_URL": "http://keycloak:8080", "NGINX_DISABLE_API_AUTH_REQUEST": "false", } with patch( "os.environ.get", side_effect=lambda key, default=None: env_values.get(key, default), ): result = await nginx_service.generate_config_async(sample_servers) assert result is True @pytest.mark.unit @pytest.mark.asyncio async def test_generate_config_async_template_not_found(nginx_service, sample_servers): """Test configuration generation when template is not found.""" with patch.object(nginx_service.nginx_template_path, "exists", return_value=False): result = await nginx_service.generate_config_async(sample_servers) assert result is False @pytest.mark.unit @pytest.mark.asyncio async def test_generate_config_async_unhealthy_servers( nginx_service, sample_servers, mock_health_service ): """Test configuration generation with unhealthy servers.""" template_content = """ server { listen 80; {{LOCATION_BLOCKS}} } """ with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): with patch("builtins.open", mock_open(read_data=template_content)) as mock_file: with patch("registry.health.service.health_service", mock_health_service): # Mark servers as unhealthy mock_health_service.server_health_status = { "/test-server": HealthStatus.UNHEALTHY_TIMEOUT, "/test-server-2": HealthStatus.UNHEALTHY_CONNECTION_ERROR, } with patch.object(nginx_service, "get_additional_server_names", return_value=""): with patch.object(nginx_service, "reload_nginx", return_value=True): with patch("os.environ.get", return_value="http://keycloak:8080"): result = await nginx_service.generate_config_async(sample_servers) assert result is True # Verify that config was written mock_file.assert_called() @pytest.mark.unit @pytest.mark.asyncio async def test_generate_config_async_exception(nginx_service, sample_servers): """Test configuration generation with exception.""" with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): with patch("builtins.open", side_effect=Exception("File error")): result = await nginx_service.generate_config_async(sample_servers) assert result is False # ============================================================================= # RELOAD_NGINX TESTS # ============================================================================= @pytest.mark.unit def test_reload_nginx_success(nginx_service): """Test successful Nginx reload.""" mock_test_result = MagicMock() mock_test_result.returncode = 0 mock_reload_result = MagicMock() mock_reload_result.returncode = 0 with patch("subprocess.run") as mock_run: mock_run.side_effect = [mock_test_result, mock_reload_result] result = nginx_service.reload_nginx() assert result is True assert mock_run.call_count == 2 @pytest.mark.unit def test_reload_nginx_config_test_failure(nginx_service): """Test Nginx reload when config test fails.""" mock_test_result = MagicMock() mock_test_result.returncode = 1 mock_test_result.stderr = "Config error" with patch("subprocess.run", return_value=mock_test_result): result = nginx_service.reload_nginx() assert result is False @pytest.mark.unit def test_reload_nginx_reload_failure(nginx_service): """Test Nginx reload when reload command fails.""" mock_test_result = MagicMock() mock_test_result.returncode = 0 mock_reload_result = MagicMock() mock_reload_result.returncode = 1 mock_reload_result.stderr = "Reload failed" with patch("subprocess.run") as mock_run: mock_run.side_effect = [mock_test_result, mock_reload_result] result = nginx_service.reload_nginx() assert result is False @pytest.mark.unit def test_reload_nginx_not_found(nginx_service): """Test Nginx reload when nginx command is not found.""" with patch("subprocess.run", side_effect=FileNotFoundError("nginx not found")): result = nginx_service.reload_nginx() assert result is False @pytest.mark.unit def test_reload_nginx_exception(nginx_service): """Test Nginx reload with unexpected exception.""" with patch("subprocess.run", side_effect=Exception("Unexpected error")): result = nginx_service.reload_nginx() assert result is False # ============================================================================= # TRANSPORT LOCATION BLOCKS TESTS # ============================================================================= @pytest.mark.unit def test_generate_transport_location_blocks_streamable_http(nginx_service): """Test generating location blocks for streamable-http transport.""" server_info = { "proxy_pass_url": "http://localhost:8000/mcp", "supported_transports": ["streamable-http"], } blocks = nginx_service._generate_transport_location_blocks("/test", server_info) assert len(blocks) == 1 assert "location {{ROOT_PATH}}/test" in blocks[0] assert "proxy_pass http://localhost:8000/mcp" in blocks[0] @pytest.mark.unit def test_generate_transport_location_blocks_sse(nginx_service): """Test generating location blocks for SSE transport.""" server_info = { "proxy_pass_url": "http://localhost:8000/sse", "supported_transports": ["sse"], } blocks = nginx_service._generate_transport_location_blocks("/test", server_info) assert len(blocks) == 1 assert "location {{ROOT_PATH}}/test" in blocks[0] assert "proxy_pass http://localhost:8000/sse" in blocks[0] @pytest.mark.unit def test_generate_transport_location_blocks_both_transports(nginx_service): """Test generating location blocks when both transports are supported.""" server_info = { "proxy_pass_url": "http://localhost:8000/mcp", "supported_transports": ["streamable-http", "sse"], } blocks = nginx_service._generate_transport_location_blocks("/test", server_info) # Should prefer streamable-http assert len(blocks) == 1 assert "location {{ROOT_PATH}}/test" in blocks[0] @pytest.mark.unit def test_generate_transport_location_blocks_no_transports(nginx_service): """Test generating location blocks with no specified transports.""" server_info = { "proxy_pass_url": "http://localhost:8000", "supported_transports": [], } blocks = nginx_service._generate_transport_location_blocks("/test", server_info) # Should default to streamable-http assert len(blocks) == 1 assert "location {{ROOT_PATH}}/test" in blocks[0] # ============================================================================= # CREATE_LOCATION_BLOCK TESTS # ============================================================================= @pytest.mark.unit def test_create_location_block_streamable_http(nginx_service): """Test creating location block for streamable-http.""" block = nginx_service._create_location_block( "/test", "http://localhost:8000/mcp", "streamable-http" ) assert "location {{ROOT_PATH}}/test" in block assert "proxy_pass http://localhost:8000/mcp" in block assert "proxy_buffering off" in block assert "auth_request /validate" in block @pytest.mark.unit def test_create_location_block_sse(nginx_service): """Test creating location block for SSE.""" block = nginx_service._create_location_block("/test", "http://localhost:8000/sse", "sse") assert "location {{ROOT_PATH}}/test" in block assert "proxy_pass http://localhost:8000/sse" in block assert "proxy_buffering off" in block assert "proxy_set_header Connection $http_connection" in block @pytest.mark.unit def test_create_location_block_external_service(nginx_service): """Test creating location block for external HTTPS service.""" block = nginx_service._create_location_block( "/test", "https://api.example.com/mcp", "streamable-http" ) assert "location {{ROOT_PATH}}/test" in block assert "proxy_pass https://api.example.com/mcp" in block # Should use upstream hostname for external services assert "proxy_set_header Host api.example.com" in block @pytest.mark.unit def test_create_location_block_internal_service(nginx_service): """Test creating location block for internal service.""" block = nginx_service._create_location_block( "/test", "http://backend:8000/mcp", "streamable-http" ) assert "location {{ROOT_PATH}}/test" in block assert "proxy_pass http://backend:8000/mcp" in block # Should preserve original host for internal services assert "proxy_set_header Host $host" in block @pytest.mark.unit def test_create_location_block_direct_transport(nginx_service): """Test creating location block for direct transport.""" block = nginx_service._create_location_block("/test", "http://localhost:8000", "direct") assert "location {{ROOT_PATH}}/test" in block assert "proxy_pass http://localhost:8000" in block assert "proxy_cache off" in block # ============================================================================= # KEYCLOAK CONFIGURATION TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_generate_config_async_keycloak_parsing( nginx_service, sample_servers, mock_health_service ): """Test Keycloak URL parsing in configuration generation.""" template_content = """ server { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}; {{LOCATION_BLOCKS}} } """ with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): with patch("builtins.open", mock_open(read_data=template_content)) as mock_file: with patch("registry.health.service.health_service", mock_health_service): mock_health_service.server_health_status = { "/test-server": HealthStatus.HEALTHY, } with patch.object(nginx_service, "get_additional_server_names", return_value=""): with patch.object(nginx_service, "reload_nginx", return_value=True): env_values = { "AUTH_PROVIDER": "keycloak", "KEYCLOAK_URL": "https://keycloak.example.com:8443", "NGINX_DISABLE_API_AUTH_REQUEST": "false", } with patch( "os.environ.get", side_effect=lambda key, default=None: env_values.get(key, default), ): result = await nginx_service.generate_config_async(sample_servers) assert result is True # Verify file was written with parsed Keycloak values write_calls = list(mock_file().write.call_args_list) assert len(write_calls) > 0 written_content = write_calls[0][0][0] # Verify the template variables were substituted with # the parsed Keycloak URL components parsed_keycloak = urlparse("https://keycloak.example.com:8443") assert parsed_keycloak.hostname in written_content assert str(parsed_keycloak.port) in written_content @pytest.mark.unit @pytest.mark.asyncio async def test_generate_config_async_keycloak_default_port( nginx_service, sample_servers, mock_health_service ): """Test Keycloak URL parsing with default port.""" template_content = """ server { {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}} {{LOCATION_BLOCKS}} } """ with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): with patch("builtins.open", mock_open(read_data=template_content)): with patch("registry.health.service.health_service", mock_health_service): mock_health_service.server_health_status = {} with patch.object(nginx_service, "get_additional_server_names", return_value=""): with patch.object(nginx_service, "reload_nginx", return_value=True): env_values = { "AUTH_PROVIDER": "keycloak", "KEYCLOAK_URL": "http://keycloak", "NGINX_DISABLE_API_AUTH_REQUEST": "false", } with patch( "os.environ.get", side_effect=lambda key, default=None: env_values.get(key, default), ): result = await nginx_service.generate_config_async(sample_servers) assert result is True # ============================================================================= # KEYCLOAK CONDITIONAL LOCATION TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_generate_config_async_strips_keycloak_locations_for_entra( nginx_service, sample_servers, mock_health_service ): """Test that Keycloak location blocks are stripped when AUTH_PROVIDER is entra.""" template_content = """ server { listen 80; server_name localhost {{ADDITIONAL_SERVER_NAMES}}; # {{KEYCLOAK_LOCATIONS_START}} location /keycloak/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/; } location /realms/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/realms/; } # {{KEYCLOAK_LOCATIONS_END}} {{LOCATION_BLOCKS}} } """ env_values = { "AUTH_PROVIDER": "entra", "KEYCLOAK_URL": "http://keycloak:8080", "NGINX_DISABLE_API_AUTH_REQUEST": "false", } with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): with patch("builtins.open", mock_open(read_data=template_content)) as mock_file: with patch("registry.health.service.health_service", mock_health_service): mock_health_service.server_health_status = { "/test-server": HealthStatus.HEALTHY, } with patch.object(nginx_service, "get_additional_server_names", return_value=""): with patch.object(nginx_service, "reload_nginx", return_value=True): with patch( "os.environ.get", side_effect=lambda key, default=None: env_values.get(key, default), ): result = await nginx_service.generate_config_async(sample_servers) assert result is True # Verify the written config does not contain keycloak locations write_calls = mock_file().write.call_args_list assert len(write_calls) > 0 written_content = write_calls[0][0][0] assert "/keycloak/" not in written_content assert "/realms/" not in written_content assert "KEYCLOAK_LOCATIONS_START" not in written_content @pytest.mark.unit @pytest.mark.asyncio async def test_generate_config_async_keeps_keycloak_locations_for_keycloak( nginx_service, sample_servers, mock_health_service ): """Test that Keycloak location blocks are kept when AUTH_PROVIDER is keycloak.""" template_content = """ server { listen 80; server_name localhost {{ADDITIONAL_SERVER_NAMES}}; # {{KEYCLOAK_LOCATIONS_START}} location /keycloak/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/; } location /realms/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/realms/; } # {{KEYCLOAK_LOCATIONS_END}} {{LOCATION_BLOCKS}} } """ env_values = { "AUTH_PROVIDER": "keycloak", "KEYCLOAK_URL": "https://keycloak.example.com:8443", "NGINX_DISABLE_API_AUTH_REQUEST": "false", } with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): with patch("builtins.open", mock_open(read_data=template_content)) as mock_file: with patch("registry.health.service.health_service", mock_health_service): mock_health_service.server_health_status = { "/test-server": HealthStatus.HEALTHY, } with patch.object(nginx_service, "get_additional_server_names", return_value=""): with patch.object(nginx_service, "reload_nginx", return_value=True): with patch( "os.environ.get", side_effect=lambda key, default=None: env_values.get(key, default), ): result = await nginx_service.generate_config_async(sample_servers) assert result is True # Verify the written config contains keycloak locations with substituted values write_calls = mock_file().write.call_args_list assert len(write_calls) > 0 written_content = write_calls[0][0][0] assert "/keycloak/" in written_content assert "/realms/" in written_content # Verify the template variables were substituted with # the parsed Keycloak URL components parsed_keycloak = urlparse("https://keycloak.example.com:8443") assert parsed_keycloak.hostname in written_content assert str(parsed_keycloak.port) in written_content @pytest.mark.unit @pytest.mark.asyncio async def test_generate_config_async_strips_keycloak_locations_for_cognito( nginx_service, sample_servers, mock_health_service ): """Test that Keycloak location blocks are stripped when AUTH_PROVIDER is cognito.""" template_content = """ server { listen 80; server_name localhost {{ADDITIONAL_SERVER_NAMES}}; # {{KEYCLOAK_LOCATIONS_START}} location /keycloak/ { proxy_pass {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}}/; } # {{KEYCLOAK_LOCATIONS_END}} {{LOCATION_BLOCKS}} } """ env_values = { "AUTH_PROVIDER": "cognito", "NGINX_DISABLE_API_AUTH_REQUEST": "false", } with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): with patch("builtins.open", mock_open(read_data=template_content)) as mock_file: with patch("registry.health.service.health_service", mock_health_service): mock_health_service.server_health_status = {} with patch.object(nginx_service, "get_additional_server_names", return_value=""): with patch.object(nginx_service, "reload_nginx", return_value=True): with patch( "os.environ.get", side_effect=lambda key, default=None: env_values.get(key, default), ): result = await nginx_service.generate_config_async(sample_servers) assert result is True write_calls = mock_file().write.call_args_list assert len(write_calls) > 0 written_content = write_calls[0][0][0] assert "/keycloak/" not in written_content @pytest.mark.unit @pytest.mark.asyncio async def test_generate_config_async_keycloak_https_default_port( nginx_service, sample_servers, mock_health_service ): """Test Keycloak URL parsing defaults to port 443 for HTTPS without explicit port.""" template_content = """ server { {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}} {{LOCATION_BLOCKS}} } """ env_values = { "AUTH_PROVIDER": "keycloak", "KEYCLOAK_URL": "https://keycloak.example.com", "NGINX_DISABLE_API_AUTH_REQUEST": "false", } with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): with patch("builtins.open", mock_open(read_data=template_content)) as mock_file: with patch("registry.health.service.health_service", mock_health_service): mock_health_service.server_health_status = {} with patch.object(nginx_service, "get_additional_server_names", return_value=""): with patch.object(nginx_service, "reload_nginx", return_value=True): with patch( "os.environ.get", side_effect=lambda key, default=None: env_values.get(key, default), ): result = await nginx_service.generate_config_async(sample_servers) assert result is True written_content = mock_file().write.call_args_list[0][0][0] assert "https" in written_content assert "443" in written_content @pytest.mark.unit @pytest.mark.asyncio async def test_generate_config_async_keycloak_hostname_fallback( nginx_service, sample_servers, mock_health_service ): """Test Keycloak hostname fallback when hostname resolves to bare 'keycloak'.""" template_content = """ server { {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}} {{LOCATION_BLOCKS}} } """ env_values = { "AUTH_PROVIDER": "keycloak", "KEYCLOAK_URL": "http://keycloak:8080", "NGINX_DISABLE_API_AUTH_REQUEST": "false", } with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): with patch("builtins.open", mock_open(read_data=template_content)) as mock_file: with patch("registry.health.service.health_service", mock_health_service): mock_health_service.server_health_status = {} with patch.object(nginx_service, "get_additional_server_names", return_value=""): with patch.object(nginx_service, "reload_nginx", return_value=True): with patch( "os.environ.get", side_effect=lambda key, default=None: env_values.get(key, default), ): result = await nginx_service.generate_config_async(sample_servers) assert result is True written_content = mock_file().write.call_args_list[0][0][0] # Should still contain keycloak as the host (netloc fallback) assert "keycloak" in written_content @pytest.mark.unit @pytest.mark.asyncio async def test_generate_config_async_keycloak_url_parse_exception( nginx_service, sample_servers, mock_health_service ): """Test Keycloak URL parsing falls back to defaults on exception.""" template_content = """ server { {{KEYCLOAK_SCHEME}}://{{KEYCLOAK_HOST}}:{{KEYCLOAK_PORT}} {{LOCATION_BLOCKS}} } """ env_values = { "AUTH_PROVIDER": "keycloak", "KEYCLOAK_URL": "http://keycloak:8080", "NGINX_DISABLE_API_AUTH_REQUEST": "false", } with patch.object(nginx_service.nginx_template_path, "exists", return_value=True): with patch("builtins.open", mock_open(read_data=template_content)) as mock_file: with patch("registry.health.service.health_service", mock_health_service): mock_health_service.server_health_status = {} with patch.object(nginx_service, "get_additional_server_names", return_value=""): with patch.object(nginx_service, "reload_nginx", return_value=True): with patch( "os.environ.get", side_effect=lambda key, default=None: env_values.get(key, default), ): # Force urlparse to raise an exception with patch( "registry.core.nginx_service.urlparse", side_effect=Exception("parse error"), ): result = await nginx_service.generate_config_async(sample_servers) assert result is True written_content = mock_file().write.call_args_list[0][0][0] # Should fall back to defaults assert "http" in written_content assert "keycloak" in written_content assert "8080" in written_content ================================================ FILE: tests/unit/core/test_schemas_protocol_trust_fields.py ================================================ """Unit tests for supported_protocol, trust_level, and visibility field changes. Tests cover: - AgentCard default values for trust_level and visibility - AgentCard supported_protocol field (optional, None default) - AgentInfo new fields (visibility, supported_protocol) - AgentRegistrationRequest validators (supported_protocol, trust_level) - Backward compatibility for old agents without supported_protocol """ import pytest from pydantic import ValidationError from registry.schemas.agent_models import ( AgentCard, AgentInfo, AgentRegistrationRequest, ) # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- def _build_minimal_agent_card(**overrides) -> AgentCard: """Build an AgentCard with minimal required fields plus overrides.""" defaults = { "name": "test-agent", "path": "/test/agent", "url": "https://test.example.com", "version": "1.0.0", "protocol_version": "1.0", "description": "Test agent", } defaults.update(overrides) return AgentCard(**defaults) def _build_minimal_registration(**overrides) -> AgentRegistrationRequest: """Build an AgentRegistrationRequest with minimal required fields.""" defaults = { "name": "test-agent", "url": "https://test.example.com", "supported_protocol": "a2a", } defaults.update(overrides) return AgentRegistrationRequest(**defaults) # --------------------------------------------------------------------------- # AgentCard defaults and supported_protocol # --------------------------------------------------------------------------- @pytest.mark.unit class TestAgentCardDefaults: """Tests for AgentCard default field values.""" def test_trust_level_defaults_to_community(self): """AgentCard trust_level should default to 'community'.""" agent = _build_minimal_agent_card() assert agent.trust_level == "community" def test_visibility_defaults_to_public(self): """AgentCard visibility should default to 'public'.""" agent = _build_minimal_agent_card() assert agent.visibility == "public" def test_supported_protocol_defaults_to_none(self): """AgentCard supported_protocol should default to None.""" agent = _build_minimal_agent_card() assert agent.supported_protocol is None def test_supported_protocol_a2a(self): """AgentCard accepts 'a2a' as supported_protocol.""" agent = _build_minimal_agent_card(supported_protocol="a2a") assert agent.supported_protocol == "a2a" def test_supported_protocol_other(self): """AgentCard accepts 'other' as supported_protocol.""" agent = _build_minimal_agent_card(supported_protocol="other") assert agent.supported_protocol == "other" def test_supported_protocol_camel_case_alias(self): """AgentCard accepts camelCase alias 'supportedProtocol'.""" agent = _build_minimal_agent_card(supportedProtocol="a2a") assert agent.supported_protocol == "a2a" def test_supported_protocol_serializes_with_alias(self): """supported_protocol serializes as 'supportedProtocol' in camelCase output.""" agent = _build_minimal_agent_card(supported_protocol="a2a") data = agent.model_dump(by_alias=True) assert "supportedProtocol" in data assert data["supportedProtocol"] == "a2a" def test_trust_level_camel_case_alias(self): """AgentCard accepts camelCase alias 'trustLevel'.""" agent = _build_minimal_agent_card(trustLevel="verified") assert agent.trust_level == "verified" def test_trust_level_all_valid_values(self): """AgentCard accepts all valid trust_level values.""" for level in ["unverified", "community", "verified", "trusted"]: agent = _build_minimal_agent_card(trust_level=level) assert agent.trust_level == level def test_trust_level_invalid_value_rejected(self): """AgentCard rejects invalid trust_level values.""" with pytest.raises(ValidationError, match="Trust level must be one of"): _build_minimal_agent_card(trust_level="invalid") # --------------------------------------------------------------------------- # AgentCard backward compatibility # --------------------------------------------------------------------------- @pytest.mark.unit class TestAgentCardBackwardCompat: """Tests for backward compatibility with old agents.""" def test_old_agent_data_without_supported_protocol(self): """Old agent data without supported_protocol loads with None default.""" old_data = { "name": "old-agent", "path": "/old/agent", "url": "https://old.example.com", "version": "1.0.0", "protocol_version": "1.0", "description": "Old agent without supported_protocol", "visibility": "public", "trust_level": "unverified", } agent = AgentCard(**old_data) assert agent.supported_protocol is None def test_old_agent_with_unverified_trust_still_valid(self): """Old agents with 'unverified' trust_level still load correctly.""" agent = _build_minimal_agent_card(trust_level="unverified") assert agent.trust_level == "unverified" def test_old_agent_with_internal_visibility_still_valid(self): """Old agents with 'internal' visibility load correctly as 'private'.""" agent = _build_minimal_agent_card(visibility="internal") assert agent.visibility == "private" # --------------------------------------------------------------------------- # AgentInfo new fields # --------------------------------------------------------------------------- @pytest.mark.unit class TestAgentInfoFields: """Tests for AgentInfo visibility and supported_protocol fields.""" def test_trust_level_defaults_to_community(self): """AgentInfo trust_level should default to 'community'.""" info = AgentInfo( name="test", path="/test", url="https://test.example.com", ) assert info.trust_level == "community" def test_visibility_defaults_to_public(self): """AgentInfo visibility should default to 'public'.""" info = AgentInfo( name="test", path="/test", url="https://test.example.com", ) assert info.visibility == "public" def test_supported_protocol_defaults_to_none(self): """AgentInfo supported_protocol should default to None.""" info = AgentInfo( name="test", path="/test", url="https://test.example.com", ) assert info.supported_protocol is None def test_supported_protocol_a2a(self): """AgentInfo accepts 'a2a' as supported_protocol.""" info = AgentInfo( name="test", path="/test", url="https://test.example.com", supported_protocol="a2a", ) assert info.supported_protocol == "a2a" def test_supported_protocol_camel_case_alias(self): """AgentInfo accepts camelCase alias 'supportedProtocol'.""" info = AgentInfo( name="test", path="/test", url="https://test.example.com", supportedProtocol="other", ) assert info.supported_protocol == "other" def test_all_fields_serialized(self): """AgentInfo serializes visibility and supported_protocol.""" info = AgentInfo( name="test", path="/test", url="https://test.example.com", visibility="public", trust_level="community", supported_protocol="a2a", ) data = info.model_dump(by_alias=True) assert data["trustLevel"] == "community" assert data["visibility"] == "public" assert data["supportedProtocol"] == "a2a" # --------------------------------------------------------------------------- # AgentRegistrationRequest validators # --------------------------------------------------------------------------- @pytest.mark.unit class TestAgentRegistrationRequest: """Tests for AgentRegistrationRequest model and validators.""" def test_supported_protocol_required(self): """supported_protocol is required on registration.""" with pytest.raises(ValidationError, match="supportedProtocol"): AgentRegistrationRequest( name="test", url="https://test.example.com", ) def test_supported_protocol_a2a(self): """Registration accepts 'a2a' protocol.""" req = _build_minimal_registration(supported_protocol="a2a") assert req.supported_protocol == "a2a" def test_supported_protocol_other(self): """Registration accepts 'other' protocol.""" req = _build_minimal_registration(supported_protocol="other") assert req.supported_protocol == "other" def test_supported_protocol_normalized_to_lowercase(self): """supported_protocol is normalized to lowercase.""" req = _build_minimal_registration(supported_protocol="A2A") assert req.supported_protocol == "a2a" def test_supported_protocol_invalid_rejected(self): """Invalid supported_protocol values are rejected.""" with pytest.raises(ValidationError, match="supported_protocol must be one of"): _build_minimal_registration(supported_protocol="mcp") def test_supported_protocol_camel_case_alias(self): """Registration accepts camelCase alias 'supportedProtocol'.""" req = AgentRegistrationRequest( name="test", url="https://test.example.com", supportedProtocol="a2a", ) assert req.supported_protocol == "a2a" def test_trust_level_defaults_to_community(self): """Registration trust_level defaults to 'community'.""" req = _build_minimal_registration() assert req.trust_level == "community" def test_trust_level_all_valid_values(self): """Registration accepts all valid trust_level values.""" for level in ["unverified", "community", "verified", "trusted"]: req = _build_minimal_registration(trust_level=level) assert req.trust_level == level def test_trust_level_invalid_rejected(self): """Invalid trust_level values are rejected.""" with pytest.raises(ValidationError, match="trust_level must be one of"): _build_minimal_registration(trust_level="unknown") def test_trust_level_camel_case_alias(self): """Registration accepts camelCase alias 'trustLevel'.""" req = AgentRegistrationRequest( name="test", url="https://test.example.com", supportedProtocol="a2a", trustLevel="verified", ) assert req.trust_level == "verified" def test_visibility_defaults_to_public(self): """Registration visibility defaults to 'public'.""" req = _build_minimal_registration() assert req.visibility == "public" def test_full_registration_with_all_new_fields(self): """Full registration with all new fields set.""" req = _build_minimal_registration( supported_protocol="a2a", trust_level="verified", visibility="group-restricted", allowed_groups=["test-group"], ) assert req.supported_protocol == "a2a" assert req.trust_level == "verified" assert req.visibility == "group-restricted" ================================================ FILE: tests/unit/core/test_schemas_registry_card_fields.py ================================================ """Unit tests for Registry Card fields added to ServerInfo and AgentCard.""" from datetime import UTC, datetime import pytest from registry.core.schemas import AgentProvider, ServerInfo from registry.schemas.agent_models import AgentCard from registry.schemas.registry_card import LifecycleStatus @pytest.mark.unit class TestServerInfoRegistryCardFields: """Tests for Registry Card fields in ServerInfo model.""" def test_default_lifecycle_status(self): """Test that default lifecycle status is ACTIVE.""" server = ServerInfo( server_name="test-server", path="test/server", description="Test server", version="1.0.0", ) assert server.status == LifecycleStatus.ACTIVE def test_custom_lifecycle_status(self): """Test setting custom lifecycle status.""" server = ServerInfo( server_name="test-server", path="test/server", description="Test server", version="1.0.0", status=LifecycleStatus.DEPRECATED, ) assert server.status == LifecycleStatus.DEPRECATED def test_all_lifecycle_statuses(self): """Test all lifecycle status values.""" statuses = [ LifecycleStatus.ACTIVE, LifecycleStatus.DEPRECATED, LifecycleStatus.DRAFT, LifecycleStatus.BETA, ] for status in statuses: server = ServerInfo( server_name="test-server", path="test/server", description="Test server", version="1.0.0", status=status, ) assert server.status == status def test_provider_default_population(self): """Test that provider is populated with default values when None.""" server = ServerInfo( server_name="test-server", path="test/server", description="Test server", version="1.0.0", ) # Provider should be auto-populated from settings assert server.provider is not None assert isinstance(server.provider, AgentProvider) assert server.provider.organization is not None assert server.provider.url is not None def test_custom_provider(self): """Test setting custom provider.""" custom_provider = AgentProvider( organization="Custom Org", url="https://custom.example.com", ) server = ServerInfo( server_name="test-server", path="test/server", description="Test server", version="1.0.0", provider=custom_provider, ) assert server.provider == custom_provider assert server.provider.organization == "Custom Org" assert server.provider.url == "https://custom.example.com" def test_source_timestamps_default_none(self): """Test that source timestamps default to None.""" server = ServerInfo( server_name="test-server", path="test/server", description="Test server", version="1.0.0", ) assert server.source_created_at is None assert server.source_updated_at is None def test_source_timestamps_with_values(self): """Test setting source timestamps.""" created = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) updated = datetime(2024, 1, 15, 0, 0, 0, tzinfo=UTC) server = ServerInfo( server_name="test-server", path="test/server", description="Test server", version="1.0.0", source_created_at=created, source_updated_at=updated, ) assert server.source_created_at == created assert server.source_updated_at == updated def test_external_tags_default_empty(self): """Test that external_tags defaults to empty list.""" server = ServerInfo( server_name="test-server", path="test/server", description="Test server", version="1.0.0", ) assert server.external_tags == [] def test_external_tags_with_values(self): """Test setting external tags.""" server = ServerInfo( server_name="test-server", path="test/server", description="Test server", version="1.0.0", external_tags=["federated", "external", "verified"], ) assert server.external_tags == ["federated", "external", "verified"] assert len(server.external_tags) == 3 def test_all_registry_card_fields_together(self): """Test setting all registry card fields together.""" created = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) updated = datetime(2024, 1, 15, 0, 0, 0, tzinfo=UTC) provider = AgentProvider( organization="Test Org", url="https://test.example.com", ) server = ServerInfo( server_name="test-server", path="test/server", description="Test server", version="1.0.0", status=LifecycleStatus.BETA, provider=provider, source_created_at=created, source_updated_at=updated, external_tags=["tag1", "tag2"], ) assert server.status == LifecycleStatus.BETA assert server.provider == provider assert server.source_created_at == created assert server.source_updated_at == updated assert server.external_tags == ["tag1", "tag2"] def test_json_serialization_with_registry_card_fields(self): """Test JSON serialization of registry card fields.""" created = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) updated = datetime(2024, 1, 15, 0, 0, 0, tzinfo=UTC) provider = AgentProvider( organization="Test Org", url="https://test.example.com", ) server = ServerInfo( server_name="test-server", path="test/server", description="Test server", version="1.0.0", status=LifecycleStatus.DEPRECATED, provider=provider, source_created_at=created, source_updated_at=updated, external_tags=["federated"], ) json_data = server.model_dump(mode="json") assert json_data["status"] == "deprecated" assert "provider" in json_data assert json_data["provider"]["organization"] == "Test Org" assert "source_created_at" in json_data assert "source_updated_at" in json_data assert json_data["external_tags"] == ["federated"] # Round-trip restored = ServerInfo(**json_data) assert restored.status == LifecycleStatus.DEPRECATED assert restored.provider.organization == "Test Org" assert restored.external_tags == ["federated"] def test_backwards_compatibility_without_new_fields(self): """Test that old data without new fields loads successfully.""" old_data = { "server_name": "old-server", "path": "old/server", "description": "Old server without registry card fields", "version": "1.0.0", "tags": ["old"], } # Should load successfully with defaults server = ServerInfo(**old_data) assert server.status == LifecycleStatus.ACTIVE assert server.provider is not None # Auto-populated assert server.source_created_at is None assert server.source_updated_at is None assert server.external_tags == [] @pytest.mark.unit class TestAgentCardRegistryCardFields: """Tests for Registry Card fields in AgentCard model.""" def test_default_lifecycle_status(self): """Test that default lifecycle status is ACTIVE.""" agent = AgentCard( name="test-agent", path="/test/agent", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test agent", ) assert agent.status == LifecycleStatus.ACTIVE def test_custom_lifecycle_status(self): """Test setting custom lifecycle status.""" agent = AgentCard( name="test-agent", path="/test/agent", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test agent", status=LifecycleStatus.DRAFT, ) assert agent.status == LifecycleStatus.DRAFT def test_source_timestamps_default_none(self): """Test that source timestamps default to None.""" agent = AgentCard( name="test-agent", path="/test/agent", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test agent", ) assert agent.source_created_at is None assert agent.source_updated_at is None def test_source_timestamps_with_values(self): """Test setting source timestamps.""" created = datetime(2024, 2, 1, 0, 0, 0, tzinfo=UTC) updated = datetime(2024, 2, 15, 0, 0, 0, tzinfo=UTC) agent = AgentCard( name="test-agent", path="/test/agent", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test agent", sourceCreatedAt=created, sourceUpdatedAt=updated, ) assert agent.source_created_at == created assert agent.source_updated_at == updated def test_external_tags_default_empty(self): """Test that external_tags defaults to empty list.""" agent = AgentCard( name="test-agent", path="/test/agent", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test agent", ) assert agent.external_tags == [] def test_external_tags_with_values(self): """Test setting external tags.""" agent = AgentCard( name="test-agent", path="/test/agent", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test agent", externalTags=["federated", "verified"], ) assert agent.external_tags == ["federated", "verified"] def test_all_registry_card_fields_together(self): """Test setting all registry card fields together.""" created = datetime(2024, 2, 1, 0, 0, 0, tzinfo=UTC) updated = datetime(2024, 2, 15, 0, 0, 0, tzinfo=UTC) agent = AgentCard( name="test-agent", path="/test/agent", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test agent", status=LifecycleStatus.BETA, sourceCreatedAt=created, sourceUpdatedAt=updated, externalTags=["tag1", "tag2"], ) assert agent.status == LifecycleStatus.BETA assert agent.source_created_at == created assert agent.source_updated_at == updated assert agent.external_tags == ["tag1", "tag2"] def test_json_serialization_with_camel_case_aliases(self): """Test JSON serialization uses camelCase aliases.""" created = datetime(2024, 2, 1, 0, 0, 0, tzinfo=UTC) updated = datetime(2024, 2, 15, 0, 0, 0, tzinfo=UTC) agent = AgentCard( name="test-agent", path="/test/agent", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test agent", status=LifecycleStatus.ACTIVE, sourceCreatedAt=created, sourceUpdatedAt=updated, externalTags=["federated"], ) json_data = agent.model_dump(by_alias=True, mode="json") assert json_data["status"] == "active" assert "sourceCreatedAt" in json_data assert "sourceUpdatedAt" in json_data assert "externalTags" in json_data assert json_data["externalTags"] == ["federated"] def test_backwards_compatibility_without_new_fields(self): """Test that old data without new fields loads successfully.""" old_data = { "name": "old-agent", "path": "/old/agent", "url": "https://old.example.com", "version": "1.0.0", "protocol_version": "1.0.0", "description": "Old agent without registry card fields", "enabled": True, "visibility": "public", "trust_level": "verified", "tags": ["old"], } # Should load successfully with defaults agent = AgentCard(**old_data) assert agent.status == LifecycleStatus.ACTIVE assert agent.source_created_at is None assert agent.source_updated_at is None assert agent.external_tags == [] def test_snake_case_and_camel_case_both_work(self): """Test that both snake_case and camelCase field names work.""" created = datetime(2024, 2, 1, 0, 0, 0, tzinfo=UTC) # Test with camelCase (aliases) agent1 = AgentCard( name="test-agent-1", path="/test/agent1", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test", sourceCreatedAt=created, externalTags=["tag1"], ) # Test with snake_case (actual field names) agent2 = AgentCard( name="test-agent-2", path="/test/agent2", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test", source_created_at=created, external_tags=["tag2"], ) assert agent1.source_created_at == created assert agent1.external_tags == ["tag1"] assert agent2.source_created_at == created assert agent2.external_tags == ["tag2"] ================================================ FILE: tests/unit/core/test_telemetry.py ================================================ """Unit tests for telemetry module.""" import json from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from registry.core.telemetry import ( STARTUP_LOCK_INTERVAL_SECONDS, TELEMETRY_TIMEOUT_SECONDS, TelemetryScheduler, _acquire_telemetry_lock, _build_heartbeat_payload, _build_startup_payload, _get_heartbeat_interval_minutes, _get_heartbeat_lock_interval_seconds, _get_or_create_instance_id, _get_registry_id, _initialize_telemetry_collection, _is_heartbeat_enabled, _is_telemetry_enabled, _send_telemetry, send_startup_ping, start_heartbeat_scheduler, ) class TestTelemetryEnabled: """Tests for telemetry enabled/disabled checks.""" def test_telemetry_enabled_by_default(self, monkeypatch): """Test telemetry is enabled by default.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.telemetry_enabled = True assert _is_telemetry_enabled() is True def test_telemetry_disabled_via_env_var(self, monkeypatch): """Test telemetry can be disabled via env var.""" monkeypatch.setenv("MCP_TELEMETRY_DISABLED", "1") assert _is_telemetry_enabled() is False def test_telemetry_disabled_via_env_var_true(self, monkeypatch): """Test telemetry can be disabled via env var with 'true'.""" monkeypatch.setenv("MCP_TELEMETRY_DISABLED", "true") assert _is_telemetry_enabled() is False def test_telemetry_disabled_via_env_var_yes(self, monkeypatch): """Test telemetry can be disabled via env var with 'yes'.""" monkeypatch.setenv("MCP_TELEMETRY_DISABLED", "yes") assert _is_telemetry_enabled() is False def test_heartbeat_enabled_by_default(self, monkeypatch): """Test heartbeat is enabled by default (opt-out model).""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) monkeypatch.delenv("MCP_TELEMETRY_OPT_OUT", raising=False) with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.telemetry_enabled = True mock_settings.telemetry_opt_out = False assert _is_heartbeat_enabled() is True def test_heartbeat_disabled_via_opt_out_env_var(self, monkeypatch): """Test heartbeat can be disabled via MCP_TELEMETRY_OPT_OUT=1.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) monkeypatch.setenv("MCP_TELEMETRY_OPT_OUT", "1") with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.telemetry_enabled = True assert _is_heartbeat_enabled() is False def test_heartbeat_disabled_via_opt_out_true(self, monkeypatch): """Test heartbeat can be disabled via MCP_TELEMETRY_OPT_OUT=true.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) monkeypatch.setenv("MCP_TELEMETRY_OPT_OUT", "true") with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.telemetry_enabled = True assert _is_heartbeat_enabled() is False def test_heartbeat_disabled_via_opt_out_yes(self, monkeypatch): """Test heartbeat can be disabled via MCP_TELEMETRY_OPT_OUT=yes.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) monkeypatch.setenv("MCP_TELEMETRY_OPT_OUT", "yes") with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.telemetry_enabled = True assert _is_heartbeat_enabled() is False def test_heartbeat_disabled_when_telemetry_disabled(self, monkeypatch): """Test heartbeat is disabled when all telemetry is disabled.""" monkeypatch.setenv("MCP_TELEMETRY_DISABLED", "1") monkeypatch.delenv("MCP_TELEMETRY_OPT_OUT", raising=False) assert _is_heartbeat_enabled() is False class TestGetRegistryIdFallback: """Tests for _get_registry_id fallback to instance_id.""" @pytest.mark.asyncio async def test_returns_card_id_when_available(self): """Registry card UUID takes precedence over instance_id.""" mock_card = MagicMock() mock_card.id = "card-uuid-1234" mock_repo = MagicMock() mock_repo.get = AsyncMock(return_value=mock_card) with patch( "registry.repositories.factory.get_registry_card_repository", return_value=mock_repo, ): result = await _get_registry_id() assert result == "card-uuid-1234" @pytest.mark.asyncio async def test_falls_back_to_instance_id_when_no_card(self): """Falls back to telemetry instance_id when card is None.""" mock_repo = MagicMock() mock_repo.get = AsyncMock(return_value=None) with ( patch( "registry.repositories.factory.get_registry_card_repository", return_value=mock_repo, ), patch( "registry.core.telemetry._get_or_create_instance_id", new_callable=AsyncMock, return_value="instance-uuid-5678", ), ): result = await _get_registry_id() assert result == "instance-uuid-5678" @pytest.mark.asyncio async def test_falls_back_on_exception(self): """Falls back to instance_id when card repo throws.""" with ( patch( "registry.repositories.factory.get_registry_card_repository", side_effect=Exception("DB error"), ), patch( "registry.core.telemetry._get_or_create_instance_id", new_callable=AsyncMock, return_value="instance-uuid-fallback", ), ): result = await _get_registry_id() assert result == "instance-uuid-fallback" @pytest.mark.asyncio async def test_never_returns_none(self): """Verify _get_registry_id never returns None.""" mock_repo = MagicMock() mock_repo.get = AsyncMock(return_value=None) with ( patch( "registry.repositories.factory.get_registry_card_repository", return_value=mock_repo, ), patch( "registry.core.telemetry._get_or_create_instance_id", new_callable=AsyncMock, return_value="some-uuid", ), ): result = await _get_registry_id() assert result is not None assert isinstance(result, str) assert len(result) > 0 class TestPayloadBuilding: """Tests for payload construction.""" @pytest.mark.asyncio async def test_build_startup_payload_structure(self): """Test startup payload has correct fields.""" with ( patch("registry.core.telemetry.settings") as mock_settings, patch( "registry.repositories.stats_repository.get_search_counts", new_callable=AsyncMock, return_value={"total": 42, "last_24h": 5, "last_1h": 1}, ), ): mock_settings.deployment_mode.value = "with-gateway" mock_settings.registry_mode.value = "full" mock_settings.storage_backend = "file" mock_settings.auth_provider = "cognito" mock_settings.federation_static_token_auth_enabled = False payload = await _build_startup_payload() # Required fields assert "event" in payload assert payload["event"] == "startup" assert "v" in payload # Version assert "py" in payload # Python version assert "os" in payload assert "arch" in payload assert "mode" in payload assert "registry_mode" in payload assert "storage" in payload assert "auth" in payload assert "federation" in payload assert "search_queries_total" in payload assert payload["search_queries_total"] == 42 assert "ts" in payload @pytest.mark.asyncio async def test_no_pii_in_startup_payload(self): """Test startup payload contains no PII.""" with ( patch("registry.core.telemetry.settings") as mock_settings, patch( "registry.repositories.stats_repository.get_search_counts", new_callable=AsyncMock, return_value={"total": 0, "last_24h": 0, "last_1h": 0}, ), patch( "registry.core.telemetry._get_registry_id", new_callable=AsyncMock, return_value="test-registry-id", ), ): mock_settings.deployment_mode.value = "with-gateway" mock_settings.registry_mode.value = "full" mock_settings.storage_backend = "file" mock_settings.auth_provider = "cognito" mock_settings.federation_static_token_auth_enabled = False payload = await _build_startup_payload() payload_str = json.dumps(payload) # Should not contain hostnames, IPs assert "localhost" not in payload_str assert "127.0.0.1" not in payload_str @pytest.mark.asyncio async def test_build_heartbeat_payload_structure(self): """Test heartbeat payload has correct fields.""" with ( patch( "registry.api.system_routes.get_server_start_time", return_value=datetime.now(UTC), ), patch("registry.repositories.factory.get_server_repository") as mock_server_repo, patch("registry.repositories.factory.get_agent_repository") as mock_agent_repo, patch("registry.repositories.factory.get_skill_repository") as mock_skill_repo, patch("registry.repositories.factory.get_peer_federation_repository") as mock_peer_repo, patch("registry.core.telemetry.settings") as mock_settings, patch( "registry.repositories.stats_repository.get_search_counts", new_callable=AsyncMock, return_value={"total": 99, "last_24h": 10, "last_1h": 2}, ), ): mock_settings.storage_backend = "file" mock_settings.embeddings_provider = "sentence-transformers" # Mock repository methods mock_server_repo_instance = MagicMock() mock_server_repo_instance.list_all = AsyncMock(return_value=[]) mock_server_repo.return_value = mock_server_repo_instance mock_agent_repo_instance = MagicMock() mock_agent_repo_instance.list_all = AsyncMock(return_value=[]) mock_agent_repo.return_value = mock_agent_repo_instance mock_skill_repo_instance = MagicMock() mock_skill_repo_instance.list_all = AsyncMock(return_value=[]) mock_skill_repo.return_value = mock_skill_repo_instance mock_peer_repo_instance = MagicMock() mock_peer_repo_instance.list_peers = AsyncMock(return_value=[]) mock_peer_repo.return_value = mock_peer_repo_instance payload = await _build_heartbeat_payload() # Required fields assert "event" in payload assert payload["event"] == "heartbeat" assert "v" in payload assert "servers_count" in payload assert "agents_count" in payload assert "skills_count" in payload assert "peers_count" in payload assert "search_backend" in payload assert "embeddings_provider" in payload assert "uptime_hours" in payload assert "search_queries_total" in payload assert payload["search_queries_total"] == 99 assert "search_queries_24h" in payload assert "search_queries_1h" in payload assert "ts" in payload @pytest.mark.asyncio async def test_heartbeat_payload_search_backend_detection(self): """Test heartbeat payload correctly detects search backend.""" with ( patch("registry.api.system_routes.get_server_start_time", return_value=None), patch("registry.repositories.factory.get_server_repository") as mock_server_repo, patch("registry.repositories.factory.get_agent_repository") as mock_agent_repo, patch("registry.repositories.factory.get_skill_repository") as mock_skill_repo, patch("registry.repositories.factory.get_peer_federation_repository") as mock_peer_repo, patch("registry.core.telemetry.settings") as mock_settings, patch( "registry.repositories.stats_repository.get_search_counts", new_callable=AsyncMock, return_value={"total": 0, "last_24h": 0, "last_1h": 0}, ), ): # Test DocumentDB backend mock_settings.storage_backend = "documentdb" mock_settings.embeddings_provider = "litellm" # Mock repository methods for repo in [ mock_server_repo, mock_agent_repo, mock_skill_repo, mock_peer_repo, ]: repo_instance = MagicMock() if repo == mock_peer_repo: repo_instance.list_peers = AsyncMock(return_value=[]) else: repo_instance.list_all = AsyncMock(return_value=[]) repo.return_value = repo_instance payload = await _build_heartbeat_payload() assert payload["search_backend"] == "documentdb" # Test file backend (FAISS) mock_settings.storage_backend = "file" payload = await _build_heartbeat_payload() assert payload["search_backend"] == "faiss" class TestInstanceID: """Tests for instance ID management.""" @pytest.mark.asyncio async def test_instance_id_persistence_file_based(self, tmp_path, monkeypatch): """Test instance ID is stable across calls with file-based storage.""" with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.storage_backend = "file" mock_settings.data_dir = tmp_path # First call creates new ID id1 = await _get_or_create_instance_id() assert id1 # Second call returns same ID id2 = await _get_or_create_instance_id() assert id1 == id2 @pytest.mark.asyncio async def test_instance_id_file_creation(self, tmp_path): """Test instance ID file is created correctly.""" with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.storage_backend = "file" mock_settings.data_dir = tmp_path instance_id = await _get_or_create_instance_id() # Check file exists telemetry_file = tmp_path / ".telemetry_id" assert telemetry_file.exists() # Check file content file_content = telemetry_file.read_text().strip() assert file_content == instance_id class TestLockAcquisition: """Tests for distributed lock mechanism.""" @pytest.mark.asyncio async def test_acquire_lock_file_based_always_succeeds(self): """Test lock always succeeds for file-based storage.""" with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.storage_backend = "file" result = await _acquire_telemetry_lock("startup", 60) assert result is True @pytest.mark.asyncio async def test_acquire_lock_mongodb_success(self): """Test lock acquisition succeeds when not recently sent.""" with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.storage_backend = "mongodb-ce" # Mock MongoDB client with patch( "registry.repositories.documentdb.client.get_documentdb_client" ) as mock_get_client: mock_db = MagicMock() mock_collection = MagicMock() mock_db.__getitem__.return_value = mock_collection # find_one_and_update returns document (lock acquired) mock_collection.find_one_and_update = AsyncMock( return_value={"_id": "telemetry_config"} ) mock_get_client.return_value = mock_db result = await _acquire_telemetry_lock("startup", 60) assert result is True @pytest.mark.asyncio async def test_acquire_lock_mongodb_failure(self): """Test lock acquisition fails when recently sent.""" with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.storage_backend = "mongodb-ce" # Mock MongoDB client with patch( "registry.repositories.documentdb.client.get_documentdb_client" ) as mock_get_client: mock_db = MagicMock() mock_collection = MagicMock() mock_db.__getitem__.return_value = mock_collection # find_one_and_update returns None (lock not acquired) mock_collection.find_one_and_update = AsyncMock(return_value=None) mock_get_client.return_value = mock_db result = await _acquire_telemetry_lock("startup", 60) assert result is False class TestSendTelemetry: """Tests for telemetry HTTP transmission.""" @pytest.mark.asyncio async def test_send_telemetry_success(self, monkeypatch): """Test successful telemetry send.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) with ( patch("registry.core.telemetry.settings") as mock_settings, patch("registry.core.telemetry._get_or_create_instance_id") as mock_get_id, patch("registry.core.telemetry.httpx.AsyncClient") as mock_client_class, ): mock_settings.telemetry_debug = False mock_settings.telemetry_endpoint = "https://telemetry.example.com/v1/collect" mock_get_id.return_value = "test-uuid" # Mock successful HTTP response mock_response = MagicMock() mock_response.status_code = 204 mock_client = MagicMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) mock_client_class.return_value = mock_client payload = {"event": "startup", "v": "1.0.0"} await _send_telemetry(payload) # Verify HTTP call was made mock_client.post.assert_called_once() @pytest.mark.asyncio async def test_send_telemetry_timeout(self, monkeypatch): """Test telemetry send handles timeout gracefully.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) with ( patch("registry.core.telemetry.settings") as mock_settings, patch("registry.core.telemetry._get_or_create_instance_id") as mock_get_id, patch("registry.core.telemetry.httpx.AsyncClient") as mock_client_class, ): mock_settings.telemetry_debug = False mock_settings.telemetry_endpoint = "https://telemetry.example.com/v1/collect" mock_get_id.return_value = "test-uuid" # Mock timeout exception mock_client = MagicMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock() mock_client.post = AsyncMock(side_effect=httpx.TimeoutException("Timeout")) mock_client_class.return_value = mock_client payload = {"event": "startup", "v": "1.0.0"} # Should not raise exception await _send_telemetry(payload) @pytest.mark.asyncio async def test_send_telemetry_debug_mode(self, monkeypatch, caplog): """Test debug mode logs payload instead of sending.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) with ( patch("registry.core.telemetry.settings") as mock_settings, patch("registry.core.telemetry._get_or_create_instance_id") as mock_get_id, patch("registry.core.telemetry.httpx.AsyncClient") as mock_client_class, ): mock_settings.telemetry_debug = True mock_get_id.return_value = "test-uuid" mock_client = MagicMock() mock_client_class.return_value = mock_client payload = {"event": "startup", "v": "1.0.0"} await _send_telemetry(payload) # HTTP client should not be called in debug mode mock_client.post.assert_not_called() @pytest.mark.asyncio async def test_send_telemetry_retry_logic(self, monkeypatch): """Test telemetry retries once on failure.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) with ( patch("registry.core.telemetry.settings") as mock_settings, patch("registry.core.telemetry._get_or_create_instance_id") as mock_get_id, patch("registry.core.telemetry.httpx.AsyncClient") as mock_client_class, patch("registry.core.telemetry.asyncio.sleep") as mock_sleep, ): mock_settings.telemetry_debug = False mock_settings.telemetry_endpoint = "https://telemetry.example.com/v1/collect" mock_get_id.return_value = "test-uuid" # Mock exception on first call, success on second mock_response_success = MagicMock() mock_response_success.status_code = 204 call_count = 0 async def post_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count == 1: raise Exception("Network error") return mock_response_success mock_client = MagicMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock() mock_client.post = AsyncMock(side_effect=post_side_effect) mock_client_class.return_value = mock_client payload = {"event": "startup", "v": "1.0.0"} await _send_telemetry(payload) # Should retry once and succeed assert call_count == 2 mock_sleep.assert_called_once_with(1.0) class TestScheduler: """Tests for TelemetryScheduler lifecycle.""" @pytest.mark.asyncio async def test_scheduler_start_stop(self): """Test scheduler starts and stops cleanly.""" scheduler = TelemetryScheduler() # Start scheduler await scheduler.start() assert scheduler._running is True assert scheduler._task is not None # Stop scheduler await scheduler.stop() assert scheduler._running is False assert scheduler._task is None @pytest.mark.asyncio async def test_scheduler_prevents_double_start(self): """Test scheduler prevents double start.""" scheduler = TelemetryScheduler() await scheduler.start() first_task = scheduler._task # Try to start again await scheduler.start() second_task = scheduler._task # Should be same task assert first_task is second_task await scheduler.stop() class TestInitialization: """Tests for telemetry initialization.""" @pytest.mark.asyncio async def test_initialize_telemetry_file_based(self): """Test initialization with file-based storage does nothing.""" with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.storage_backend = "file" # Should not raise exception await _initialize_telemetry_collection() @pytest.mark.asyncio async def test_initialize_telemetry_creates_collection(self): """Test initialization creates MongoDB collection.""" with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.storage_backend = "mongodb-ce" with patch( "registry.repositories.documentdb.client.get_documentdb_client" ) as mock_get_client: mock_db = MagicMock() mock_collection = MagicMock() # Mock collection does not exist mock_db.list_collection_names = AsyncMock(return_value=[]) mock_db.create_collection = AsyncMock() mock_db.__getitem__.return_value = mock_collection mock_collection.find_one = AsyncMock(return_value=None) mock_collection.insert_one = AsyncMock() mock_get_client.return_value = mock_db await _initialize_telemetry_collection() # Should create collection mock_db.create_collection.assert_called_once_with("_telemetry_state") class TestPublicAPI: """Tests for public API functions.""" @pytest.mark.asyncio async def test_send_startup_ping_disabled(self, monkeypatch, caplog): """Test startup ping skips when telemetry disabled.""" import logging monkeypatch.setenv("MCP_TELEMETRY_DISABLED", "1") # Set logging level to capture INFO messages caplog.set_level(logging.INFO, logger="registry.core.telemetry") with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.telemetry_enabled = False await send_startup_ping() # Should log disabled message assert "Telemetry is disabled" in caplog.text @pytest.mark.asyncio async def test_heartbeat_scheduler_starts_by_default(self, monkeypatch): """Test heartbeat scheduler starts by default (opt-out model).""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) monkeypatch.delenv("MCP_TELEMETRY_OPT_OUT", raising=False) with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.telemetry_enabled = True mock_settings.telemetry_opt_out = False mock_settings.telemetry_heartbeat_interval_minutes = 1440 await start_heartbeat_scheduler() from registry.core.telemetry import _telemetry_scheduler # Scheduler should be started assert _telemetry_scheduler is not None # Clean up from registry.core.telemetry import stop_heartbeat_scheduler await stop_heartbeat_scheduler() @pytest.mark.asyncio async def test_heartbeat_scheduler_not_started_when_opted_out(self, monkeypatch): """Test heartbeat scheduler does not start when opted out.""" monkeypatch.delenv("MCP_TELEMETRY_DISABLED", raising=False) monkeypatch.setenv("MCP_TELEMETRY_OPT_OUT", "1") with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.telemetry_enabled = True await start_heartbeat_scheduler() from registry.core.telemetry import _telemetry_scheduler assert _telemetry_scheduler is None class TestRepositoryFailures: """Tests for graceful error handling in repository calls.""" @pytest.mark.asyncio async def test_heartbeat_repository_failure_logging(self, caplog): """Test repository failures log warnings with details.""" with ( patch("registry.api.system_routes.get_server_start_time", return_value=None), patch("registry.repositories.factory.get_server_repository") as mock_server_repo, patch("registry.repositories.factory.get_agent_repository") as mock_agent_repo, patch("registry.repositories.factory.get_skill_repository") as mock_skill_repo, patch("registry.repositories.factory.get_peer_federation_repository") as mock_peer_repo, patch("registry.core.telemetry.settings") as mock_settings, patch( "registry.repositories.stats_repository.get_search_counts", new_callable=AsyncMock, return_value={"total": 0, "last_24h": 0, "last_1h": 0}, ), ): mock_settings.storage_backend = "file" mock_settings.embeddings_provider = "sentence-transformers" # Mock server repo to raise exception mock_server_repo_instance = MagicMock() mock_server_repo_instance.list_all = AsyncMock(side_effect=Exception("Database error")) mock_server_repo.return_value = mock_server_repo_instance # Other repos succeed mock_agent_repo_instance = MagicMock() mock_agent_repo_instance.list_all = AsyncMock(return_value=[]) mock_agent_repo.return_value = mock_agent_repo_instance mock_skill_repo_instance = MagicMock() mock_skill_repo_instance.list_all = AsyncMock(return_value=[]) mock_skill_repo.return_value = mock_skill_repo_instance mock_peer_repo_instance = MagicMock() mock_peer_repo_instance.list_peers = AsyncMock(return_value=[]) mock_peer_repo.return_value = mock_peer_repo_instance payload = await _build_heartbeat_payload() # Should still return payload with zero server count assert payload["servers_count"] == 0 # Should log warning assert "[telemetry] Failed to get server count" in caplog.text class TestConstants: """Tests for telemetry constants and configurable intervals.""" def test_telemetry_constants(self): """Test telemetry constants have expected values.""" assert STARTUP_LOCK_INTERVAL_SECONDS == 60 assert TELEMETRY_TIMEOUT_SECONDS == 5 def test_heartbeat_interval_from_settings(self): """Test heartbeat interval reads from settings.""" with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.telemetry_heartbeat_interval_minutes = 1440 assert _get_heartbeat_interval_minutes() == 1440 def test_heartbeat_lock_interval_matches(self): """Test heartbeat lock interval = interval minutes * 60.""" with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.telemetry_heartbeat_interval_minutes = 1440 assert _get_heartbeat_lock_interval_seconds() == 1440 * 60 def test_custom_heartbeat_interval(self): """Test custom heartbeat interval is respected.""" with patch("registry.core.telemetry.settings") as mock_settings: mock_settings.telemetry_heartbeat_interval_minutes = 5 assert _get_heartbeat_interval_minutes() == 5 assert _get_heartbeat_lock_interval_seconds() == 300 ================================================ FILE: tests/unit/core/test_visibility_normalization.py ================================================ """Unit tests for visibility normalization in Pydantic models. Tests verify that AgentCard, AgentRegistrationRequest, and ServerInfo all normalize 'internal' -> 'private' and 'group' -> 'group-restricted'. """ import pytest from pydantic import ValidationError from registry.schemas.agent_models import ( AgentCard, AgentRegistrationRequest, ) # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- def _build_minimal_agent_card(**overrides) -> AgentCard: """Build an AgentCard with minimal required fields plus overrides.""" defaults = { "name": "test-agent", "path": "/test/agent", "url": "https://test.example.com", "version": "1.0.0", "protocol_version": "1.0", "description": "Test agent", } defaults.update(overrides) return AgentCard(**defaults) def _build_minimal_registration(**overrides) -> AgentRegistrationRequest: """Build an AgentRegistrationRequest with minimal required fields.""" defaults = { "name": "test-agent", "url": "https://test.example.com", "supported_protocol": "a2a", } defaults.update(overrides) return AgentRegistrationRequest(**defaults) # --------------------------------------------------------------------------- # AgentCard visibility normalization # --------------------------------------------------------------------------- @pytest.mark.unit class TestAgentCardVisibilityNormalization: """Tests for visibility normalization in AgentCard.""" def test_internal_normalized_to_private(self): """AgentCard with visibility='internal' should normalize to 'private'.""" agent = _build_minimal_agent_card(visibility="internal") assert agent.visibility == "private" def test_private_accepted(self): """AgentCard with visibility='private' should stay 'private'.""" agent = _build_minimal_agent_card(visibility="private") assert agent.visibility == "private" def test_public_accepted(self): """AgentCard with visibility='public' should stay 'public'.""" agent = _build_minimal_agent_card(visibility="public") assert agent.visibility == "public" def test_group_normalized_to_group_restricted(self): """AgentCard with visibility='group' should normalize to 'group-restricted'.""" agent = _build_minimal_agent_card( visibility="group", allowed_groups=["developers"], ) assert agent.visibility == "group-restricted" def test_group_restricted_accepted(self): """AgentCard with visibility='group-restricted' should stay.""" agent = _build_minimal_agent_card( visibility="group-restricted", allowed_groups=["developers"], ) assert agent.visibility == "group-restricted" def test_case_insensitive(self): """AgentCard should accept visibility in any case.""" agent = _build_minimal_agent_card(visibility="Internal") assert agent.visibility == "private" def test_invalid_visibility_rejected(self): """AgentCard should reject invalid visibility values.""" with pytest.raises(ValidationError, match="Visibility must be one of"): _build_minimal_agent_card(visibility="secret") def test_backward_compat_old_data_with_internal(self): """Old agent data with 'internal' should load as 'private'.""" old_data = { "name": "old-agent", "path": "/old/agent", "url": "https://old.example.com", "version": "1.0.0", "protocol_version": "1.0", "description": "Old agent with internal visibility", "visibility": "internal", "trust_level": "community", } agent = AgentCard(**old_data) assert agent.visibility == "private" # --------------------------------------------------------------------------- # AgentRegistrationRequest visibility normalization # --------------------------------------------------------------------------- @pytest.mark.unit class TestRegistrationVisibilityNormalization: """Tests for visibility normalization in AgentRegistrationRequest.""" def test_internal_normalized_to_private(self): """Registration with visibility='internal' should normalize to 'private'.""" req = _build_minimal_registration(visibility="internal") assert req.visibility == "private" def test_private_accepted(self): """Registration with visibility='private' should stay 'private'.""" req = _build_minimal_registration(visibility="private") assert req.visibility == "private" def test_group_normalized_to_group_restricted(self): """Registration with visibility='group' should normalize to 'group-restricted'.""" req = _build_minimal_registration(visibility="group", allowed_groups=["test-group"]) assert req.visibility == "group-restricted" def test_default_is_public(self): """Registration visibility defaults to 'public'.""" req = _build_minimal_registration() assert req.visibility == "public" def test_invalid_visibility_rejected(self): """Registration should reject invalid visibility values.""" with pytest.raises(ValidationError, match="Visibility must be one of"): _build_minimal_registration(visibility="hidden") ================================================ FILE: tests/unit/embeddings/__init__.py ================================================ """Embeddings service unit tests.""" ================================================ FILE: tests/unit/embeddings/test_embeddings_client.py ================================================ """ Unit tests for registry.embeddings.client module. This module tests the embeddings client abstraction including: - EmbeddingsClient abstract base class - SentenceTransformersClient implementation - LiteLLMClient implementation - create_embeddings_client() factory function """ import logging import os from pathlib import Path from unittest.mock import MagicMock, patch import numpy as np import pytest from registry.embeddings.client import ( EmbeddingsClient, LiteLLMClient, SentenceTransformersClient, create_embeddings_client, ) logger = logging.getLogger(__name__) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def mock_sentence_transformer(): """ Create a mock Sentence Transformer model. Returns: Mock SentenceTransformer instance """ mock_model = MagicMock() mock_model.encode.return_value = np.array([[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], dtype=np.float32) mock_model.get_sentence_embedding_dimension.return_value = 384 return mock_model @pytest.fixture def mock_litellm_response(): """ Create a mock LiteLLM embedding response. Returns: Mock response dictionary """ return { "data": [ {"embedding": [0.1, 0.2, 0.3, 0.4], "index": 0}, {"embedding": [0.5, 0.6, 0.7, 0.8], "index": 1}, ] } @pytest.fixture def temp_model_dir(tmp_path: Path) -> Path: """ Create a temporary model directory with mock model files. Args: tmp_path: Pytest temporary path fixture Returns: Path to temporary model directory """ model_dir = tmp_path / "models" / "test-model" model_dir.mkdir(parents=True, exist_ok=True) # Create a dummy file to make the directory non-empty (model_dir / "config.json").write_text('{"model_type": "test"}') return model_dir @pytest.fixture def empty_model_dir(tmp_path: Path) -> Path: """ Create an empty model directory. Args: tmp_path: Pytest temporary path fixture Returns: Path to empty directory """ model_dir = tmp_path / "models" / "empty-model" model_dir.mkdir(parents=True, exist_ok=True) return model_dir # ============================================================================= # TESTS: EmbeddingsClient Abstract Base Class # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestEmbeddingsClient: """Tests for EmbeddingsClient abstract base class.""" def test_cannot_instantiate_abstract_class(self): """Test that EmbeddingsClient cannot be instantiated directly.""" # Arrange & Act & Assert with pytest.raises(TypeError, match="Can't instantiate abstract class"): EmbeddingsClient() def test_abstract_encode_method(self): """Test that encode method is abstract and must be implemented.""" # Arrange class IncompleteClient(EmbeddingsClient): def get_embedding_dimension(self) -> int: return 384 # Act & Assert with pytest.raises(TypeError, match="Can't instantiate abstract class"): IncompleteClient() def test_abstract_get_embedding_dimension_method(self): """Test that get_embedding_dimension method is abstract.""" # Arrange class IncompleteClient(EmbeddingsClient): def encode(self, texts: list[str]) -> np.ndarray: return np.array([]) # Act & Assert with pytest.raises(TypeError, match="Can't instantiate abstract class"): IncompleteClient() def test_concrete_implementation_works(self): """Test that concrete implementation can be instantiated.""" # Arrange class ConcreteClient(EmbeddingsClient): def encode(self, texts: list[str]) -> np.ndarray: return np.array([[0.1, 0.2, 0.3]], dtype=np.float32) def get_embedding_dimension(self) -> int: return 3 # Act client = ConcreteClient() # Assert assert isinstance(client, EmbeddingsClient) assert client.get_embedding_dimension() == 3 # ============================================================================= # TESTS: SentenceTransformersClient # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestSentenceTransformersClient: """Tests for SentenceTransformersClient implementation.""" def test_initialization(self): """Test SentenceTransformersClient initialization.""" # Arrange model_name = "all-MiniLM-L6-v2" model_dir = Path("/tmp/models") cache_dir = Path("/tmp/cache") # Act client = SentenceTransformersClient( model_name=model_name, model_dir=model_dir, cache_dir=cache_dir, ) # Assert assert client.model_name == model_name assert client.model_dir == model_dir assert client.cache_dir == cache_dir assert client._model is None assert client._dimension is None def test_initialization_minimal(self): """Test SentenceTransformersClient with minimal parameters.""" # Arrange model_name = "all-MiniLM-L6-v2" # Act client = SentenceTransformersClient(model_name=model_name) # Assert assert client.model_name == model_name assert client.model_dir is None assert client.cache_dir is None def test_load_model_from_huggingface(self, mock_sentence_transformer): """Test loading model from Hugging Face Hub.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer client = SentenceTransformersClient(model_name="all-MiniLM-L6-v2") # Act client._load_model() # Assert mock_st_class.assert_called_once_with("all-MiniLM-L6-v2") assert client._model == mock_sentence_transformer assert client._dimension == 384 def test_load_model_from_local_directory(self, mock_sentence_transformer, temp_model_dir): """Test loading model from local directory.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer client = SentenceTransformersClient( model_name="all-MiniLM-L6-v2", model_dir=temp_model_dir, ) # Act client._load_model() # Assert mock_st_class.assert_called_once_with(str(temp_model_dir)) assert client._model == mock_sentence_transformer assert client._dimension == 384 def test_load_model_empty_local_directory(self, mock_sentence_transformer, empty_model_dir): """Test loading model when local directory exists but is empty.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer client = SentenceTransformersClient( model_name="all-MiniLM-L6-v2", model_dir=empty_model_dir, ) # Act client._load_model() # Assert # Should fall back to downloading from Hugging Face mock_st_class.assert_called_once_with("all-MiniLM-L6-v2") assert client._model == mock_sentence_transformer def test_load_model_with_cache_dir(self, mock_sentence_transformer, tmp_path): """Test loading model with custom cache directory.""" # Arrange cache_dir = tmp_path / "cache" with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer client = SentenceTransformersClient( model_name="all-MiniLM-L6-v2", cache_dir=cache_dir, ) # Act client._load_model() # Assert assert cache_dir.exists() assert client._model == mock_sentence_transformer def test_load_model_restores_environment_variable(self, mock_sentence_transformer, tmp_path): """Test that loading model restores original SENTENCE_TRANSFORMERS_HOME.""" # Arrange original_value = "/original/path" os.environ["SENTENCE_TRANSFORMERS_HOME"] = original_value cache_dir = tmp_path / "cache" with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer client = SentenceTransformersClient( model_name="all-MiniLM-L6-v2", cache_dir=cache_dir, ) # Act client._load_model() # Assert assert os.environ.get("SENTENCE_TRANSFORMERS_HOME") == original_value # Cleanup del os.environ["SENTENCE_TRANSFORMERS_HOME"] def test_load_model_removes_environment_variable_if_not_set( self, mock_sentence_transformer, tmp_path ): """Test that loading model removes env var if it wasn't set originally.""" # Arrange if "SENTENCE_TRANSFORMERS_HOME" in os.environ: del os.environ["SENTENCE_TRANSFORMERS_HOME"] cache_dir = tmp_path / "cache" with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer client = SentenceTransformersClient( model_name="all-MiniLM-L6-v2", cache_dir=cache_dir, ) # Act client._load_model() # Assert assert "SENTENCE_TRANSFORMERS_HOME" not in os.environ def test_load_model_only_once(self, mock_sentence_transformer): """Test that model is only loaded once, not on subsequent calls.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer client = SentenceTransformersClient(model_name="all-MiniLM-L6-v2") # Act client._load_model() client._load_model() client._load_model() # Assert # Should only be called once assert mock_st_class.call_count == 1 def test_load_model_failure(self): """Test handling of model loading failure.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.side_effect = Exception("Model not found") client = SentenceTransformersClient(model_name="invalid-model") # Act & Assert with pytest.raises(RuntimeError, match="Failed to load SentenceTransformer model"): client._load_model() def test_encode_single_text(self, mock_sentence_transformer): """Test encoding a single text.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer mock_sentence_transformer.encode.return_value = np.array( [[0.1, 0.2, 0.3]], dtype=np.float32 ) client = SentenceTransformersClient(model_name="all-MiniLM-L6-v2") # Act result = client.encode(["test text"]) # Assert assert isinstance(result, np.ndarray) assert result.shape == (1, 3) assert result.dtype == np.float32 mock_sentence_transformer.encode.assert_called_once_with(["test text"]) def test_encode_multiple_texts(self, mock_sentence_transformer): """Test encoding multiple texts.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer mock_sentence_transformer.encode.return_value = np.array( [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]], dtype=np.float32 ) client = SentenceTransformersClient(model_name="all-MiniLM-L6-v2") texts = ["first text", "second text"] # Act result = client.encode(texts) # Assert assert isinstance(result, np.ndarray) assert result.shape == (2, 3) assert result.dtype == np.float32 mock_sentence_transformer.encode.assert_called_once_with(texts) def test_encode_lazy_loads_model(self, mock_sentence_transformer): """Test that encode lazy loads the model if not already loaded.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer client = SentenceTransformersClient(model_name="all-MiniLM-L6-v2") assert client._model is None # Act client.encode(["test"]) # Assert assert client._model is not None mock_st_class.assert_called_once() def test_encode_failure(self, mock_sentence_transformer): """Test handling of encoding failure.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer mock_sentence_transformer.encode.side_effect = Exception("Encoding error") client = SentenceTransformersClient(model_name="all-MiniLM-L6-v2") # Act & Assert with pytest.raises(RuntimeError, match="Failed to encode texts"): client.encode(["test"]) def test_get_embedding_dimension(self, mock_sentence_transformer): """Test getting embedding dimension.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer client = SentenceTransformersClient(model_name="all-MiniLM-L6-v2") # Act dimension = client.get_embedding_dimension() # Assert assert dimension == 384 mock_sentence_transformer.get_sentence_embedding_dimension.assert_called_once() def test_get_embedding_dimension_lazy_loads_model(self, mock_sentence_transformer): """Test that get_embedding_dimension lazy loads model if needed.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer client = SentenceTransformersClient(model_name="all-MiniLM-L6-v2") assert client._dimension is None # Act dimension = client.get_embedding_dimension() # Assert assert dimension == 384 assert client._dimension == 384 mock_st_class.assert_called_once() def test_get_embedding_dimension_cached(self, mock_sentence_transformer): """Test that dimension is cached after first load.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer client = SentenceTransformersClient(model_name="all-MiniLM-L6-v2") client._load_model() # Act dimension1 = client.get_embedding_dimension() dimension2 = client.get_embedding_dimension() # Assert assert dimension1 == 384 assert dimension2 == 384 # Should only load model once assert mock_st_class.call_count == 1 # ============================================================================= # TESTS: LiteLLMClient # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestLiteLLMClient: """Tests for LiteLLMClient implementation.""" def test_initialization_minimal(self): """Test LiteLLMClient initialization with minimal parameters.""" # Arrange & Act client = LiteLLMClient(model_name="openai/text-embedding-3-small") # Assert assert client.model_name == "openai/text-embedding-3-small" assert client.api_key is None assert client.api_base is None assert client.aws_region is None assert client._embedding_dimension is None assert client._validated_dimension is None def test_initialization_with_all_parameters(self): """Test LiteLLMClient initialization with all parameters.""" # Arrange & Act client = LiteLLMClient( model_name="openai/text-embedding-3-small", api_key="test-api-key", api_base="https://api.test.com", aws_region="us-west-2", embedding_dimension=1536, ) # Assert assert client.model_name == "openai/text-embedding-3-small" assert client.api_key == "test-api-key" assert client.api_base == "https://api.test.com" assert client.aws_region == "us-west-2" assert client._embedding_dimension == 1536 def test_initialization_sets_aws_region_env_var(self): """Test that AWS region is set as environment variable.""" # Arrange original_value = os.environ.get("AWS_REGION_NAME") try: # Act LiteLLMClient( model_name="bedrock/amazon.titan-embed-text-v1", aws_region="us-east-1", ) # Assert assert os.environ.get("AWS_REGION_NAME") == "us-east-1" finally: # Cleanup if original_value: os.environ["AWS_REGION_NAME"] = original_value elif "AWS_REGION_NAME" in os.environ: del os.environ["AWS_REGION_NAME"] def test_set_api_key_env_openai(self): """Test setting OpenAI API key environment variable.""" # Arrange original_value = os.environ.get("OPENAI_API_KEY") try: client = LiteLLMClient( model_name="openai/text-embedding-3-small", api_key="test-openai-key", ) # Act client._set_api_key_env() # Assert assert os.environ.get("OPENAI_API_KEY") == "test-openai-key" finally: # Cleanup if original_value: os.environ["OPENAI_API_KEY"] = original_value elif "OPENAI_API_KEY" in os.environ: del os.environ["OPENAI_API_KEY"] def test_set_api_key_env_cohere(self): """Test setting Cohere API key environment variable.""" # Arrange original_value = os.environ.get("COHERE_API_KEY") try: client = LiteLLMClient( model_name="cohere/embed-english-v3.0", api_key="test-cohere-key", ) # Act client._set_api_key_env() # Assert assert os.environ.get("COHERE_API_KEY") == "test-cohere-key" finally: # Cleanup if original_value: os.environ["COHERE_API_KEY"] = original_value elif "COHERE_API_KEY" in os.environ: del os.environ["COHERE_API_KEY"] def test_set_api_key_env_azure(self): """Test setting Azure API key environment variable.""" # Arrange original_value = os.environ.get("AZURE_API_KEY") try: client = LiteLLMClient( model_name="azure/deployment-name", api_key="test-azure-key", ) # Act client._set_api_key_env() # Assert assert os.environ.get("AZURE_API_KEY") == "test-azure-key" finally: # Cleanup if original_value: os.environ["AZURE_API_KEY"] = original_value elif "AZURE_API_KEY" in os.environ: del os.environ["AZURE_API_KEY"] def test_set_api_key_env_bedrock_skips(self): """Test that Bedrock does not set API key (uses AWS credential chain).""" # Arrange client = LiteLLMClient( model_name="bedrock/amazon.titan-embed-text-v1", api_key="should-not-be-used", ) # Act client._set_api_key_env() # Assert # No BEDROCK_API_KEY should be set assert "BEDROCK_API_KEY" not in os.environ def test_encode_single_text(self, mock_litellm_response): """Test encoding a single text with LiteLLM.""" # Arrange with patch("litellm.embedding") as mock_embedding: mock_embedding.return_value = mock_litellm_response client = LiteLLMClient(model_name="openai/text-embedding-3-small") # Act result = client.encode(["test text"]) # Assert assert isinstance(result, np.ndarray) assert result.shape == (2, 4) # 2 embeddings from mock response assert result.dtype == np.float32 mock_embedding.assert_called_once_with( model="openai/text-embedding-3-small", input=["test text"], ) def test_encode_multiple_texts(self, mock_litellm_response): """Test encoding multiple texts with LiteLLM.""" # Arrange with patch("litellm.embedding") as mock_embedding: mock_embedding.return_value = mock_litellm_response client = LiteLLMClient(model_name="openai/text-embedding-3-small") texts = ["first text", "second text"] # Act result = client.encode(texts) # Assert assert isinstance(result, np.ndarray) assert result.dtype == np.float32 mock_embedding.assert_called_once_with( model="openai/text-embedding-3-small", input=texts, ) def test_encode_with_api_base(self, mock_litellm_response): """Test encoding with custom API base URL.""" # Arrange with patch("litellm.embedding") as mock_embedding: mock_embedding.return_value = mock_litellm_response client = LiteLLMClient( model_name="openai/text-embedding-3-small", api_base="https://custom.api.com", ) # Act client.encode(["test"]) # Assert mock_embedding.assert_called_once_with( model="openai/text-embedding-3-small", input=["test"], api_base="https://custom.api.com", ) def test_encode_validates_dimension(self, mock_litellm_response): """Test that encode validates embedding dimension on first call.""" # Arrange with patch("litellm.embedding") as mock_embedding: mock_embedding.return_value = mock_litellm_response client = LiteLLMClient( model_name="openai/text-embedding-3-small", embedding_dimension=4, # Matches mock response ) # Act client.encode(["test"]) # Assert assert client._validated_dimension == 4 def test_encode_warns_on_dimension_mismatch(self, mock_litellm_response, caplog): """Test warning when dimension doesn't match expected.""" # Arrange with patch("litellm.embedding") as mock_embedding: mock_embedding.return_value = mock_litellm_response client = LiteLLMClient( model_name="openai/text-embedding-3-small", embedding_dimension=1536, # Doesn't match mock response (4) ) # Act with caplog.at_level(logging.WARNING): client.encode(["test"]) # Assert assert "Embedding dimension mismatch" in caplog.text def test_encode_caches_validated_dimension(self, mock_litellm_response): """Test that validated dimension is cached after first call.""" # Arrange with patch("litellm.embedding") as mock_embedding: mock_embedding.return_value = mock_litellm_response client = LiteLLMClient(model_name="openai/text-embedding-3-small") # Act client.encode(["first"]) first_dimension = client._validated_dimension client.encode(["second"]) second_dimension = client._validated_dimension # Assert assert first_dimension == 4 assert second_dimension == 4 def test_encode_handles_api_error(self): """Test handling of API errors during encoding.""" # Arrange with patch("litellm.embedding") as mock_embedding: mock_embedding.side_effect = Exception("API error") client = LiteLLMClient(model_name="openai/text-embedding-3-small") # Act & Assert with pytest.raises(RuntimeError, match="Failed to generate embeddings via LiteLLM"): client.encode(["test"]) def test_get_embedding_dimension_from_validated(self, mock_litellm_response): """Test getting dimension from validated dimension (after encode).""" # Arrange with patch("litellm.embedding") as mock_embedding: mock_embedding.return_value = mock_litellm_response client = LiteLLMClient(model_name="openai/text-embedding-3-small") client.encode(["test"]) # Validates dimension # Act dimension = client.get_embedding_dimension() # Assert assert dimension == 4 def test_get_embedding_dimension_from_config(self): """Test getting dimension from configured value.""" # Arrange client = LiteLLMClient( model_name="openai/text-embedding-3-small", embedding_dimension=1536, ) # Act dimension = client.get_embedding_dimension() # Assert assert dimension == 1536 def test_get_embedding_dimension_makes_test_call(self, mock_litellm_response): """Test that dimension is determined via test call if not known.""" # Arrange with patch("litellm.embedding") as mock_embedding: mock_embedding.return_value = mock_litellm_response client = LiteLLMClient(model_name="openai/text-embedding-3-small") # Act dimension = client.get_embedding_dimension() # Assert assert dimension == 4 mock_embedding.assert_called_once_with( model="openai/text-embedding-3-small", input=["test"], ) def test_get_embedding_dimension_test_call_failure(self): """Test error handling when test call fails.""" # Arrange with patch("litellm.embedding") as mock_embedding: mock_embedding.side_effect = Exception("API error") client = LiteLLMClient(model_name="openai/text-embedding-3-small") # Act & Assert with pytest.raises(RuntimeError, match="Failed to determine embedding dimension"): client.get_embedding_dimension() # ============================================================================= # TESTS: create_embeddings_client Factory Function # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestCreateEmbeddingsClient: """Tests for create_embeddings_client factory function.""" def test_create_sentence_transformers_client(self, mock_sentence_transformer): """Test creating SentenceTransformersClient via factory.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer # Act client = create_embeddings_client( provider="sentence-transformers", model_name="all-MiniLM-L6-v2", ) # Assert assert isinstance(client, SentenceTransformersClient) assert client.model_name == "all-MiniLM-L6-v2" def test_create_sentence_transformers_client_case_insensitive(self, mock_sentence_transformer): """Test that provider name is case-insensitive.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer # Act client = create_embeddings_client( provider="SENTENCE-TRANSFORMERS", model_name="all-MiniLM-L6-v2", ) # Assert assert isinstance(client, SentenceTransformersClient) def test_create_sentence_transformers_client_with_dirs( self, mock_sentence_transformer, tmp_path ): """Test creating SentenceTransformersClient with directories.""" # Arrange with patch("sentence_transformers.SentenceTransformer") as mock_st_class: mock_st_class.return_value = mock_sentence_transformer model_dir = tmp_path / "models" cache_dir = tmp_path / "cache" # Act client = create_embeddings_client( provider="sentence-transformers", model_name="all-MiniLM-L6-v2", model_dir=model_dir, cache_dir=cache_dir, ) # Assert assert isinstance(client, SentenceTransformersClient) assert client.model_dir == model_dir assert client.cache_dir == cache_dir def test_create_litellm_client(self): """Test creating LiteLLMClient via factory.""" # Arrange & Act client = create_embeddings_client( provider="litellm", model_name="openai/text-embedding-3-small", ) # Assert assert isinstance(client, LiteLLMClient) assert client.model_name == "openai/text-embedding-3-small" def test_create_litellm_client_case_insensitive(self): """Test that provider name is case-insensitive for LiteLLM.""" # Arrange & Act client = create_embeddings_client( provider="LITELLM", model_name="openai/text-embedding-3-small", ) # Assert assert isinstance(client, LiteLLMClient) def test_create_litellm_client_with_parameters(self): """Test creating LiteLLMClient with all parameters.""" # Arrange & Act client = create_embeddings_client( provider="litellm", model_name="bedrock/amazon.titan-embed-text-v1", api_key="test-key", api_base="https://api.test.com", aws_region="us-west-2", embedding_dimension=1536, ) # Assert assert isinstance(client, LiteLLMClient) assert client.model_name == "bedrock/amazon.titan-embed-text-v1" assert client.api_key == "test-key" assert client.api_base == "https://api.test.com" assert client.aws_region == "us-west-2" assert client._embedding_dimension == 1536 def test_create_litellm_client_requires_provider_prefix(self): """Test that LiteLLM requires provider prefix in model name.""" # Arrange & Act & Assert with pytest.raises(ValueError, match="Invalid model name for LiteLLM provider"): create_embeddings_client( provider="litellm", model_name="text-embedding-3-small", # Missing "openai/" prefix ) def test_create_litellm_client_error_message_helpful(self): """Test that error message provides helpful examples.""" # Arrange & Act & Assert with pytest.raises(ValueError) as exc_info: create_embeddings_client( provider="litellm", model_name="all-MiniLM-L6-v2", ) error_message = str(exc_info.value) assert "openai/text-embedding-3-small" in error_message assert "bedrock/amazon.titan-embed-text-v1" in error_message assert "cohere/embed-english-v3.0" in error_message assert "EMBEDDINGS_PROVIDER=sentence-transformers" in error_message def test_create_unsupported_provider(self): """Test error with unsupported provider.""" # Arrange & Act & Assert with pytest.raises(ValueError, match="Unsupported embeddings provider: invalid"): create_embeddings_client( provider="invalid", model_name="some-model", ) def test_create_unsupported_provider_lists_supported(self): """Test that error message lists supported providers.""" # Arrange & Act & Assert with pytest.raises(ValueError) as exc_info: create_embeddings_client( provider="invalid", model_name="some-model", ) error_message = str(exc_info.value) assert "sentence-transformers" in error_message assert "litellm" in error_message ================================================ FILE: tests/unit/health/__init__.py ================================================ """Health monitoring unit tests.""" ================================================ FILE: tests/unit/health/test_health_service.py ================================================ """ Unit tests for registry/health/service.py Tests the HealthMonitoringService and HighPerformanceWebSocketManager. """ import asyncio from datetime import datetime from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from fastapi import WebSocket from registry.constants import HealthStatus from registry.health.service import ( HealthMonitoringService, HighPerformanceWebSocketManager, ) # ============================================================================= # TEST FIXTURES # ============================================================================= @pytest.fixture def mock_websocket(): """Create a mock WebSocket connection.""" ws = AsyncMock(spec=WebSocket) ws.client = MagicMock() ws.client.host = "127.0.0.1" ws.accept = AsyncMock() ws.send_text = AsyncMock() ws.close = AsyncMock() return ws @pytest.fixture def ws_manager(): """Create a HighPerformanceWebSocketManager instance.""" return HighPerformanceWebSocketManager() @pytest.fixture def health_service(): """Create a HealthMonitoringService instance.""" service = HealthMonitoringService() return service @pytest.fixture def mock_server_info(): """Create mock server info.""" return { "server_name": "test-server", "proxy_pass_url": "http://localhost:8000/mcp", "supported_transports": ["streamable-http"], "headers": [{"X-Test-Header": "test-value"}], "tool_list": [{"name": "test_tool", "description": "A test tool"}], "num_tools": 1, "is_enabled": True, } # ============================================================================= # HIGHPERFORMANCEWEBSOCKETMANAGER TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_add_connection_success(ws_manager, mock_websocket): """Test adding a WebSocket connection successfully.""" with patch.object(ws_manager, "_send_initial_status_optimized", new=AsyncMock()): success = await ws_manager.add_connection(mock_websocket) assert success is True assert mock_websocket in ws_manager.connections assert mock_websocket in ws_manager.connection_metadata mock_websocket.accept.assert_awaited_once() @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_add_connection_at_capacity(ws_manager, mock_settings): """Test adding connection when at capacity limit.""" # Set low limit for testing mock_settings.max_websocket_connections = 1 with patch("registry.health.service.settings", mock_settings): ws1 = AsyncMock(spec=WebSocket) ws1.client = MagicMock(host="127.0.0.1") ws2 = AsyncMock(spec=WebSocket) ws2.client = MagicMock(host="127.0.0.2") with patch.object(ws_manager, "_send_initial_status_optimized", new=AsyncMock()): # Add first connection - should succeed success1 = await ws_manager.add_connection(ws1) assert success1 is True # Add second connection - should fail success2 = await ws_manager.add_connection(ws2) assert success2 is False ws2.close.assert_awaited_once() @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_remove_connection(ws_manager, mock_websocket): """Test removing a WebSocket connection.""" ws_manager.connections.add(mock_websocket) ws_manager.connection_metadata[mock_websocket] = {"connected_at": 123456} await ws_manager.remove_connection(mock_websocket) assert mock_websocket not in ws_manager.connections assert mock_websocket not in ws_manager.connection_metadata @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_broadcast_update_no_connections(ws_manager): """Test broadcast with no active connections.""" await ws_manager.broadcast_update("test-path", {"status": "healthy"}) # Should not raise any errors @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_broadcast_update_rate_limiting(ws_manager, mock_websocket, mock_settings): """Test that broadcasts are rate-limited.""" mock_settings.websocket_broadcast_interval_ms = 1000 # 1 second with patch("registry.health.service.settings", mock_settings): ws_manager.connections.add(mock_websocket) # First broadcast should go through await ws_manager.broadcast_update("test-path", {"status": "healthy"}) # Immediate second broadcast should be queued (not sent) await ws_manager.broadcast_update("test-path-2", {"status": "unhealthy"}) # Check that update was queued assert "test-path-2" in ws_manager.pending_updates @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_safe_send_message_success(ws_manager, mock_websocket): """Test safe message sending.""" message = "test message" result = await ws_manager._safe_send_message(mock_websocket, message) assert result is True mock_websocket.send_text.assert_awaited_once_with(message) @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_safe_send_message_timeout(ws_manager, mock_websocket): """Test safe message sending with timeout.""" mock_websocket.send_text.side_effect = TimeoutError() result = await ws_manager._safe_send_message(mock_websocket, "test") assert isinstance(result, TimeoutError) @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_send_to_connections_optimized(ws_manager): """Test optimized sending to multiple connections.""" # Create mock connections connections = [] for i in range(5): ws = AsyncMock(spec=WebSocket) ws.client = MagicMock(host=f"127.0.0.{i}") connections.append(ws) ws_manager.connections.add(ws) data = {"test": "data"} with patch.object(ws_manager, "_safe_send_message", return_value=True) as mock_send: await ws_manager._send_to_connections_optimized(data) # Should have sent to all connections assert mock_send.call_count == len(connections) @pytest.mark.unit def test_ws_manager_get_stats(ws_manager): """Test getting WebSocket manager statistics.""" ws_manager.broadcast_count = 10 ws_manager.failed_send_count = 2 stats = ws_manager.get_stats() assert stats["active_connections"] == 0 assert stats["total_broadcasts"] == 10 assert stats["failed_sends"] == 2 # ============================================================================= # HEALTHMONITORINGSERVICE TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_initialize(health_service): """Test health service initialization.""" with patch.object(health_service, "_run_health_checks", return_value=AsyncMock()): await health_service.initialize() assert health_service.health_check_task is not None @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_shutdown(health_service): """Test health service shutdown.""" # Create a proper asyncio Task async def dummy_task(): while True: await asyncio.sleep(1) # Create and immediately cancel the task task = asyncio.create_task(dummy_task()) health_service.health_check_task = task # Add mock connections mock_ws = AsyncMock(spec=WebSocket) mock_ws.close = AsyncMock() health_service.websocket_manager.connections.add(mock_ws) await health_service.shutdown() # Task should be cancelled assert task.cancelled() mock_ws.close.assert_awaited_once() @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_add_websocket_connection(health_service, mock_websocket): """Test adding WebSocket connection to health service.""" with patch.object( health_service.websocket_manager, "add_connection", return_value=True ) as mock_add: success = await health_service.add_websocket_connection(mock_websocket) assert success is True mock_add.assert_awaited_once_with(mock_websocket) @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_remove_websocket_connection(health_service, mock_websocket): """Test removing WebSocket connection from health service.""" with patch.object(health_service.websocket_manager, "remove_connection") as mock_remove: await health_service.remove_websocket_connection(mock_websocket) mock_remove.assert_awaited_once_with(mock_websocket) @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_broadcast_health_update_no_connections(health_service): """Test broadcasting health update with no connections.""" # Should not raise any errors await health_service.broadcast_health_update() @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_broadcast_health_update_specific_service( health_service, mock_server_info ): """Test broadcasting health update for specific service.""" service_path = "/test-server" with patch("registry.services.server_service.server_service") as mock_server_service: mock_server_service.get_server_info = AsyncMock(return_value=mock_server_info) # Add a mock connection mock_ws = AsyncMock(spec=WebSocket) health_service.websocket_manager.connections.add(mock_ws) with patch.object(health_service.websocket_manager, "broadcast_update") as mock_broadcast: await health_service.broadcast_health_update(service_path) mock_broadcast.assert_awaited_once() # Check that service_path was passed args = mock_broadcast.call_args assert args[0][0] == service_path @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_get_cached_health_data(health_service): """Test getting cached health data.""" with patch("registry.services.server_service.server_service") as mock_server_service: mock_server_service.get_all_servers = AsyncMock( return_value={"/test-server": {"server_name": "test", "proxy_pass_url": "http://test"}} ) data = await health_service._get_cached_health_data() assert isinstance(data, dict) assert "/test-server" in data @pytest.mark.unit def test_health_service_get_websocket_stats(health_service): """Test getting WebSocket statistics.""" health_service.websocket_manager.broadcast_count = 5 stats = health_service.get_websocket_stats() assert "active_connections" in stats assert "total_broadcasts" in stats @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_server_endpoint_transport_aware_healthy( health_service, mock_server_info ): """Test checking server endpoint that is healthy.""" proxy_url = "http://localhost:8000/mcp" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() mock_response.status_code = 200 mock_client.post.return_value = mock_response with patch.object(health_service, "_initialize_mcp_session", return_value="session-123"): is_healthy, status = await health_service._check_server_endpoint_transport_aware( mock_client, proxy_url, mock_server_info ) assert is_healthy is True assert status == HealthStatus.HEALTHY @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_server_endpoint_missing_url(health_service, mock_server_info): """Test checking server endpoint with missing URL.""" mock_client = AsyncMock(spec=httpx.AsyncClient) is_healthy, status = await health_service._check_server_endpoint_transport_aware( mock_client, "", mock_server_info ) assert is_healthy is False assert status == HealthStatus.UNHEALTHY_MISSING_PROXY_URL @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_server_endpoint_stdio_transport( health_service, mock_server_info ): """Test checking server with stdio transport (should skip check).""" mock_server_info["supported_transports"] = ["stdio"] mock_client = AsyncMock(spec=httpx.AsyncClient) is_healthy, status = await health_service._check_server_endpoint_transport_aware( mock_client, "http://localhost:8000", mock_server_info ) assert is_healthy is True assert status == HealthStatus.UNKNOWN @pytest.mark.unit def test_health_service_build_headers_for_server(health_service, mock_server_info): """Test building headers for server requests.""" headers = health_service._build_headers_for_server(mock_server_info) assert "Accept" in headers assert "Content-Type" in headers assert headers["X-Test-Header"] == "test-value" @pytest.mark.unit def test_health_service_build_headers_with_session_id(health_service, mock_server_info): """Test building headers with session ID.""" headers = health_service._build_headers_for_server(mock_server_info, include_session_id=True) assert "Mcp-Session-Id" in headers # Should be a valid UUID import uuid try: uuid.UUID(headers["Mcp-Session-Id"]) assert True except ValueError: pytest.fail("Session ID is not a valid UUID") @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_initialize_mcp_session_success(health_service): """Test initializing MCP session successfully.""" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {"Mcp-Session-Id": "server-session-123"} mock_client.post.return_value = mock_response session_id = await health_service._initialize_mcp_session( mock_client, "http://localhost:8000/mcp", {} ) assert session_id == "server-session-123" @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_initialize_mcp_session_failure(health_service): """Test initializing MCP session with failure.""" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() mock_response.status_code = 500 mock_response.text = "Internal Server Error" mock_client.post.return_value = mock_response session_id = await health_service._initialize_mcp_session( mock_client, "http://localhost:8000/mcp", {} ) assert session_id is None @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_try_ping_without_auth_success(health_service): """Test ping without auth when server is reachable.""" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() mock_response.status_code = 200 mock_client.post.return_value = mock_response result = await health_service._try_ping_without_auth(mock_client, "http://localhost:8000/mcp") assert result is True @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_try_ping_without_auth_failure(health_service): """Test ping without auth when server is unreachable.""" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_client.post.side_effect = httpx.ConnectError("Connection refused") result = await health_service._try_ping_without_auth(mock_client, "http://localhost:8000/mcp") assert result is False @pytest.mark.unit def test_health_service_is_mcp_endpoint_healthy_200(health_service): """Test MCP endpoint health check with 200 status.""" mock_response = MagicMock() mock_response.status_code = 200 result = health_service._is_mcp_endpoint_healthy(mock_response) assert result is True @pytest.mark.unit def test_health_service_is_mcp_endpoint_healthy_400_with_session_error(health_service): """Test MCP endpoint health check with 400 and session error.""" mock_response = MagicMock() mock_response.status_code = 400 mock_response.json.return_value = { "jsonrpc": "2.0", "id": "server-error", "error": {"code": -32600, "message": "Missing session ID"}, } result = health_service._is_mcp_endpoint_healthy(mock_response) assert result is True @pytest.mark.unit def test_health_service_is_mcp_endpoint_healthy_streamable_200(health_service): """Test streamable-http endpoint health check with 200 status.""" mock_response = MagicMock() mock_response.status_code = 200 result = health_service._is_mcp_endpoint_healthy_streamable(mock_response) assert result is True @pytest.mark.unit def test_health_service_is_mcp_endpoint_healthy_streamable_400_with_jsonrpc_error( health_service, ): """Test streamable-http endpoint health check with 400 and JSON-RPC error.""" mock_response = MagicMock() mock_response.status_code = 400 mock_response.json.return_value = {"error": {"code": -32600}} result = health_service._is_mcp_endpoint_healthy_streamable(mock_response) assert result is True @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_perform_immediate_health_check(health_service, mock_server_info): """Test performing immediate health check.""" service_path = "/test-server" with patch("registry.services.server_service.server_service") as mock_server_service: mock_server_service.get_server_info = AsyncMock(return_value=mock_server_info) mock_server_service.get_enabled_services = AsyncMock(return_value=[service_path]) with patch.object( health_service, "_check_server_endpoint_transport_aware", return_value=(True, HealthStatus.HEALTHY), ): with patch("registry.core.nginx_service.nginx_service") as mock_nginx: mock_nginx.generate_config_async = AsyncMock() status, last_checked = await health_service.perform_immediate_health_check( service_path ) assert status == HealthStatus.HEALTHY assert isinstance(last_checked, datetime) @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_single_service_status_changed(health_service, mock_server_info): """Test checking single service when status changes.""" service_path = "/test-server" health_service.server_health_status[service_path] = HealthStatus.UNHEALTHY_TIMEOUT mock_client = AsyncMock(spec=httpx.AsyncClient) with patch.object( health_service, "_check_server_endpoint_transport_aware", return_value=(True, HealthStatus.HEALTHY), ): with patch.object(health_service, "_update_tools_background"): status_changed = await health_service._check_single_service( mock_client, service_path, mock_server_info ) assert status_changed is True assert health_service.server_health_status[service_path] == HealthStatus.HEALTHY @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_update_tools_background(health_service, mock_server_info): """Test updating tools in background.""" service_path = "/test-server" proxy_url = "http://localhost:8000/mcp" # Mock the server_info to not have tool_list initially mock_server_info_copy = mock_server_info.copy() mock_server_info_copy["tool_list"] = [] mock_server_info_copy["num_tools"] = 0 with patch("registry.core.mcp_client.mcp_client_service") as mock_mcp: mock_mcp.get_mcp_connection_result = AsyncMock( return_value={ "tools": [{"name": "test_tool", "description": "Test"}], "server_info": {"name": "test-server", "version": "1.0.0"}, } ) with patch("registry.services.server_service.server_service") as mock_server_service: # First call returns server info without tools, second call returns it with tools mock_server_service.get_server_info = AsyncMock(return_value=mock_server_info_copy) mock_server_service.update_server = AsyncMock() with patch("registry.utils.scopes_manager.update_server_scopes", new=AsyncMock()): # Add small sleep to allow background coroutine to run await health_service._update_tools_background(service_path, proxy_url) await asyncio.sleep(0.01) # Should have called update_server mock_server_service.update_server.assert_called_once() @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_get_all_health_status(health_service, mock_server_info): """Test getting all health status.""" with patch("registry.services.server_service.server_service") as mock_server_service: mock_server_service.get_all_servers = AsyncMock( return_value={"/test-server": mock_server_info} ) all_status = await health_service.get_all_health_status() assert isinstance(all_status, dict) assert "/test-server" in all_status assert "status" in all_status["/test-server"] @pytest.mark.unit def test_health_service_get_service_health_data_fast(health_service, mock_server_info): """Test getting service health data fast.""" service_path = "/test-server" health_service.server_health_status[service_path] = HealthStatus.HEALTHY health_data = health_service._get_service_health_data_fast(service_path, mock_server_info) assert health_data["status"] == HealthStatus.HEALTHY assert health_data["num_tools"] == 1 @pytest.mark.unit def test_health_service_get_service_health_data_disabled(health_service, mock_server_info): """Test getting service health data for disabled service.""" service_path = "/test-server" # Set is_enabled to False in server_info mock_server_info["is_enabled"] = False health_data = health_service._get_service_health_data_fast(service_path, mock_server_info) assert health_data["status"] == "disabled" @pytest.mark.unit def test_health_service_enabled_status_consistency(health_service): """Test that health data correctly reflects enabled status from server_info (Issue #612).""" service_path = "/test-server" # Test Case 1: Enabled service should NOT return "disabled" status server_info_enabled = { "server_name": "test-server", "is_enabled": True, "num_tools": 5, } health_data = health_service._get_service_health_data_fast(service_path, server_info_enabled) # Should NOT return "disabled" status for enabled service assert health_data["status"] != "disabled" assert health_data["status"] in ["healthy", "unhealthy", "unknown", "checking"] # Test Case 2: Disabled service should return "disabled" status server_info_disabled = { "server_name": "test-server", "is_enabled": False, "num_tools": 5, } health_data = health_service._get_service_health_data_fast(service_path, server_info_disabled) # Should return "disabled" status assert health_data["status"] == "disabled" # Test Case 3: Missing is_enabled defaults to False (disabled) server_info_missing = { "server_name": "test-server", "num_tools": 5, } health_data = health_service._get_service_health_data_fast(service_path, server_info_missing) # Should default to disabled when is_enabled is missing assert health_data["status"] == "disabled" # ============================================================================= # ADDITIONAL TESTS FOR MISSING COVERAGE # ============================================================================= @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_add_connection_exception(ws_manager, mock_websocket): """Test adding connection when exception occurs.""" mock_websocket.accept.side_effect = Exception("Connection error") success = await ws_manager.add_connection(mock_websocket) assert success is False @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_send_initial_status_optimized_with_cached_data( ws_manager, mock_websocket ): """Test sending initial status with cached data.""" with patch("registry.health.service.health_service") as mock_health_service: mock_health_service._get_cached_health_data = AsyncMock(return_value={"test": "data"}) await ws_manager._send_initial_status_optimized(mock_websocket) mock_websocket.send_text.assert_awaited_once() @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_send_initial_status_optimized_exception(ws_manager, mock_websocket): """Test sending initial status when exception occurs.""" mock_websocket.send_text.side_effect = Exception("Send failed") with patch("registry.health.service.health_service") as mock_health_service: mock_health_service._get_cached_health_data = AsyncMock(return_value={"test": "data"}) with patch.object(ws_manager, "remove_connection", new=AsyncMock()) as mock_remove: await ws_manager._send_initial_status_optimized(mock_websocket) mock_remove.assert_awaited_once_with(mock_websocket) @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_broadcast_update_single_service(ws_manager, mock_websocket): """Test broadcast update for single service.""" ws_manager.connections.add(mock_websocket) ws_manager.last_broadcast_time = 0 with patch.object(ws_manager, "_send_to_connections_optimized", new=AsyncMock()) as mock_send: await ws_manager.broadcast_update("test-path", {"status": "healthy"}) mock_send.assert_awaited_once() call_args = mock_send.call_args[0][0] assert "test-path" in call_args @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_broadcast_update_with_pending_updates( ws_manager, mock_websocket, mock_settings ): """Test broadcast update with pending updates batch.""" mock_settings.websocket_broadcast_interval_ms = 10 mock_settings.websocket_max_batch_size = 5 with patch("registry.health.service.settings", mock_settings): ws_manager.connections.add(mock_websocket) ws_manager.last_broadcast_time = 0 ws_manager.pending_updates = { "path1": {"status": "healthy"}, "path2": {"status": "unhealthy"}, } with patch.object( ws_manager, "_send_to_connections_optimized", new=AsyncMock() ) as mock_send: await ws_manager.broadcast_update() mock_send.assert_awaited_once() # Pending updates should be sent call_args = mock_send.call_args[0][0] assert "path1" in call_args or "path2" in call_args @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_broadcast_update_full_status(ws_manager, mock_websocket): """Test broadcast update with full status when no pending updates.""" ws_manager.connections.add(mock_websocket) ws_manager.last_broadcast_time = 0 with patch("registry.health.service.health_service") as mock_health_service: mock_health_service._get_cached_health_data = AsyncMock(return_value={"full": "status"}) with patch.object( ws_manager, "_send_to_connections_optimized", new=AsyncMock() ) as mock_send: await ws_manager.broadcast_update() mock_send.assert_awaited_once() @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_send_to_connections_no_connections(ws_manager): """Test sending to connections when no connections exist.""" data = {"test": "data"} # Should not raise any errors await ws_manager._send_to_connections_optimized(data) @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_send_to_connections_with_failures(ws_manager): """Test sending to connections with some failures.""" # Create connections where some will fail good_ws = AsyncMock(spec=WebSocket) good_ws.client = MagicMock(host="127.0.0.1") bad_ws = AsyncMock(spec=WebSocket) bad_ws.client = MagicMock(host="127.0.0.2") ws_manager.connections.add(good_ws) ws_manager.connections.add(bad_ws) data = {"test": "data"} with patch.object(ws_manager, "_safe_send_message") as mock_send: mock_send.side_effect = [True, Exception("Send failed")] with patch.object(ws_manager, "_cleanup_failed_connections", new=AsyncMock()): await ws_manager._send_to_connections_optimized(data) assert len(ws_manager.failed_connections) > 0 @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_cleanup_failed_connections(ws_manager): """Test cleanup of failed connections.""" mock_ws = AsyncMock(spec=WebSocket) ws_manager.connections.add(mock_ws) ws_manager.failed_connections.add(mock_ws) await ws_manager._cleanup_failed_connections() assert mock_ws not in ws_manager.connections assert len(ws_manager.failed_connections) == 0 @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_cleanup_failed_connections_empty(ws_manager): """Test cleanup with no failed connections.""" # Should not raise any errors await ws_manager._cleanup_failed_connections() @pytest.mark.unit @pytest.mark.asyncio async def test_ws_manager_safe_send_message_exception(ws_manager, mock_websocket): """Test safe send message with general exception.""" mock_websocket.send_text.side_effect = RuntimeError("Connection closed") result = await ws_manager._safe_send_message(mock_websocket, "test") assert isinstance(result, Exception) @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_shutdown_no_task(health_service): """Test shutdown when no health check task exists.""" health_service.health_check_task = None # Should not raise any errors await health_service.shutdown() @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_shutdown_with_connection_errors(health_service): """Test shutdown with connection close errors.""" mock_ws1 = AsyncMock(spec=WebSocket) mock_ws1.close.side_effect = Exception("Close failed") mock_ws2 = AsyncMock(spec=WebSocket) mock_ws2.close = AsyncMock() health_service.websocket_manager.connections.add(mock_ws1) health_service.websocket_manager.connections.add(mock_ws2) # Should handle exceptions gracefully await health_service.shutdown() @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_add_websocket_connection_failure(health_service, mock_websocket): """Test adding WebSocket connection when it fails.""" with patch.object(health_service.websocket_manager, "add_connection", return_value=False): success = await health_service.add_websocket_connection(mock_websocket) assert success is False @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_broadcast_health_update_full(health_service): """Test broadcasting full health update.""" mock_ws = AsyncMock(spec=WebSocket) health_service.websocket_manager.connections.add(mock_ws) with patch.object(health_service.websocket_manager, "broadcast_update") as mock_broadcast: await health_service.broadcast_health_update() mock_broadcast.assert_awaited_once_with() @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_broadcast_health_update_no_server_info(health_service): """Test broadcasting health update when server info not found.""" service_path = "/missing-server" mock_ws = AsyncMock(spec=WebSocket) health_service.websocket_manager.connections.add(mock_ws) with patch("registry.services.server_service.server_service") as mock_server_service: mock_server_service.get_server_info = AsyncMock(return_value=None) # Should not raise errors await health_service.broadcast_health_update(service_path) @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_get_cached_health_data_with_valid_cache(health_service): """Test getting cached health data when cache is still valid.""" from time import time # Set up valid cache health_service._cached_health_data = {"test": "data"} health_service._cache_timestamp = time() data = await health_service._get_cached_health_data() assert data == {"test": "data"} @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_run_health_checks_loop(health_service): """Test health check loop execution.""" call_count = 0 async def mock_perform_health_checks(): nonlocal call_count call_count += 1 if call_count >= 2: # Raise CancelledError directly to stop the loop raise asyncio.CancelledError() with patch.object( health_service, "_perform_health_checks", side_effect=mock_perform_health_checks ): with patch("asyncio.sleep", new=AsyncMock()): try: await health_service._run_health_checks() except asyncio.CancelledError: pass assert call_count >= 2 @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_run_health_checks_with_exception(health_service, mock_settings): """Test health check loop handles exceptions.""" mock_settings.health_check_interval_seconds = 0.01 call_count = 0 async def mock_perform_with_error(): nonlocal call_count call_count += 1 if call_count == 1: raise Exception("Health check error") else: # Raise CancelledError directly to stop the loop after error recovery raise asyncio.CancelledError() with patch("registry.health.service.settings", mock_settings): with patch.object( health_service, "_perform_health_checks", side_effect=mock_perform_with_error ): with patch("asyncio.sleep", new=AsyncMock()): try: await health_service._run_health_checks() except asyncio.CancelledError: pass assert call_count >= 1 @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_perform_health_checks_no_services(health_service): """Test performing health checks when no services are enabled.""" with patch("registry.services.server_service.server_service") as mock_server_service: mock_server_service.get_enabled_services = AsyncMock(return_value=[]) # Should not raise errors await health_service._perform_health_checks() @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_perform_health_checks_many_services(health_service, mock_server_info): """Test performing health checks on many services.""" with patch("registry.services.server_service.server_service") as mock_server_service: # Multiple services to trigger debug logging mock_server_service.get_enabled_services = AsyncMock( return_value=["/service1", "/service2", "/service3"] ) mock_server_service.get_server_info = AsyncMock(return_value=mock_server_info) with patch.object(health_service, "_check_single_service", return_value=False): await health_service._perform_health_checks() @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_perform_health_checks_status_changed( health_service, mock_server_info ): """Test performing health checks when status changes.""" with patch("registry.services.server_service.server_service") as mock_server_service: mock_server_service.get_enabled_services = AsyncMock(return_value=["/test-server"]) mock_server_service.get_server_info = AsyncMock(return_value=mock_server_info) with patch.object(health_service, "_check_single_service", return_value=True): with patch.object( health_service, "broadcast_health_update", new=AsyncMock() ) as mock_broadcast: with patch("registry.core.nginx_service.nginx_service") as mock_nginx: mock_nginx.generate_config_async = AsyncMock() await health_service._perform_health_checks() mock_broadcast.assert_awaited_once() @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_perform_health_checks_nginx_error(health_service, mock_server_info): """Test performing health checks when nginx regeneration fails.""" with patch("registry.services.server_service.server_service") as mock_server_service: mock_server_service.get_enabled_services = AsyncMock(return_value=["/test-server"]) mock_server_service.get_server_info = AsyncMock(return_value=mock_server_info) with patch.object(health_service, "_check_single_service", return_value=True): with patch.object(health_service, "broadcast_health_update", new=AsyncMock()): with patch("registry.core.nginx_service.nginx_service") as mock_nginx: mock_nginx.generate_config_async = AsyncMock( side_effect=Exception("Nginx error") ) # Should handle exception gracefully await health_service._perform_health_checks() @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_single_service_timeout(health_service, mock_server_info): """Test checking single service with timeout.""" service_path = "/test-server" mock_client = AsyncMock(spec=httpx.AsyncClient) with patch.object( health_service, "_check_server_endpoint_transport_aware", side_effect=httpx.TimeoutException("Timeout"), ): await health_service._check_single_service(mock_client, service_path, mock_server_info) assert health_service.server_health_status[service_path] == HealthStatus.UNHEALTHY_TIMEOUT @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_single_service_connection_error( health_service, mock_server_info ): """Test checking single service with connection error.""" service_path = "/test-server" mock_client = AsyncMock(spec=httpx.AsyncClient) with patch.object( health_service, "_check_server_endpoint_transport_aware", side_effect=httpx.ConnectError("Connection failed"), ): await health_service._check_single_service(mock_client, service_path, mock_server_info) assert ( health_service.server_health_status[service_path] == HealthStatus.UNHEALTHY_CONNECTION_ERROR ) @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_single_service_generic_error(health_service, mock_server_info): """Test checking single service with generic error.""" service_path = "/test-server" mock_client = AsyncMock(spec=httpx.AsyncClient) with patch.object( health_service, "_check_server_endpoint_transport_aware", side_effect=ValueError("Something went wrong"), ): await health_service._check_single_service(mock_client, service_path, mock_server_info) assert "error: ValueError" in health_service.server_health_status[service_path] @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_single_service_first_time_healthy( health_service, mock_server_info ): """Test checking service for the first time when healthy.""" service_path = "/test-server" health_service.server_health_status[service_path] = HealthStatus.UNKNOWN mock_client = AsyncMock(spec=httpx.AsyncClient) with patch.object( health_service, "_check_server_endpoint_transport_aware", return_value=(True, HealthStatus.HEALTHY), ): with patch.object(health_service, "_update_tools_background"): status_changed = await health_service._check_single_service( mock_client, service_path, mock_server_info ) # Should trigger tool fetch on first healthy check assert status_changed is True @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_single_service_transition_to_healthy( health_service, mock_server_info ): """Test service transitioning from unhealthy to healthy.""" service_path = "/test-server" health_service.server_health_status[service_path] = HealthStatus.UNHEALTHY_TIMEOUT mock_client = AsyncMock(spec=httpx.AsyncClient) with patch.object( health_service, "_check_server_endpoint_transport_aware", return_value=(True, HealthStatus.HEALTHY), ): with patch.object(health_service, "_update_tools_background"): status_changed = await health_service._check_single_service( mock_client, service_path, mock_server_info ) assert status_changed is True @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_single_service_already_healthy_no_tools( health_service, mock_server_info ): """Test service that is already healthy but has no tools.""" service_path = "/test-server" health_service.server_health_status[service_path] = HealthStatus.HEALTHY # Remove tools from server info mock_server_info_no_tools = mock_server_info.copy() mock_server_info_no_tools["tool_list"] = [] mock_client = AsyncMock(spec=httpx.AsyncClient) with patch.object( health_service, "_check_server_endpoint_transport_aware", return_value=(True, HealthStatus.HEALTHY), ): with patch.object(health_service, "_update_tools_background"): status_changed = await health_service._check_single_service( mock_client, service_path, mock_server_info_no_tools ) # Should still fetch tools if none exist assert status_changed is False @pytest.mark.unit def test_health_service_build_headers_for_server_no_headers(health_service): """Test building headers when server has no custom headers.""" server_info = { "server_name": "test-server", "proxy_pass_url": "http://localhost:8000/mcp", } headers = health_service._build_headers_for_server(server_info) assert "Accept" in headers assert "Content-Type" in headers @pytest.mark.unit def test_health_service_build_headers_for_server_invalid_headers(health_service): """Test building headers when server has invalid headers.""" server_info = { "server_name": "test-server", "proxy_pass_url": "http://localhost:8000/mcp", "headers": "invalid_string", } headers = health_service._build_headers_for_server(server_info) # Should still return base headers assert "Accept" in headers assert "Content-Type" in headers @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_initialize_mcp_session_no_server_session_id(health_service): """Test initializing MCP session when server doesn't return session ID.""" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {} mock_client.post.return_value = mock_response session_id = await health_service._initialize_mcp_session( mock_client, "http://localhost:8000/mcp", {} ) # Should generate client-side session ID assert session_id is not None import uuid uuid.UUID(session_id) # Verify it's a valid UUID @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_initialize_mcp_session_exception(health_service): """Test initializing MCP session with exception.""" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_client.post.side_effect = Exception("Network error") session_id = await health_service._initialize_mcp_session( mock_client, "http://localhost:8000/mcp", {} ) assert session_id is None @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_try_ping_without_auth_auth_errors(health_service): """Test ping without auth when server returns auth errors.""" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() mock_response.status_code = 401 mock_client.post.return_value = mock_response result = await health_service._try_ping_without_auth(mock_client, "http://localhost:8000/mcp") assert result is True @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_try_ping_without_auth_server_error(health_service): """Test ping without auth when server returns error.""" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() mock_response.status_code = 500 mock_client.post.return_value = mock_response result = await health_service._try_ping_without_auth(mock_client, "http://localhost:8000/mcp") assert result is False @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_server_endpoint_sse_transport(health_service, mock_server_info): """Test checking server endpoint with SSE transport.""" mock_server_info["supported_transports"] = ["sse"] proxy_url = "http://localhost:8000" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() mock_response.status_code = 200 mock_client.get.return_value = mock_response with patch.object(health_service, "_is_mcp_endpoint_healthy", return_value=True): is_healthy, status = await health_service._check_server_endpoint_transport_aware( mock_client, proxy_url, mock_server_info ) assert is_healthy is True assert status == HealthStatus.HEALTHY @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_server_endpoint_sse_timeout(health_service, mock_server_info): """Test checking server endpoint with SSE transport timeout.""" mock_server_info["supported_transports"] = ["sse"] proxy_url = "http://localhost:8000" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_client.get.side_effect = TimeoutError() is_healthy, status = await health_service._check_server_endpoint_transport_aware( mock_client, proxy_url, mock_server_info ) # SSE timeout is considered healthy assert is_healthy is True @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_server_endpoint_url_with_mcp(health_service, mock_server_info): """Test checking server endpoint when URL already has /mcp.""" proxy_url = "http://localhost:8000/mcp" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() mock_response.status_code = 200 mock_client.post.return_value = mock_response with patch.object(health_service, "_initialize_mcp_session", return_value="session-123"): is_healthy, status = await health_service._check_server_endpoint_transport_aware( mock_client, proxy_url, mock_server_info ) assert is_healthy is True assert status == HealthStatus.HEALTHY @pytest.mark.unit @pytest.mark.asyncio async def test_health_service_check_server_endpoint_auth_failure(health_service, mock_server_info): """Test checking server endpoint with auth failure.""" proxy_url = "http://localhost:8000/mcp" mock_client = AsyncMock(spec=httpx.AsyncClient) mock_response = MagicMock() mock_response.status_code = 401 mock_client.get.return_value = mock_response with patch.object(health_service, "_try_ping_without_auth", return_value=True): is_healthy, status = await health_service._check_server_endpoint_transport_aware( mock_client, proxy_url, mock_server_info ) assert is_healthy is True @pytest.mark.unit def test_health_service_get_service_health_data_fast_transitioning_from_disabled( health_service, mock_server_info ): """Test getting service health data when transitioning from disabled.""" service_path = "/test-server" health_service.server_health_status[service_path] = "disabled" health_data = health_service._get_service_health_data_fast(service_path, mock_server_info) # Should transition to checking assert health_data["status"] == HealthStatus.CHECKING @pytest.mark.unit def test_health_service_get_service_health_data_legacy_method(health_service, mock_server_info): """Test legacy _get_service_health_data method.""" service_path = "/test-server" health_service.server_health_status[service_path] = HealthStatus.HEALTHY health_data = health_service._get_service_health_data(service_path, mock_server_info) assert health_data["status"] == HealthStatus.HEALTHY ================================================ FILE: tests/unit/lambda/__init__.py ================================================ ================================================ FILE: tests/unit/lambda/conftest.py ================================================ """Conftest for Lambda collector tests. Sets required environment variables before the Lambda module is imported, since it reads os.environ[] at module level. """ import os os.environ.setdefault("RATE_LIMIT_TABLE", "test-rate-limit-table") os.environ.setdefault("DOCUMENTDB_SECRET_ARN", "test-secret-arn") os.environ.setdefault("DOCUMENTDB_ENDPOINT", "test-endpoint:27017") ================================================ FILE: tests/unit/lambda/test_collector.py ================================================ """ Unit tests for telemetry collector Lambda function. Tests validation, rate limiting, storage, and fail-silent behavior. """ import json import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest from botocore.exceptions import ClientError from pydantic import ValidationError # Add Lambda collector to path for imports lambda_path = ( Path(__file__).parent.parent.parent.parent / "terraform" / "telemetry-collector" / "lambda" / "collector" ) sys.path.insert(0, str(lambda_path)) from index import ( # noqa: E402 _check_rate_limit, _get_credentials, _get_database, _hash_ip, _store_event, lambda_handler, ) from schemas import HeartbeatEvent, StartupEvent # noqa: E402 # Reset global singletons between tests @pytest.fixture(autouse=True) def _reset_globals(): """Reset module-level singletons before each test.""" import index index._mongo_client = None index._mongo_database = None index._credentials = None yield class TestSchemas: """Test Pydantic validation schemas.""" def test_startup_event_valid(self): payload = { "event": "startup", "schema_version": "1", "instance_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "v": "1.0.16", "py": "3.12", "os": "linux", "arch": "x86_64", "mode": "with-gateway", "registry_mode": "full", "storage": "documentdb", "auth": "keycloak", "federation": True, "ts": "2026-03-18T00:00:00Z", } event = StartupEvent(**payload) assert event.event == "startup" assert event.v == "1.0.16" assert event.storage == "documentdb" def test_startup_event_invalid_event_type(self): payload = { "event": "heartbeat", "schema_version": "1", "instance_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "v": "1.0.16", "py": "3.12", "os": "linux", "arch": "x86_64", "mode": "with-gateway", "registry_mode": "full", "storage": "documentdb", "auth": "keycloak", "federation": True, "ts": "2026-03-18T00:00:00Z", } with pytest.raises(ValidationError): StartupEvent(**payload) def test_startup_event_missing_required_field(self): payload = { "event": "startup", "schema_version": "1", "instance_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "py": "3.12", "os": "linux", "arch": "x86_64", "mode": "with-gateway", "registry_mode": "full", "storage": "documentdb", "auth": "keycloak", "federation": True, "ts": "2026-03-18T00:00:00Z", } with pytest.raises(ValidationError): StartupEvent(**payload) def test_heartbeat_event_valid(self): payload = { "event": "heartbeat", "schema_version": "1", "instance_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "v": "1.0.16", "servers_count": 15, "agents_count": 8, "skills_count": 23, "peers_count": 2, "search_backend": "documentdb", "embeddings_provider": "sentence-transformers", "uptime_hours": 48, "ts": "2026-03-18T12:00:00Z", } event = HeartbeatEvent(**payload) assert event.event == "heartbeat" assert event.servers_count == 15 def test_heartbeat_event_negative_count(self): payload = { "event": "heartbeat", "schema_version": "1", "instance_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "v": "1.0.16", "servers_count": -5, "agents_count": 8, "skills_count": 23, "peers_count": 2, "search_backend": "documentdb", "embeddings_provider": "sentence-transformers", "uptime_hours": 48, "ts": "2026-03-18T12:00:00Z", } with pytest.raises(ValidationError): HeartbeatEvent(**payload) class TestIPHashing: """Test IP hashing for privacy-preserving rate limiting.""" def test_hash_ip_consistent(self): hash1 = _hash_ip("192.168.1.100") hash2 = _hash_ip("192.168.1.100") assert hash1 == hash2 assert len(hash1) == 64 def test_hash_ip_different_ips(self): assert _hash_ip("192.168.1.100") != _hash_ip("192.168.1.101") class TestRateLimiting: """Test rate limiting logic with DynamoDB.""" @patch("index.dynamodb") def test_rate_limit_allows_new_entry(self, mock_dynamodb): """First request in a new window succeeds (reset path).""" mock_table = MagicMock() mock_dynamodb.Table.return_value = mock_table # First update_item succeeds (window expired or new entry) mock_table.update_item.return_value = {} assert _check_rate_limit("abc123") is True @patch("index.dynamodb") def test_rate_limit_allows_within_window(self, mock_dynamodb): """Request within active window under limit succeeds.""" mock_table = MagicMock() mock_dynamodb.Table.return_value = mock_table # First call: ConditionalCheckFailed (window still active) # Second call: succeeds (under limit) mock_table.update_item.side_effect = [ ClientError({"Error": {"Code": "ConditionalCheckFailedException"}}, "update_item"), {}, ] assert _check_rate_limit("abc123") is True @patch("index.dynamodb") def test_rate_limit_blocks_request(self, mock_dynamodb): """Request over limit is blocked.""" mock_table = MagicMock() mock_dynamodb.Table.return_value = mock_table # First call: ConditionalCheckFailed (window still active) # Second call: ConditionalCheckFailed (over limit) mock_table.update_item.side_effect = [ ClientError({"Error": {"Code": "ConditionalCheckFailedException"}}, "update_item"), ClientError({"Error": {"Code": "ConditionalCheckFailedException"}}, "update_item"), ] assert _check_rate_limit("abc123") is False @patch("index.dynamodb") def test_rate_limit_fails_open_on_error(self, mock_dynamodb): """DynamoDB error fails open (allows request).""" mock_table = MagicMock() mock_dynamodb.Table.return_value = mock_table mock_table.update_item.side_effect = ClientError( {"Error": {"Code": "InternalServerError"}}, "update_item" ) assert _check_rate_limit("abc123") is True class TestDocumentDBConnection: """Test DocumentDB connection and credential retrieval.""" @patch("index._init_aws_clients") @patch("index.secretsmanager") def test_get_credentials(self, mock_sm, _mock_init): mock_sm.get_secret_value.return_value = { "SecretString": json.dumps( { "username": "telemetry_admin", "password": "test_password", "database": "telemetry", } ) } creds = _get_credentials() assert creds["username"] == "telemetry_admin" assert creds["database"] == "telemetry" @patch("index.pymongo.MongoClient") @patch("index._get_credentials") def test_get_database(self, mock_creds, mock_client_cls): mock_creds.return_value = { "username": "admin", "password": "pass", "database": "telemetry", } mock_client = MagicMock() mock_client.server_info.return_value = {"version": "5.0.0"} mock_client.__getitem__ = MagicMock(return_value="mock_db") mock_client_cls.return_value = mock_client db = _get_database() assert db == "mock_db" mock_client_cls.assert_called_once() class TestEventStorage: """Test event storage in DocumentDB.""" @patch("index._get_database") def test_store_startup_event(self, mock_get_db): mock_collection = MagicMock() mock_collection.insert_one.return_value = MagicMock(inserted_id="123") mock_db = MagicMock() mock_db.__getitem__ = MagicMock(return_value=mock_collection) mock_get_db.return_value = mock_db _store_event("startup", {"event": "startup", "instance_id": "test-id", "v": "1.0.0"}) mock_collection.insert_one.assert_called_once() call_args = mock_collection.insert_one.call_args[0][0] assert call_args["event"] == "startup" assert "received_at" in call_args class TestLambdaHandler: """Test Lambda handler function.""" @patch("index._store_event") @patch("index._verify_signature", return_value=True) @patch("index._check_rate_limit") @patch("index._hash_ip") def test_valid_startup_event(self, mock_hash, mock_rate, mock_verify, mock_store): mock_hash.return_value = "abc123" mock_rate.return_value = True event = { "requestContext": {"http": {"sourceIp": "1.2.3.4"}}, "headers": {"x-telemetry-signature": "valid"}, "body": json.dumps( { "event": "startup", "schema_version": "1", "instance_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "v": "1.0.16", "py": "3.12", "os": "linux", "arch": "x86_64", "mode": "with-gateway", "registry_mode": "full", "storage": "file", "auth": "keycloak", "federation": False, "ts": "2026-03-18T00:00:00Z", } ), } response = lambda_handler(event, {}) assert response["statusCode"] == 204 mock_store.assert_called_once() @patch("index._store_event") @patch("index._verify_signature", return_value=True) @patch("index._check_rate_limit") @patch("index._hash_ip") def test_valid_heartbeat_event(self, mock_hash, mock_rate, mock_verify, mock_store): mock_hash.return_value = "abc123" mock_rate.return_value = True event = { "requestContext": {"http": {"sourceIp": "1.2.3.4"}}, "headers": {"x-telemetry-signature": "valid"}, "body": json.dumps( { "event": "heartbeat", "schema_version": "1", "instance_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "v": "1.0.16", "servers_count": 10, "agents_count": 5, "skills_count": 20, "peers_count": 1, "search_backend": "faiss", "embeddings_provider": "sentence-transformers", "uptime_hours": 24, "ts": "2026-03-18T12:00:00Z", } ), } response = lambda_handler(event, {}) assert response["statusCode"] == 204 mock_store.assert_called_once() @patch("index._check_rate_limit") @patch("index._hash_ip") def test_rate_limited_returns_204(self, mock_hash, mock_rate): mock_hash.return_value = "abc123" mock_rate.return_value = False event = { "requestContext": {"http": {"sourceIp": "1.2.3.4"}}, "body": json.dumps({"event": "startup"}), } assert lambda_handler(event, {})["statusCode"] == 204 @patch("index._hash_ip") def test_invalid_json_returns_204(self, mock_hash): mock_hash.return_value = "abc123" event = { "requestContext": {"http": {"sourceIp": "1.2.3.4"}}, "body": "invalid json", } assert lambda_handler(event, {})["statusCode"] == 204 @patch("index._check_rate_limit") @patch("index._hash_ip") def test_unknown_event_type_returns_204(self, mock_hash, mock_rate): mock_hash.return_value = "abc123" mock_rate.return_value = True event = { "requestContext": {"http": {"sourceIp": "1.2.3.4"}}, "body": json.dumps({"event": "unknown_type"}), } assert lambda_handler(event, {})["statusCode"] == 204 @patch("index._store_event", side_effect=Exception("DB down")) @patch("index._verify_signature", return_value=True) @patch("index._check_rate_limit", return_value=True) @patch("index._hash_ip", return_value="abc123") def test_storage_failure_returns_204(self, mock_hash, mock_rate, mock_verify, mock_store): event = { "requestContext": {"http": {"sourceIp": "1.2.3.4"}}, "headers": {"x-telemetry-signature": "valid"}, "body": json.dumps( { "event": "startup", "schema_version": "1", "instance_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "v": "1.0.16", "py": "3.12", "os": "linux", "arch": "x86_64", "mode": "with-gateway", "registry_mode": "full", "storage": "file", "auth": "keycloak", "federation": False, "ts": "2026-03-18T00:00:00Z", } ), } assert lambda_handler(event, {})["statusCode"] == 204 ================================================ FILE: tests/unit/middleware/__init__.py ================================================ """Middleware unit tests package.""" ================================================ FILE: tests/unit/middleware/test_mode_filter.py ================================================ """ Unit tests for registry mode filter middleware. Tests the endpoint filtering logic based on REGISTRY_MODE setting. """ from unittest.mock import patch import pytest from registry.core.config import RegistryMode from registry.middleware.mode_filter import ( _get_path_category, _is_path_allowed, ) # ============================================================================= # TEST CLASS: Path Allowed Logic # ============================================================================= @pytest.mark.unit class TestPathAllowed: """Test _is_path_allowed function.""" def test_health_always_allowed(self): """Health endpoint should always be allowed.""" assert _is_path_allowed("/health", RegistryMode.FULL) is True assert _is_path_allowed("/health", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/health", RegistryMode.MCP_SERVERS_ONLY) is True assert _is_path_allowed("/health", RegistryMode.AGENTS_ONLY) is True def test_version_always_allowed(self): """Version endpoint should always be allowed.""" assert _is_path_allowed("/api/version", RegistryMode.FULL) is True assert _is_path_allowed("/api/version", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/api/version", RegistryMode.MCP_SERVERS_ONLY) is True def test_config_always_allowed(self): """Config endpoint should always be allowed.""" assert _is_path_allowed("/api/config", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/api/config/mode", RegistryMode.SKILLS_ONLY) is True def test_docs_always_allowed(self): """Documentation endpoints should always be allowed.""" assert _is_path_allowed("/docs", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/openapi.json", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/redoc", RegistryMode.SKILLS_ONLY) is True def test_auth_always_allowed(self): """Auth endpoints should always be allowed.""" assert _is_path_allowed("/api/auth/login", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/api/tokens/generate", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/oauth2/callback", RegistryMode.SKILLS_ONLY) is True def test_audit_always_allowed(self): """Audit endpoints should always be allowed (administrative functionality).""" assert _is_path_allowed("/api/audit/logs", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/api/audit/export", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/api/audit/logs", RegistryMode.MCP_SERVERS_ONLY) is True assert _is_path_allowed("/api/audit/logs", RegistryMode.AGENTS_ONLY) is True def test_management_always_allowed(self): """Management endpoints should always be allowed (administrative functionality).""" assert _is_path_allowed("/api/management/settings", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/api/management/", RegistryMode.MCP_SERVERS_ONLY) is True assert _is_path_allowed("/api/management/", RegistryMode.AGENTS_ONLY) is True def test_full_mode_allows_all(self): """Full mode should allow all endpoints.""" assert _is_path_allowed("/api/servers", RegistryMode.FULL) is True assert _is_path_allowed("/api/agents", RegistryMode.FULL) is True assert _is_path_allowed("/api/skills", RegistryMode.FULL) is True assert _is_path_allowed("/api/federation", RegistryMode.FULL) is True assert _is_path_allowed("/api/peers", RegistryMode.FULL) is True def test_skills_only_allows_skills(self): """Skills-only mode should allow skills endpoints.""" assert _is_path_allowed("/api/skills", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/api/skills/discovery", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/api/search/semantic", RegistryMode.SKILLS_ONLY) is True def test_skills_only_blocks_servers(self): """Skills-only mode should block servers endpoints.""" assert _is_path_allowed("/api/servers", RegistryMode.SKILLS_ONLY) is False assert _is_path_allowed("/api/servers/test", RegistryMode.SKILLS_ONLY) is False def test_skills_only_blocks_agents(self): """Skills-only mode should block agents endpoints.""" assert _is_path_allowed("/api/agents", RegistryMode.SKILLS_ONLY) is False assert _is_path_allowed("/api/agents/discover", RegistryMode.SKILLS_ONLY) is False def test_skills_only_blocks_federation(self): """Skills-only mode should block federation endpoints.""" assert _is_path_allowed("/api/federation", RegistryMode.SKILLS_ONLY) is False assert _is_path_allowed("/api/peers", RegistryMode.SKILLS_ONLY) is False def test_skills_only_allows_wellknown(self): """Skills-only mode should allow well-known endpoints (returns empty list).""" assert _is_path_allowed("/.well-known/mcp-servers", RegistryMode.SKILLS_ONLY) is True def test_mcp_servers_only_allows_servers(self): """MCP-servers-only mode should allow servers endpoints.""" assert _is_path_allowed("/api/servers", RegistryMode.MCP_SERVERS_ONLY) is True assert _is_path_allowed("/api/servers/test", RegistryMode.MCP_SERVERS_ONLY) is True assert _is_path_allowed("/api/search/semantic", RegistryMode.MCP_SERVERS_ONLY) is True def test_mcp_servers_only_blocks_agents(self): """MCP-servers-only mode should block agents endpoints.""" assert _is_path_allowed("/api/agents", RegistryMode.MCP_SERVERS_ONLY) is False def test_mcp_servers_only_blocks_skills(self): """MCP-servers-only mode should block skills endpoints.""" assert _is_path_allowed("/api/skills", RegistryMode.MCP_SERVERS_ONLY) is False def test_agents_only_allows_agents(self): """Agents-only mode should allow agents endpoints.""" assert _is_path_allowed("/api/agents", RegistryMode.AGENTS_ONLY) is True assert _is_path_allowed("/api/agents/discover", RegistryMode.AGENTS_ONLY) is True assert _is_path_allowed("/api/search/semantic", RegistryMode.AGENTS_ONLY) is True def test_agents_only_blocks_servers(self): """Agents-only mode should block servers endpoints.""" assert _is_path_allowed("/api/servers", RegistryMode.AGENTS_ONLY) is False def test_agents_only_blocks_skills(self): """Agents-only mode should block skills endpoints.""" assert _is_path_allowed("/api/skills", RegistryMode.AGENTS_ONLY) is False def test_frontend_paths_allowed(self): """Frontend static paths should be allowed in all modes.""" assert _is_path_allowed("/static/app.js", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/assets/logo.png", RegistryMode.SKILLS_ONLY) is True assert _is_path_allowed("/_next/static/chunks/main.js", RegistryMode.SKILLS_ONLY) is True # ============================================================================= # TEST CLASS: Path Category Extraction # ============================================================================= @pytest.mark.unit class TestPathCategory: """Test _get_path_category function for metrics labeling.""" def test_servers_category(self): """Should extract 'servers' category.""" assert _get_path_category("/api/servers") == "servers" assert _get_path_category("/api/servers/test") == "servers" def test_agents_category(self): """Should extract 'agents' category.""" assert _get_path_category("/api/agents") == "agents" assert _get_path_category("/api/agents/discover") == "agents" def test_skills_category(self): """Should extract 'skills' category.""" assert _get_path_category("/api/skills") == "skills" assert _get_path_category("/api/skills/discovery") == "skills" def test_federation_category(self): """Should extract 'federation' category.""" assert _get_path_category("/api/federation") == "federation" assert _get_path_category("/api/federation/sync") == "federation" assert _get_path_category("/api/peers") == "federation" def test_other_category(self): """Should return 'other' for unrecognized paths.""" assert _get_path_category("/api/unknown") == "unknown" assert _get_path_category("/something/else") == "other" # ============================================================================= # TEST CLASS: Middleware Integration # ============================================================================= @pytest.mark.unit class TestMiddlewareIntegration: """Test middleware behavior.""" @pytest.mark.asyncio @patch("registry.middleware.mode_filter.settings") @patch("registry.middleware.mode_filter.MODE_BLOCKED_REQUESTS") async def test_middleware_blocks_disabled_endpoint( self, mock_metrics, mock_settings, ): """Middleware should return 403 for disabled endpoints.""" mock_settings.registry_mode = RegistryMode.SKILLS_ONLY from starlette.applications import Starlette from starlette.responses import PlainTextResponse from starlette.routing import Route from starlette.testclient import TestClient from registry.middleware.mode_filter import RegistryModeMiddleware async def api_servers(request): return PlainTextResponse("ok") app = Starlette(routes=[Route("/api/servers", api_servers)]) app.add_middleware(RegistryModeMiddleware) client = TestClient(app, raise_server_exceptions=False) response = client.get("/api/servers") assert response.status_code == 403 data = response.json() assert data["error"] == "endpoint_disabled" assert "skills-only" in data["detail"] @pytest.mark.asyncio @patch("registry.middleware.mode_filter.settings") @patch("registry.middleware.mode_filter.MODE_BLOCKED_REQUESTS") async def test_middleware_allows_enabled_endpoint( self, mock_metrics, mock_settings, ): """Middleware should allow enabled endpoints.""" mock_settings.registry_mode = RegistryMode.SKILLS_ONLY from starlette.applications import Starlette from starlette.responses import PlainTextResponse from starlette.routing import Route from starlette.testclient import TestClient from registry.middleware.mode_filter import RegistryModeMiddleware async def api_skills(request): return PlainTextResponse("ok") app = Starlette(routes=[Route("/api/skills", api_skills)]) app.add_middleware(RegistryModeMiddleware) client = TestClient(app, raise_server_exceptions=False) response = client.get("/api/skills") assert response.status_code == 200 assert response.text == "ok" ================================================ FILE: tests/unit/repositories/__init__.py ================================================ ================================================ FILE: tests/unit/repositories/test_app_log_repository.py ================================================ """Unit tests for registry/repositories/app_log_repository.py.""" from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from registry.repositories.app_log_repository import AppLogRepository @pytest.fixture def mock_collection(): collection = AsyncMock() collection.count_documents = AsyncMock(return_value=0) collection.estimated_document_count = AsyncMock(return_value=0) collection.distinct = AsyncMock(return_value=[]) cursor = MagicMock() cursor.sort = MagicMock(return_value=cursor) cursor.skip = MagicMock(return_value=cursor) cursor.limit = MagicMock(return_value=cursor) cursor.__aiter__ = lambda self: self cursor._items = [] cursor._index = 0 async def anext_impl(self): if self._index >= len(self._items): raise StopAsyncIteration item = self._items[self._index] self._index += 1 return item cursor.__anext__ = anext_impl collection.find = MagicMock(return_value=cursor) return collection @pytest.fixture def repo(mock_collection): r = AppLogRepository.__new__(AppLogRepository) r._collection = mock_collection r._collection_name = "application_logs_test" return r @pytest.fixture def sample_docs() -> list[dict[str, Any]]: return [ { "_id": "abc123", "timestamp": datetime(2026, 4, 24, 10, 0, 0, tzinfo=UTC), "hostname": "pod-abc", "service": "registry", "level": "INFO", "level_no": 20, "logger": "registry.main", "filename": "main.py", "lineno": 42, "process": 130, "message": "Server started", "created_at": datetime(2026, 4, 24, 10, 0, 0, tzinfo=UTC), }, ] class TestQuery: """Test the query method.""" @pytest.mark.asyncio async def test_empty_result(self, repo, mock_collection): entries, total = await repo.query() assert entries == [] assert total == 0 mock_collection.estimated_document_count.assert_called_once() @pytest.mark.asyncio async def test_uses_estimated_count_when_no_filter(self, repo, mock_collection): mock_collection.estimated_document_count.return_value = 100 _, total = await repo.query() assert total == 100 mock_collection.count_documents.assert_not_called() @pytest.mark.asyncio async def test_uses_count_documents_with_filter(self, repo, mock_collection): mock_collection.count_documents.return_value = 5 _, total = await repo.query(service="registry") assert total == 5 mock_collection.estimated_document_count.assert_not_called() @pytest.mark.asyncio async def test_service_filter(self, repo, mock_collection): await repo.query(service="registry") filter_arg = mock_collection.find.call_args[0][0] assert filter_arg["service"] == "registry" @pytest.mark.asyncio async def test_level_no_gte_filter(self, repo, mock_collection): await repo.query(level_no=30) filter_arg = mock_collection.find.call_args[0][0] assert filter_arg["level_no"] == {"$gte": 30} @pytest.mark.asyncio async def test_hostname_filter(self, repo, mock_collection): await repo.query(hostname="pod-abc") filter_arg = mock_collection.find.call_args[0][0] assert filter_arg["hostname"] == "pod-abc" @pytest.mark.asyncio async def test_time_range_filter(self, repo, mock_collection): start = datetime(2026, 4, 24, 0, 0, 0, tzinfo=UTC) end = datetime(2026, 4, 24, 23, 59, 59, tzinfo=UTC) await repo.query(start=start, end=end) filter_arg = mock_collection.find.call_args[0][0] assert filter_arg["timestamp"]["$gte"] == start assert filter_arg["timestamp"]["$lte"] == end @pytest.mark.asyncio async def test_search_regex_filter(self, repo, mock_collection): await repo.query(search="timeout") filter_arg = mock_collection.find.call_args[0][0] assert filter_arg["message"] == {"$regex": "timeout", "$options": "i"} @pytest.mark.asyncio async def test_pagination(self, repo, mock_collection): await repo.query(skip=10, limit=25) cursor = mock_collection.find.return_value cursor.skip.assert_called_with(10) cursor.limit.assert_called_with(25) @pytest.mark.asyncio async def test_results_strip_id(self, repo, mock_collection, sample_docs): cursor = mock_collection.find.return_value cursor._items = sample_docs.copy() cursor._index = 0 mock_collection.estimated_document_count.return_value = 1 entries, _ = await repo.query() assert len(entries) == 1 assert "_id" not in entries[0] @pytest.mark.asyncio async def test_sort_by_timestamp_descending(self, repo, mock_collection): await repo.query() cursor = mock_collection.find.return_value cursor.sort.assert_called_with("timestamp", -1) @pytest.mark.asyncio async def test_error_returns_empty(self, repo, mock_collection): mock_collection.find.side_effect = Exception("db error") entries, total = await repo.query() assert entries == [] assert total == 0 class TestGetDistinctServices: """Test the get_distinct_services method.""" @pytest.mark.asyncio async def test_returns_services(self, repo, mock_collection): mock_collection.distinct.return_value = ["registry", "auth-server"] result = await repo.get_distinct_services() assert result == ["registry", "auth-server"] mock_collection.distinct.assert_called_with("service") @pytest.mark.asyncio async def test_error_returns_empty(self, repo, mock_collection): mock_collection.distinct.side_effect = Exception("db error") result = await repo.get_distinct_services() assert result == [] class TestGetDistinctHostnames: """Test the get_distinct_hostnames method.""" @pytest.mark.asyncio async def test_returns_hostnames(self, repo, mock_collection): mock_collection.distinct.return_value = ["pod-abc", "pod-def"] result = await repo.get_distinct_hostnames() assert result == ["pod-abc", "pod-def"] mock_collection.distinct.assert_called_with("hostname") @pytest.mark.asyncio async def test_error_returns_empty(self, repo, mock_collection): mock_collection.distinct.side_effect = Exception("db error") result = await repo.get_distinct_hostnames() assert result == [] ================================================ FILE: tests/unit/repositories/test_file_server_repository.py ================================================ """ Unit tests for FileServerRepository. Tests the file-based repository implementation for MCP server storage. This includes file I/O operations, state management, and path conversions. """ import json import logging from pathlib import Path from typing import Any from unittest.mock import MagicMock, mock_open, patch import pytest from registry.repositories.file.server_repository import FileServerRepository logger = logging.getLogger(__name__) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def mock_settings(): """Mock settings with test directories.""" with patch("registry.repositories.file.server_repository.settings") as mock_settings: # Create mock Path objects mock_servers_dir = MagicMock(spec=Path) mock_servers_dir.__truediv__ = lambda self, other: MagicMock(spec=Path) mock_servers_dir.mkdir = MagicMock() mock_state_path = MagicMock(spec=Path) mock_state_path.exists = MagicMock(return_value=False) mock_settings.servers_dir = mock_servers_dir mock_settings.state_file_path = mock_state_path yield mock_settings @pytest.fixture def server_repository(mock_settings): """Create a FileServerRepository instance for testing.""" return FileServerRepository() @pytest.fixture def sample_server_dict() -> dict[str, Any]: """Sample server data for testing.""" return { "path": "/test-server", "server_name": "Test Server", "description": "A test server", "tags": ["test"], "num_tools": 5, } # ============================================================================= # TEST: _path_to_filename Method # ============================================================================= @pytest.mark.unit @pytest.mark.repositories class TestPathToFilename: """Tests for _path_to_filename helper method.""" def test_path_to_filename_simple(self, server_repository): """Test conversion of simple path to filename.""" # Act result = server_repository._path_to_filename("/test-server") # Assert assert result == "test-server.json" def test_path_to_filename_nested(self, server_repository): """Test conversion of nested path to filename.""" # Act result = server_repository._path_to_filename("/api/v1/test-server") # Assert assert result == "api_v1_test-server.json" def test_path_to_filename_with_trailing_slash(self, server_repository): """Test path with trailing slash.""" # Act result = server_repository._path_to_filename("/test-server/") # Assert assert result == "test-server_.json" def test_path_to_filename_already_has_json(self, server_repository): """Test path that already has .json extension.""" # Act result = server_repository._path_to_filename("/test-server.json") # Assert assert result == "test-server.json" def test_path_to_filename_multiple_slashes(self, server_repository): """Test path with multiple directory levels.""" # Act result = server_repository._path_to_filename("/api/v1/servers/test") # Assert assert result == "api_v1_servers_test.json" # ============================================================================= # TEST: _save_to_file Method # ============================================================================= @pytest.mark.unit @pytest.mark.repositories class TestSaveToFile: """Tests for _save_to_file method.""" @pytest.mark.asyncio async def test_save_to_file_success(self, server_repository, sample_server_dict, mock_settings): """Test successful file save.""" # Arrange m = mock_open() with patch("builtins.open", m): # Act result = await server_repository._save_to_file(sample_server_dict) # Assert assert result is True mock_settings.servers_dir.mkdir.assert_called_with(parents=True, exist_ok=True) m.assert_called_once() # Verify JSON was written handle = m() written_data = "".join(call.args[0] for call in handle.write.call_args_list) assert "Test Server" in written_data @pytest.mark.asyncio async def test_save_to_file_creates_directory( self, server_repository, sample_server_dict, mock_settings ): """Test that save creates directory if missing.""" # Arrange m = mock_open() with patch("builtins.open", m): # Act await server_repository._save_to_file(sample_server_dict) # Assert mock_settings.servers_dir.mkdir.assert_called_with(parents=True, exist_ok=True) @pytest.mark.asyncio async def test_save_to_file_handles_errors( self, server_repository, sample_server_dict, mock_settings ): """Test error handling when save fails.""" # Arrange with patch("builtins.open", side_effect=OSError("Disk full")): # Act result = await server_repository._save_to_file(sample_server_dict) # Assert assert result is False # ============================================================================= # TEST: _save_state Method # ============================================================================= @pytest.mark.unit @pytest.mark.repositories class TestSaveState: """Tests for _save_state method.""" @pytest.mark.asyncio async def test_save_state_success(self, server_repository, mock_settings): """Test successful state persistence.""" # Arrange server_repository._state = {"/test1": True, "/test2": False} m = mock_open() with patch("builtins.open", m): # Act await server_repository._save_state() # Assert m.assert_called_once_with(mock_settings.state_file_path, "w") handle = m() written_data = "".join(call.args[0] for call in handle.write.call_args_list) parsed_data = json.loads(written_data) assert parsed_data == {"/test1": True, "/test2": False} @pytest.mark.asyncio async def test_save_state_handles_errors(self, server_repository, mock_settings): """Test error handling when state save fails.""" # Arrange server_repository._state = {"/test": True} with patch("builtins.open", side_effect=OSError("Permission denied")): # Act - should not raise exception await server_repository._save_state() # Assert - just verify it doesn't crash # Error is logged, operation continues # ============================================================================= # TEST: _load_state Method # ============================================================================= @pytest.mark.unit @pytest.mark.repositories class TestLoadState: """Tests for _load_state method.""" @pytest.mark.asyncio async def test_load_state_with_existing_file(self, server_repository, mock_settings): """Test loading state from existing file.""" # Arrange server_repository._servers = {"/test1": {}, "/test2": {}} state_data = {"/test1": True, "/test2": False} mock_settings.state_file_path.exists.return_value = True m = mock_open(read_data=json.dumps(state_data)) with patch("builtins.open", m): # Act await server_repository._load_state() # Assert assert server_repository._state == {"/test1": True, "/test2": False} @pytest.mark.asyncio async def test_load_state_no_file(self, server_repository, mock_settings): """Test loading state when file doesn't exist.""" # Arrange server_repository._servers = {"/test1": {}, "/test2": {}} mock_settings.state_file_path.exists.return_value = False # Act await server_repository._load_state() # Assert # All servers should default to False (disabled) assert server_repository._state == {"/test1": False, "/test2": False} @pytest.mark.asyncio async def test_load_state_handles_trailing_slash_normalization( self, server_repository, mock_settings ): """Test state loading normalizes trailing slashes.""" # Arrange server_repository._servers = {"/test": {}} state_data = {"/test/": True} # State has trailing slash mock_settings.state_file_path.exists.return_value = True m = mock_open(read_data=json.dumps(state_data)) with patch("builtins.open", m): # Act await server_repository._load_state() # Assert assert server_repository._state["/test"] is True @pytest.mark.asyncio async def test_load_state_handles_corrupt_file(self, server_repository, mock_settings): """Test loading state when file is corrupted.""" # Arrange server_repository._servers = {"/test": {}} mock_settings.state_file_path.exists.return_value = True m = mock_open(read_data="invalid json {{{") with patch("builtins.open", m): # Act await server_repository._load_state() # Assert # Should fall back to default (disabled) assert server_repository._state == {"/test": False} # ============================================================================= # TEST: Integration Tests # ============================================================================= @pytest.mark.unit @pytest.mark.repositories class TestFileServerRepositoryIntegration: """Integration tests for file repository operations.""" @pytest.mark.asyncio async def test_create_and_get_server( self, server_repository, sample_server_dict, mock_settings ): """Test creating and retrieving a server.""" # Arrange m = mock_open() with patch("builtins.open", m): # Act create_result = await server_repository.create(sample_server_dict) get_result = await server_repository.get("/test-server") # Assert assert create_result is True assert get_result == sample_server_dict assert server_repository._state["/test-server"] is False # Disabled by default @pytest.mark.asyncio async def test_update_server_saves_to_file( self, server_repository, sample_server_dict, mock_settings ): """Test updating server writes to file.""" # Arrange server_repository._servers["/test-server"] = sample_server_dict.copy() updated_data = sample_server_dict.copy() updated_data["description"] = "Updated description" m = mock_open() with patch("builtins.open", m): # Act result = await server_repository.update("/test-server", updated_data) # Assert assert result is True # Verify file was written m.assert_called() ================================================ FILE: tests/unit/repositories/test_registry_card_repository.py ================================================ """Unit tests for RegistryCard repository.""" from datetime import datetime from unittest.mock import AsyncMock, patch from uuid import UUID import pytest from registry.repositories.documentdb.registry_card_repository import ( DocumentDBRegistryCardRepository, ) from registry.schemas.registry_card import ( RegistryAuthConfig, RegistryCapabilities, RegistryCard, RegistryContact, ) @pytest.fixture def mock_collection(): """Fixture for mock MongoDB collection.""" collection = AsyncMock() collection.find_one = AsyncMock(return_value=None) collection.replace_one = AsyncMock() return collection @pytest.fixture def sample_registry_card(): """Fixture for sample RegistryCard.""" return RegistryCard( id=UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"), name="Test Registry", description="A test registry", federation_endpoint="https://registry.example.com/api/v1/federation", contact=RegistryContact(email="admin@example.com"), metadata={"region": "us-east-1"}, ) @pytest.mark.unit class TestDocumentDBRegistryCardRepository: """Tests for DocumentDB RegistryCard repository.""" @pytest.mark.asyncio async def test_get_when_no_card_exists(self, mock_collection): """Test get() returns None when no card exists.""" mock_collection.find_one.return_value = None repo = DocumentDBRegistryCardRepository() repo._collection = mock_collection result = await repo.get() assert result is None mock_collection.find_one.assert_called_once_with({"_id": "default"}) @pytest.mark.asyncio async def test_get_when_card_exists(self, mock_collection, sample_registry_card): """Test get() returns RegistryCard when it exists.""" stored_doc = sample_registry_card.model_dump(mode="json") stored_doc["_id"] = "default" stored_doc["created_at"] = "2024-01-01T00:00:00Z" stored_doc["updated_at"] = "2024-01-01T00:00:00Z" mock_collection.find_one.return_value = stored_doc repo = DocumentDBRegistryCardRepository() repo._collection = mock_collection result = await repo.get() assert result is not None assert isinstance(result, RegistryCard) assert str(result.id) == str(sample_registry_card.id) assert result.name == sample_registry_card.name assert result.description == sample_registry_card.description mock_collection.find_one.assert_called_once_with({"_id": "default"}) @pytest.mark.asyncio async def test_save_creates_new_card(self, mock_collection, sample_registry_card): """Test save() creates a new card when none exists.""" mock_collection.find_one.return_value = None repo = DocumentDBRegistryCardRepository() repo._collection = mock_collection result = await repo.save(sample_registry_card) assert result == sample_registry_card mock_collection.replace_one.assert_called_once() # Verify the document structure call_args = mock_collection.replace_one.call_args filter_dict = call_args[0][0] document = call_args[0][1] options = call_args[1] assert filter_dict == {"_id": "default"} assert document["_id"] == "default" assert document["id"] == str(sample_registry_card.id) assert document["name"] == sample_registry_card.name assert "created_at" in document assert "updated_at" in document assert options["upsert"] is True @pytest.mark.asyncio async def test_save_updates_existing_card(self, mock_collection, sample_registry_card): """Test save() updates an existing card.""" existing_doc = sample_registry_card.model_dump(mode="json") existing_doc["_id"] = "default" existing_doc["created_at"] = "2024-01-01T00:00:00Z" existing_doc["updated_at"] = "2024-01-01T00:00:00Z" mock_collection.find_one.return_value = existing_doc # Create updated card updated_card = RegistryCard( id=sample_registry_card.id, name="Updated Name", description="Updated description", federation_endpoint=sample_registry_card.federation_endpoint, ) repo = DocumentDBRegistryCardRepository() repo._collection = mock_collection result = await repo.save(updated_card) assert result == updated_card mock_collection.replace_one.assert_called_once() # Verify the document preserves created_at but updates updated_at call_args = mock_collection.replace_one.call_args document = call_args[0][1] assert document["created_at"] == "2024-01-01T00:00:00Z" assert document["updated_at"] != "2024-01-01T00:00:00Z" assert document["name"] == "Updated Name" assert document["description"] == "Updated description" @pytest.mark.asyncio async def test_save_preserves_all_fields(self, mock_collection): """Test save() preserves all RegistryCard fields.""" contact = RegistryContact( email="admin@full.example.com", url="https://full.example.com/contact" ) card = RegistryCard( schema_version="1.1.0", id=UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"), name="Full Test Registry", description="Complete test", federation_api_version="2.0.0", federation_endpoint="https://full.example.com/api/v1/federation", contact=contact, capabilities=RegistryCapabilities(servers=False, agents=True), authentication=RegistryAuthConfig( schemes=["bearer"], oauth2_issuer="https://auth.test.com" ), visibility_policy="authenticated", metadata={"tier": "production", "region": "us-west-2"}, ) mock_collection.find_one.return_value = None repo = DocumentDBRegistryCardRepository() repo._collection = mock_collection await repo.save(card) call_args = mock_collection.replace_one.call_args document = call_args[0][1] assert document["schema_version"] == "1.1.0" assert document["id"] == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" assert document["name"] == "Full Test Registry" assert document["description"] == "Complete test" assert document["federation_api_version"] == "2.0.0" assert document["contact"]["email"] == "admin@full.example.com" assert document["contact"]["url"] == "https://full.example.com/contact" assert document["capabilities"]["servers"] is False assert document["capabilities"]["agents"] is True assert document["authentication"]["schemes"] == ["bearer"] assert document["visibility_policy"] == "authenticated" assert document["metadata"]["tier"] == "production" @pytest.mark.asyncio async def test_fixed_id_always_default(self, mock_collection, sample_registry_card): """Test that repository always uses fixed _id: 'default'.""" mock_collection.find_one.return_value = None repo = DocumentDBRegistryCardRepository() repo._collection = mock_collection await repo.save(sample_registry_card) # Check find_one was called with default ID mock_collection.find_one.assert_called_with({"_id": "default"}) # Check replace_one was called with default ID call_args = mock_collection.replace_one.call_args filter_dict = call_args[0][0] document = call_args[0][1] assert filter_dict == {"_id": "default"} assert document["_id"] == "default" @pytest.mark.asyncio async def test_upsert_option_enabled(self, mock_collection, sample_registry_card): """Test that upsert option is enabled for replace_one.""" mock_collection.find_one.return_value = None repo = DocumentDBRegistryCardRepository() repo._collection = mock_collection await repo.save(sample_registry_card) call_args = mock_collection.replace_one.call_args options = call_args[1] assert options["upsert"] is True @pytest.mark.asyncio async def test_get_handles_missing_optional_fields(self, mock_collection): """Test get() handles documents with missing optional fields gracefully.""" # Minimal document with only required fields minimal_doc = { "_id": "default", "id": "cccccccc-cccc-cccc-cccc-cccccccccccc", "name": "Minimal Registry", "federation_endpoint": "https://minimal.example.com/api/v1/federation", "federation_api_version": "1.0", "schema_version": "1.0.0", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", } mock_collection.find_one.return_value = minimal_doc repo = DocumentDBRegistryCardRepository() repo._collection = mock_collection result = await repo.get() assert result is not None assert str(result.id) == "cccccccc-cccc-cccc-cccc-cccccccccccc" assert result.name == "Minimal Registry" assert result.description is None assert result.contact is None @pytest.mark.asyncio async def test_collection_name_is_correct(self): """Test that repository uses correct collection name.""" repo = DocumentDBRegistryCardRepository() # The collection name should follow the pattern from get_collection_name assert "registry_cards" in repo._collection_name @pytest.mark.asyncio async def test_lazy_initialization_of_collection(self): """Test that collection is lazily initialized.""" repo = DocumentDBRegistryCardRepository() # Initially, collection should be None assert repo._collection is None # After first operation, collection should be initialized with patch.object(repo, "_get_collection", new_callable=AsyncMock) as mock_get: mock_collection = AsyncMock() mock_collection.find_one = AsyncMock(return_value=None) mock_get.return_value = mock_collection await repo.get() mock_get.assert_called_once() @pytest.mark.asyncio async def test_save_handles_none_optional_fields(self, mock_collection): """Test save() correctly handles None values in optional fields.""" card = RegistryCard( id=UUID("dddddddd-dddd-dddd-dddd-dddddddddddd"), name="Test", federation_endpoint="https://example.com/api/v1/federation", description=None, contact=None, ) mock_collection.find_one.return_value = None repo = DocumentDBRegistryCardRepository() repo._collection = mock_collection await repo.save(card) call_args = mock_collection.replace_one.call_args document = call_args[0][1] assert document["description"] is None assert document["contact"] is None @pytest.mark.asyncio async def test_get_returns_card_with_default_capabilities(self, mock_collection): """Test get() returns card with default capabilities if not specified.""" doc = { "_id": "default", "id": "dddddddd-dddd-dddd-dddd-dddddddddddd", "name": "Test", "federation_endpoint": "https://example.com/api/v1/federation", "federation_api_version": "1.0", "schema_version": "1.0.0", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", # capabilities and authentication not explicitly set } mock_collection.find_one.return_value = doc repo = DocumentDBRegistryCardRepository() repo._collection = mock_collection result = await repo.get() assert result is not None # Pydantic should apply defaults assert result.capabilities.servers is True assert result.authentication.schemes == ["oauth2", "bearer"] @pytest.mark.asyncio async def test_save_timestamps_are_iso_format(self, mock_collection, sample_registry_card): """Test that save() creates ISO format timestamps.""" mock_collection.find_one.return_value = None repo = DocumentDBRegistryCardRepository() repo._collection = mock_collection await repo.save(sample_registry_card) call_args = mock_collection.replace_one.call_args document = call_args[0][1] created_at = document["created_at"] updated_at = document["updated_at"] # Verify ISO format by parsing assert isinstance(created_at, str) assert isinstance(updated_at, str) # Should be valid ISO timestamps datetime.fromisoformat(created_at.replace("Z", "+00:00")) datetime.fromisoformat(updated_at.replace("Z", "+00:00")) ================================================ FILE: tests/unit/repositories/test_search_result_distribution.py ================================================ """Unit tests for search result distribution logic. Tests the _distribute_results() function and _tool_extraction_limit() helper that replace the old hardcoded cap of 3 per entity type with global ranking and competitive soft caps. Covers: - Empty results - Single-type dominance (no artificial cap when no competition) - Multi-type competition with soft cap enforcement - Soft cap lifted when no other types remain - Edge cases (max_results=1, max_results >= total) - Backward compatibility with default max_results=10 - Tool extraction limit scaling """ import math from registry.repositories.documentdb.search_repository import ( SOFT_CAP_RATIO, _distribute_results, _tool_extraction_limit, ) # ============================================================================= # HELPERS # ============================================================================= def _make_doc( entity_type: str, name: str, score: float, ) -> tuple[dict, float]: """Create a (doc, score) tuple for testing. Args: entity_type: Entity type string (e.g. "mcp_server") name: Document name for identification score: Relevance score Returns: Tuple of (doc_dict, score) """ return ( {"entity_type": entity_type, "name": name}, score, ) def _make_servers( count: int, start_score: float = 0.95, step: float = 0.02, ) -> list[tuple[dict, float]]: """Create a list of server result tuples with descending scores. Args: count: Number of servers to create start_score: Score of the first server step: Score decrement per server Returns: List of (doc, score) tuples sorted by score descending """ return [ _make_doc("mcp_server", f"server-{i}", round(start_score - i * step, 4)) for i in range(count) ] def _make_agents( count: int, start_score: float = 0.80, step: float = 0.05, ) -> list[tuple[dict, float]]: """Create a list of agent result tuples with descending scores.""" return [ _make_doc("a2a_agent", f"agent-{i}", round(start_score - i * step, 4)) for i in range(count) ] def _make_tools( count: int, start_score: float = 0.75, step: float = 0.05, ) -> list[tuple[dict, float]]: """Create a list of tool result tuples with descending scores.""" return [ _make_doc("mcp_tool", f"tool-{i}", round(start_score - i * step, 4)) for i in range(count) ] def _make_skills( count: int, start_score: float = 0.70, step: float = 0.05, ) -> list[tuple[dict, float]]: """Create a list of skill result tuples with descending scores.""" return [ _make_doc("skill", f"skill-{i}", round(start_score - i * step, 4)) for i in range(count) ] def _count_types( results: list[tuple[dict, float]], ) -> dict[str, int]: """Count results per entity type. Args: results: List of (doc, score) tuples Returns: Dict mapping entity_type to count """ counts: dict[str, int] = {} for doc, _ in results: entity_type = doc.get("entity_type", "") counts[entity_type] = counts.get(entity_type, 0) + 1 return counts # ============================================================================= # TESTS: _distribute_results() # ============================================================================= class TestDistributeResults: """Tests for the _distribute_results() function.""" def test_empty_results(self): """Empty input returns empty output.""" result = _distribute_results([], 10) assert result == [] def test_zero_max_results(self): """max_results=0 returns empty output.""" scored = _make_servers(5) result = _distribute_results(scored, 0) assert result == [] def test_single_type_no_cap(self): """Only servers in results -- all slots go to servers, no artificial limit.""" servers = _make_servers(20) result = _distribute_results(servers, 10) assert len(result) == 10 counts = _count_types(result) assert counts["mcp_server"] == 10 def test_single_type_respects_max_results(self): """20 servers with max_results=10 returns exactly 10.""" servers = _make_servers(20) result = _distribute_results(servers, 10) assert len(result) == 10 def test_single_type_fewer_than_max(self): """5 servers with max_results=10 returns all 5.""" servers = _make_servers(5) result = _distribute_results(servers, 10) assert len(result) == 5 counts = _count_types(result) assert counts["mcp_server"] == 5 def test_mixed_types_global_ranking(self): """Higher-relevance items win regardless of type.""" # 3 servers at 0.95, 0.93, 0.91 # 3 agents at 0.80, 0.75, 0.70 scored = _make_servers(3) + _make_agents(3) scored.sort(key=lambda x: x[1], reverse=True) result = _distribute_results(scored, 6) assert len(result) == 6 # First 3 should be servers (highest scores) for doc, _ in result[:3]: assert doc["entity_type"] == "mcp_server" def test_soft_cap_enforced(self): """Dominant type capped at 60% when other types have results.""" # 10 servers (0.95 to 0.77) + 5 agents (0.80 to 0.60) servers = _make_servers(10) agents = _make_agents(5) scored = servers + agents scored.sort(key=lambda x: x[1], reverse=True) max_results = 10 soft_cap = math.ceil(max_results * SOFT_CAP_RATIO) # 6 result = _distribute_results(scored, max_results) assert len(result) == max_results counts = _count_types(result) # Servers should be capped at soft_cap since agents are competing assert counts["mcp_server"] <= soft_cap # Agents should have gotten some slots assert counts.get("a2a_agent", 0) > 0 def test_soft_cap_lifted_when_no_competition(self): """Cap removed when only one type remains in the tail.""" # 15 servers (high scores) + 1 agent (lower score) servers = _make_servers(15, start_score=0.95, step=0.02) agents = [_make_doc("a2a_agent", "agent-0", 0.50)] scored = servers + agents scored.sort(key=lambda x: x[1], reverse=True) max_results = 10 soft_cap = math.ceil(max_results * SOFT_CAP_RATIO) # 6 result = _distribute_results(scored, max_results) assert len(result) == max_results counts = _count_types(result) # Agent should be included (it's in the top candidates) # But servers should get more than soft_cap since after the agent # there are no more agents remaining, so the cap is lifted assert counts["mcp_server"] >= soft_cap def test_max_results_1(self): """Edge case: max_results=1 returns exactly 1 result.""" scored = _make_servers(5) + _make_agents(3) scored.sort(key=lambda x: x[1], reverse=True) result = _distribute_results(scored, 1) assert len(result) == 1 # Should be the highest scored item assert result[0][1] == max(s for _, s in scored) def test_max_results_equals_total(self): """max_results >= total results returns all results.""" servers = _make_servers(3) agents = _make_agents(2) scored = servers + agents scored.sort(key=lambda x: x[1], reverse=True) result = _distribute_results(scored, 100) assert len(result) == 5 # All results returned def test_backward_compatible_default(self): """max_results=10 with mixed types produces diverse results.""" servers = _make_servers(8, start_score=0.95) agents = _make_agents(5, start_score=0.80) tools = _make_tools(4, start_score=0.75) scored = servers + agents + tools scored.sort(key=lambda x: x[1], reverse=True) result = _distribute_results(scored, 10) assert len(result) == 10 counts = _count_types(result) # Should have diversity -- at least 2 types present assert len(counts) >= 2 # No type should have more than 6 (soft cap for max_results=10) for count in counts.values(): assert count <= math.ceil(10 * SOFT_CAP_RATIO) def test_entity_types_filter_single(self): """When only one entity_type in input, all slots go to it.""" agents = _make_agents(20, start_score=0.90, step=0.01) result = _distribute_results(agents, 15) assert len(result) == 15 counts = _count_types(result) assert counts["a2a_agent"] == 15 def test_results_contain_highest_scores(self): """Selected results include the highest-scoring items available.""" scored = _make_servers(5) + _make_agents(5) scored.sort(key=lambda x: x[1], reverse=True) result = _distribute_results(scored, 8) result_scores = sorted([s for _, s in result], reverse=True) # The top score from the input should be in the results assert result_scores[0] == scored[0][1] assert len(result) == 8 def test_three_types_competing(self): """Three entity types compete fairly.""" servers = _make_servers(10, start_score=0.95) agents = _make_agents(8, start_score=0.85) tools = _make_tools(6, start_score=0.75) scored = servers + agents + tools scored.sort(key=lambda x: x[1], reverse=True) result = _distribute_results(scored, 15) assert len(result) == 15 counts = _count_types(result) soft_cap = math.ceil(15 * SOFT_CAP_RATIO) # 9 # All three types should be represented assert len(counts) == 3 # No type exceeds soft cap (since all three have results) for count in counts.values(): assert count <= soft_cap def test_five_types_all_present(self): """All five entity types get fair representation.""" servers = _make_servers(5, start_score=0.95) agents = _make_agents(5, start_score=0.85) tools = _make_tools(5, start_score=0.75) skills = _make_skills(5, start_score=0.65) virtual = [ _make_doc("virtual_server", f"vs-{i}", round(0.60 - i * 0.05, 4)) for i in range(5) ] scored = servers + agents + tools + skills + virtual scored.sort(key=lambda x: x[1], reverse=True) result = _distribute_results(scored, 20) assert len(result) == 20 counts = _count_types(result) # All 5 types should be present assert len(counts) == 5 def test_small_max_results_5(self): """max_results=5 with mixed types -- soft_cap=3.""" servers = _make_servers(8, start_score=0.95) agents = _make_agents(5, start_score=0.80) scored = servers + agents scored.sort(key=lambda x: x[1], reverse=True) result = _distribute_results(scored, 5) assert len(result) == 5 counts = _count_types(result) soft_cap = math.ceil(5 * SOFT_CAP_RATIO) # 3 # Servers should be capped at 3 since agents are competing assert counts["mcp_server"] <= soft_cap # Agents should get remaining slots assert counts.get("a2a_agent", 0) > 0 # ============================================================================= # TESTS: _tool_extraction_limit() # ============================================================================= class TestToolExtractionLimit: """Tests for the _tool_extraction_limit() helper.""" def test_default_max_results(self): """max_results=10 gives ceil(10*0.6)=6, which is >=3.""" result = _tool_extraction_limit(10) assert result == 6 def test_small_max_results(self): """max_results=1 still returns at least 3 (backward compat).""" result = _tool_extraction_limit(1) assert result == 3 def test_max_results_3(self): """max_results=3 gives ceil(3*0.6)=2, floor is 3.""" result = _tool_extraction_limit(3) assert result == 3 def test_large_max_results(self): """max_results=50 gives ceil(50*0.6)=30.""" result = _tool_extraction_limit(50) assert result == 30 def test_never_below_3(self): """Tool limit never goes below 3 regardless of max_results.""" for max_results in range(1, 10): assert _tool_extraction_limit(max_results) >= 3 def test_scales_with_max_results(self): """Larger max_results produces larger tool limit.""" limit_10 = _tool_extraction_limit(10) limit_50 = _tool_extraction_limit(50) assert limit_50 > limit_10 # ============================================================================= # TESTS: SOFT_CAP_RATIO constant # ============================================================================= class TestSoftCapRatio: """Tests for the SOFT_CAP_RATIO constant value.""" def test_ratio_value(self): """SOFT_CAP_RATIO is 0.6 as designed.""" assert SOFT_CAP_RATIO == 0.6 def test_ratio_produces_expected_caps(self): """Verify soft cap values for common max_results values.""" assert math.ceil(10 * SOFT_CAP_RATIO) == 6 assert math.ceil(5 * SOFT_CAP_RATIO) == 3 assert math.ceil(50 * SOFT_CAP_RATIO) == 30 assert math.ceil(1 * SOFT_CAP_RATIO) == 1 ================================================ FILE: tests/unit/schemas/__init__.py ================================================ """Unit tests for schema models.""" ================================================ FILE: tests/unit/schemas/test_agent_models.py ================================================ """Tests for AgentRegistrationRequest allowed_groups field and validators.""" import pytest from registry.schemas.agent_models import AgentRegistrationRequest MINIMAL_AGENT_KWARGS = { "name": "test-agent", "url": "https://example.com", "supported_protocol": "a2a", } @pytest.mark.unit class TestAgentRegistrationRequestAllowedGroups: """Tests for allowed_groups on AgentRegistrationRequest.""" def test_allowed_groups_defaults_to_empty_list(self): """allowed_groups should default to empty list.""" req = AgentRegistrationRequest(**MINIMAL_AGENT_KWARGS) assert req.allowed_groups == [] def test_allowed_groups_accepted_via_camel_case_alias(self): """allowedGroups alias should work.""" req = AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="group-restricted", allowedGroups=["team-a", "team-b"], ) assert req.allowed_groups == ["team-a", "team-b"] def test_allowed_groups_accepted_via_snake_case(self): """allowed_groups should work directly.""" req = AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="group-restricted", allowed_groups=["team-a"], ) assert req.allowed_groups == ["team-a"] def test_allowed_groups_from_comma_separated_string(self): """Comma-separated string should be normalized to list.""" req = AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="group-restricted", allowed_groups="finance-team, dev-team, ops-team", ) assert req.allowed_groups == ["finance-team", "dev-team", "ops-team"] def test_allowed_groups_string_strips_whitespace(self): """Whitespace around group names should be stripped.""" req = AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="group-restricted", allowed_groups=" team-a , team-b , team-c ", ) assert req.allowed_groups == ["team-a", "team-b", "team-c"] def test_allowed_groups_list_strips_whitespace(self): """Whitespace in list elements should be stripped.""" req = AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="group-restricted", allowed_groups=[" team-a ", "team-b "], ) assert req.allowed_groups == ["team-a", "team-b"] def test_allowed_groups_string_filters_empty_segments(self): """Empty segments from trailing commas should be filtered out.""" req = AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="group-restricted", allowed_groups="team-a,,team-b,", ) assert req.allowed_groups == ["team-a", "team-b"] def test_allowed_groups_none_normalizes_to_empty_list(self): """None should be normalized to empty list.""" req = AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="public", allowed_groups=None, ) assert req.allowed_groups == [] def test_group_restricted_without_groups_raises_error(self): """group-restricted without allowed_groups should raise ValueError.""" with pytest.raises(ValueError, match="requires at least one allowed_group"): AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="group-restricted", allowed_groups=[], ) def test_group_restricted_with_empty_string_raises_error(self): """group-restricted with empty string should raise ValueError.""" with pytest.raises(ValueError, match="requires at least one allowed_group"): AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="group-restricted", allowed_groups="", ) def test_public_visibility_with_empty_groups_is_valid(self): """Public visibility should not require allowed_groups.""" req = AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="public", ) assert req.allowed_groups == [] def test_private_visibility_with_empty_groups_is_valid(self): """Private visibility should not require allowed_groups.""" req = AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="private", ) assert req.allowed_groups == [] def test_invalid_group_name_format_raises_error(self): """Group names with special characters should raise ValueError.""" with pytest.raises(ValueError, match="Invalid group name"): AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="group-restricted", allowed_groups=["valid-team", "invalid team with spaces"], ) def test_group_name_with_allowed_special_chars(self): """Group names with hyphens, underscores, dots should be accepted.""" req = AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="group-restricted", allowed_groups=["finance-team", "dev_ops", "org.engineering"], ) assert req.allowed_groups == ["finance-team", "dev_ops", "org.engineering"] def test_max_items_exceeded_raises_error(self): """More than 50 groups should raise a validation error.""" with pytest.raises(ValueError): AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="group-restricted", allowed_groups=[f"group-{i}" for i in range(51)], ) def test_exactly_50_groups_is_valid(self): """Exactly 50 groups should be accepted.""" req = AgentRegistrationRequest( **MINIMAL_AGENT_KWARGS, visibility="group-restricted", allowed_groups=[f"group-{i}" for i in range(50)], ) assert len(req.allowed_groups) == 50 ================================================ FILE: tests/unit/schemas/test_agentcore_federation_schema.py ================================================ """ Unit tests for AWS Registry federation schema models. This module provides tests for the AWS Registry federation Pydantic models: - AwsRegistryConfig (aliased as AgentCoreRegistryConfig): Configuration for a single AWS Agent Registry - AwsRegistryFederationConfig (aliased as AgentCoreFederationConfig): AWS Agent Registry federation configuration - FederationConfig: Root federation config with aws_registry support Tests cover: - Default values for all fields - Custom value assignment - FederationConfig.aws_registry integration - is_any_federation_enabled() with aws_registry - get_enabled_federations() with aws_registry - Backward compatibility: old 'agentcore' key in dict input """ import pytest from registry.schemas.federation_schema import ( AgentCoreFederationConfig, AgentCoreRegistryConfig, AnthropicFederationConfig, AsorFederationConfig, FederationConfig, ) # ============================================================================= # AgentCoreRegistryConfig Tests # ============================================================================= @pytest.mark.unit class TestAgentCoreRegistryConfig: """Tests for AgentCoreRegistryConfig model.""" def test_required_registry_id(self): """Registry ID is required and must be provided.""" config = AgentCoreRegistryConfig(registry_id="my-registry-123") assert config.registry_id == "my-registry-123" def test_default_descriptor_types(self): """Default descriptor types should include MCP, A2A, CUSTOM, AGENT_SKILLS.""" config = AgentCoreRegistryConfig(registry_id="test-reg") assert config.descriptor_types == ["MCP", "A2A", "CUSTOM", "AGENT_SKILLS"] def test_custom_descriptor_types(self): """Custom descriptor types should override the defaults.""" config = AgentCoreRegistryConfig( registry_id="test-reg", descriptor_types=["MCP", "A2A"], ) assert config.descriptor_types == ["MCP", "A2A"] def test_empty_descriptor_types(self): """Empty descriptor types list should be allowed.""" config = AgentCoreRegistryConfig( registry_id="test-reg", descriptor_types=[], ) assert config.descriptor_types == [] def test_default_sync_status_filter(self): """Default sync status filter should be APPROVED.""" config = AgentCoreRegistryConfig(registry_id="test-reg") assert config.sync_status_filter == "APPROVED" def test_custom_sync_status_filter(self): """Custom sync status filter should override the default.""" config = AgentCoreRegistryConfig( registry_id="test-reg", sync_status_filter="PENDING", ) assert config.sync_status_filter == "PENDING" def test_missing_registry_id_raises_error(self): """Creating without registry_id should raise a validation error.""" with pytest.raises(Exception): AgentCoreRegistryConfig() def test_default_aws_account_id_is_none(self): """Default aws_account_id should be None (same-account).""" config = AgentCoreRegistryConfig(registry_id="test-reg") assert config.aws_account_id is None def test_custom_aws_account_id(self): """aws_account_id should accept a custom value.""" config = AgentCoreRegistryConfig( registry_id="test-reg", aws_account_id="123456789012", ) assert config.aws_account_id == "123456789012" def test_default_registry_aws_region_is_none(self): """Default aws_region should be None (inherits from parent config).""" config = AgentCoreRegistryConfig(registry_id="test-reg") assert config.aws_region is None def test_custom_registry_aws_region(self): """Per-registry aws_region should override parent.""" config = AgentCoreRegistryConfig( registry_id="test-reg", aws_region="eu-west-1", ) assert config.aws_region == "eu-west-1" def test_default_assume_role_arn_is_none(self): """Default assume_role_arn should be None.""" config = AgentCoreRegistryConfig(registry_id="test-reg") assert config.assume_role_arn is None def test_custom_assume_role_arn(self): """assume_role_arn should accept a custom IAM role ARN.""" config = AgentCoreRegistryConfig( registry_id="test-reg", aws_account_id="123456789012", assume_role_arn="arn:aws:iam::123456789012:role/AgentCoreReadOnly", ) assert config.assume_role_arn == "arn:aws:iam::123456789012:role/AgentCoreReadOnly" def test_cross_account_config_all_fields(self): """Cross-account config should set account, region, role, and registry.""" config = AgentCoreRegistryConfig( registry_id="reg-cross-001", aws_account_id="987654321098", aws_region="eu-west-1", assume_role_arn="arn:aws:iam::987654321098:role/FederationRole", descriptor_types=["MCP"], sync_status_filter="APPROVED", ) assert config.registry_id == "reg-cross-001" assert config.aws_account_id == "987654321098" assert config.aws_region == "eu-west-1" assert config.assume_role_arn == "arn:aws:iam::987654321098:role/FederationRole" assert config.descriptor_types == ["MCP"] # ============================================================================= # AgentCoreFederationConfig Tests # ============================================================================= @pytest.mark.unit class TestAgentCoreFederationConfig: """Tests for AgentCoreFederationConfig model.""" def test_default_enabled_is_false(self): """Default enabled should be False.""" config = AgentCoreFederationConfig() assert config.enabled is False def test_default_aws_region(self): """Default AWS region should be us-east-1.""" config = AgentCoreFederationConfig() assert config.aws_region == "us-east-1" def test_custom_aws_region(self): """Custom AWS region should override the default.""" config = AgentCoreFederationConfig(aws_region="eu-west-1") assert config.aws_region == "eu-west-1" def test_default_sync_on_startup_is_false(self): """Default sync_on_startup should be False.""" config = AgentCoreFederationConfig() assert config.sync_on_startup is False def test_default_sync_interval_minutes(self): """Default sync interval should be 60 minutes.""" config = AgentCoreFederationConfig() assert config.sync_interval_minutes == 60 def test_custom_sync_interval_minutes(self): """Custom sync interval should override the default.""" config = AgentCoreFederationConfig(sync_interval_minutes=30) assert config.sync_interval_minutes == 30 def test_default_sync_timeout_seconds(self): """Default sync timeout should be 300 seconds.""" config = AgentCoreFederationConfig() assert config.sync_timeout_seconds == 300 def test_custom_sync_timeout_seconds(self): """Custom sync timeout should override the default.""" config = AgentCoreFederationConfig(sync_timeout_seconds=120) assert config.sync_timeout_seconds == 120 def test_default_max_concurrent_fetches(self): """Default max concurrent fetches should be 5.""" config = AgentCoreFederationConfig() assert config.max_concurrent_fetches == 5 def test_custom_max_concurrent_fetches(self): """Custom max concurrent fetches should override the default.""" config = AgentCoreFederationConfig(max_concurrent_fetches=10) assert config.max_concurrent_fetches == 10 def test_default_registries_is_empty(self): """Default registries should be an empty list.""" config = AgentCoreFederationConfig() assert config.registries == [] def test_registries_with_entries(self): """Registries should accept a list of AgentCoreRegistryConfig objects.""" registry = AgentCoreRegistryConfig(registry_id="reg-001") config = AgentCoreFederationConfig(registries=[registry]) assert len(config.registries) == 1 assert config.registries[0].registry_id == "reg-001" def test_multiple_registries(self): """Multiple registries should be supported.""" registries = [ AgentCoreRegistryConfig(registry_id="reg-001"), AgentCoreRegistryConfig(registry_id="reg-002"), AgentCoreRegistryConfig( registry_id="reg-003", descriptor_types=["MCP"], sync_status_filter="PENDING", ), ] config = AgentCoreFederationConfig(registries=registries) assert len(config.registries) == 3 assert config.registries[2].descriptor_types == ["MCP"] assert config.registries[2].sync_status_filter == "PENDING" def test_fully_custom_config(self): """All fields should be overridable at once.""" config = AgentCoreFederationConfig( enabled=True, aws_region="ap-southeast-1", sync_on_startup=True, sync_interval_minutes=15, sync_timeout_seconds=60, max_concurrent_fetches=2, registries=[ AgentCoreRegistryConfig(registry_id="prod-reg"), ], ) assert config.enabled is True assert config.aws_region == "ap-southeast-1" assert config.sync_on_startup is True assert config.sync_interval_minutes == 15 assert config.sync_timeout_seconds == 60 assert config.max_concurrent_fetches == 2 assert len(config.registries) == 1 # ============================================================================= # FederationConfig AgentCore Integration Tests # ============================================================================= @pytest.mark.unit class TestFederationConfigAwsRegistry: """Tests for FederationConfig with aws_registry field.""" def test_default_aws_registry_field_exists(self): """FederationConfig should have an aws_registry field with defaults.""" config = FederationConfig() assert isinstance(config.aws_registry, AgentCoreFederationConfig) assert config.aws_registry.enabled is False def test_aws_registry_custom_config(self): """FederationConfig should accept custom aws_registry configuration.""" aws_config = AgentCoreFederationConfig( enabled=True, aws_region="us-west-2", registries=[ AgentCoreRegistryConfig(registry_id="my-reg"), ], ) config = FederationConfig(aws_registry=aws_config) assert config.aws_registry.enabled is True assert config.aws_registry.aws_region == "us-west-2" assert len(config.aws_registry.registries) == 1 def test_backward_compat_agentcore_key(self): """FederationConfig should accept old 'agentcore' key from MongoDB.""" config = FederationConfig( **{ "agentcore": {"enabled": True, "aws_region": "eu-west-1"}, } ) assert config.aws_registry.enabled is True assert config.aws_registry.aws_region == "eu-west-1" def test_is_any_federation_enabled_all_disabled(self): """is_any_federation_enabled should return False when all are disabled.""" config = FederationConfig() assert config.is_any_federation_enabled() is False def test_is_any_federation_enabled_only_aws_registry(self): """is_any_federation_enabled should return True when only aws_registry is enabled.""" config = FederationConfig( aws_registry=AgentCoreFederationConfig(enabled=True), ) assert config.is_any_federation_enabled() is True def test_is_any_federation_enabled_aws_registry_and_anthropic(self): """is_any_federation_enabled should return True when multiple are enabled.""" config = FederationConfig( aws_registry=AgentCoreFederationConfig(enabled=True), ) assert config.anthropic.enabled is False assert config.is_any_federation_enabled() is True def test_get_enabled_federations_none_enabled(self): """get_enabled_federations should return empty list when none are enabled.""" config = FederationConfig() assert config.get_enabled_federations() == [] def test_get_enabled_federations_only_aws_registry(self): """get_enabled_federations should include 'aws_registry' when enabled.""" config = FederationConfig( aws_registry=AgentCoreFederationConfig(enabled=True), ) enabled = config.get_enabled_federations() assert "aws_registry" in enabled assert len(enabled) == 1 def test_get_enabled_federations_excludes_disabled(self): """get_enabled_federations should not include disabled federations.""" config = FederationConfig( aws_registry=AgentCoreFederationConfig(enabled=False), ) enabled = config.get_enabled_federations() assert "aws_registry" not in enabled def test_get_enabled_federations_multiple_enabled(self): """get_enabled_federations should list all enabled federation names.""" config = FederationConfig( anthropic=AnthropicFederationConfig(enabled=True), asor=AsorFederationConfig(enabled=True), aws_registry=AgentCoreFederationConfig(enabled=True), ) enabled = config.get_enabled_federations() assert "anthropic" in enabled assert "asor" in enabled assert "aws_registry" in enabled assert len(enabled) == 3 ================================================ FILE: tests/unit/schemas/test_peer_federation_schema.py ================================================ """ Unit tests for peer federation schema models. This module provides comprehensive tests for the peer-to-peer federation Pydantic models including: - PeerRegistryConfig: Configuration for peer registry connections - SyncMetadata: Metadata for items synced from peer registries - SyncHistoryEntry: Record of sync operations - PeerSyncStatus: Current sync status for a peer registry - SyncResult: Result of a sync operation - FederationExportResponse: Response model for federation export API Tests cover: - Field validation (required fields, types, constraints) - URL validation and normalization - Peer ID validation (filename safety) - Sync interval constraints - Datetime serialization/deserialization - Default values - Edge cases (unicode, whitespace, invalid characters) - JSON schema generation for OpenAPI """ import json from datetime import UTC, datetime, timedelta import pytest from pydantic import ValidationError from registry.schemas.peer_federation_schema import ( DEFAULT_SYNC_INTERVAL_MINUTES, MAX_SYNC_HISTORY_ENTRIES, MAX_SYNC_INTERVAL_MINUTES, MIN_SYNC_INTERVAL_MINUTES, FederationExportResponse, PeerRegistryConfig, PeerSyncStatus, SyncHistoryEntry, SyncMetadata, SyncResult, _validate_endpoint_url, _validate_peer_id, ) # ============================================================================= # Test Helper Functions # ============================================================================= @pytest.mark.unit class TestValidateEndpointUrl: """Tests for _validate_endpoint_url helper function.""" def test_valid_http_url(self): """Valid HTTP URL should be accepted.""" url = "http://registry.example.com" result = _validate_endpoint_url(url) assert result == url def test_valid_https_url(self): """Valid HTTPS URL should be accepted.""" url = "https://registry.example.com" result = _validate_endpoint_url(url) assert result == url def test_trailing_slash_removed(self): """Trailing slash should be removed for consistency.""" url = "https://registry.example.com/" result = _validate_endpoint_url(url) assert result == "https://registry.example.com" def test_multiple_trailing_slashes_removed(self): """Multiple trailing slashes should be removed.""" url = "https://registry.example.com///" result = _validate_endpoint_url(url) assert result == "https://registry.example.com" def test_url_with_port(self): """URL with port should be valid.""" url = "https://registry.example.com:8080" result = _validate_endpoint_url(url) assert result == url def test_url_with_path(self): """URL with path should be valid.""" url = "https://registry.example.com/api/v1" result = _validate_endpoint_url(url) assert result == url def test_empty_url_rejected(self): """Empty URL should be rejected.""" with pytest.raises(ValueError, match="Endpoint URL cannot be empty"): _validate_endpoint_url("") def test_missing_protocol_rejected(self): """URL without protocol should be rejected.""" with pytest.raises(ValueError, match="must use HTTP or HTTPS protocol"): _validate_endpoint_url("registry.example.com") def test_invalid_protocol_rejected(self): """URL with invalid protocol should be rejected.""" with pytest.raises(ValueError, match="must use HTTP or HTTPS protocol"): _validate_endpoint_url("ftp://registry.example.com") def test_missing_hostname_rejected(self): """URL without hostname should be rejected.""" with pytest.raises(ValueError, match="must include a valid hostname"): _validate_endpoint_url("https://") def test_very_long_url(self): """Very long URL should be accepted if valid.""" long_path = "/".join(["segment"] * 50) url = f"https://registry.example.com/{long_path}" result = _validate_endpoint_url(url) assert result == url @pytest.mark.unit class TestValidatePeerId: """Tests for _validate_peer_id helper function.""" def test_valid_simple_peer_id(self): """Simple alphanumeric peer ID should be valid.""" peer_id = "central-registry" result = _validate_peer_id(peer_id) assert result == peer_id def test_valid_peer_id_with_underscores(self): """Peer ID with underscores should be valid.""" peer_id = "central_registry_prod" result = _validate_peer_id(peer_id) assert result == peer_id def test_valid_peer_id_with_dots(self): """Peer ID with dots should be valid.""" peer_id = "registry.central.prod" result = _validate_peer_id(peer_id) assert result == peer_id def test_unicode_peer_id(self): """Peer ID with unicode characters should be valid.""" peer_id = "registry-中文-test" result = _validate_peer_id(peer_id) assert result == peer_id def test_whitespace_trimmed(self): """Leading/trailing whitespace should be trimmed.""" peer_id = " central-registry " result = _validate_peer_id(peer_id) assert result == "central-registry" def test_empty_string_rejected(self): """Empty string should be rejected.""" with pytest.raises(ValueError, match="Peer ID cannot be empty"): _validate_peer_id("") def test_whitespace_only_rejected(self): """Whitespace-only string should be rejected.""" with pytest.raises(ValueError, match="Peer ID cannot be whitespace only"): _validate_peer_id(" ") def test_forward_slash_rejected(self): """Forward slash should be rejected (invalid filename character).""" with pytest.raises(ValueError, match="cannot contain '/' character"): _validate_peer_id("central/registry") def test_backslash_rejected(self): """Backslash should be rejected (invalid filename character).""" with pytest.raises(ValueError, match="cannot contain"): _validate_peer_id("central\\registry") def test_colon_rejected(self): """Colon should be rejected (invalid filename character).""" with pytest.raises(ValueError, match="cannot contain ':' character"): _validate_peer_id("central:registry") def test_asterisk_rejected(self): """Asterisk should be rejected (invalid filename character).""" with pytest.raises(ValueError, match="cannot contain '\\*' character"): _validate_peer_id("central*registry") def test_question_mark_rejected(self): """Question mark should be rejected (invalid filename character).""" with pytest.raises(ValueError, match="cannot contain '\\?' character"): _validate_peer_id("central?registry") def test_quote_rejected(self): """Quote should be rejected (invalid filename character).""" with pytest.raises(ValueError, match="cannot contain '\"' character"): _validate_peer_id('central"registry') def test_less_than_rejected(self): """Less-than sign should be rejected (invalid filename character).""" with pytest.raises(ValueError, match="cannot contain '<' character"): _validate_peer_id("centralregistry") def test_pipe_rejected(self): """Pipe character should be rejected (invalid filename character).""" with pytest.raises(ValueError, match="cannot contain '\\|' character"): _validate_peer_id("central|registry") def test_max_length_accepted(self): """Peer ID at max length (255 chars) should be accepted.""" peer_id = "a" * 255 result = _validate_peer_id(peer_id) assert result == peer_id def test_exceeds_max_length_rejected(self): """Peer ID exceeding max length should be rejected.""" peer_id = "a" * 256 with pytest.raises(ValueError, match="cannot exceed 255 characters"): _validate_peer_id(peer_id) # ============================================================================= # Test PeerRegistryConfig Model # ============================================================================= @pytest.mark.unit class TestPeerRegistryConfig: """Tests for PeerRegistryConfig model.""" def test_valid_minimal_config(self): """Minimal valid configuration should be accepted.""" # Arrange, Act config = PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com", ) # Assert assert config.peer_id == "central-registry" assert config.name == "Central Registry" assert config.endpoint == "https://registry.example.com" assert config.enabled is True assert config.sync_mode == "all" assert config.sync_interval_minutes == DEFAULT_SYNC_INTERVAL_MINUTES def test_valid_full_config(self): """Full configuration with all fields should be accepted.""" # Arrange, Act now = datetime.now(UTC) config = PeerRegistryConfig( peer_id="team-registry", name="Team Registry", endpoint="https://team.registry.com", enabled=False, sync_mode="whitelist", whitelist_servers=["/server1", "/server2"], whitelist_agents=["/agent1"], tag_filters=["production"], sync_interval_minutes=120, created_at=now, updated_at=now, ) # Assert assert config.peer_id == "team-registry" assert config.enabled is False assert config.sync_mode == "whitelist" assert config.whitelist_servers == ["/server1", "/server2"] assert config.whitelist_agents == ["/agent1"] assert config.sync_interval_minutes == 120 def test_required_field_peer_id_missing(self): """Missing peer_id should raise validation error.""" with pytest.raises(ValidationError) as exc_info: PeerRegistryConfig( name="Central Registry", endpoint="https://registry.example.com", ) assert "peer_id" in str(exc_info.value) def test_required_field_name_missing(self): """Missing name should raise validation error.""" with pytest.raises(ValidationError) as exc_info: PeerRegistryConfig( peer_id="central-registry", endpoint="https://registry.example.com", ) assert "name" in str(exc_info.value) def test_required_field_endpoint_missing(self): """Missing endpoint should raise validation error.""" with pytest.raises(ValidationError) as exc_info: PeerRegistryConfig( peer_id="central-registry", name="Central Registry", ) assert "endpoint" in str(exc_info.value) def test_invalid_endpoint_url(self): """Invalid endpoint URL should raise validation error.""" with pytest.raises(ValidationError) as exc_info: PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="not-a-url", ) assert "endpoint" in str(exc_info.value).lower() def test_endpoint_trailing_slash_removed(self): """Trailing slash in endpoint should be removed.""" config = PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com/", ) assert config.endpoint == "https://registry.example.com" def test_sync_interval_minimum_enforced(self): """Sync interval below minimum should raise validation error.""" with pytest.raises(ValidationError) as exc_info: PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com", sync_interval_minutes=MIN_SYNC_INTERVAL_MINUTES - 1, ) assert "sync_interval_minutes" in str(exc_info.value) def test_sync_interval_maximum_enforced(self): """Sync interval above maximum should raise validation error.""" with pytest.raises(ValidationError) as exc_info: PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com", sync_interval_minutes=MAX_SYNC_INTERVAL_MINUTES + 1, ) assert "sync_interval_minutes" in str(exc_info.value) def test_sync_interval_at_minimum(self): """Sync interval at minimum should be accepted.""" config = PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com", sync_interval_minutes=MIN_SYNC_INTERVAL_MINUTES, ) assert config.sync_interval_minutes == MIN_SYNC_INTERVAL_MINUTES def test_sync_interval_at_maximum(self): """Sync interval at maximum should be accepted.""" config = PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com", sync_interval_minutes=MAX_SYNC_INTERVAL_MINUTES, ) assert config.sync_interval_minutes == MAX_SYNC_INTERVAL_MINUTES def test_invalid_sync_mode(self): """Invalid sync_mode should raise validation error.""" with pytest.raises(ValidationError) as exc_info: PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com", sync_mode="invalid", ) assert "sync_mode" in str(exc_info.value) def test_sync_mode_all(self): """sync_mode 'all' should be valid.""" config = PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com", sync_mode="all", ) assert config.sync_mode == "all" def test_sync_mode_whitelist(self): """sync_mode 'whitelist' should be valid.""" config = PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com", sync_mode="whitelist", whitelist_servers=["/server1"], ) assert config.sync_mode == "whitelist" def test_sync_mode_tag_filter(self): """sync_mode 'tag_filter' should be valid.""" config = PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com", sync_mode="tag_filter", tag_filters=["production"], ) assert config.sync_mode == "tag_filter" def test_whitelist_empty_list_accepted(self): """Empty whitelist should be accepted.""" config = PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com", sync_mode="whitelist", whitelist_servers=[], whitelist_agents=[], ) assert config.whitelist_servers == [] assert config.whitelist_agents == [] def test_tag_filters_empty_list_accepted(self): """Empty tag_filters should be accepted.""" config = PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com", sync_mode="tag_filter", tag_filters=[], ) assert config.tag_filters == [] def test_peer_id_with_invalid_characters_rejected(self): """Peer ID with invalid filename characters should be rejected.""" with pytest.raises(ValidationError): PeerRegistryConfig( peer_id="central/registry", name="Central Registry", endpoint="https://registry.example.com", ) def test_peer_id_unicode_accepted(self): """Peer ID with unicode characters should be accepted.""" config = PeerRegistryConfig( peer_id="registry-中文", name="Central Registry", endpoint="https://registry.example.com", ) assert config.peer_id == "registry-中文" def test_name_unicode_accepted(self): """Name with unicode characters should be accepted.""" config = PeerRegistryConfig( peer_id="central-registry", name="中央注册表", endpoint="https://registry.example.com", ) assert config.name == "中央注册表" def test_json_serialization(self): """Config should serialize to JSON correctly.""" config = PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://registry.example.com", ) json_str = config.model_dump_json() data = json.loads(json_str) assert data["peer_id"] == "central-registry" assert data["name"] == "Central Registry" assert data["endpoint"] == "https://registry.example.com" def test_json_deserialization(self): """Config should deserialize from JSON correctly.""" json_data = { "peer_id": "central-registry", "name": "Central Registry", "endpoint": "https://registry.example.com", } config = PeerRegistryConfig(**json_data) assert config.peer_id == "central-registry" assert config.name == "Central Registry" def test_model_has_json_schema(self): """Model should generate JSON schema for OpenAPI.""" schema = PeerRegistryConfig.model_json_schema() assert schema["type"] == "object" assert "properties" in schema assert "peer_id" in schema["properties"] assert "name" in schema["properties"] assert "endpoint" in schema["properties"] # ============================================================================= # Test SyncMetadata Model # ============================================================================= @pytest.mark.unit class TestSyncMetadata: """Tests for SyncMetadata model.""" def test_valid_minimal_metadata(self): """Minimal valid metadata should be accepted.""" now = datetime.now(UTC) metadata = SyncMetadata( upstream_peer_id="central-registry", upstream_path="/finance-tools", last_synced_at=now, ) assert metadata.upstream_peer_id == "central-registry" assert metadata.upstream_path == "/finance-tools" assert metadata.sync_generation == 1 assert metadata.is_orphaned is False assert metadata.is_read_only is True def test_valid_full_metadata(self): """Full metadata with all fields should be accepted.""" now = datetime.now(UTC) orphaned_time = now - timedelta(days=1) metadata = SyncMetadata( upstream_peer_id="central-registry", upstream_path="/finance-tools", sync_generation=42, last_synced_at=now, is_orphaned=True, orphaned_at=orphaned_time, local_overrides={"tags": ["local-tag"]}, is_read_only=False, ) assert metadata.sync_generation == 42 assert metadata.is_orphaned is True assert metadata.orphaned_at == orphaned_time assert metadata.local_overrides == {"tags": ["local-tag"]} assert metadata.is_read_only is False def test_sync_generation_minimum_enforced(self): """Sync generation below 1 should raise validation error.""" now = datetime.now(UTC) with pytest.raises(ValidationError): SyncMetadata( upstream_peer_id="central-registry", upstream_path="/finance-tools", sync_generation=0, last_synced_at=now, ) def test_orphaned_at_auto_set(self): """orphaned_at should be auto-set when is_orphaned is True.""" now = datetime.now(UTC) metadata = SyncMetadata( upstream_peer_id="central-registry", upstream_path="/finance-tools", last_synced_at=now, is_orphaned=True, ) assert metadata.orphaned_at is not None assert isinstance(metadata.orphaned_at, datetime) def test_datetime_serialization(self): """Datetime fields should serialize correctly.""" now = datetime.now(UTC) metadata = SyncMetadata( upstream_peer_id="central-registry", upstream_path="/finance-tools", last_synced_at=now, ) json_str = metadata.model_dump_json() data = json.loads(json_str) assert "last_synced_at" in data assert isinstance(data["last_synced_at"], str) def test_datetime_deserialization(self): """Datetime fields should deserialize correctly.""" now = datetime.now(UTC) json_data = { "upstream_peer_id": "central-registry", "upstream_path": "/finance-tools", "last_synced_at": now.isoformat(), } metadata = SyncMetadata(**json_data) assert isinstance(metadata.last_synced_at, datetime) def test_local_overrides_dict(self): """local_overrides should accept dictionary.""" now = datetime.now(UTC) overrides = {"tags": ["tag1"], "description": "Custom desc"} metadata = SyncMetadata( upstream_peer_id="central-registry", upstream_path="/finance-tools", last_synced_at=now, local_overrides=overrides, ) assert metadata.local_overrides == overrides def test_model_has_json_schema(self): """Model should generate JSON schema for OpenAPI.""" schema = SyncMetadata.model_json_schema() assert schema["type"] == "object" assert "properties" in schema assert "upstream_peer_id" in schema["properties"] assert "last_synced_at" in schema["properties"] # ============================================================================= # Test SyncHistoryEntry Model # ============================================================================= @pytest.mark.unit class TestSyncHistoryEntry: """Tests for SyncHistoryEntry model.""" def test_valid_minimal_entry(self): """Minimal valid sync history entry should be accepted.""" now = datetime.now(UTC) entry = SyncHistoryEntry( sync_id="sync-123", started_at=now, ) assert entry.sync_id == "sync-123" assert entry.success is False assert entry.servers_synced == 0 assert entry.agents_synced == 0 def test_valid_successful_entry(self): """Successful sync entry with all fields should be accepted.""" started = datetime.now(UTC) completed = started + timedelta(seconds=15) entry = SyncHistoryEntry( sync_id="sync-123", started_at=started, completed_at=completed, success=True, servers_synced=42, agents_synced=15, servers_orphaned=2, agents_orphaned=1, sync_generation=100, full_sync=False, ) assert entry.success is True assert entry.servers_synced == 42 assert entry.agents_synced == 15 assert entry.servers_orphaned == 2 assert entry.agents_orphaned == 1 def test_valid_failed_entry(self): """Failed sync entry with error message should be accepted.""" now = datetime.now(UTC) entry = SyncHistoryEntry( sync_id="sync-123", started_at=now, completed_at=now, success=False, error_message="Connection timeout", ) assert entry.success is False assert entry.error_message == "Connection timeout" def test_negative_counts_rejected(self): """Negative sync counts should be rejected.""" now = datetime.now(UTC) with pytest.raises(ValidationError): SyncHistoryEntry( sync_id="sync-123", started_at=now, servers_synced=-1, ) def test_model_has_json_schema(self): """Model should generate JSON schema for OpenAPI.""" schema = SyncHistoryEntry.model_json_schema() assert schema["type"] == "object" assert "properties" in schema # ============================================================================= # Test PeerSyncStatus Model # ============================================================================= @pytest.mark.unit class TestPeerSyncStatus: """Tests for PeerSyncStatus model.""" def test_valid_minimal_status(self): """Minimal valid sync status should be accepted.""" status = PeerSyncStatus( peer_id="central-registry", ) assert status.peer_id == "central-registry" assert status.is_healthy is False assert status.current_generation == 0 assert status.sync_in_progress is False assert len(status.sync_history) == 0 def test_valid_full_status(self): """Full sync status with all fields should be accepted.""" now = datetime.now(UTC) status = PeerSyncStatus( peer_id="central-registry", is_healthy=True, last_health_check=now, last_successful_sync=now, last_sync_attempt=now, current_generation=100, total_servers_synced=42, total_agents_synced=15, sync_in_progress=True, consecutive_failures=0, ) assert status.is_healthy is True assert status.current_generation == 100 assert status.total_servers_synced == 42 def test_add_history_entry(self): """Adding history entry should work correctly.""" now = datetime.now(UTC) status = PeerSyncStatus(peer_id="central-registry") entry = SyncHistoryEntry( sync_id="sync-123", started_at=now, ) status.add_history_entry(entry) assert len(status.sync_history) == 1 assert status.sync_history[0] == entry def test_add_history_entry_maintains_max_limit(self): """Adding entries beyond max should maintain limit.""" now = datetime.now(UTC) status = PeerSyncStatus(peer_id="central-registry") # Add more than max entries for i in range(MAX_SYNC_HISTORY_ENTRIES + 10): entry = SyncHistoryEntry( sync_id=f"sync-{i}", started_at=now, ) status.add_history_entry(entry) assert len(status.sync_history) == MAX_SYNC_HISTORY_ENTRIES def test_add_history_entry_newest_first(self): """Newest history entries should appear first.""" now = datetime.now(UTC) status = PeerSyncStatus(peer_id="central-registry") entry1 = SyncHistoryEntry(sync_id="sync-1", started_at=now) entry2 = SyncHistoryEntry(sync_id="sync-2", started_at=now) status.add_history_entry(entry1) status.add_history_entry(entry2) assert status.sync_history[0].sync_id == "sync-2" assert status.sync_history[1].sync_id == "sync-1" def test_model_has_json_schema(self): """Model should generate JSON schema for OpenAPI.""" schema = PeerSyncStatus.model_json_schema() assert schema["type"] == "object" assert "properties" in schema # ============================================================================= # Test SyncResult Model # ============================================================================= @pytest.mark.unit class TestSyncResult: """Tests for SyncResult model.""" def test_valid_successful_result(self): """Valid successful sync result should be accepted.""" result = SyncResult( success=True, peer_id="central-registry", servers_synced=42, agents_synced=15, duration_seconds=12.5, new_generation=101, ) assert result.success is True assert result.servers_synced == 42 assert result.duration_seconds == 12.5 def test_valid_failed_result(self): """Valid failed sync result with error should be accepted.""" result = SyncResult( success=False, peer_id="central-registry", error_message="Connection timeout", duration_seconds=5.0, ) assert result.success is False assert result.error_message == "Connection timeout" def test_negative_duration_rejected(self): """Negative duration should be rejected.""" with pytest.raises(ValidationError): SyncResult( success=True, peer_id="central-registry", duration_seconds=-1.0, ) def test_model_has_json_schema(self): """Model should generate JSON schema for OpenAPI.""" schema = SyncResult.model_json_schema() assert schema["type"] == "object" assert "properties" in schema # ============================================================================= # Test FederationExportResponse Model # ============================================================================= @pytest.mark.unit class TestFederationExportResponse: """Tests for FederationExportResponse model.""" def test_valid_minimal_response(self): """Minimal valid export response should be accepted.""" response = FederationExportResponse( sync_generation=100, total_count=0, registry_id="central-registry", ) assert response.sync_generation == 100 assert response.total_count == 0 assert response.has_more is False assert len(response.items) == 0 def test_valid_full_response(self): """Full export response with items should be accepted.""" items = [ {"path": "/server1", "name": "Server 1"}, {"path": "/server2", "name": "Server 2"}, ] response = FederationExportResponse( items=items, sync_generation=100, total_count=10, has_more=True, registry_id="central-registry", ) assert len(response.items) == 2 assert response.has_more is True assert response.total_count == 10 def test_empty_items_list(self): """Empty items list should be accepted.""" response = FederationExportResponse( items=[], sync_generation=100, total_count=0, registry_id="central-registry", ) assert response.items == [] def test_negative_total_count_rejected(self): """Negative total_count should be rejected.""" with pytest.raises(ValidationError): FederationExportResponse( sync_generation=100, total_count=-1, registry_id="central-registry", ) def test_model_has_json_schema(self): """Model should generate JSON schema for OpenAPI.""" schema = FederationExportResponse.model_json_schema() assert schema["type"] == "object" assert "properties" in schema assert "items" in schema["properties"] # ============================================================================= # Test Backward Compatibility # ============================================================================= @pytest.mark.unit class TestBackwardCompatibility: """Tests to ensure backward compatibility with existing schemas.""" def test_server_detail_still_works(self): """Verify that server models still serialize correctly.""" # This is a basic smoke test - actual server models tested elsewhere from registry.schemas.anthropic_schema import ServerDetail server = ServerDetail( name="Test Server", description="Test description", version="1.0.0", repository={"url": "https://github.com/test/repo", "source": "github"}, ) # Should serialize without errors json_str = server.model_dump_json() assert json_str is not None # Should deserialize without errors data = json.loads(json_str) server2 = ServerDetail(**data) assert server2.name == "Test Server" def test_agent_card_still_works(self): """Verify that agent models still serialize correctly.""" from registry.schemas.agent_models import AgentCard, Skill agent = AgentCard( version="1.0.0", protocol_version="1.0", name="Test Agent", description="Test description", url="https://example.com", path="/test-agent", visibility="internal", trust_level="verified", skills=[ Skill( id="test", name="Test Skill", description="Test", tags=["test"], ) ], ) # Should serialize without errors json_str = agent.model_dump_json() assert json_str is not None # Should deserialize without errors data = json.loads(json_str) agent2 = AgentCard(**data) assert agent2.name == "Test Agent" # ============================================================================= # Test Edge Cases and Special Scenarios # ============================================================================= @pytest.mark.unit class TestEdgeCases: """Tests for edge cases and special scenarios.""" def test_peer_config_with_all_sync_modes(self): """Test creating configs with each sync mode.""" modes = ["all", "whitelist", "tag_filter"] for mode in modes: config = PeerRegistryConfig( peer_id=f"peer-{mode}", name=f"Peer {mode}", endpoint="https://registry.example.com", sync_mode=mode, ) assert config.sync_mode == mode def test_unicode_in_all_string_fields(self): """Test unicode support in all string fields.""" config = PeerRegistryConfig( peer_id="registry-中文-日本語", name="مسجل / реестр / レジストリ", endpoint="https://registry.example.com", ) assert "中文" in config.peer_id assert "مسجل" in config.name def test_very_long_field_values(self): """Test handling of very long field values.""" # Name has max_length=255, so test at the boundary long_name = "A" * 255 config = PeerRegistryConfig( peer_id="test", name=long_name, endpoint="https://registry.example.com", ) assert len(config.name) == 255 # Test that exceeding max_length fails with pytest.raises(ValidationError): PeerRegistryConfig( peer_id="test", name="A" * 256, endpoint="https://registry.example.com", ) def test_special_characters_in_allowed_fields(self): """Test special characters in fields where they're allowed.""" config = PeerRegistryConfig( peer_id="test-peer_123", name="Test: Peer (Production) [v2.0]", endpoint="https://registry.example.com:8080/api/v2", ) assert ":" in config.name assert "(" in config.name assert ":8080" in config.endpoint def test_datetime_with_timezone(self): """Test datetime fields with various timezones.""" utc_time = datetime.now(UTC) metadata = SyncMetadata( upstream_peer_id="test", upstream_path="/test", last_synced_at=utc_time, ) assert metadata.last_synced_at.tzinfo is not None def test_empty_local_overrides(self): """Test SyncMetadata with empty local_overrides.""" now = datetime.now(UTC) metadata = SyncMetadata( upstream_peer_id="test", upstream_path="/test", last_synced_at=now, local_overrides={}, ) assert metadata.local_overrides == {} def test_zero_values_in_numeric_fields(self): """Test zero values in numeric fields where allowed.""" now = datetime.now(UTC) entry = SyncHistoryEntry( sync_id="sync-0", started_at=now, servers_synced=0, agents_synced=0, servers_orphaned=0, agents_orphaned=0, sync_generation=0, ) assert entry.servers_synced == 0 assert entry.sync_generation == 0 def test_model_round_trip_serialization(self): """Test complete serialization round-trip for all models.""" now = datetime.now(UTC) # Test each model models = [ PeerRegistryConfig( peer_id="test", name="Test", endpoint="https://example.com", ), SyncMetadata( upstream_peer_id="test", upstream_path="/test", last_synced_at=now, ), SyncHistoryEntry( sync_id="sync-1", started_at=now, ), PeerSyncStatus(peer_id="test"), SyncResult(success=True, peer_id="test"), FederationExportResponse( sync_generation=1, total_count=0, registry_id="test", ), ] for model in models: # Serialize to JSON json_str = model.model_dump_json() # Deserialize back data = json.loads(json_str) model2 = type(model)(**data) # Should be equivalent assert model.model_dump() == model2.model_dump() ================================================ FILE: tests/unit/schemas/test_registry_card.py ================================================ """Unit tests for RegistryCard model and LifecycleStatus enum.""" from datetime import UTC, datetime from uuid import UUID import pytest from pydantic import ValidationError from registry.schemas.registry_card import ( LifecycleStatus, RegistryAuthConfig, RegistryCapabilities, RegistryCard, RegistryContact, ) @pytest.mark.unit class TestLifecycleStatus: """Tests for LifecycleStatus enum.""" def test_all_values_defined(self): """Test that all expected lifecycle status values are defined.""" assert LifecycleStatus.ACTIVE == "active" assert LifecycleStatus.DEPRECATED == "deprecated" assert LifecycleStatus.DRAFT == "draft" assert LifecycleStatus.BETA == "beta" def test_enum_values_are_strings(self): """Test that enum values are strings.""" for status in LifecycleStatus: assert isinstance(status.value, str) @pytest.mark.unit class TestRegistryCapabilities: """Tests for RegistryCapabilities model.""" def test_default_values(self): """Test default values for capabilities.""" caps = RegistryCapabilities() assert caps.servers is True assert caps.agents is True assert caps.skills is True assert caps.prompts is False assert caps.security_scans is True assert caps.incremental_sync is False assert caps.webhooks is False def test_custom_values(self): """Test custom capability values.""" caps = RegistryCapabilities( servers=False, agents=True, skills=False, webhooks=True, ) assert caps.servers is False assert caps.agents is True assert caps.skills is False assert caps.webhooks is True def test_json_serialization(self): """Test JSON serialization round-trip.""" caps = RegistryCapabilities(servers=False, incremental_sync=True) json_data = caps.model_dump(mode="json") assert json_data["servers"] is False assert json_data["incremental_sync"] is True # Round-trip restored = RegistryCapabilities(**json_data) assert restored.servers is False assert restored.incremental_sync is True @pytest.mark.unit class TestRegistryAuthConfig: """Tests for RegistryAuthConfig model.""" def test_default_values(self): """Test default values for authentication.""" auth = RegistryAuthConfig() assert auth.schemes == ["oauth2", "bearer"] assert auth.oauth2_issuer is None assert auth.oauth2_token_endpoint is None assert auth.scopes_supported == ["federation/read"] def test_custom_values(self): """Test custom authentication values.""" auth = RegistryAuthConfig( schemes=["bearer"], oauth2_issuer="https://auth.example.com", oauth2_token_endpoint="https://auth.example.com/token", scopes_supported=["read", "write"], ) assert auth.schemes == ["bearer"] assert auth.oauth2_issuer == "https://auth.example.com" assert auth.oauth2_token_endpoint == "https://auth.example.com/token" assert auth.scopes_supported == ["read", "write"] def test_json_serialization(self): """Test JSON serialization round-trip.""" auth = RegistryAuthConfig(schemes=["api_key"], oauth2_issuer="https://auth.test.com") json_data = auth.model_dump(mode="json") assert json_data["schemes"] == ["api_key"] assert json_data["oauth2_issuer"] == "https://auth.test.com" # Round-trip restored = RegistryAuthConfig(**json_data) assert restored.schemes == ["api_key"] assert restored.oauth2_issuer == "https://auth.test.com" @pytest.mark.unit class TestRegistryContact: """Tests for RegistryContact model.""" def test_default_values(self): """Test default values for contact.""" contact = RegistryContact() assert contact.email is None assert contact.url is None def test_with_email_and_url(self): """Test contact with email and URL.""" contact = RegistryContact( email="admin@example.com", url="https://example.com/contact", ) assert contact.email == "admin@example.com" assert contact.url == "https://example.com/contact" def test_json_serialization(self): """Test JSON serialization round-trip.""" contact = RegistryContact(email="test@example.com", url="https://test.com") json_data = contact.model_dump(mode="json") assert json_data["email"] == "test@example.com" assert json_data["url"] == "https://test.com" # Round-trip restored = RegistryContact(**json_data) assert restored.email == "test@example.com" assert restored.url == "https://test.com" @pytest.mark.unit class TestRegistryCard: """Tests for RegistryCard model.""" def test_minimal_valid_card(self): """Test creating a card with minimal required fields.""" card = RegistryCard( id=UUID("44444444-4444-4444-4444-444444444444"), name="Test Registry", federation_endpoint="https://registry.example.com/api/v1/federation", ) assert card.id == UUID("44444444-4444-4444-4444-444444444444") assert card.name == "Test Registry" assert card.schema_version == "1.0.0" assert card.description is None assert card.contact is None assert isinstance(card.capabilities, RegistryCapabilities) assert isinstance(card.authentication, RegistryAuthConfig) assert card.metadata == {} def test_full_card_with_all_fields(self): """Test creating a card with all fields populated.""" contact = RegistryContact( email="admin@example.com", url="https://example.com/contact", ) card = RegistryCard( schema_version="1.1.0", id=UUID("22222222-2222-2222-2222-222222222222"), name="Full Registry", description="A comprehensive test registry", federation_api_version="2.0", federation_endpoint="https://full.example.com/api/v1/federation", contact=contact, capabilities=RegistryCapabilities(servers=True, agents=False), authentication=RegistryAuthConfig( schemes=["bearer"], oauth2_issuer="https://auth.test.com" ), visibility_policy="authenticated", metadata={"region": "us-east-1", "tier": "production"}, ) assert card.id == UUID("22222222-2222-2222-2222-222222222222") assert card.name == "Full Registry" assert card.description == "A comprehensive test registry" assert card.contact.email == "admin@example.com" assert card.contact.url == "https://example.com/contact" assert card.capabilities.servers is True assert card.capabilities.agents is False assert card.authentication.schemes == ["bearer"] assert card.visibility_policy == "authenticated" assert card.metadata == {"region": "us-east-1", "tier": "production"} def test_missing_required_fields(self): """Test that missing required fields raise validation errors.""" with pytest.raises(ValidationError) as exc_info: RegistryCard() errors = exc_info.value.errors() required_fields = {error["loc"][0] for error in errors if error["type"] == "missing"} # id is not required because it has default_factory=uuid4 assert "name" in required_fields assert "federation_endpoint" in required_fields def test_description_max_length_validation(self): """Test description field max length validation.""" long_description = "x" * 1001 with pytest.raises(ValidationError) as exc_info: RegistryCard( id=UUID("33333333-3333-3333-3333-333333333333"), name="Test", federation_endpoint="https://example.com/api/v1/federation", description=long_description, ) errors = exc_info.value.errors() assert any(error["loc"] == ("description",) for error in errors) def test_description_within_length_limit(self): """Test description field with exactly 1000 characters.""" description_1000 = "x" * 1000 card = RegistryCard( id=UUID("33333333-3333-3333-3333-333333333333"), name="Test", federation_endpoint="https://example.com/api/v1/federation", description=description_1000, ) assert len(card.description) == 1000 def test_https_endpoint_validation(self): """Test that HTTP endpoints trigger warning but are accepted.""" # HTTP URLs for production domains are accepted with a warning # (The validator logs a warning but doesn't reject) card = RegistryCard( id=UUID("33333333-3333-3333-3333-333333333333"), name="Test", federation_endpoint="http://insecure.example.com/api/v1/federation", ) # HttpUrl adds trailing slash assert str(card.federation_endpoint).startswith( "http://insecure.example.com/api/v1/federation" ) def test_valid_https_endpoint(self): """Test that HTTPS endpoints are accepted.""" card = RegistryCard( id=UUID("33333333-3333-3333-3333-333333333333"), name="Test", federation_endpoint="https://secure.example.com/api/v1/federation", ) # HttpUrl adds trailing slash automatically assert str(card.federation_endpoint).startswith( "https://secure.example.com/api/v1/federation" ) def test_visibility_policy_validation(self): """Test visibility_policy validation.""" # Valid policies for policy in ["public_only", "authenticated", "private"]: card = RegistryCard( id=UUID("33333333-3333-3333-3333-333333333333"), name="Test", federation_endpoint="https://example.com/api/v1/federation", visibility_policy=policy, ) assert card.visibility_policy == policy # Invalid policy with pytest.raises(ValidationError) as exc_info: RegistryCard( id=UUID("33333333-3333-3333-3333-333333333333"), name="Test", federation_endpoint="https://example.com/api/v1/federation", visibility_policy="invalid_policy", ) errors = exc_info.value.errors() assert any("visibility_policy" in str(error) for error in errors) def test_metadata_size_limit_validation(self): """Test metadata field size limit validation (10KB).""" # Create metadata that exceeds 10KB when serialized large_metadata = {f"key_{i}": "x" * 100 for i in range(200)} with pytest.raises(ValidationError) as exc_info: RegistryCard( id=UUID("33333333-3333-3333-3333-333333333333"), name="Test", federation_endpoint="https://example.com/api/v1/federation", metadata=large_metadata, ) errors = exc_info.value.errors() assert any("exceeds 10KB size limit" in str(error) for error in errors) def test_metadata_within_size_limit(self): """Test metadata field within size limit.""" # Create metadata under 10KB metadata = {f"key_{i}": "value" for i in range(100)} card = RegistryCard( id=UUID("33333333-3333-3333-3333-333333333333"), name="Test", federation_endpoint="https://example.com/api/v1/federation", metadata=metadata, ) assert len(card.metadata) == 100 def test_json_serialization_round_trip(self): """Test JSON serialization and deserialization.""" contact = RegistryContact(email="admin@example.com", url="https://example.com/contact") original = RegistryCard( id=UUID("44444444-4444-4444-4444-444444444444"), name="Test Registry", description="Test description", federation_endpoint="https://registry.example.com/api/v1/federation", contact=contact, metadata={"region": "us-west-2"}, ) # Serialize to JSON json_data = original.model_dump(mode="json") # Deserialize back restored = RegistryCard(**json_data) # Verify fields match assert str(restored.id) == str(original.id) assert restored.name == original.name assert restored.description == original.description assert str(restored.federation_endpoint) == str(original.federation_endpoint) assert restored.contact.email == original.contact.email assert restored.metadata == original.metadata def test_unicode_in_text_fields(self): """Test handling of unicode characters in text fields.""" card = RegistryCard( id=UUID("55555555-5555-5555-5555-555555555555"), name="Test Registry 测试 🚀", description="Description with unicode: 日本語, العربية, 한글", federation_endpoint="https://example.com/api/v1/federation", ) assert "测试" in card.name assert "日本語" in card.description def test_default_capabilities_and_authentication(self): """Test that default capabilities and authentication are set.""" card = RegistryCard( id=UUID("33333333-3333-3333-3333-333333333333"), name="Test", federation_endpoint="https://example.com/api/v1/federation", ) # Verify default capabilities assert card.capabilities.servers is True assert card.capabilities.agents is True assert card.capabilities.skills is True assert card.capabilities.security_scans is True # Verify default authentication assert card.authentication.schemes == ["oauth2", "bearer"] assert card.authentication.scopes_supported == ["federation/read"] def test_invalid_url_format(self): """Test that invalid URL formats raise validation errors.""" with pytest.raises(ValidationError): RegistryCard( id=UUID("33333333-3333-3333-3333-333333333333"), name="Test", federation_endpoint="not-a-valid-url", ) def test_timestamps_are_optional(self): """Test that created_at and updated_at are optional.""" card = RegistryCard( id=UUID("33333333-3333-3333-3333-333333333333"), name="Test", federation_endpoint="https://example.com/api/v1/federation", ) assert card.created_at is None assert card.updated_at is None def test_timestamps_with_values(self): """Test setting timestamp values.""" created = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) updated = datetime(2024, 1, 15, 0, 0, 0, tzinfo=UTC) card = RegistryCard( id=UUID("33333333-3333-3333-3333-333333333333"), name="Test", federation_endpoint="https://example.com/api/v1/federation", created_at=created, updated_at=updated, ) assert card.created_at == created assert card.updated_at == updated ================================================ FILE: tests/unit/schemas/test_skill_models_registry_card_fields.py ================================================ """Unit tests for Registry Card fields added to SkillCard and SkillInfo.""" from datetime import UTC, datetime from uuid import uuid4 import pytest from pydantic import HttpUrl from registry.schemas.registry_card import LifecycleStatus from registry.schemas.skill_models import ( SkillCard, SkillInfo, SkillRegistrationRequest, SkillTier1_Metadata, ) @pytest.mark.unit class TestSkillCardRegistryCardFields: """Tests for Registry Card fields in SkillCard model.""" def test_default_lifecycle_status(self): """Test that default lifecycle status is ACTIVE.""" skill = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), ) assert skill.status == LifecycleStatus.ACTIVE def test_custom_lifecycle_status(self): """Test setting custom lifecycle status.""" skill = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), status=LifecycleStatus.DEPRECATED, ) assert skill.status == LifecycleStatus.DEPRECATED def test_all_lifecycle_statuses(self): """Test all lifecycle status values.""" statuses = [ LifecycleStatus.ACTIVE, LifecycleStatus.DEPRECATED, LifecycleStatus.DRAFT, LifecycleStatus.BETA, ] for status in statuses: skill = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), status=status, ) assert skill.status == status def test_source_timestamps_default_none(self): """Test that source timestamps default to None.""" skill = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), ) assert skill.source_created_at is None assert skill.source_updated_at is None def test_source_timestamps_with_values(self): """Test setting source timestamps.""" created = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) updated = datetime(2024, 1, 15, 0, 0, 0, tzinfo=UTC) skill = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), source_created_at=created, source_updated_at=updated, ) assert skill.source_created_at == created assert skill.source_updated_at == updated def test_external_tags_default_empty(self): """Test that external_tags defaults to empty list.""" skill = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), ) assert skill.external_tags == [] def test_external_tags_with_values(self): """Test setting external tags.""" skill = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), external_tags=["federated", "external", "verified"], ) assert skill.external_tags == ["federated", "external", "verified"] assert len(skill.external_tags) == 3 def test_all_registry_card_fields_together(self): """Test setting all registry card fields together.""" created = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) updated = datetime(2024, 1, 15, 0, 0, 0, tzinfo=UTC) skill = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), status=LifecycleStatus.BETA, source_created_at=created, source_updated_at=updated, external_tags=["tag1", "tag2"], ) assert skill.status == LifecycleStatus.BETA assert skill.source_created_at == created assert skill.source_updated_at == updated assert skill.external_tags == ["tag1", "tag2"] def test_json_serialization_with_registry_card_fields(self): """Test JSON serialization of registry card fields.""" created = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) updated = datetime(2024, 1, 15, 0, 0, 0, tzinfo=UTC) skill = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), status=LifecycleStatus.DEPRECATED, source_created_at=created, source_updated_at=updated, external_tags=["federated"], ) json_data = skill.model_dump(mode="json") assert json_data["status"] == "deprecated" assert "source_created_at" in json_data assert "source_updated_at" in json_data assert json_data["external_tags"] == ["federated"] # Round-trip restored = SkillCard(**json_data) assert restored.status == LifecycleStatus.DEPRECATED assert restored.external_tags == ["federated"] def test_backwards_compatibility_without_new_fields(self): """Test that old data without new fields loads successfully.""" old_data = { "path": "/skills/old-skill", "name": "old-skill", "description": "Old skill without registry card fields", "skill_md_url": "https://example.com/SKILL.md", "tags": ["old"], } # Should load successfully with defaults skill = SkillCard(**old_data) assert skill.status == LifecycleStatus.ACTIVE assert skill.source_created_at is None assert skill.source_updated_at is None assert skill.external_tags == [] @pytest.mark.unit class TestSkillInfoRegistryCardFields: """Tests for Registry Card fields in SkillInfo model.""" def test_default_lifecycle_status(self): """Test that default lifecycle status is ACTIVE.""" skill = SkillInfo( id=uuid4(), path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url="https://example.com/SKILL.md", ) assert skill.status == LifecycleStatus.ACTIVE def test_custom_lifecycle_status(self): """Test setting custom lifecycle status.""" skill = SkillInfo( id=uuid4(), path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url="https://example.com/SKILL.md", status=LifecycleStatus.DRAFT, ) assert skill.status == LifecycleStatus.DRAFT def test_source_timestamps_default_none(self): """Test that source timestamps default to None.""" skill = SkillInfo( id=uuid4(), path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url="https://example.com/SKILL.md", ) assert skill.source_created_at is None assert skill.source_updated_at is None def test_source_timestamps_with_values(self): """Test setting source timestamps.""" created = datetime(2024, 2, 1, 0, 0, 0, tzinfo=UTC) updated = datetime(2024, 2, 15, 0, 0, 0, tzinfo=UTC) skill = SkillInfo( id=uuid4(), path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url="https://example.com/SKILL.md", source_created_at=created, source_updated_at=updated, ) assert skill.source_created_at == created assert skill.source_updated_at == updated def test_external_tags_default_empty(self): """Test that external_tags defaults to empty list.""" skill = SkillInfo( id=uuid4(), path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url="https://example.com/SKILL.md", ) assert skill.external_tags == [] def test_external_tags_with_values(self): """Test setting external tags.""" skill = SkillInfo( id=uuid4(), path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url="https://example.com/SKILL.md", external_tags=["federated", "verified"], ) assert skill.external_tags == ["federated", "verified"] def test_all_registry_card_fields_together(self): """Test setting all registry card fields together.""" created = datetime(2024, 2, 1, 0, 0, 0, tzinfo=UTC) updated = datetime(2024, 2, 15, 0, 0, 0, tzinfo=UTC) skill = SkillInfo( id=uuid4(), path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url="https://example.com/SKILL.md", status=LifecycleStatus.BETA, source_created_at=created, source_updated_at=updated, external_tags=["tag1", "tag2"], ) assert skill.status == LifecycleStatus.BETA assert skill.source_created_at == created assert skill.source_updated_at == updated assert skill.external_tags == ["tag1", "tag2"] def test_backwards_compatibility_without_new_fields(self): """Test that old data without new fields loads successfully.""" old_data = { "id": str(uuid4()), "path": "/skills/old-skill", "name": "old-skill", "description": "Old skill without registry card fields", "skill_md_url": "https://example.com/SKILL.md", "tags": ["old"], } # Should load successfully with defaults skill = SkillInfo(**old_data) assert skill.status == LifecycleStatus.ACTIVE assert skill.source_created_at is None assert skill.source_updated_at is None assert skill.external_tags == [] @pytest.mark.unit class TestSkillRegistrationRequestStatus: """Tests for status field in SkillRegistrationRequest.""" def test_default_status(self): """Test that default status is DRAFT for new registrations.""" request = SkillRegistrationRequest( name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), ) assert request.status == "draft" def test_custom_status(self): """Test setting custom status during registration.""" request = SkillRegistrationRequest( name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), status=LifecycleStatus.DRAFT, ) assert request.status == LifecycleStatus.DRAFT def test_all_statuses_allowed(self): """Test that all lifecycle statuses can be set during registration.""" statuses = [ LifecycleStatus.ACTIVE, LifecycleStatus.DEPRECATED, LifecycleStatus.DRAFT, LifecycleStatus.BETA, ] for status in statuses: request = SkillRegistrationRequest( name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), status=status, ) assert request.status == status @pytest.mark.unit class TestSkillTier1MetadataStatus: """Tests for status field in SkillTier1_Metadata.""" def test_default_status(self): """Test that default status is ACTIVE.""" metadata = SkillTier1_Metadata( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url="https://example.com/SKILL.md", ) assert metadata.status == LifecycleStatus.ACTIVE def test_custom_status(self): """Test setting custom status in tier 1 metadata.""" metadata = SkillTier1_Metadata( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url="https://example.com/SKILL.md", status=LifecycleStatus.BETA, ) assert metadata.status == LifecycleStatus.BETA ================================================ FILE: tests/unit/schemas/test_uuid_federation.py ================================================ """Unit tests for UUID field preservation from federated registries.""" from uuid import UUID import pytest from registry.core.schemas import ServerInfo from registry.schemas.agent_models import AgentCard from registry.schemas.skill_models import SkillCard @pytest.mark.unit class TestFederatedUUIDPreservation: """Tests that UUIDs from federated registries are preserved.""" def test_serverinfo_preserves_federated_uuid(self): """Test that UUID from federated registry is preserved.""" # Simulate data from a peer registry with existing UUID federated_uuid = "550e8400-e29b-41d4-a716-446655440000" federated_data = { "id": federated_uuid, "server_name": "federated-server", "path": "/federated/server", "description": "Server from peer registry", "external_tags": ["federated"], } # Load the data server = ServerInfo(**federated_data) # UUID should be preserved, not regenerated assert isinstance(server.id, UUID) assert str(server.id) == federated_uuid def test_serverinfo_generates_uuid_when_missing(self): """Test that UUID is generated when not present in federated data.""" federated_data = { "server_name": "federated-server", "path": "/federated/server", "description": "Server from old peer registry", "external_tags": ["federated"], } # Load the data server = ServerInfo(**federated_data) # UUID should be auto-generated assert isinstance(server.id, UUID) assert server.id is not None def test_agentcard_preserves_federated_uuid(self): """Test that Agent UUID from federated registry is preserved.""" federated_uuid = "660e8400-e29b-41d4-a716-446655440000" federated_data = { "id": federated_uuid, "name": "federated-agent", "path": "/federated/agent", "url": "https://federated.example.com", "version": "1.0.0", "protocol_version": "1.0.0", "description": "Agent from peer registry", "external_tags": ["federated"], } agent = AgentCard(**federated_data) # UUID should be preserved assert isinstance(agent.id, UUID) assert str(agent.id) == federated_uuid def test_agentcard_generates_uuid_when_missing(self): """Test that Agent UUID is generated when not present.""" federated_data = { "name": "federated-agent", "path": "/federated/agent", "url": "https://federated.example.com", "version": "1.0.0", "protocol_version": "1.0.0", "description": "Agent from old peer registry", "external_tags": ["federated"], } agent = AgentCard(**federated_data) # UUID should be auto-generated assert isinstance(agent.id, UUID) assert agent.id is not None def test_skillcard_preserves_federated_uuid(self): """Test that Skill UUID from federated registry is preserved.""" federated_uuid = "770e8400-e29b-41d4-a716-446655440000" federated_data = { "id": federated_uuid, "path": "/skills/federated-skill", "name": "federated-skill", "description": "Skill from peer registry", "skill_md_url": "https://federated.example.com/SKILL.md", "external_tags": ["federated"], } skill = SkillCard(**federated_data) # UUID should be preserved assert isinstance(skill.id, UUID) assert str(skill.id) == federated_uuid def test_skillcard_generates_uuid_when_missing(self): """Test that Skill UUID is generated when not present.""" federated_data = { "path": "/skills/federated-skill", "name": "federated-skill", "description": "Skill from old peer registry", "skill_md_url": "https://federated.example.com/SKILL.md", "external_tags": ["federated"], } skill = SkillCard(**federated_data) # UUID should be auto-generated assert isinstance(skill.id, UUID) assert skill.id is not None def test_multiple_servers_same_data_different_uuids(self): """Test that creating multiple servers from same data generates different UUIDs.""" # Simulate syncing same server from peer registry at different times # without UUID in the data (old peer registry) federated_data = { "server_name": "federated-server", "path": "/federated/server", "description": "Server from old peer", } # First sync server1 = ServerInfo(**federated_data) # Second sync (data without UUID) server2 = ServerInfo(**federated_data) # Each instance gets a unique UUID assert server1.id != server2.id def test_uuid_in_json_roundtrip(self): """Test UUID preservation through JSON serialization/deserialization.""" original_uuid = "880e8400-e29b-41d4-a716-446655440000" server = ServerInfo( id=original_uuid, server_name="test-server", path="/test/server", external_tags=["federated"], ) # Serialize to JSON json_data = server.model_dump(mode="json") # UUID should be in JSON as string assert json_data["id"] == original_uuid # Deserialize back restored = ServerInfo(**json_data) # UUID should be preserved assert str(restored.id) == original_uuid ================================================ FILE: tests/unit/schemas/test_uuid_fields.py ================================================ """Unit tests for UUID fields in all card models.""" from uuid import UUID import pytest from pydantic import HttpUrl from registry.core.schemas import ServerInfo from registry.schemas.agent_models import AgentCard from registry.schemas.registry_card import RegistryCard from registry.schemas.skill_models import SkillCard @pytest.mark.unit class TestRegistryCardUUID: """Tests for UUID field in RegistryCard.""" def test_uuid_auto_generated(self): """Test that UUID is auto-generated on creation.""" card = RegistryCard( registry_id="test-registry", name="Test Registry", federation_endpoint=HttpUrl("https://example.com/api/v1/federation"), ) assert isinstance(card.id, UUID) assert card.id is not None def test_uuid_unique_per_instance(self): """Test that each instance gets a unique UUID.""" card1 = RegistryCard( registry_id="test-registry", name="Test Registry", federation_endpoint=HttpUrl("https://example.com/api/v1/federation"), ) card2 = RegistryCard( registry_id="test-registry", name="Test Registry", federation_endpoint=HttpUrl("https://example.com/api/v1/federation"), ) assert card1.id != card2.id def test_uuid_serialization(self): """Test that UUID serializes to string in JSON.""" card = RegistryCard( registry_id="test-registry", name="Test Registry", federation_endpoint=HttpUrl("https://example.com/api/v1/federation"), ) json_data = card.model_dump(mode="json") assert "id" in json_data assert isinstance(json_data["id"], str) # Should be a valid UUID string UUID(json_data["id"]) def test_uuid_deserialization(self): """Test that UUID deserializes from string.""" uuid_str = "550e8400-e29b-41d4-a716-446655440000" card = RegistryCard( id=uuid_str, registry_id="test-registry", name="Test Registry", federation_endpoint=HttpUrl("https://example.com/api/v1/federation"), ) assert isinstance(card.id, UUID) assert str(card.id) == uuid_str @pytest.mark.unit class TestServerInfoUUID: """Tests for UUID field in ServerInfo.""" def test_uuid_auto_generated(self): """Test that UUID is auto-generated on creation.""" server = ServerInfo( server_name="test-server", path="/test/server", ) assert isinstance(server.id, UUID) assert server.id is not None def test_uuid_unique_per_instance(self): """Test that each instance gets a unique UUID.""" server1 = ServerInfo( server_name="test-server", path="/test/server", ) server2 = ServerInfo( server_name="test-server", path="/test/server", ) assert server1.id != server2.id def test_uuid_serialization(self): """Test that UUID serializes correctly.""" server = ServerInfo( server_name="test-server", path="/test/server", ) json_data = server.model_dump(mode="json") assert "id" in json_data assert isinstance(json_data["id"], str) UUID(json_data["id"]) @pytest.mark.unit class TestAgentCardUUID: """Tests for UUID field in AgentCard.""" def test_uuid_auto_generated(self): """Test that UUID is auto-generated on creation.""" agent = AgentCard( name="test-agent", path="/test/agent", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test agent", ) assert isinstance(agent.id, UUID) assert agent.id is not None def test_uuid_unique_per_instance(self): """Test that each instance gets a unique UUID.""" agent1 = AgentCard( name="test-agent", path="/test/agent", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test agent", ) agent2 = AgentCard( name="test-agent", path="/test/agent", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test agent", ) assert agent1.id != agent2.id def test_uuid_serialization(self): """Test that UUID serializes correctly.""" agent = AgentCard( name="test-agent", path="/test/agent", url="https://test.example.com", version="1.0.0", protocol_version="1.0.0", description="Test agent", ) json_data = agent.model_dump(mode="json") assert "id" in json_data assert isinstance(json_data["id"], str) UUID(json_data["id"]) @pytest.mark.unit class TestSkillCardUUID: """Tests for UUID field in SkillCard.""" def test_uuid_auto_generated(self): """Test that UUID is auto-generated on creation.""" skill = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), ) assert isinstance(skill.id, UUID) assert skill.id is not None def test_uuid_unique_per_instance(self): """Test that each instance gets a unique UUID.""" skill1 = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), ) skill2 = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), ) assert skill1.id != skill2.id def test_uuid_serialization(self): """Test that UUID serializes correctly.""" skill = SkillCard( path="/skills/test-skill", name="test-skill", description="Test skill", skill_md_url=HttpUrl("https://example.com/SKILL.md"), ) json_data = skill.model_dump(mode="json") assert "id" in json_data assert isinstance(json_data["id"], str) UUID(json_data["id"]) @pytest.mark.unit class TestUUIDBackwardsCompatibility: """Tests for backwards compatibility with existing data without UUID.""" def test_serverinfo_without_uuid(self): """Test loading ServerInfo data without UUID field.""" old_data = { "server_name": "old-server", "path": "/old/server", "description": "Old server without UUID", } # Should auto-generate UUID server = ServerInfo(**old_data) assert isinstance(server.id, UUID) assert server.id is not None def test_agentcard_without_uuid(self): """Test loading AgentCard data without UUID field.""" old_data = { "name": "old-agent", "path": "/old/agent", "url": "https://old.example.com", "version": "1.0.0", "protocol_version": "1.0.0", "description": "Old agent without UUID", } # Should auto-generate UUID agent = AgentCard(**old_data) assert isinstance(agent.id, UUID) assert agent.id is not None def test_skillcard_without_uuid(self): """Test loading SkillCard data without UUID field.""" old_data = { "path": "/skills/old-skill", "name": "old-skill", "description": "Old skill without UUID", "skill_md_url": "https://example.com/SKILL.md", } # Should auto-generate UUID skill = SkillCard(**old_data) assert isinstance(skill.id, UUID) assert skill.id is not None ================================================ FILE: tests/unit/search/__init__.py ================================================ """Search and FAISS service unit tests.""" ================================================ FILE: tests/unit/search/test_faiss_service.py ================================================ """ Unit tests for registry/search/service.py (FaissService). This module tests all core functionality of the FaissService including: - FAISS index initialization and management - Adding/updating/removing servers and agents - Semantic search with hybrid keyword boosting - Index persistence (save/load) - Embeddings generation and normalization """ import json import logging from typing import Any import numpy as np import pytest from registry.schemas.agent_models import AgentCard from registry.search.service import FaissService, _PydanticAwareJSONEncoder from tests.fixtures.factories import AgentCardFactory from tests.fixtures.mocks.mock_embeddings import MockEmbeddingsClient logger = logging.getLogger(__name__) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def mock_embeddings_client(): """Create a mock embeddings client for testing.""" return MockEmbeddingsClient(model_name="test-model", dimension=384) @pytest.fixture def faiss_service(mock_settings, mock_embeddings_client): """ Create a FaissService instance with mocked dependencies. This fixture provides a pre-initialized FaissService with: - Mock embeddings client - Mock FAISS index - Test settings with temporary directories """ service = FaissService() service.embedding_model = mock_embeddings_client service._initialize_new_index() return service @pytest.fixture def sample_server_info() -> dict[str, Any]: """Create sample server info dictionary for testing.""" return { "server_name": "test-server", "description": "A test server for search testing", "tags": ["test", "search", "demo"], "num_tools": 2, "entity_type": "mcp_server", "tool_list": [ { "name": "get_data", "description": "Retrieve data from source", "parsed_description": {"main": "Retrieve data from source", "args": "id: string"}, "schema": {"type": "object", "properties": {"id": {"type": "string"}}}, }, { "name": "set_data", "description": "Update data in source", "parsed_description": { "main": "Update data in source", "args": "id: string, value: any", }, "schema": { "type": "object", "properties": {"id": {"type": "string"}, "value": {"type": "string"}}, }, }, ], } @pytest.fixture def sample_agent_card() -> AgentCard: """Create sample agent card for testing.""" return AgentCardFactory( name="test-agent", description="A test agent for search testing", tags=["test", "agent", "demo"], ) # ============================================================================= # INITIALIZATION TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestFaissServiceInitialization: """Tests for FaissService initialization.""" def test_init_creates_empty_service(self): """Test that FaissService.__init__ creates empty service.""" service = FaissService() assert service.embedding_model is None assert service.faiss_index is None assert service.metadata_store == {} assert service.next_id_counter == 0 def test_initialize_new_index_creates_index(self, mock_settings): """Test that _initialize_new_index creates a new FAISS index.""" service = FaissService() service._initialize_new_index() assert service.faiss_index is not None assert service.faiss_index.d == mock_settings.embeddings_model_dimensions assert service.faiss_index.ntotal == 0 assert service.metadata_store == {} assert service.next_id_counter == 0 @pytest.mark.asyncio async def test_initialize_loads_model_and_index(self, mock_settings, monkeypatch): """Test that initialize() loads embedding model and FAISS data.""" service = FaissService() # Mock the internal methods load_model_called = False load_data_called = False async def mock_load_model(): nonlocal load_model_called load_model_called = True service.embedding_model = MockEmbeddingsClient(dimension=384) async def mock_load_data(): nonlocal load_data_called load_data_called = True service._initialize_new_index() monkeypatch.setattr(service, "_load_embedding_model", mock_load_model) monkeypatch.setattr(service, "_load_faiss_data", mock_load_data) await service.initialize() assert load_model_called assert load_data_called assert service.embedding_model is not None assert service.faiss_index is not None @pytest.mark.asyncio async def test_load_faiss_data_creates_new_when_missing(self, mock_settings): """Test that _load_faiss_data creates new index when files don't exist.""" service = FaissService() # Ensure files don't exist assert not mock_settings.faiss_index_path.exists() assert not mock_settings.faiss_metadata_path.exists() await service._load_faiss_data() assert service.faiss_index is not None assert service.faiss_index.ntotal == 0 assert service.metadata_store == {} assert service.next_id_counter == 0 @pytest.mark.asyncio async def test_load_faiss_data_loads_existing(self, mock_settings, tmp_path): """Test that _load_faiss_data loads existing index and metadata.""" service = FaissService() # Create mock metadata file metadata = { "metadata": { "test-server": { "id": 0, "text_for_embedding": "test text", "full_server_info": {"server_name": "test-server"}, "entity_type": "mcp_server", } }, "next_id": 1, } mock_settings.faiss_metadata_path.parent.mkdir(parents=True, exist_ok=True) with open(mock_settings.faiss_metadata_path, "w") as f: json.dump(metadata, f) # Create mock index file (will be handled by mock faiss.read_index) mock_settings.faiss_index_path.touch() await service._load_faiss_data() assert service.metadata_store == metadata["metadata"] assert service.next_id_counter == 1 # ============================================================================= # TEXT PREPARATION TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestTextPreparation: """Tests for text preparation methods.""" def test_get_text_for_embedding_server(self, faiss_service, sample_server_info): """Test _get_text_for_embedding generates correct text for server.""" text = faiss_service._get_text_for_embedding(sample_server_info) assert "test-server" in text assert "A test server for search testing" in text assert "test, search, demo" in text assert "get_data" in text assert "set_data" in text assert "Retrieve data from source" in text def test_get_text_for_embedding_handles_missing_fields(self, faiss_service): """Test _get_text_for_embedding handles missing fields gracefully.""" server_info = {"server_name": "minimal-server"} text = faiss_service._get_text_for_embedding(server_info) assert "minimal-server" in text assert text # Should not be empty def test_get_text_for_agent(self, faiss_service, sample_agent_card): """Test _get_text_for_agent generates correct text for agent.""" text = faiss_service._get_text_for_agent(sample_agent_card) assert sample_agent_card.name in text assert sample_agent_card.description in text assert "Skills:" in text or "test, agent, demo" in text def test_get_text_for_agent_with_skills(self, faiss_service): """Test _get_text_for_agent includes skill details.""" agent = AgentCardFactory( name="skilled-agent", description="Agent with skills", ) text = faiss_service._get_text_for_agent(agent) assert "skilled-agent" in text assert "Skills:" in text def test_get_text_for_embedding_includes_metadata(self, faiss_service): """Test _get_text_for_embedding includes metadata in embedding text.""" server_info = { "server_name": "test-server", "description": "Test server with metadata", "tags": ["test"], "tool_list": [], "metadata": { "team": "data-platform", "owner": "alice@example.com", "compliance_level": "PCI-DSS", }, } text = faiss_service._get_text_for_embedding(server_info) assert "test-server" in text assert "Metadata:" in text assert "team: data-platform" in text assert "owner: alice@example.com" in text assert "compliance_level: PCI-DSS" in text def test_get_text_for_embedding_without_metadata(self, faiss_service): """Test _get_text_for_embedding works without metadata field.""" server_info = { "server_name": "test-server", "description": "Test server without metadata", "tags": ["test"], "tool_list": [], } text = faiss_service._get_text_for_embedding(server_info) assert "test-server" in text assert "Metadata:" not in text def test_get_text_for_embedding_with_nested_metadata(self, faiss_service): """Test _get_text_for_embedding handles nested metadata structures.""" server_info = { "server_name": "test-server", "description": "Test server", "tags": [], "tool_list": [], "metadata": { "compliance": {"level": "PCI-DSS", "audited": True}, "tags": ["production", "critical"], }, } text = faiss_service._get_text_for_embedding(server_info) assert "Metadata:" in text assert "compliance:" in text assert "tags:" in text def test_get_text_for_agent_includes_metadata(self, faiss_service): """Test _get_text_for_agent includes metadata in embedding text.""" agent = AgentCardFactory( name="test-agent", description="Test agent with metadata", metadata={"team": "ai-platform", "owner": "bob@example.com", "version": "2.1.0"}, ) text = faiss_service._get_text_for_agent(agent) assert "test-agent" in text assert "Metadata:" in text assert "team: ai-platform" in text assert "owner: bob@example.com" in text assert "version: 2.1.0" in text def test_get_text_for_agent_without_metadata(self, faiss_service): """Test _get_text_for_agent works without metadata.""" agent = AgentCardFactory(name="test-agent", description="Test agent without metadata") text = faiss_service._get_text_for_agent(agent) assert "test-agent" in text assert "Metadata:" not in text # ============================================================================= # EMBEDDING AND NORMALIZATION TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestEmbeddingOperations: """Tests for embedding generation and normalization.""" def test_normalize_embedding(self, faiss_service): """Test _normalize_embedding normalizes vectors to unit length.""" # Create a non-normalized vector vector = np.array([3.0, 4.0, 0.0], dtype=np.float32) normalized = faiss_service._normalize_embedding(vector) # Check L2 norm is 1.0 (unit length) norm = np.linalg.norm(normalized) assert np.isclose(norm, 1.0, atol=1e-6) # Check values are correct (3,4,0) normalized is (0.6, 0.8, 0) assert np.isclose(normalized[0], 0.6, atol=1e-6) assert np.isclose(normalized[1], 0.8, atol=1e-6) assert np.isclose(normalized[2], 0.0, atol=1e-6) def test_normalize_embedding_zero_vector(self, faiss_service): """Test _normalize_embedding handles zero vector.""" vector = np.array([0.0, 0.0, 0.0], dtype=np.float32) normalized = faiss_service._normalize_embedding(vector) # Should return original vector when norm is 0 assert np.array_equal(normalized, vector) def test_normalize_embedding_already_normalized(self, faiss_service): """Test _normalize_embedding handles already normalized vector.""" # Create already normalized vector vector = np.array([1.0, 0.0, 0.0], dtype=np.float32) normalized = faiss_service._normalize_embedding(vector) # Should remain the same assert np.allclose(normalized, vector, atol=1e-6) # ============================================================================= # ADD/UPDATE ENTITY TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestAddUpdateService: """Tests for adding and updating services in FAISS index.""" @pytest.mark.asyncio async def test_add_new_service(self, faiss_service, sample_server_info, mock_settings): """Test adding a new service to the index.""" service_path = "/servers/test-server" await faiss_service.add_or_update_service(service_path, sample_server_info, is_enabled=True) # Check metadata store assert service_path in faiss_service.metadata_store metadata = faiss_service.metadata_store[service_path] assert metadata["id"] == 0 assert metadata["entity_type"] == "mcp_server" assert metadata["full_server_info"]["is_enabled"] is True # Check FAISS index assert faiss_service.faiss_index.ntotal == 1 assert faiss_service.next_id_counter == 1 @pytest.mark.asyncio async def test_update_existing_service_same_text(self, faiss_service, sample_server_info): """Test updating service with same text doesn't re-embed.""" service_path = "/servers/test-server" # Add service first await faiss_service.add_or_update_service( service_path, sample_server_info, is_enabled=False ) initial_total = faiss_service.faiss_index.ntotal initial_counter = faiss_service.next_id_counter # Update with same info but different enabled state sample_server_info["extra_field"] = "new value" await faiss_service.add_or_update_service(service_path, sample_server_info, is_enabled=True) # Should not create new embedding assert faiss_service.faiss_index.ntotal == initial_total assert faiss_service.next_id_counter == initial_counter # But should update metadata metadata = faiss_service.metadata_store[service_path] assert metadata["full_server_info"]["is_enabled"] is True @pytest.mark.asyncio async def test_update_existing_service_different_text(self, faiss_service, sample_server_info): """Test updating service with different text re-embeds.""" service_path = "/servers/test-server" # Add service first await faiss_service.add_or_update_service( service_path, sample_server_info, is_enabled=False ) initial_id = faiss_service.metadata_store[service_path]["id"] # Update with different description (changes embedding text) sample_server_info["description"] = "Completely different description" await faiss_service.add_or_update_service(service_path, sample_server_info, is_enabled=True) # Should use same ID metadata = faiss_service.metadata_store[service_path] assert metadata["id"] == initial_id # Should have re-embedded assert "Completely different description" in metadata["text_for_embedding"] @pytest.mark.asyncio async def test_add_service_without_model(self, mock_settings): """Test adding service fails gracefully without embedding model.""" service = FaissService() service._initialize_new_index() # Don't set embedding_model await service.add_or_update_service( "/servers/test", {"server_name": "test"}, is_enabled=False ) # Should not add to index assert service.faiss_index.ntotal == 0 assert "/servers/test" not in service.metadata_store @pytest.mark.unit @pytest.mark.search class TestAddUpdateAgent: """Tests for adding and updating agents in FAISS index.""" @pytest.mark.asyncio async def test_add_new_agent(self, faiss_service, sample_agent_card): """Test adding a new agent to the index.""" agent_path = "/agents/test-agent" await faiss_service.add_or_update_agent(agent_path, sample_agent_card, is_enabled=True) # Check metadata store assert agent_path in faiss_service.metadata_store metadata = faiss_service.metadata_store[agent_path] assert metadata["id"] == 0 assert metadata["entity_type"] == "a2a_agent" assert metadata["full_agent_card"]["name"] == sample_agent_card.name # Check FAISS index assert faiss_service.faiss_index.ntotal == 1 assert faiss_service.next_id_counter == 1 @pytest.mark.asyncio async def test_update_existing_agent_same_text(self, faiss_service, sample_agent_card): """Test updating agent with same text doesn't re-embed.""" agent_path = "/agents/test-agent" # Add agent first await faiss_service.add_or_update_agent(agent_path, sample_agent_card, is_enabled=False) initial_total = faiss_service.faiss_index.ntotal initial_counter = faiss_service.next_id_counter # Update with same card await faiss_service.add_or_update_agent(agent_path, sample_agent_card, is_enabled=True) # Should not create new embedding assert faiss_service.faiss_index.ntotal == initial_total assert faiss_service.next_id_counter == initial_counter @pytest.mark.asyncio async def test_update_existing_agent_different_text(self, faiss_service): """Test updating agent with different text re-embeds.""" agent_path = "/agents/test-agent" agent1 = AgentCardFactory(name="test-agent", description="Original description") # Add agent first await faiss_service.add_or_update_agent(agent_path, agent1, is_enabled=False) initial_id = faiss_service.metadata_store[agent_path]["id"] # Update with different description agent2 = AgentCardFactory(name="test-agent", description="New description") await faiss_service.add_or_update_agent(agent_path, agent2, is_enabled=True) # Should use same ID metadata = faiss_service.metadata_store[agent_path] assert metadata["id"] == initial_id # Should have re-embedded assert "New description" in metadata["text_for_embedding"] @pytest.mark.asyncio async def test_add_agent_without_model(self, mock_settings): """Test adding agent fails gracefully without embedding model.""" service = FaissService() service._initialize_new_index() # Don't set embedding_model agent = AgentCardFactory() await service.add_or_update_agent("/agents/test", agent, is_enabled=False) # Should not add to index assert service.faiss_index.ntotal == 0 assert "/agents/test" not in service.metadata_store # ============================================================================= # REMOVE ENTITY TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestRemoveEntities: """Tests for removing entities from FAISS index.""" @pytest.mark.asyncio async def test_remove_service(self, faiss_service, sample_server_info): """Test removing a service from the index.""" service_path = "/servers/test-server" # Add service first await faiss_service.add_or_update_service(service_path, sample_server_info, is_enabled=True) assert service_path in faiss_service.metadata_store # Remove service await faiss_service.remove_service(service_path) # Should be removed from metadata assert service_path not in faiss_service.metadata_store @pytest.mark.asyncio async def test_remove_nonexistent_service(self, faiss_service): """Test removing non-existent service logs warning.""" # Should not raise error await faiss_service.remove_service("/servers/nonexistent") @pytest.mark.asyncio async def test_remove_agent(self, faiss_service, sample_agent_card): """Test removing an agent from the index.""" agent_path = "/agents/test-agent" # Add agent first await faiss_service.add_or_update_agent(agent_path, sample_agent_card, is_enabled=True) assert agent_path in faiss_service.metadata_store # Remove agent await faiss_service.remove_agent(agent_path) # Should be removed from metadata assert agent_path not in faiss_service.metadata_store @pytest.mark.asyncio async def test_remove_nonexistent_agent(self, faiss_service): """Test removing non-existent agent logs warning.""" # Should not raise error await faiss_service.remove_agent("/agents/nonexistent") @pytest.mark.asyncio async def test_remove_entity_wrapper(self, faiss_service, sample_agent_card): """Test remove_entity wrapper method.""" agent_path = "/agents/test-agent" # Add agent await faiss_service.add_or_update_agent(agent_path, sample_agent_card) # Remove using wrapper await faiss_service.remove_entity(agent_path) # Should be removed assert agent_path not in faiss_service.metadata_store # ============================================================================= # SEARCH TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestSearch: """Tests for search functionality.""" @pytest.mark.asyncio async def test_search_mixed_empty_query(self, faiss_service): """Test search_mixed raises error on empty query.""" with pytest.raises(ValueError, match="Query text is required"): await faiss_service.search_mixed("") @pytest.mark.asyncio async def test_search_mixed_no_model(self): """Test search_mixed raises error without embedding model.""" service = FaissService() service._initialize_new_index() with pytest.raises(RuntimeError, match="not initialized"): await service.search_mixed("test query") @pytest.mark.asyncio async def test_search_mixed_empty_index(self, faiss_service): """Test search_mixed returns empty results on empty index.""" results = await faiss_service.search_mixed("test query") assert results == {"servers": [], "tools": [], "agents": []} @pytest.mark.asyncio async def test_search_mixed_finds_servers(self, faiss_service, sample_server_info): """Test search_mixed finds matching servers.""" # Add a server await faiss_service.add_or_update_service( "/servers/test-server", sample_server_info, is_enabled=True ) # Search for it results = await faiss_service.search_mixed("test server") assert len(results["servers"]) == 1 server = results["servers"][0] assert server["entity_type"] == "mcp_server" assert server["path"] == "/servers/test-server" assert server["server_name"] == "test-server" assert "relevance_score" in server assert 0 <= server["relevance_score"] <= 1 @pytest.mark.asyncio async def test_search_mixed_finds_agents(self, faiss_service, sample_agent_card): """Test search_mixed finds matching agents.""" # Add an agent await faiss_service.add_or_update_agent( "/agents/test-agent", sample_agent_card, is_enabled=True ) # Search for it results = await faiss_service.search_mixed("test agent") assert len(results["agents"]) == 1 agent = results["agents"][0] assert agent["entity_type"] == "a2a_agent" assert agent["path"] == "/agents/test-agent" assert agent["agent_name"] == sample_agent_card.name assert "relevance_score" in agent assert 0 <= agent["relevance_score"] <= 1 @pytest.mark.asyncio async def test_search_mixed_with_entity_type_filter( self, faiss_service, sample_server_info, sample_agent_card ): """Test search_mixed filters by entity_type.""" # Add both server and agent await faiss_service.add_or_update_service( "/servers/test-server", sample_server_info, is_enabled=True ) await faiss_service.add_or_update_agent( "/agents/test-agent", sample_agent_card, is_enabled=True ) # Search for servers only results = await faiss_service.search_mixed("test", entity_types=["mcp_server"]) assert len(results["servers"]) >= 0 # May or may not find server depending on mock assert len(results["agents"]) == 0 # Should not return agents @pytest.mark.asyncio async def test_search_mixed_extracts_tools(self, faiss_service, sample_server_info): """Test search_mixed extracts matching tools.""" # Add server with tools await faiss_service.add_or_update_service( "/servers/test-server", sample_server_info, is_enabled=True ) # Search for specific tool results = await faiss_service.search_mixed("get data", entity_types=["tool"]) # Should extract tools even if server doesn't match well assert "tools" in results @pytest.mark.asyncio async def test_search_mixed_respects_max_results(self, faiss_service): """Test search_mixed respects max_results parameter.""" # Add multiple servers for i in range(10): server_info = { "server_name": f"server-{i}", "description": f"Test server {i}", "tags": ["test"], "entity_type": "mcp_server", } await faiss_service.add_or_update_service( f"/servers/server-{i}", server_info, is_enabled=True ) # Search with limit results = await faiss_service.search_mixed("test server", max_results=5) assert len(results["servers"]) <= 5 @pytest.mark.asyncio async def test_search_entities_wrapper(self, faiss_service, sample_server_info): """Test search_entities wrapper method.""" await faiss_service.add_or_update_service( "/servers/test-server", sample_server_info, is_enabled=True ) # Use wrapper method results = await faiss_service.search_entities("test server") # Should return combined list assert isinstance(results, list) @pytest.mark.asyncio async def test_search_agents_wrapper(self, faiss_service, sample_agent_card): """Test search_agents wrapper method.""" await faiss_service.add_or_update_agent( "/agents/test-agent", sample_agent_card, is_enabled=True ) # Use wrapper method results = await faiss_service.search_agents("test agent") # Should return list of agents assert isinstance(results, list) # ============================================================================= # KEYWORD BOOST TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestKeywordBoost: """Tests for keyword boosting in hybrid search.""" def test_calculate_keyword_boost_no_match(self, faiss_service, sample_server_info): """Test keyword boost returns 1.0 when no keywords match.""" boost = faiss_service._calculate_keyword_boost("unrelated query xyz", sample_server_info) assert boost == 1.0 def test_calculate_keyword_boost_name_match(self, faiss_service, sample_server_info): """Test keyword boost increases for name match.""" boost = faiss_service._calculate_keyword_boost("test server", sample_server_info) # Should have boost from name match assert boost > 1.0 def test_calculate_keyword_boost_tool_match(self, faiss_service, sample_server_info): """Test keyword boost increases for tool name match.""" boost = faiss_service._calculate_keyword_boost("get data", sample_server_info) # Should have boost from tool match assert boost > 1.0 def test_calculate_keyword_boost_tag_match(self, faiss_service, sample_server_info): """Test keyword boost increases for tag match.""" boost = faiss_service._calculate_keyword_boost("search", sample_server_info) # Should have boost from tag match assert boost > 1.0 def test_calculate_keyword_boost_filters_stopwords(self, faiss_service, sample_server_info): """Test keyword boost filters out stopwords.""" boost = faiss_service._calculate_keyword_boost("the is are", sample_server_info) # Stopwords should not contribute to boost assert boost == 1.0 def test_calculate_keyword_boost_capped_at_max(self, faiss_service): """Test keyword boost is capped at maximum value.""" # Create server with many matching keywords server_info = { "server_name": "test search demo server", "description": "test search demo testing searching", "tags": ["test", "search", "demo", "testing"], "tool_list": [{"name": "test_tool"}, {"name": "search_tool"}, {"name": "demo_tool"}], } boost = faiss_service._calculate_keyword_boost("test search demo", server_info) # Should be capped at 2.0 assert boost <= 2.0 # ============================================================================= # TOOL EXTRACTION TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestToolExtraction: """Tests for tool extraction from search results.""" def test_extract_matching_tools_no_tools(self, faiss_service): """Test _extract_matching_tools returns empty list when no tools.""" server_info = {"server_name": "test-server", "tool_list": None} tools = faiss_service._extract_matching_tools("query", server_info) assert tools == [] def test_extract_matching_tools_name_match(self, faiss_service, sample_server_info): """Test _extract_matching_tools finds tools by name.""" tools = faiss_service._extract_matching_tools("get data", sample_server_info) # Should find get_data tool assert len(tools) > 0 assert any("get_data" in tool["tool_name"] for tool in tools) def test_extract_matching_tools_description_match(self, faiss_service, sample_server_info): """Test _extract_matching_tools finds tools by description.""" tools = faiss_service._extract_matching_tools("retrieve source", sample_server_info) # Should find tools matching description assert len(tools) >= 0 def test_extract_matching_tools_filters_stopwords(self, faiss_service, sample_server_info): """Test _extract_matching_tools filters stopwords.""" tools = faiss_service._extract_matching_tools("the is are", sample_server_info) # Stopwords alone should not match assert tools == [] def test_extract_matching_tools_scores_name_higher(self, faiss_service): """Test _extract_matching_tools scores name matches higher.""" server_info = { "tool_list": [ { "name": "search_tool", "description": "Does something else", "parsed_description": {"main": "Does something else"}, }, { "name": "other_tool", "description": "search search search", "parsed_description": {"main": "search search search"}, }, ] } tools = faiss_service._extract_matching_tools("search", server_info) # Name match should be scored higher than description match if len(tools) >= 2: assert "search_tool" in tools[0]["tool_name"] def test_extract_matching_tools_server_name_match(self, faiss_service): """Test _extract_matching_tools returns tools when query contains server name. This handles cases like "use context7 to look up mongodb docs" where the query mentions the server name but not specific tool names. """ server_info = { "server_name": "Context7 MCP Server", "tool_list": [ { "name": "resolve-library-id", "schema": {"type": "object"}, }, { "name": "query-docs", "schema": {"type": "object"}, }, ], } # Query contains "context7" but no tool-specific keywords tools = faiss_service._extract_matching_tools( "MongoDB vector index support context7", server_info ) # Should return both tools since server name matches assert len(tools) == 2 tool_names = [t["tool_name"] for t in tools] assert "resolve-library-id" in tool_names assert "query-docs" in tool_names # All tools should have base score of 0.5 for tool in tools: assert tool["raw_score"] == 0.5 # ============================================================================= # DISTANCE/RELEVANCE CONVERSION TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestDistanceConversion: """Tests for distance to relevance score conversion.""" def test_distance_to_relevance_positive_distance(self, faiss_service): """Test _distance_to_relevance handles positive distances.""" # Positive distance (1 - inner_product) relevance = faiss_service._distance_to_relevance(0.05) # Should convert: 1 - 0.05 = 0.95 assert 0.94 <= relevance <= 0.96 def test_distance_to_relevance_negative_distance(self, faiss_service): """Test _distance_to_relevance handles negative distances.""" # Negative distance (-inner_product) relevance = faiss_service._distance_to_relevance(-0.95) # Should convert: -(-0.95) = 0.95 assert 0.94 <= relevance <= 0.96 def test_distance_to_relevance_zero(self, faiss_service): """Test _distance_to_relevance handles zero distance.""" relevance = faiss_service._distance_to_relevance(0.0) assert relevance == 1.0 def test_distance_to_relevance_clamped(self, faiss_service): """Test _distance_to_relevance clamps to [0, 1] range.""" # Test upper bound relevance_high = faiss_service._distance_to_relevance(-2.0) assert relevance_high <= 1.0 # Test lower bound relevance_low = faiss_service._distance_to_relevance(2.0) assert relevance_low >= 0.0 # ============================================================================= # PERSISTENCE TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestPersistence: """Tests for FAISS index persistence (save/load).""" @pytest.mark.asyncio async def test_save_data_creates_files(self, faiss_service, sample_server_info, mock_settings): """Test save_data creates index and metadata files.""" # Add some data await faiss_service.add_or_update_service( "/servers/test-server", sample_server_info, is_enabled=True ) # Save data await faiss_service.save_data() # Check that metadata file exists assert mock_settings.faiss_metadata_path.exists() # Verify metadata content with open(mock_settings.faiss_metadata_path) as f: saved_data = json.load(f) assert "metadata" in saved_data assert "next_id" in saved_data assert "/servers/test-server" in saved_data["metadata"] @pytest.mark.asyncio async def test_save_data_without_index(self, mock_settings): """Test save_data handles missing index gracefully.""" service = FaissService() # Don't initialize index await service.save_data() # Should not create files assert not mock_settings.faiss_metadata_path.exists() def test_get_indexed_count(self, faiss_service): """Test getting the count of indexed items.""" # Initially empty assert faiss_service.faiss_index.ntotal == 0 # The count is directly from FAISS index count = faiss_service.faiss_index.ntotal assert count == 0 # ============================================================================= # PYDANTIC JSON ENCODER TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestPydanticJSONEncoder: """Tests for custom Pydantic JSON encoder.""" def test_encoder_handles_httpurl(self): """Test encoder handles Pydantic HttpUrl type.""" from pydantic import HttpUrl encoder = _PydanticAwareJSONEncoder() url = HttpUrl("https://example.com") result = encoder.default(url) assert result == "https://example.com/" def test_encoder_handles_datetime(self): """Test encoder handles datetime objects.""" from datetime import datetime encoder = _PydanticAwareJSONEncoder() dt = datetime(2024, 1, 1, 12, 0, 0) result = encoder.default(dt) assert "2024-01-01" in result assert "12:00:00" in result # ============================================================================= # INTEGRATION-STYLE TESTS # ============================================================================= @pytest.mark.unit @pytest.mark.search class TestFaissServiceIntegration: """Integration-style tests for complete workflows.""" @pytest.mark.asyncio async def test_full_server_workflow(self, faiss_service, sample_server_info, mock_settings): """Test complete workflow: add, search, update, search, remove.""" service_path = "/servers/workflow-test" # Step 1: Add server await faiss_service.add_or_update_service(service_path, sample_server_info, is_enabled=True) # Step 2: Search for it results1 = await faiss_service.search_mixed("test server") assert len(results1["servers"]) >= 0 # Step 3: Update server sample_server_info["description"] = "Updated description" await faiss_service.add_or_update_service(service_path, sample_server_info, is_enabled=True) # Step 4: Search again await faiss_service.search_mixed("updated") # Results should still work # Step 5: Remove server await faiss_service.remove_service(service_path) assert service_path not in faiss_service.metadata_store @pytest.mark.asyncio async def test_full_agent_workflow(self, faiss_service, sample_agent_card, mock_settings): """Test complete workflow for agents.""" agent_path = "/agents/workflow-test" # Add, search, update, remove await faiss_service.add_or_update_agent(agent_path, sample_agent_card, is_enabled=True) results1 = await faiss_service.search_agents("test agent") assert isinstance(results1, list) # Update sample_agent_card.description = "Updated agent description" await faiss_service.add_or_update_agent(agent_path, sample_agent_card, is_enabled=True) # Remove await faiss_service.remove_agent(agent_path) assert agent_path not in faiss_service.metadata_store @pytest.mark.asyncio async def test_mixed_entities_workflow( self, faiss_service, sample_server_info, sample_agent_card ): """Test workflow with both servers and agents.""" # Add both types await faiss_service.add_or_update_service( "/servers/mixed-server", sample_server_info, is_enabled=True ) await faiss_service.add_or_update_agent( "/agents/mixed-agent", sample_agent_card, is_enabled=True ) # Search for all entities results = await faiss_service.search_entities("test") # Should return combined results assert isinstance(results, list) # Check index has both assert faiss_service.faiss_index.ntotal >= 2 assert len(faiss_service.metadata_store) == 2 ================================================ FILE: tests/unit/servers/__init__.py ================================================ ================================================ FILE: tests/unit/servers/mcpgw/__init__.py ================================================ ================================================ FILE: tests/unit/servers/mcpgw/test_intelligent_tool_finder.py ================================================ """Unit tests for intelligent_tool_finder in servers/mcpgw/server.py. Tests verify the fix for GitHub Issue #682: top_n parameter was ignored due to wrong field names in the HTTP request and missing client-side truncation. """ import sys import types from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest # The mcpgw server depends on `fastmcp` which is not installed in the main # project venv. Stub it out before importing the server module. # FastMCP.tool() is a decorator — make it a passthrough so the original # async functions remain callable. _fastmcp_stub = types.ModuleType("fastmcp") _fastmcp_stub.Context = type("Context", (), {}) _mock_mcp = MagicMock() _mock_mcp.tool.return_value = lambda fn: fn # decorator is a no-op _fastmcp_stub.FastMCP = MagicMock(return_value=_mock_mcp) sys.modules["fastmcp"] = _fastmcp_stub # Force re-import of the server module with the stub in place sys.modules.pop("servers.mcpgw.server", None) # Add servers/mcpgw to sys.path so that `from models import ...` works # when importing servers.mcpgw.server _mcpgw_path = str(Path(__file__).resolve().parents[4] / "servers" / "mcpgw") if _mcpgw_path not in sys.path: sys.path.insert(0, _mcpgw_path) from servers.mcpgw.server import _validate_top_n, intelligent_tool_finder def _make_mock_response(servers=None, status_code=200): """Create a mock httpx response with the given servers payload.""" mock_resp = MagicMock() mock_resp.status_code = status_code mock_resp.raise_for_status = MagicMock() mock_resp.json.return_value = {"servers": servers or []} return mock_resp def _make_server_with_tools(n_tools, server_name="test-server", path="/test"): """Create a mock server dict with n_tools matching_tools.""" return { "server_name": server_name, "path": path, "matching_tools": [ { "tool_name": f"tool_{i}", "description": f"Tool {i} description", "relevance_score": round(1.0 - i * 0.05, 2), } for i in range(n_tools) ], } async def _call_finder(mock_response, query="test", top_n=None, capture=None): """Helper to call intelligent_tool_finder with mocked HTTP client and token. Args: mock_response: The mock httpx response to return from POST. query: Search query string. top_n: Number of results (omit to use default). capture: If provided, a dict that will be populated with the POST kwargs. Returns: The result dict from intelligent_tool_finder. """ captured_kwargs = {} async def mock_post(url, **kwargs): captured_kwargs.update(kwargs) return mock_response mock_client = AsyncMock() mock_client.post = mock_post mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with ( patch("servers.mcpgw.server.httpx.AsyncClient", return_value=mock_client), patch("servers.mcpgw.server._extract_bearer_token", return_value="test-token"), ): if top_n is not None: result = await intelligent_tool_finder(query=query, top_n=top_n) else: result = await intelligent_tool_finder(query=query) if capture is not None: capture.update(captured_kwargs) return result # --------------------------------------------------------------------------- # test_request_payload_uses_correct_field_names # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_request_payload_uses_correct_field_names(): """Verify POST body uses max_results and entity_types (not top_k / entity_type).""" mock_resp = _make_mock_response(servers=[]) captured = {} await _call_finder(mock_resp, query="test", top_n=7, capture=captured) body = captured["json"] assert "max_results" in body assert body["max_results"] == 7 assert "entity_types" in body assert body["entity_types"] == ["mcp_server", "tool", "virtual_server"] assert "top_k" not in body assert "entity_type" not in body # --------------------------------------------------------------------------- # test_top_n_limits_results # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_top_n_limits_results(): """With 10 tools available and top_n=3, only 3 results should be returned.""" server = _make_server_with_tools(10) mock_resp = _make_mock_response(servers=[server]) result = await _call_finder(mock_resp, top_n=3) assert len(result["results"]) == 3 assert result["total_results"] == 3 # --------------------------------------------------------------------------- # test_top_n_default_value # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_top_n_default_value(): """Without specifying top_n, default (5) should limit results.""" server = _make_server_with_tools(10) mock_resp = _make_mock_response(servers=[server]) result = await _call_finder(mock_resp) # no top_n → default 5 assert len(result["results"]) <= 5 # --------------------------------------------------------------------------- # test_top_n_equals_result_count # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_top_n_equals_result_count(): """When registry returns exactly top_n tools, all should be returned.""" server = _make_server_with_tools(3) mock_resp = _make_mock_response(servers=[server]) result = await _call_finder(mock_resp, top_n=3) assert len(result["results"]) == 3 assert result["total_results"] == 3 # --------------------------------------------------------------------------- # test_top_n_greater_than_results # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_top_n_greater_than_results(): """When registry returns fewer than top_n, return what's available (no padding).""" server = _make_server_with_tools(2) mock_resp = _make_mock_response(servers=[server]) result = await _call_finder(mock_resp, top_n=10) assert len(result["results"]) == 2 # --------------------------------------------------------------------------- # test_top_n_validation_rejects_out_of_bounds # --------------------------------------------------------------------------- def test_top_n_validation_rejects_out_of_bounds(): """_validate_top_n rejects values outside [1, 50] and accepts boundaries.""" with pytest.raises(ValueError): _validate_top_n(0) with pytest.raises(ValueError): _validate_top_n(51) with pytest.raises(ValueError): _validate_top_n(-1) assert _validate_top_n(50) == 50 assert _validate_top_n(1) == 1 # --------------------------------------------------------------------------- # test_total_results_matches_truncated_list # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_total_results_matches_truncated_list(): """total_results must equal len(results) after truncation to top_n.""" # 2 servers × 4 tools each = 8 total tools server_a = _make_server_with_tools(4, server_name="server-a", path="/a") server_b = _make_server_with_tools(4, server_name="server-b", path="/b") mock_resp = _make_mock_response(servers=[server_a, server_b]) result = await _call_finder(mock_resp, top_n=5) assert result["total_results"] == len(result["results"]) assert result["total_results"] == 5 ================================================ FILE: tests/unit/services/__init__.py ================================================ """Service layer unit tests.""" ================================================ FILE: tests/unit/services/federation/__init__.py ================================================ """Tests for federation services.""" ================================================ FILE: tests/unit/services/federation/test_agentcore_client.py ================================================ """ Unit tests for AgentCoreFederationClient. Tests boto3 API interactions (mocked), descriptor type transformations, parallel fetching, sync timeout, and health indicator. """ import json from unittest.mock import MagicMock, patch import pytest from botocore.exceptions import ClientError from registry.services.federation.agentcore_client import ( AGENTCORE_ATTRIBUTION, AGENTCORE_SOURCE, AgentCoreFederationClient, _safe_parse_json, _sanitize_path_segment, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def mock_boto3(): """Patch boto3.client so no real AWS calls are made.""" with patch("registry.services.federation.agentcore_client.boto3") as mock: mock_client = MagicMock() mock.client.return_value = mock_client yield mock_client @pytest.fixture def client(mock_boto3): """Return an AgentCoreFederationClient with a mocked boto3 backend.""" return AgentCoreFederationClient(aws_region="us-east-1") # --------------------------------------------------------------------------- # Sample AgentCore API responses # --------------------------------------------------------------------------- def _mcp_record( name: str = "my-mcp-server", record_id: str = "rec-001", registry_id: str = "reg-abc123", ) -> dict: """Build a sample MCP descriptor record.""" server_content = json.dumps( { "title": "My MCP Server", "description": "A test MCP server", "remotes": [{"type": "streamable-http", "url": "https://example.com/mcp"}], } ) tools_content = json.dumps( { "tools": [{"name": "tool1"}, {"name": "tool2"}], } ) return { "recordId": record_id, "name": name, "description": "Test MCP server record", "descriptorType": "MCP", "recordVersion": "1.0.0", "descriptors": { "mcp": { "server": {"inlineContent": server_content}, "tools": {"inlineContent": tools_content}, } }, } def _a2a_record( name: str = "my-a2a-agent", record_id: str = "rec-002", ) -> dict: """Build a sample A2A descriptor record.""" agent_card = json.dumps( { "name": "My A2A Agent", "description": "An A2A agent", "url": "https://agent.example.com", "version": "2.0.0", "protocolVersion": "1.0", "capabilities": {"streaming": True}, "skills": [{"name": "chat"}], } ) return { "recordId": record_id, "name": name, "description": "Test A2A agent", "descriptorType": "A2A", "recordVersion": "2.0.0", "descriptors": { "a2a": { "agentCard": {"inlineContent": agent_card}, } }, } def _custom_record( name: str = "my-custom-thing", record_id: str = "rec-003", ) -> dict: """Build a sample CUSTOM descriptor record.""" custom_content = json.dumps( { "url": "https://original.example.com/api", "capabilities": {"invoke": True}, "provider": {"organization": "TestCorp"}, } ) return { "recordId": record_id, "name": name, "description": "A custom descriptor", "descriptorType": "CUSTOM", "recordVersion": "1.0.0", "descriptors": { "custom": { "inlineContent": custom_content, } }, } def _skills_record( name: str = "my-skill", record_id: str = "rec-004", ) -> dict: """Build a sample AGENT_SKILLS descriptor record.""" skill_md = "# My Skill\n\nDo something useful." skill_def = json.dumps( { "description": "A useful skill", "targetAgents": ["claude-code"], "allowedTools": ["Read", "Write"], } ) return { "recordId": record_id, "name": name, "description": "Test skill", "descriptorType": "AGENT_SKILLS", "recordVersion": "1.0.0", "descriptors": { "agentSkills": { "skillMd": {"inlineContent": skill_md}, "skillDefinition": {"inlineContent": skill_def}, } }, } # --------------------------------------------------------------------------- # Helper function tests # --------------------------------------------------------------------------- class TestSafeParseJson: """Tests for _safe_parse_json utility.""" def test_valid_json(self): result = _safe_parse_json('{"key": "value"}', "test") assert result == {"key": "value"} def test_invalid_json_returns_empty_dict(self): result = _safe_parse_json("not json at all", "test") assert result == {} def test_none_input_returns_empty_dict(self): result = _safe_parse_json(None, "test") assert result == {} def test_empty_string_returns_empty_dict(self): result = _safe_parse_json("", "test") assert result == {} class TestSanitizePathSegment: """Tests for _sanitize_path_segment utility.""" def test_simple_name(self): assert _sanitize_path_segment("my-server") == "my-server" def test_slashes_replaced(self): assert _sanitize_path_segment("org/server") == "org-server" def test_spaces_replaced(self): assert _sanitize_path_segment("my cool server") == "my-cool-server" def test_uppercase_lowered(self): assert _sanitize_path_segment("MyServer") == "myserver" def test_leading_trailing_hyphens_stripped(self): assert _sanitize_path_segment("-server-") == "server" # --------------------------------------------------------------------------- # Client API tests (boto3 mocked) # --------------------------------------------------------------------------- class TestListRegistries: """Tests for list_registries.""" def test_success(self, client, mock_boto3): mock_boto3.list_registries.return_value = { "registries": [{"name": "reg-1", "registryId": "id-1", "status": "READY"}], } result = client.list_registries() assert len(result) == 1 assert result[0]["name"] == "reg-1" def test_pagination(self, client, mock_boto3): mock_boto3.list_registries.side_effect = [ { "registries": [{"name": "reg-1"}], "nextToken": "page2", }, { "registries": [{"name": "reg-2"}], }, ] result = client.list_registries() assert len(result) == 2 def test_error_returns_empty(self, client, mock_boto3): mock_boto3.list_registries.side_effect = ClientError( {"Error": {"Code": "AccessDeniedException", "Message": "forbidden"}}, "ListRegistries", ) result = client.list_registries() assert result == [] class TestListRegistryRecords: """Tests for list_registry_records.""" def test_success_with_filters(self, client, mock_boto3): mock_boto3.list_registry_records.return_value = { "registryRecords": [ {"recordId": "rec-1", "descriptorType": "MCP", "name": "s1"}, ], } result = client.list_registry_records( registry_id="reg-123", descriptor_type="MCP", status="APPROVED", ) assert len(result) == 1 call_kwargs = mock_boto3.list_registry_records.call_args[1] assert call_kwargs["registryId"] == "reg-123" assert call_kwargs["descriptorType"] == "MCP" assert call_kwargs["status"] == "APPROVED" def test_pagination(self, client, mock_boto3): mock_boto3.list_registry_records.side_effect = [ {"registryRecords": [{"recordId": "r1"}], "nextToken": "tok"}, {"registryRecords": [{"recordId": "r2"}]}, ] result = client.list_registry_records(registry_id="reg-123") assert len(result) == 2 def test_client_error_returns_empty(self, client, mock_boto3): mock_boto3.list_registry_records.side_effect = ClientError( {"Error": {"Code": "ValidationException", "Message": "bad"}}, "ListRegistryRecords", ) result = client.list_registry_records(registry_id="reg-123") assert result == [] class TestGetRegistryRecord: """Tests for get_registry_record.""" def test_success(self, client, mock_boto3): mock_boto3.get_registry_record.return_value = { "recordId": "rec-1", "name": "test", "ResponseMetadata": {"RequestId": "xxx"}, } result = client.get_registry_record("reg-123", "rec-1") assert result is not None assert result["recordId"] == "rec-1" assert "ResponseMetadata" not in result def test_not_found_returns_none(self, client, mock_boto3): mock_boto3.get_registry_record.side_effect = ClientError( {"Error": {"Code": "ResourceNotFoundException", "Message": "nope"}}, "GetRegistryRecord", ) result = client.get_registry_record("reg-123", "rec-1") assert result is None def test_other_error_returns_none(self, client, mock_boto3): mock_boto3.get_registry_record.side_effect = ClientError( {"Error": {"Code": "InternalServerException", "Message": "oops"}}, "GetRegistryRecord", ) result = client.get_registry_record("reg-123", "rec-1") assert result is None # --------------------------------------------------------------------------- # Transformation tests # --------------------------------------------------------------------------- class TestTransformMcpRecord: """Tests for MCP descriptor -> server dict transformation.""" def test_basic_transform(self, client): record = _mcp_record() result = client._transform_record(record, "reg-abc123") assert result is not None assert result["source"] == AGENTCORE_SOURCE assert result["server_name"] == "my-mcp-server" assert result["description"] == "Test MCP server record" assert result["proxy_pass_url"] == "https://example.com/mcp" assert result["transport_type"] == "streamable-http" assert result["is_read_only"] is True assert result["attribution_label"] == AGENTCORE_ATTRIBUTION assert result["num_tools"] == 2 assert result["path"] == "/agentcore-my-mcp-server" assert "agentcore" in result["tags"] assert "mcp" in result["tags"] def test_fallback_to_sync_url(self, client): """When no remotes/packages, fall back to synchronizationConfiguration URL.""" record = _mcp_record() record["descriptors"]["mcp"]["server"]["inlineContent"] = json.dumps({}) record["synchronizationConfiguration"] = { "fromUrl": {"url": "https://sync.example.com/mcp"} } result = client._transform_record(record, "reg-abc") assert result["proxy_pass_url"] == "https://sync.example.com/mcp" class TestTransformA2aRecord: """Tests for A2A descriptor -> agent dict transformation.""" def test_basic_transform(self, client): record = _a2a_record() result = client._transform_record(record, "reg-abc123") assert result is not None assert result["source"] == AGENTCORE_SOURCE assert result["name"] == "My A2A Agent" assert result["url"] == "https://agent.example.com" assert result["version"] == "2.0.0" assert result["supported_protocol"] == "a2a" assert result["path"] == "/agents/agentcore-my-a2a-agent" assert "a2a" in result["tags"] assert result["is_read_only"] is True def test_capabilities_preserved(self, client): record = _a2a_record() result = client._transform_record(record, "reg-abc123") assert result["capabilities"] == {"streaming": True} class TestTransformCustomRecord: """Tests for CUSTOM descriptor -> agent dict transformation.""" @patch("registry.core.config.settings") def test_basic_transform(self, mock_settings, client): mock_settings.registry_url = "https://my-registry.com" record = _custom_record() result = client._transform_record(record, "reg-abc123") assert result is not None assert result["source"] == AGENTCORE_SOURCE assert result["name"] == "my-custom-thing" assert result["supported_protocol"] == "other" assert result["path"] == "/agents/agentcore-custom-my-custom-thing" # Self-referencing URL assert ( result["url"] == "https://my-registry.com/api/agents/agentcore-custom-my-custom-thing" ) # Original URL preserved in metadata assert result["metadata"]["original_url"] == "https://original.example.com/api" @patch("registry.core.config.settings") def test_no_original_url(self, mock_settings, client): mock_settings.registry_url = "http://localhost:8000" record = _custom_record() record["descriptors"]["custom"]["inlineContent"] = json.dumps({"foo": "bar"}) result = client._transform_record(record, "reg-abc123") assert result["metadata"]["original_url"] is None class TestTransformSkillsRecord: """Tests for AGENT_SKILLS descriptor -> skill dict transformation.""" @patch("registry.core.config.settings") def test_basic_transform(self, mock_settings, client): mock_settings.registry_url = "https://my-registry.com" record = _skills_record() result = client._transform_record(record, "reg-abc123") assert result is not None assert result["source"] == AGENTCORE_SOURCE assert result["name"] == "my-skill" assert result["path"] == "/skills/agentcore-my-skill" assert result["skill_md_content"] == "# My Skill\n\nDo something useful." assert ( result["skill_md_url"] == "https://my-registry.com/api/skills/agentcore-my-skill/content" ) assert result["target_agents"] == ["claude-code"] assert result["registry_name"] == AGENTCORE_SOURCE assert result["is_read_only"] is True @patch("registry.core.config.settings") def test_empty_skill_md_content(self, mock_settings, client): mock_settings.registry_url = "http://localhost:8000" record = _skills_record() record["descriptors"]["agentSkills"]["skillMd"]["inlineContent"] = "" result = client._transform_record(record, "reg-abc123") assert result["skill_md_content"] == "" class TestTransformUnknownDescriptor: """Tests for unknown descriptor type handling.""" def test_unknown_returns_none(self, client): record = {"descriptorType": "FUTURE_TYPE", "descriptors": {}} result = client._transform_record(record, "reg-abc") assert result is None # --------------------------------------------------------------------------- # fetch_all_records tests # --------------------------------------------------------------------------- class TestFetchAllRecords: """Tests for fetch_all_records (parallel fetch, grouping, timeout).""" def test_grouped_by_type(self, client, mock_boto3): """Records should be routed to servers/agents/skills buckets.""" from registry.schemas.federation_schema import AgentCoreRegistryConfig # Mock list_registry_records to return 3 records mock_boto3.list_registry_records.return_value = { "registryRecords": [ {"recordId": "r1", "descriptorType": "MCP", "name": "s1"}, {"recordId": "r2", "descriptorType": "A2A", "name": "a1"}, {"recordId": "r3", "descriptorType": "AGENT_SKILLS", "name": "sk1"}, ], } # Mock get_registry_record to return full records mock_boto3.get_registry_record.side_effect = [ {**_mcp_record(name="s1", record_id="r1"), "ResponseMetadata": {}}, {**_a2a_record(name="a1", record_id="r2"), "ResponseMetadata": {}}, {**_skills_record(name="sk1", record_id="r3"), "ResponseMetadata": {}}, ] config = AgentCoreRegistryConfig(registry_id="reg-123") with patch("registry.core.config.settings") as mock_s: mock_s.registry_url = "http://localhost:8000" result = client.fetch_all_records([config]) assert len(result["servers"]) == 1 assert len(result["agents"]) == 1 assert len(result["skills"]) == 1 def test_health_updated_after_sync(self, client, mock_boto3): """Health indicator should be updated after successful sync.""" from registry.schemas.federation_schema import AgentCoreRegistryConfig mock_boto3.list_registry_records.return_value = {"registryRecords": []} config = AgentCoreRegistryConfig(registry_id="reg-123") assert client._last_sync_success is False client.fetch_all_records([config]) assert client._last_sync_success is True assert client._last_sync_time is not None assert client._last_sync_record_count == 0 def test_descriptor_type_filter(self, client, mock_boto3): """Records with descriptor types not in config should be skipped.""" from registry.schemas.federation_schema import AgentCoreRegistryConfig mock_boto3.list_registry_records.return_value = { "registryRecords": [ {"recordId": "r1", "descriptorType": "MCP", "name": "s1"}, {"recordId": "r2", "descriptorType": "CUSTOM", "name": "c1"}, ], } mock_boto3.get_registry_record.return_value = { **_mcp_record(name="s1", record_id="r1"), "ResponseMetadata": {}, } # Only sync MCP, not CUSTOM config = AgentCoreRegistryConfig( registry_id="reg-123", descriptor_types=["MCP"], ) result = client.fetch_all_records([config]) assert len(result["servers"]) == 1 assert len(result["agents"]) == 0 # get_registry_record should only be called once (for MCP) assert mock_boto3.get_registry_record.call_count == 1 def test_empty_registries(self, client, mock_boto3): """No configs means no API calls.""" result = client.fetch_all_records([]) assert result == {"servers": [], "agents": [], "skills": []} mock_boto3.list_registry_records.assert_not_called() # --------------------------------------------------------------------------- # Health indicator tests # --------------------------------------------------------------------------- class TestHealthStatus: """Tests for get_health_status.""" def test_initial_state(self, client): health = client.get_health_status() assert health["source"] == AGENTCORE_SOURCE assert health["healthy"] is False assert health["last_sync_time"] is None assert health["last_sync_record_count"] == 0 def test_after_sync(self, client, mock_boto3): from registry.schemas.federation_schema import AgentCoreRegistryConfig mock_boto3.list_registry_records.return_value = {"registryRecords": []} client.fetch_all_records([AgentCoreRegistryConfig(registry_id="reg-1")]) health = client.get_health_status() assert health["healthy"] is True assert health["last_sync_time"] is not None assert health["aws_region"] == "us-east-1" # --------------------------------------------------------------------------- # Cross-account client tests # --------------------------------------------------------------------------- class TestGetClientForRegistry: """Tests for _get_client_for_registry (cross-account/cross-region).""" def test_same_account_same_region_returns_default(self, client): """When no role or custom region, return the default client.""" from registry.schemas.federation_schema import AgentCoreRegistryConfig config = AgentCoreRegistryConfig(registry_id="reg-123") result = client._get_client_for_registry(config) assert result is client._client def test_same_region_explicit_returns_default(self, client): """Explicitly setting aws_region to same as client still returns default.""" from registry.schemas.federation_schema import AgentCoreRegistryConfig config = AgentCoreRegistryConfig( registry_id="reg-123", aws_region="us-east-1", ) result = client._get_client_for_registry(config) assert result is client._client def test_different_region_creates_new_client(self, client): """Different aws_region should create a region-specific client.""" from registry.schemas.federation_schema import AgentCoreRegistryConfig mock_regional_client = MagicMock() with patch("registry.services.federation.agentcore_client.boto3") as mock_b3: mock_b3.client.return_value = mock_regional_client config = AgentCoreRegistryConfig( registry_id="reg-eu", aws_region="eu-west-1", ) result = client._get_client_for_registry(config) assert result is mock_regional_client def test_cross_account_calls_sts_assume_role(self, client): """When assume_role_arn is set, STS AssumeRole should be called.""" from registry.schemas.federation_schema import AgentCoreRegistryConfig mock_sts = MagicMock() mock_sts.assume_role.return_value = { "Credentials": { "AccessKeyId": "AKIA_TEMP", "SecretAccessKey": "secret_temp", "SessionToken": "token_temp", } } mock_cross_client = MagicMock() with patch("registry.services.federation.agentcore_client.boto3") as mock_b3: mock_b3.client.side_effect = lambda service, **kwargs: ( mock_sts if service == "sts" else mock_cross_client ) config = AgentCoreRegistryConfig( registry_id="reg-cross", aws_account_id="123456789012", assume_role_arn="arn:aws:iam::123456789012:role/ReadRole", ) result = client._get_client_for_registry(config) assert result is mock_cross_client mock_sts.assume_role.assert_called_once() call_kwargs = mock_sts.assume_role.call_args[1] assert call_kwargs["RoleArn"] == "arn:aws:iam::123456789012:role/ReadRole" def test_cross_account_with_custom_region(self, client): """Role assumption should use the per-registry region.""" from registry.schemas.federation_schema import AgentCoreRegistryConfig mock_sts = MagicMock() mock_sts.assume_role.return_value = { "Credentials": { "AccessKeyId": "AK", "SecretAccessKey": "SK", "SessionToken": "ST", } } mock_cross_client = MagicMock() with patch("registry.services.federation.agentcore_client.boto3") as mock_b3: mock_b3.client.side_effect = lambda service, **kwargs: ( mock_sts if service == "sts" else mock_cross_client ) config = AgentCoreRegistryConfig( registry_id="reg-eu-cross", aws_account_id="999888777666", aws_region="eu-west-1", assume_role_arn="arn:aws:iam::999888777666:role/EuRole", ) result = client._get_client_for_registry(config) assert result is mock_cross_client # STS client should be created in the registry's region sts_call = mock_b3.client.call_args_list[0] assert sts_call[0][0] == "sts" assert sts_call[1]["region_name"] == "eu-west-1" def test_client_is_cached_by_region_and_role(self, client): """Second call with same region+role should return cached client.""" from registry.schemas.federation_schema import AgentCoreRegistryConfig mock_cached = MagicMock() cache_key = "eu-west-1:arn:aws:iam::111111111111:role/CachedRole" client._registry_clients[cache_key] = mock_cached config = AgentCoreRegistryConfig( registry_id="reg-cached", aws_region="eu-west-1", assume_role_arn="arn:aws:iam::111111111111:role/CachedRole", ) result = client._get_client_for_registry(config) assert result is mock_cached # --------------------------------------------------------------------------- # Compatibility interface tests # --------------------------------------------------------------------------- class TestFetchServerInterface: """Tests for BaseFederationClient interface methods.""" def test_fetch_server_no_registry_id(self, client): result = client.fetch_server("test-server") assert result is None def test_fetch_all_servers_no_registry_id(self, client): result = client.fetch_all_servers(["s1", "s2"]) assert result == [] ================================================ FILE: tests/unit/services/federation/test_federation_auth.py ================================================ """ Unit tests for FederationAuthManager. Tests OAuth2 client credentials authentication including token caching, expiry handling, and error scenarios. """ from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock, Mock, patch import httpx import pytest from registry.services.federation.federation_auth import ( FederationAuthManager, ) @pytest.fixture def auth_env_vars( monkeypatch, ): """Set up environment variables for authentication.""" monkeypatch.setenv("FEDERATION_TOKEN_ENDPOINT", "https://auth.example.com/token") monkeypatch.setenv("FEDERATION_CLIENT_ID", "test-client-id") monkeypatch.setenv("FEDERATION_CLIENT_SECRET", "test-client-secret") @pytest.fixture def missing_env_vars( monkeypatch, ): """Remove authentication environment variables.""" monkeypatch.delenv("FEDERATION_TOKEN_ENDPOINT", raising=False) monkeypatch.delenv("FEDERATION_CLIENT_ID", raising=False) monkeypatch.delenv("FEDERATION_CLIENT_SECRET", raising=False) @pytest.fixture def mock_http_client(): """Create a mock HTTP client for token requests.""" with patch("registry.services.federation.federation_auth.httpx.Client") as mock: client_instance = MagicMock() mock.return_value = client_instance yield client_instance @pytest.fixture def clear_singleton(): """Clear singleton instance before each test.""" # Reset the singleton instance FederationAuthManager._instance = None yield # Clean up after test FederationAuthManager._instance = None class TestFederationAuthManagerSingleton: """Test singleton pattern implementation.""" def test_singleton_same_instance( self, auth_env_vars, clear_singleton, mock_http_client, ): """Test that FederationAuthManager returns the same instance.""" # Arrange & Act instance1 = FederationAuthManager() instance2 = FederationAuthManager() # Assert assert instance1 is instance2 def test_singleton_initialization_once( self, auth_env_vars, clear_singleton, mock_http_client, ): """Test that initialization only happens once.""" # Arrange & Act instance1 = FederationAuthManager() instance1._test_marker = "initialized" instance2 = FederationAuthManager() # Assert assert hasattr(instance2, "_test_marker") assert instance2._test_marker == "initialized" class TestFederationAuthManagerConfiguration: """Test configuration validation and setup.""" def test_is_configured_with_all_env_vars( self, auth_env_vars, clear_singleton, mock_http_client, ): """Test is_configured returns True when all env vars are set.""" # Arrange auth_manager = FederationAuthManager() # Act is_configured = auth_manager.is_configured() # Assert assert is_configured is True def test_is_configured_missing_token_endpoint( self, monkeypatch, clear_singleton, mock_http_client, ): """Test is_configured returns False when token endpoint is missing.""" # Arrange monkeypatch.setenv("FEDERATION_CLIENT_ID", "test-client-id") monkeypatch.setenv("FEDERATION_CLIENT_SECRET", "test-client-secret") auth_manager = FederationAuthManager() # Act is_configured = auth_manager.is_configured() # Assert assert is_configured is False def test_is_configured_missing_client_id( self, monkeypatch, clear_singleton, mock_http_client, ): """Test is_configured returns False when client ID is missing.""" # Arrange monkeypatch.setenv("FEDERATION_TOKEN_ENDPOINT", "https://auth.example.com/token") monkeypatch.setenv("FEDERATION_CLIENT_SECRET", "test-client-secret") auth_manager = FederationAuthManager() # Act is_configured = auth_manager.is_configured() # Assert assert is_configured is False def test_is_configured_missing_client_secret( self, monkeypatch, clear_singleton, mock_http_client, ): """Test is_configured returns False when client secret is missing.""" # Arrange monkeypatch.setenv("FEDERATION_TOKEN_ENDPOINT", "https://auth.example.com/token") monkeypatch.setenv("FEDERATION_CLIENT_ID", "test-client-id") auth_manager = FederationAuthManager() # Act is_configured = auth_manager.is_configured() # Assert assert is_configured is False def test_missing_env_vars_logged_at_startup( self, missing_env_vars, clear_singleton, mock_http_client, caplog, ): """Test that missing env vars are logged clearly at startup.""" # Arrange & Act auth_manager = FederationAuthManager() # Assert assert "Federation authentication not configured" in caplog.text assert "FEDERATION_TOKEN_ENDPOINT" in caplog.text assert "FEDERATION_CLIENT_ID" in caplog.text assert "FEDERATION_CLIENT_SECRET" in caplog.text def test_configured_env_vars_logged_at_startup( self, auth_env_vars, clear_singleton, mock_http_client, caplog, ): """Test that configuration is logged at startup.""" # Arrange & Act import logging caplog.set_level(logging.INFO) auth_manager = FederationAuthManager() # Assert assert "Federation authentication configured" in caplog.text assert "https://auth.example.com/token" in caplog.text class TestFederationAuthManagerTokenRequest: """Test token request and caching behavior.""" def test_get_token_obtains_jwt_using_credentials( self, auth_env_vars, clear_singleton, mock_http_client, ): """Test that client obtains JWT using credentials from env vars.""" # Arrange auth_manager = FederationAuthManager() mock_response = Mock() mock_response.json.return_value = { "access_token": "test-jwt-token", "expires_in": 3600, } mock_http_client.post.return_value = mock_response # Act token = auth_manager.get_token() # Assert assert token == "test-jwt-token" mock_http_client.post.assert_called_once() call_args = mock_http_client.post.call_args # Verify correct endpoint assert call_args[0][0] == "https://auth.example.com/token" # Verify correct data data = call_args[1]["data"] assert data["grant_type"] == "client_credentials" assert data["client_id"] == "test-client-id" assert data["client_secret"] == "test-client-secret" def test_get_token_raises_when_not_configured( self, missing_env_vars, clear_singleton, mock_http_client, ): """Test get_token raises ValueError when not configured.""" # Arrange auth_manager = FederationAuthManager() # Act & Assert with pytest.raises(ValueError, match="Federation authentication not configured"): auth_manager.get_token() def test_token_is_cached_and_reused( self, auth_env_vars, clear_singleton, mock_http_client, ): """Test that token is cached and reused until near expiry.""" # Arrange auth_manager = FederationAuthManager() mock_response = Mock() mock_response.json.return_value = { "access_token": "test-jwt-token", "expires_in": 3600, } mock_http_client.post.return_value = mock_response # Act - First request token1 = auth_manager.get_token() # Act - Second request (should use cache) token2 = auth_manager.get_token() # Assert assert token1 == token2 assert token1 == "test-jwt-token" # Should only make one HTTP request assert mock_http_client.post.call_count == 1 def test_expired_token_triggers_automatic_refresh( self, auth_env_vars, clear_singleton, mock_http_client, ): """Test that expired token triggers automatic refresh.""" # Arrange auth_manager = FederationAuthManager() mock_response1 = Mock() mock_response1.json.return_value = { "access_token": "first-token", "expires_in": 1, # Expires very soon } mock_response2 = Mock() mock_response2.json.return_value = { "access_token": "second-token", "expires_in": 3600, } mock_http_client.post.side_effect = [mock_response1, mock_response2] # Act - First request token1 = auth_manager.get_token() # Manually expire the token by setting expiry in the past auth_manager._token_expiry = datetime.now(UTC) - timedelta(seconds=1) # Act - Second request (should refresh) token2 = auth_manager.get_token() # Assert assert token1 == "first-token" assert token2 == "second-token" assert mock_http_client.post.call_count == 2 def test_token_refresh_with_60s_buffer( self, auth_env_vars, clear_singleton, mock_http_client, ): """Test that token is refreshed with 60s buffer before expiry.""" # Arrange auth_manager = FederationAuthManager() mock_response1 = Mock() mock_response1.json.return_value = { "access_token": "first-token", "expires_in": 3600, } mock_response2 = Mock() mock_response2.json.return_value = { "access_token": "second-token", "expires_in": 3600, } mock_http_client.post.side_effect = [mock_response1, mock_response2] # Act - First request token1 = auth_manager.get_token() # Set token expiry to 30 seconds from now (within buffer) auth_manager._token_expiry = datetime.now(UTC) + timedelta(seconds=30) # Act - Second request (should refresh due to buffer) token2 = auth_manager.get_token() # Assert assert token1 == "first-token" assert token2 == "second-token" assert mock_http_client.post.call_count == 2 def test_token_not_refreshed_outside_buffer( self, auth_env_vars, clear_singleton, mock_http_client, ): """Test that token is not refreshed outside 60s buffer.""" # Arrange auth_manager = FederationAuthManager() mock_response = Mock() mock_response.json.return_value = { "access_token": "test-token", "expires_in": 3600, } mock_http_client.post.return_value = mock_response # Act - First request token1 = auth_manager.get_token() # Set token expiry to 120 seconds from now (outside buffer) auth_manager._token_expiry = datetime.now(UTC) + timedelta(seconds=120) # Act - Second request (should use cache) token2 = auth_manager.get_token() # Assert assert token1 == token2 assert token1 == "test-token" # Should only make one HTTP request assert mock_http_client.post.call_count == 1 class TestFederationAuthManagerErrorHandling: """Test error handling for various failure scenarios.""" def test_http_401_error_handled_gracefully( self, auth_env_vars, clear_singleton, mock_http_client, caplog, ): """Test that HTTP 401 errors are handled gracefully.""" # Arrange auth_manager = FederationAuthManager() mock_response = Mock() mock_response.status_code = 401 mock_http_client.post.side_effect = httpx.HTTPStatusError( "Unauthorized", request=Mock(), response=mock_response, ) # Act token = auth_manager.get_token() # Assert assert token is None assert "HTTP error obtaining access token: 401" in caplog.text assert "Authentication failed" in caplog.text assert "FEDERATION_CLIENT_ID" in caplog.text def test_http_403_error_handled_gracefully( self, auth_env_vars, clear_singleton, mock_http_client, caplog, ): """Test that HTTP 403 errors are handled gracefully.""" # Arrange auth_manager = FederationAuthManager() mock_response = Mock() mock_response.status_code = 403 mock_http_client.post.side_effect = httpx.HTTPStatusError( "Forbidden", request=Mock(), response=mock_response, ) # Act token = auth_manager.get_token() # Assert assert token is None assert "HTTP error obtaining access token: 403" in caplog.text assert "Authentication failed" in caplog.text def test_http_500_error_handled_gracefully( self, auth_env_vars, clear_singleton, mock_http_client, caplog, ): """Test that HTTP 500 errors are handled gracefully.""" # Arrange auth_manager = FederationAuthManager() mock_response = Mock() mock_response.status_code = 500 mock_http_client.post.side_effect = httpx.HTTPStatusError( "Internal Server Error", request=Mock(), response=mock_response, ) # Act token = auth_manager.get_token() # Assert assert token is None assert "HTTP error obtaining access token: 500" in caplog.text def test_network_timeout_handled_gracefully( self, auth_env_vars, clear_singleton, mock_http_client, caplog, ): """Test that network timeouts are handled gracefully.""" # Arrange auth_manager = FederationAuthManager() mock_http_client.post.side_effect = httpx.TimeoutException("Request timed out") # Act token = auth_manager.get_token() # Assert assert token is None assert "Network error obtaining access token" in caplog.text def test_network_connection_error_handled_gracefully( self, auth_env_vars, clear_singleton, mock_http_client, caplog, ): """Test that network connection errors are handled gracefully.""" # Arrange auth_manager = FederationAuthManager() mock_http_client.post.side_effect = httpx.ConnectError("Connection failed") # Act token = auth_manager.get_token() # Assert assert token is None assert "Network error obtaining access token" in caplog.text assert "https://auth.example.com/token" in caplog.text def test_missing_access_token_in_response( self, auth_env_vars, clear_singleton, mock_http_client, caplog, ): """Test handling of response missing access_token field.""" # Arrange auth_manager = FederationAuthManager() mock_response = Mock() mock_response.json.return_value = { "expires_in": 3600, # access_token is missing } mock_http_client.post.return_value = mock_response # Act token = auth_manager.get_token() # Assert assert token is None assert "Token response missing access_token field" in caplog.text def test_unexpected_error_handled_gracefully( self, auth_env_vars, clear_singleton, mock_http_client, caplog, ): """Test that unexpected errors are handled gracefully.""" # Arrange auth_manager = FederationAuthManager() mock_http_client.post.side_effect = Exception("Unexpected error") # Act token = auth_manager.get_token() # Assert assert token is None assert "Unexpected error obtaining access token" in caplog.text class TestFederationAuthManagerClearToken: """Test token clearing functionality.""" def test_clear_token_removes_cached_token( self, auth_env_vars, clear_singleton, mock_http_client, ): """Test that clear_token removes cached token.""" # Arrange auth_manager = FederationAuthManager() mock_response = Mock() mock_response.json.return_value = { "access_token": "test-token", "expires_in": 3600, } mock_http_client.post.return_value = mock_response # Get a token token1 = auth_manager.get_token() assert token1 == "test-token" # Act - Clear the token auth_manager.clear_token() # Assert assert auth_manager._access_token is None assert auth_manager._token_expiry is None def test_clear_token_forces_refresh_on_next_get( self, auth_env_vars, clear_singleton, mock_http_client, ): """Test that clearing token forces refresh on next get.""" # Arrange auth_manager = FederationAuthManager() mock_response1 = Mock() mock_response1.json.return_value = { "access_token": "first-token", "expires_in": 3600, } mock_response2 = Mock() mock_response2.json.return_value = { "access_token": "second-token", "expires_in": 3600, } mock_http_client.post.side_effect = [mock_response1, mock_response2] # Get first token token1 = auth_manager.get_token() # Act - Clear and get again auth_manager.clear_token() token2 = auth_manager.get_token() # Assert assert token1 == "first-token" assert token2 == "second-token" assert mock_http_client.post.call_count == 2 ================================================ FILE: tests/unit/services/federation/test_peer_registry_client.py ================================================ """ Unit tests for PeerRegistryClient. Tests peer registry federation client including server/agent fetching, health checks, and authentication integration. """ from unittest.mock import MagicMock, Mock, patch import httpx import pytest from registry.schemas.peer_federation_schema import PeerRegistryConfig from registry.services.federation.peer_registry_client import PeerRegistryClient @pytest.fixture def peer_config(): """Create a test peer registry configuration.""" return PeerRegistryConfig( peer_id="test-peer", name="Test Peer Registry", endpoint="https://peer.example.com", enabled=True, sync_mode="all", sync_interval_minutes=60, ) @pytest.fixture def mock_auth_manager(): """Mock FederationAuthManager.""" with patch("registry.services.federation.peer_registry_client.FederationAuthManager") as mock: instance = MagicMock() instance.is_configured.return_value = True instance.get_token.return_value = "test-jwt-token" mock.return_value = instance yield instance @pytest.fixture def mock_http_client(): """Mock httpx.Client for HTTP requests.""" with patch("registry.services.federation.base_client.httpx.Client") as mock: instance = MagicMock() mock.return_value = instance yield instance class TestPeerRegistryClientInitialization: """Test client initialization and configuration.""" def test_client_initialization( self, peer_config, mock_auth_manager, mock_http_client, ): """Test basic client initialization.""" # Arrange & Act client = PeerRegistryClient(peer_config) # Assert assert client.peer_config == peer_config assert client.endpoint == "https://peer.example.com" assert client.timeout_seconds == 30 assert client.retry_attempts == 3 def test_client_initialization_with_custom_params( self, peer_config, mock_auth_manager, mock_http_client, ): """Test client initialization with custom timeout and retries.""" # Arrange & Act client = PeerRegistryClient( peer_config, timeout_seconds=60, retry_attempts=5, ) # Assert assert client.timeout_seconds == 60 assert client.retry_attempts == 5 def test_client_warns_when_auth_not_configured( self, peer_config, mock_http_client, caplog, ): """Test that client warns when authentication is not configured.""" # Arrange with patch( "registry.services.federation.peer_registry_client.FederationAuthManager" ) as mock_auth: instance = MagicMock() instance.is_configured.return_value = False mock_auth.return_value = instance # Act client = PeerRegistryClient(peer_config) # Assert assert "Federation authentication not configured for peer 'test-peer'" in caplog.text class TestPeerRegistryClientFetchServers: """Test fetch_servers functionality.""" def test_fetch_servers_returns_parsed_list( self, peer_config, mock_auth_manager, mock_http_client, ): """Test that fetch_servers returns parsed list of server dictionaries.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = { "items": [ {"path": "/server1", "name": "Server 1"}, {"path": "/server2", "name": "Server 2"}, ], "sync_generation": 100, "total_count": 2, } # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response): # Act servers = client.fetch_servers() # Assert assert servers is not None assert len(servers) == 2 assert servers[0]["path"] == "/server1" assert servers[1]["path"] == "/server2" def test_fetch_servers_passes_bearer_token_in_header( self, peer_config, mock_auth_manager, mock_http_client, ): """Test that client passes JWT in Authorization Bearer header.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = {"items": [], "sync_generation": 0, "total_count": 0} # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response) as mock_request: # Act client.fetch_servers() # Assert mock_request.assert_called_once() call_args = mock_request.call_args headers = call_args[1]["headers"] assert "Authorization" in headers assert headers["Authorization"] == "Bearer test-jwt-token" def test_fetch_servers_without_since_generation( self, peer_config, mock_auth_manager, mock_http_client, ): """Test fetch_servers without since_generation parameter.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = {"items": [], "sync_generation": 0, "total_count": 0} # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response) as mock_request: # Act client.fetch_servers() # Assert mock_request.assert_called_once() call_args = mock_request.call_args params = call_args[1].get("params", {}) assert "since_generation" not in params def test_fetch_servers_with_since_generation( self, peer_config, mock_auth_manager, mock_http_client, ): """Test that since_generation parameter is correctly passed to API.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = {"items": [], "sync_generation": 50, "total_count": 0} # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response) as mock_request: # Act client.fetch_servers(since_generation=42) # Assert mock_request.assert_called_once() call_args = mock_request.call_args params = call_args[1]["params"] assert params["since_generation"] == 42 def test_fetch_servers_with_dict_response( self, peer_config, mock_auth_manager, mock_http_client, ): """Test fetch_servers handles dict response format.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = { "items": [{"path": "/server1"}], "sync_generation": 100, "total_count": 1, } # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response): # Act servers = client.fetch_servers() # Assert assert servers is not None assert len(servers) == 1 assert servers[0]["path"] == "/server1" def test_fetch_servers_with_direct_list_response( self, peer_config, mock_auth_manager, mock_http_client, ): """Test fetch_servers handles direct list response format.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = [ {"path": "/server1"}, {"path": "/server2"}, ] # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response): # Act servers = client.fetch_servers() # Assert assert servers is not None assert len(servers) == 2 def test_fetch_servers_handles_auth_failure( self, peer_config, mock_http_client, caplog, ): """Test fetch_servers handles authentication failure.""" # Arrange with patch( "registry.services.federation.peer_registry_client.FederationAuthManager" ) as mock_auth: instance = MagicMock() instance.is_configured.return_value = True instance.get_token.return_value = None # Auth failure mock_auth.return_value = instance client = PeerRegistryClient(peer_config) # Act servers = client.fetch_servers() # Assert assert servers is None assert "Failed to obtain authentication token" in caplog.text def test_fetch_servers_handles_auth_not_configured( self, peer_config, mock_http_client, caplog, ): """Test fetch_servers handles authentication not configured.""" # Arrange with patch( "registry.services.federation.peer_registry_client.FederationAuthManager" ) as mock_auth: instance = MagicMock() instance.is_configured.return_value = True instance.get_token.side_effect = ValueError("Not configured") mock_auth.return_value = instance client = PeerRegistryClient(peer_config) # Act servers = client.fetch_servers() # Assert assert servers is None assert "Cannot fetch servers" in caplog.text def test_fetch_servers_handles_request_failure( self, peer_config, mock_auth_manager, mock_http_client, caplog, ): """Test fetch_servers handles HTTP request failure.""" # Arrange client = PeerRegistryClient(peer_config) # Mock the _make_request method to return None (failure) with patch.object(client, "_make_request", return_value=None): # Act servers = client.fetch_servers() # Assert assert servers is None assert "Failed to fetch servers from peer 'test-peer'" in caplog.text def test_fetch_servers_handles_unexpected_response_format( self, peer_config, mock_auth_manager, mock_http_client, caplog, ): """Test fetch_servers handles unexpected response format.""" # Arrange client = PeerRegistryClient(peer_config) # Mock the _make_request method to return unexpected format with patch.object(client, "_make_request", return_value="invalid"): # Act servers = client.fetch_servers() # Assert assert servers is None assert "Unexpected response format" in caplog.text class TestPeerRegistryClientFetchAgents: """Test fetch_agents functionality.""" def test_fetch_agents_returns_parsed_list( self, peer_config, mock_auth_manager, mock_http_client, ): """Test that fetch_agents returns parsed list of agent dictionaries.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = { "items": [ {"path": "/agent1", "name": "Agent 1"}, {"path": "/agent2", "name": "Agent 2"}, ], "sync_generation": 100, "total_count": 2, } # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response): # Act agents = client.fetch_agents() # Assert assert agents is not None assert len(agents) == 2 assert agents[0]["path"] == "/agent1" assert agents[1]["path"] == "/agent2" def test_fetch_agents_passes_bearer_token_in_header( self, peer_config, mock_auth_manager, mock_http_client, ): """Test that client passes JWT in Authorization Bearer header.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = {"items": [], "sync_generation": 0, "total_count": 0} # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response) as mock_request: # Act client.fetch_agents() # Assert mock_request.assert_called_once() call_args = mock_request.call_args headers = call_args[1]["headers"] assert "Authorization" in headers assert headers["Authorization"] == "Bearer test-jwt-token" def test_fetch_agents_with_since_generation( self, peer_config, mock_auth_manager, mock_http_client, ): """Test that since_generation parameter is correctly passed to API.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = {"items": [], "sync_generation": 50, "total_count": 0} # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response) as mock_request: # Act client.fetch_agents(since_generation=42) # Assert mock_request.assert_called_once() call_args = mock_request.call_args params = call_args[1]["params"] assert params["since_generation"] == 42 def test_fetch_agents_handles_auth_failure( self, peer_config, mock_http_client, caplog, ): """Test fetch_agents handles authentication failure.""" # Arrange with patch( "registry.services.federation.peer_registry_client.FederationAuthManager" ) as mock_auth: instance = MagicMock() instance.is_configured.return_value = True instance.get_token.return_value = None # Auth failure mock_auth.return_value = instance client = PeerRegistryClient(peer_config) # Act agents = client.fetch_agents() # Assert assert agents is None assert "Failed to obtain authentication token" in caplog.text class TestPeerRegistryClientCheckHealth: """Test check_peer_health functionality.""" def test_check_peer_health_returns_true_for_healthy_peer( self, peer_config, mock_auth_manager, mock_http_client, ): """Test that check_peer_health returns True for healthy peer.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = Mock() mock_response.status_code = 200 mock_http_client.get.return_value = mock_response # Act is_healthy = client.check_peer_health() # Assert assert is_healthy is True mock_http_client.get.assert_called_once_with("https://peer.example.com/health") def test_check_peer_health_returns_false_for_unhealthy_peer( self, peer_config, mock_auth_manager, mock_http_client, ): """Test that check_peer_health returns False for unhealthy peer.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = Mock() mock_response.status_code = 503 mock_http_client.get.return_value = mock_response # Act is_healthy = client.check_peer_health() # Assert assert is_healthy is False def test_check_peer_health_accepts_2xx_status_codes( self, peer_config, mock_auth_manager, mock_http_client, ): """Test that check_peer_health accepts various 2xx status codes.""" # Arrange client = PeerRegistryClient(peer_config) # Test various 2xx codes for status_code in [200, 201, 204, 299]: mock_response = Mock() mock_response.status_code = status_code mock_http_client.get.return_value = mock_response # Act is_healthy = client.check_peer_health() # Assert assert is_healthy is True def test_check_peer_health_handles_network_errors( self, peer_config, mock_auth_manager, mock_http_client, caplog, ): """Test that check_peer_health handles network errors gracefully.""" # Arrange client = PeerRegistryClient(peer_config) mock_http_client.get.side_effect = httpx.ConnectError("Connection failed") # Act is_healthy = client.check_peer_health() # Assert assert is_healthy is False assert "Health check failed for peer 'test-peer'" in caplog.text def test_check_peer_health_handles_timeout_errors( self, peer_config, mock_auth_manager, mock_http_client, ): """Test that check_peer_health handles timeout errors gracefully.""" # Arrange client = PeerRegistryClient(peer_config) mock_http_client.get.side_effect = httpx.TimeoutException("Request timed out") # Act is_healthy = client.check_peer_health() # Assert assert is_healthy is False class TestPeerRegistryClientRetryLogic: """Test retry logic inherited from BaseFederationClient.""" def test_client_follows_base_federation_client_retry_logic( self, peer_config, mock_auth_manager, mock_http_client, ): """Test that client follows BaseFederationClient retry logic.""" # Arrange client = PeerRegistryClient(peer_config, retry_attempts=3) # Mock intermittent failure then success mock_response = Mock() mock_response.json.return_value = { "items": [{"path": "/server1"}], "sync_generation": 1, "total_count": 1, } mock_http_client.request.side_effect = [ httpx.RequestError("Network error"), # First attempt fails httpx.RequestError("Network error"), # Second attempt fails mock_response, # Third attempt succeeds ] # Act servers = client.fetch_servers() # Assert assert servers is not None assert len(servers) == 1 assert mock_http_client.request.call_count == 3 def test_http_4xx_errors_not_retried( self, peer_config, mock_auth_manager, mock_http_client, caplog, ): """Test that HTTP 4xx errors are not retried.""" # Arrange client = PeerRegistryClient(peer_config, retry_attempts=3) # Mock 404 error mock_response = Mock() mock_response.status_code = 404 mock_http_client.request.side_effect = httpx.HTTPStatusError( "Not found", request=Mock(), response=mock_response, ) # Act servers = client.fetch_servers() # Assert assert servers is None # Should only attempt once (no retries for 404) assert mock_http_client.request.call_count == 1 def test_http_5xx_errors_retried( self, peer_config, mock_auth_manager, mock_http_client, ): """Test that HTTP 5xx errors are retried.""" # Arrange client = PeerRegistryClient(peer_config, retry_attempts=3) # Mock 500 error on all attempts mock_response = Mock() mock_response.status_code = 500 mock_http_client.request.side_effect = httpx.HTTPStatusError( "Internal server error", request=Mock(), response=mock_response, ) # Act servers = client.fetch_servers() # Assert assert servers is None # Should attempt 3 times assert mock_http_client.request.call_count == 3 class TestPeerRegistryClientFetchSingleServer: """Test fetch_server functionality.""" def test_fetch_server_by_path( self, peer_config, mock_auth_manager, mock_http_client, ): """Test fetching a single server by path.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = { "items": [ {"path": "/server1", "name": "Server 1"}, {"path": "/server2", "name": "Server 2"}, ], "sync_generation": 1, "total_count": 2, } # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response): # Act server = client.fetch_server("/server1") # Assert assert server is not None assert server["path"] == "/server1" assert server["name"] == "Server 1" def test_fetch_server_not_found( self, peer_config, mock_auth_manager, mock_http_client, caplog, ): """Test fetching a server that doesn't exist.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = { "items": [ {"path": "/server1", "name": "Server 1"}, ], "sync_generation": 1, "total_count": 1, } # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response): # Act server = client.fetch_server("/nonexistent") # Assert assert server is None assert "Server '/nonexistent' not found in peer 'test-peer'" in caplog.text def test_fetch_server_handles_fetch_failure( self, peer_config, mock_auth_manager, mock_http_client, ): """Test fetch_server handles failure to fetch servers.""" # Arrange client = PeerRegistryClient(peer_config) # Mock the _make_request method to return None with patch.object(client, "_make_request", return_value=None): # Act server = client.fetch_server("/server1") # Assert assert server is None class TestPeerRegistryClientFetchAllServers: """Test fetch_all_servers functionality.""" def test_fetch_all_servers_with_no_filter( self, peer_config, mock_auth_manager, mock_http_client, ): """Test fetching all servers without filtering.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = { "items": [ {"path": "/server1", "name": "Server 1"}, {"path": "/server2", "name": "Server 2"}, ], "sync_generation": 1, "total_count": 2, } # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response): # Act servers = client.fetch_all_servers([]) # Assert assert servers is not None assert len(servers) == 2 def test_fetch_all_servers_with_filter( self, peer_config, mock_auth_manager, mock_http_client, ): """Test fetching all servers with name filtering.""" # Arrange client = PeerRegistryClient(peer_config) mock_response = { "items": [ {"path": "/server1", "name": "Server 1"}, {"path": "/server2", "name": "Server 2"}, {"path": "/server3", "name": "Server 3"}, ], "sync_generation": 1, "total_count": 3, } # Mock the _make_request method with patch.object(client, "_make_request", return_value=mock_response): # Act servers = client.fetch_all_servers(["/server1", "/server3"]) # Assert assert servers is not None assert len(servers) == 2 assert servers[0]["path"] == "/server1" assert servers[1]["path"] == "/server3" def test_fetch_all_servers_handles_fetch_failure( self, peer_config, mock_auth_manager, mock_http_client, ): """Test fetch_all_servers handles failure to fetch servers.""" # Arrange client = PeerRegistryClient(peer_config) # Mock the _make_request method to return None with patch.object(client, "_make_request", return_value=None): # Act servers = client.fetch_all_servers(["/server1"]) # Assert assert servers == [] ================================================ FILE: tests/unit/services/test_agent_service.py ================================================ """ Unit tests for registry.services.agent_service module. These tests exercise AgentService against an in-memory fake implementation of AgentRepositoryBase so we test the real service-to-repo contract rather than MagicMock behavior. """ import logging from datetime import UTC, datetime from typing import Any from unittest.mock import AsyncMock import pytest from registry.repositories.interfaces import AgentRepositoryBase from registry.schemas.agent_models import AgentCard from registry.services.agent_service import AgentService from tests.fixtures.constants import ( TEST_AGENT_NAME_1, TEST_AGENT_NAME_2, TEST_AGENT_PATH_1, TEST_AGENT_PATH_2, TEST_AGENT_URL_1, TEST_AGENT_URL_2, TRUST_UNVERIFIED, VISIBILITY_PUBLIC, ) from tests.fixtures.factories import AgentCardFactory logger = logging.getLogger(__name__) # ============================================================================= # In-memory fake repository # ============================================================================= class InMemoryAgentRepository(AgentRepositoryBase): """In-memory AgentRepositoryBase implementation for tests. Stores AgentCard objects keyed by path and a parallel enabled/disabled map. Mirrors the persistence contract used by real repository implementations. """ def __init__(self) -> None: self._agents: dict[str, AgentCard] = {} self._enabled: dict[str, bool] = {} async def get(self, path: str) -> AgentCard | None: return self._agents.get(path) async def list_all(self) -> list[AgentCard]: return list(self._agents.values()) async def list_paginated( self, skip: int = 0, limit: int = 100, ) -> list[AgentCard]: return list(self._agents.values())[skip : skip + limit] async def create(self, agent: AgentCard) -> AgentCard: if agent.path in self._agents: raise ValueError(f"Agent path '{agent.path}' already exists") if not agent.registered_at: agent.registered_at = datetime.now(UTC) if not agent.updated_at: agent.updated_at = datetime.now(UTC) self._agents[agent.path] = agent self._enabled.setdefault(agent.path, False) return agent async def update(self, path: str, updates: dict[str, Any]) -> AgentCard: existing = self._agents.get(path) if existing is None: raise ValueError(f"Agent not found at path: {path}") data = existing.model_dump() data.update(updates) data["path"] = path data["updated_at"] = datetime.now(UTC) new_agent = AgentCard(**data) self._agents[path] = new_agent return new_agent async def delete(self, path: str) -> bool: if path not in self._agents: return False del self._agents[path] self._enabled.pop(path, None) return True async def get_state(self, path: str) -> bool: return self._enabled.get(path, False) async def get_all_states(self) -> dict[str, bool]: return dict(self._enabled) async def set_state(self, path: str, enabled: bool) -> bool: if path not in self._agents: return False self._enabled[path] = enabled agent = self._agents[path] data = agent.model_dump() data["is_enabled"] = enabled self._agents[path] = AgentCard(**data) return True async def load_all(self) -> None: return None async def count(self) -> int: return len(self._agents) async def update_field(self, path: str, field: str, value: Any) -> bool: agent = self._agents.get(path) if agent is None: return False data = agent.model_dump() data[field] = value self._agents[path] = AgentCard(**data) return True async def find_with_filter( self, filter_dict: dict[str, Any] ) -> dict[str, dict]: results: dict[str, dict] = {} for path, agent in self._agents.items(): data = agent.model_dump() if all(data.get(k) == v for k, v in filter_dict.items()): results[path] = data return results # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def fake_repo() -> InMemoryAgentRepository: return InMemoryAgentRepository() @pytest.fixture def fake_search_repo() -> AsyncMock: """Search repository is an integration boundary we don't exercise here.""" mock = AsyncMock() mock.index_agent.return_value = None mock.remove_entity.return_value = None mock.index_entity.return_value = None return mock @pytest.fixture def agent_service( mock_settings, fake_repo: InMemoryAgentRepository, fake_search_repo: AsyncMock, ) -> AgentService: """AgentService backed by an in-memory repository.""" service = AgentService() service._repo = fake_repo service._search_repo = fake_search_repo return service @pytest.fixture def sample_agent_dict() -> dict[str, Any]: return { "protocol_version": "1.0", "name": TEST_AGENT_NAME_1, "description": "A test agent for unit tests", "url": TEST_AGENT_URL_1, "version": "1.0", "path": TEST_AGENT_PATH_1, "capabilities": {"streaming": False, "tools": True}, "default_input_modes": ["text/plain"], "default_output_modes": ["text/plain"], "skills": [ { "id": "skill-1", "name": "Data Processing", "description": "Process data efficiently", "tags": ["data", "processing"], } ], "tags": ["test", "data"], "is_enabled": False, "num_stars": 0.0, "rating_details": [], "license": "MIT", "visibility": VISIBILITY_PUBLIC, "trust_level": TRUST_UNVERIFIED, } @pytest.fixture def sample_agent_dict_2() -> dict[str, Any]: return { "protocol_version": "1.0", "name": TEST_AGENT_NAME_2, "description": "Another test agent", "url": TEST_AGENT_URL_2, "version": "1.0", "path": TEST_AGENT_PATH_2, "capabilities": {"streaming": True, "tools": False}, "default_input_modes": ["text/plain"], "default_output_modes": ["text/plain"], "skills": [], "tags": ["test"], "is_enabled": False, "num_stars": 0.0, "rating_details": [], "license": "Apache-2.0", "visibility": VISIBILITY_PUBLIC, "trust_level": TRUST_UNVERIFIED, } # ============================================================================= # TEST: Register Agent # ============================================================================= @pytest.mark.unit @pytest.mark.agents class TestRegisterAgent: @pytest.mark.asyncio async def test_register_new_agent_successfully( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, fake_search_repo: AsyncMock, ): agent_card = AgentCardFactory(path="/new-agent") result = await agent_service.register_agent(agent_card) assert result.path == "/new-agent" assert await fake_repo.get("/new-agent") is not None fake_search_repo.index_agent.assert_called_once() @pytest.mark.asyncio async def test_register_agent_fails_for_duplicate_path( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/duplicate")) with pytest.raises(ValueError, match="already exists"): await agent_service.register_agent(AgentCardFactory(path="/duplicate")) @pytest.mark.asyncio async def test_register_agent_defaults_to_disabled( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): agent_card = AgentCardFactory(path="/new-agent") await agent_service.register_agent(agent_card) assert await fake_repo.get_state("/new-agent") is False # ============================================================================= # TEST: Get Agent # ============================================================================= @pytest.mark.unit @pytest.mark.agents class TestGetAgent: @pytest.mark.asyncio async def test_get_existing_agent( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): agent_card = AgentCardFactory(path="/test-agent") await fake_repo.create(agent_card) result = await agent_service.get_agent("/test-agent") assert result.path == "/test-agent" assert result.name == agent_card.name @pytest.mark.asyncio async def test_get_agent_not_found( self, agent_service: AgentService, ): with pytest.raises(ValueError, match="not found"): await agent_service.get_agent("/nonexistent") @pytest.mark.asyncio async def test_get_agent_handles_trailing_slash( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) result = await agent_service.get_agent("/test-agent/") assert result.path == "/test-agent" @pytest.mark.asyncio async def test_get_agent_falls_back_when_query_has_extra_slash( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): """A query with a trailing slash should still find an agent stored without one.""" await fake_repo.create(AgentCardFactory(path="/test-agent")) result = await agent_service.get_agent("/test-agent/") assert result is not None assert result.path == "/test-agent" # ============================================================================= # TEST: List Agents # ============================================================================= @pytest.mark.unit @pytest.mark.agents class TestListAgents: @pytest.mark.asyncio async def test_list_agents_empty(self, agent_service: AgentService): result = await agent_service.list_agents() assert result == [] @pytest.mark.asyncio async def test_list_agents_returns_all( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/agent-1")) await fake_repo.create(AgentCardFactory(path="/agent-2")) result = await agent_service.list_agents() paths = [a.path for a in result] assert set(paths) == {"/agent-1", "/agent-2"} @pytest.mark.asyncio async def test_get_all_agents_alias( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test")) list_result = await agent_service.list_agents() get_all_result = await agent_service.get_all_agents() assert len(list_result) == len(get_all_result) == 1 assert list_result[0].path == get_all_result[0].path # ============================================================================= # TEST: Update Agent # ============================================================================= @pytest.mark.unit @pytest.mark.agents class TestUpdateAgent: @pytest.mark.asyncio async def test_update_agent_successfully( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create( AgentCardFactory(path="/test-agent", description="Original description") ) result = await agent_service.update_agent( "/test-agent", {"description": "Updated description"} ) assert result.description == "Updated description" assert result.path == "/test-agent" persisted = await fake_repo.get("/test-agent") assert persisted.description == "Updated description" @pytest.mark.asyncio async def test_update_agent_updates_timestamp( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): original_time = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) await fake_repo.create( AgentCardFactory(path="/test-agent", updated_at=original_time) ) result = await agent_service.update_agent("/test-agent", {"description": "New"}) assert result.updated_at > original_time @pytest.mark.asyncio async def test_update_agent_not_found(self, agent_service: AgentService): with pytest.raises(ValueError, match="not found"): await agent_service.update_agent("/nonexistent", {"description": "test"}) @pytest.mark.asyncio async def test_update_agent_preserves_path( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/original-path")) result = await agent_service.update_agent( "/original-path", {"path": "/new-path", "description": "Updated"}, ) assert result.path == "/original-path" assert await fake_repo.get("/new-path") is None @pytest.mark.asyncio async def test_update_agent_with_invalid_data( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) with pytest.raises(ValueError, match="Invalid"): await agent_service.update_agent("/test-agent", {"num_stars": 10.0}) # ============================================================================= # TEST: Delete Agent # ============================================================================= @pytest.mark.unit @pytest.mark.agents class TestDeleteAgent: @pytest.mark.asyncio async def test_delete_agent_successfully( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) result = await agent_service.delete_agent("/test-agent") assert result is True assert await fake_repo.get("/test-agent") is None @pytest.mark.asyncio async def test_delete_agent_not_found(self, agent_service: AgentService): with pytest.raises(ValueError, match="not found"): await agent_service.delete_agent("/nonexistent") @pytest.mark.asyncio async def test_remove_agent_alias( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) result = await agent_service.remove_agent("/test-agent") assert result is True assert await fake_repo.get("/test-agent") is None @pytest.mark.asyncio async def test_remove_agent_returns_false_for_not_found( self, agent_service: AgentService ): result = await agent_service.remove_agent("/nonexistent") assert result is False # ============================================================================= # TEST: Enable/Disable Agent # ============================================================================= @pytest.mark.unit @pytest.mark.agents class TestEnableDisableAgent: @pytest.mark.asyncio async def test_enable_agent( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) await agent_service.enable_agent("/test-agent") assert await fake_repo.get_state("/test-agent") is True @pytest.mark.asyncio async def test_enable_already_enabled_agent( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) await fake_repo.set_state("/test-agent", True) await agent_service.enable_agent("/test-agent") assert await fake_repo.get_state("/test-agent") is True @pytest.mark.asyncio async def test_enable_agent_not_found(self, agent_service: AgentService): with pytest.raises(ValueError, match="not found"): await agent_service.enable_agent("/nonexistent") @pytest.mark.asyncio async def test_disable_agent( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) await fake_repo.set_state("/test-agent", True) await agent_service.disable_agent("/test-agent") assert await fake_repo.get_state("/test-agent") is False @pytest.mark.asyncio async def test_disable_already_disabled_agent( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) await agent_service.disable_agent("/test-agent") assert await fake_repo.get_state("/test-agent") is False @pytest.mark.asyncio async def test_disable_agent_not_found(self, agent_service: AgentService): with pytest.raises(ValueError, match="not found"): await agent_service.disable_agent("/nonexistent") @pytest.mark.asyncio async def test_toggle_agent_enable( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) result = await agent_service.toggle_agent("/test-agent", enabled=True) assert result is True assert await fake_repo.get_state("/test-agent") is True @pytest.mark.asyncio async def test_toggle_agent_disable( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) await fake_repo.set_state("/test-agent", True) result = await agent_service.toggle_agent("/test-agent", enabled=False) assert result is True assert await fake_repo.get_state("/test-agent") is False @pytest.mark.asyncio async def test_toggle_agent_not_found(self, agent_service: AgentService): result = await agent_service.toggle_agent("/nonexistent", enabled=True) assert result is False # ============================================================================= # TEST: Agent State Queries # ============================================================================= @pytest.mark.unit @pytest.mark.agents class TestAgentStateQueries: @pytest.mark.asyncio async def test_is_agent_enabled_true( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) await fake_repo.set_state("/test-agent", True) assert await agent_service.is_agent_enabled("/test-agent") is True @pytest.mark.asyncio async def test_is_agent_enabled_false( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) assert await agent_service.is_agent_enabled("/test-agent") is False @pytest.mark.asyncio async def test_is_agent_enabled_handles_trailing_slash( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/test-agent")) await fake_repo.set_state("/test-agent", True) assert await agent_service.is_agent_enabled("/test-agent/") is True @pytest.mark.asyncio async def test_get_enabled_agents( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/agent-1")) await fake_repo.create(AgentCardFactory(path="/agent-2")) await fake_repo.set_state("/agent-1", True) result = await agent_service.get_enabled_agents() assert result == ["/agent-1"] @pytest.mark.asyncio async def test_get_disabled_agents( self, agent_service: AgentService, fake_repo: InMemoryAgentRepository, ): await fake_repo.create(AgentCardFactory(path="/agent-1")) await fake_repo.create(AgentCardFactory(path="/agent-2")) await fake_repo.set_state("/agent-1", True) result = await agent_service.get_disabled_agents() assert result == ["/agent-2"] ================================================ FILE: tests/unit/services/test_agentcore_reconciliation.py ================================================ """ Unit tests for AgentCore federation reconciliation functions. Tests cover: - _build_expected_agentcore_paths: disabled vs enabled config - _reconcile_agentcore_servers: stale removal, no stale, errors - _reconcile_agentcore_agents: stale removal by tag+prefix filter - _reconcile_agentcore_skills: stale removal by tag+prefix filter - reconcile_agentcore_records: dry run, full run, None synced_paths """ from types import SimpleNamespace from unittest.mock import ( AsyncMock, patch, ) import pytest from registry.schemas.federation_schema import ( AgentCoreFederationConfig, FederationConfig, ) from registry.services.federation_reconciliation import ( _build_expected_agentcore_paths, _reconcile_agentcore_agents, _reconcile_agentcore_servers, _reconcile_agentcore_skills, reconcile_agentcore_records, ) # ============================================================================= # Helper: create mock agent/skill objects # ============================================================================= def _make_agent( name: str, path: str, tags: list[str] | None = None, ) -> SimpleNamespace: """Create a mock agent object with name, path, tags.""" return SimpleNamespace(name=name, path=path, tags=tags or []) def _make_skill( name: str, path: str, tags: list[str] | None = None, ) -> SimpleNamespace: """Create a mock skill object with name, path, tags.""" return SimpleNamespace(name=name, path=path, tags=tags or []) # ============================================================================= # _build_expected_agentcore_paths Tests # ============================================================================= @pytest.mark.unit class TestBuildExpectedAgentcorePaths: """Tests for _build_expected_agentcore_paths.""" def test_disabled_config_returns_empty_sets(self): """When agentcore is disabled, all sets should be empty.""" config = FederationConfig( agentcore=AgentCoreFederationConfig(enabled=False), ) synced = { "servers": {"/s1", "/s2"}, "agents": {"/a1"}, "skills": {"/sk1"}, } result = _build_expected_agentcore_paths(config, synced) assert result["servers"] == set() assert result["agents"] == set() assert result["skills"] == set() def test_enabled_config_passes_through_synced_paths(self): """When agentcore is enabled, synced paths should be returned.""" config = FederationConfig( agentcore=AgentCoreFederationConfig(enabled=True), ) synced = { "servers": {"/server-a", "/server-b"}, "agents": {"/agents/agentcore-x"}, "skills": {"/skills/agentcore-y"}, } result = _build_expected_agentcore_paths(config, synced) assert result["servers"] == {"/server-a", "/server-b"} assert result["agents"] == {"/agents/agentcore-x"} assert result["skills"] == {"/skills/agentcore-y"} def test_enabled_config_missing_keys_default_to_empty(self): """Missing keys in synced_paths should default to empty sets.""" config = FederationConfig( agentcore=AgentCoreFederationConfig(enabled=True), ) synced = {"servers": {"/s1"}} result = _build_expected_agentcore_paths(config, synced) assert result["servers"] == {"/s1"} assert result["agents"] == set() assert result["skills"] == set() # ============================================================================= # _reconcile_agentcore_servers Tests # ============================================================================= @pytest.mark.unit class TestReconcileAgentcoreServers: """Tests for _reconcile_agentcore_servers.""" @pytest.mark.asyncio async def test_no_stale_servers(self): """When all actual servers are expected, nothing is removed.""" server_repo = AsyncMock() server_repo.list_by_source.return_value = { "/s1": {"server_name": "Server 1"}, } server_service = AsyncMock() result = await _reconcile_agentcore_servers( expected_paths={"/s1"}, server_service=server_service, server_repo=server_repo, ) assert result["removed"] == [] assert result["errors"] == [] server_service.remove_server.assert_not_called() @pytest.mark.asyncio async def test_stale_servers_removed(self): """Stale servers (in DB but not expected) should be removed.""" server_repo = AsyncMock() server_repo.list_by_source.return_value = { "/s1": {"server_name": "Server 1"}, "/s2": {"server_name": "Server 2"}, "/s3": {"server_name": "Server 3"}, } server_service = AsyncMock() server_service.remove_server.return_value = True result = await _reconcile_agentcore_servers( expected_paths={"/s1"}, server_service=server_service, server_repo=server_repo, ) assert set(result["removed"]) == {"Server 2", "Server 3"} assert result["errors"] == [] @pytest.mark.asyncio async def test_removal_failure_records_error(self): """When remove_server returns False, an error is recorded.""" server_repo = AsyncMock() server_repo.list_by_source.return_value = { "/s1": {"server_name": "Server 1"}, } server_service = AsyncMock() server_service.remove_server.return_value = False result = await _reconcile_agentcore_servers( expected_paths=set(), server_service=server_service, server_repo=server_repo, ) assert result["removed"] == [] assert len(result["errors"]) == 1 @pytest.mark.asyncio async def test_removal_exception_records_error(self): """When remove_server raises an exception, an error is recorded.""" server_repo = AsyncMock() server_repo.list_by_source.return_value = { "/s1": {"server_name": "Server 1"}, } server_service = AsyncMock() server_service.remove_server.side_effect = RuntimeError("db failure") result = await _reconcile_agentcore_servers( expected_paths=set(), server_service=server_service, server_repo=server_repo, ) assert result["removed"] == [] assert len(result["errors"]) == 1 assert "db failure" in result["errors"][0] @pytest.mark.asyncio async def test_no_agentcore_servers_in_db(self): """When no agentcore servers exist in DB, nothing happens.""" server_repo = AsyncMock() server_repo.list_by_source.return_value = {} server_service = AsyncMock() result = await _reconcile_agentcore_servers( expected_paths=set(), server_service=server_service, server_repo=server_repo, ) assert result["removed"] == [] assert result["errors"] == [] # ============================================================================= # _reconcile_agentcore_agents Tests # ============================================================================= @pytest.mark.unit class TestReconcileAgentcoreAgents: """Tests for _reconcile_agentcore_agents.""" @pytest.mark.asyncio async def test_no_stale_agents(self): """When all agentcore agents are expected, nothing is removed.""" agent_repo = AsyncMock() agent_repo.list_all.return_value = [ _make_agent("Agent A", "/agents/agentcore-a", tags=["agentcore"]), ] result = await _reconcile_agentcore_agents( expected_paths={"/agents/agentcore-a"}, agent_repo=agent_repo, ) assert result["removed"] == [] assert result["errors"] == [] agent_repo.delete.assert_not_called() @pytest.mark.asyncio async def test_stale_agents_removed(self): """Stale agentcore agents should be removed.""" agent_repo = AsyncMock() agent_repo.list_all.return_value = [ _make_agent("Agent A", "/agents/agentcore-a", tags=["agentcore"]), _make_agent("Agent B", "/agents/agentcore-b", tags=["agentcore"]), ] agent_repo.delete.return_value = True result = await _reconcile_agentcore_agents( expected_paths={"/agents/agentcore-a"}, agent_repo=agent_repo, ) assert result["removed"] == ["Agent B"] assert result["errors"] == [] agent_repo.delete.assert_called_once_with("/agents/agentcore-b") @pytest.mark.asyncio async def test_non_agentcore_agents_ignored(self): """Agents without 'agentcore' tag or wrong path prefix are ignored.""" agent_repo = AsyncMock() agent_repo.list_all.return_value = [ _make_agent("Normal Agent", "/agents/my-agent", tags=["production"]), _make_agent("Tagged Wrong Path", "/agents/other-agent", tags=["agentcore"]), _make_agent("Right Path No Tag", "/agents/agentcore-x", tags=["other"]), ] result = await _reconcile_agentcore_agents( expected_paths=set(), agent_repo=agent_repo, ) assert result["removed"] == [] agent_repo.delete.assert_not_called() @pytest.mark.asyncio async def test_delete_failure_records_error(self): """When agent_repo.delete returns False, an error is recorded.""" agent_repo = AsyncMock() agent_repo.list_all.return_value = [ _make_agent("Agent A", "/agents/agentcore-a", tags=["agentcore"]), ] agent_repo.delete.return_value = False result = await _reconcile_agentcore_agents( expected_paths=set(), agent_repo=agent_repo, ) assert result["removed"] == [] assert len(result["errors"]) == 1 # ============================================================================= # _reconcile_agentcore_skills Tests # ============================================================================= @pytest.mark.unit class TestReconcileAgentcoreSkills: """Tests for _reconcile_agentcore_skills.""" @pytest.mark.asyncio async def test_no_stale_skills(self): """When all agentcore skills are expected, nothing is removed.""" skill_repo = AsyncMock() skill_repo.list_all.return_value = [ _make_skill("Skill X", "/skills/agentcore-x", tags=["agentcore"]), ] result = await _reconcile_agentcore_skills( expected_paths={"/skills/agentcore-x"}, skill_repo=skill_repo, ) assert result["removed"] == [] assert result["errors"] == [] skill_repo.delete.assert_not_called() @pytest.mark.asyncio async def test_stale_skills_removed(self): """Stale agentcore skills should be removed.""" skill_repo = AsyncMock() skill_repo.list_all.return_value = [ _make_skill("Skill X", "/skills/agentcore-x", tags=["agentcore"]), _make_skill("Skill Y", "/skills/agentcore-y", tags=["agentcore"]), ] skill_repo.delete.return_value = True result = await _reconcile_agentcore_skills( expected_paths={"/skills/agentcore-x"}, skill_repo=skill_repo, ) assert result["removed"] == ["Skill Y"] assert result["errors"] == [] @pytest.mark.asyncio async def test_non_agentcore_skills_ignored(self): """Skills without 'agentcore' tag or wrong path prefix are ignored.""" skill_repo = AsyncMock() skill_repo.list_all.return_value = [ _make_skill("Normal Skill", "/skills/my-skill", tags=["production"]), _make_skill("Tagged Wrong Path", "/skills/other", tags=["agentcore"]), _make_skill("Right Path No Tag", "/skills/agentcore-z", tags=["other"]), ] result = await _reconcile_agentcore_skills( expected_paths=set(), skill_repo=skill_repo, ) assert result["removed"] == [] skill_repo.delete.assert_not_called() # ============================================================================= # reconcile_agentcore_records Tests # ============================================================================= @pytest.mark.unit class TestReconcileAgentcoreRecords: """Tests for reconcile_agentcore_records orchestrator.""" @pytest.mark.asyncio async def test_dry_run_skips_removal(self): """dry_run=True should return without deleting anything.""" config = FederationConfig( agentcore=AgentCoreFederationConfig(enabled=True), ) result = await reconcile_agentcore_records( config=config, server_service=AsyncMock(), server_repo=AsyncMock(), agent_repo=AsyncMock(), skill_repo=AsyncMock(), synced_paths={"servers": set(), "agents": set(), "skills": set()}, dry_run=True, ) assert result["dry_run"] is True @pytest.mark.asyncio async def test_none_synced_paths_defaults_to_empty(self): """When synced_paths is None, it should default to empty sets.""" config = FederationConfig( agentcore=AgentCoreFederationConfig(enabled=True), ) server_repo = AsyncMock() server_repo.list_by_source.return_value = {} agent_repo = AsyncMock() agent_repo.list_all.return_value = [] skill_repo = AsyncMock() skill_repo.list_all.return_value = [] with patch("registry.services.federation_reconciliation._record_reconciliation_metrics"): result = await reconcile_agentcore_records( config=config, server_service=AsyncMock(), server_repo=server_repo, agent_repo=agent_repo, skill_repo=skill_repo, synced_paths=None, dry_run=False, ) assert result["dry_run"] is False assert result["total_removed"] == 0 @pytest.mark.asyncio async def test_full_run_removes_stale_records(self): """Full run should remove stale servers, agents, and skills.""" config = FederationConfig( agentcore=AgentCoreFederationConfig(enabled=True), ) # Server repo: one stale server server_repo = AsyncMock() server_repo.list_by_source.return_value = { "/stale-server": {"server_name": "Stale Server"}, } server_service = AsyncMock() server_service.remove_server.return_value = True # Agent repo: one stale agent agent_repo = AsyncMock() agent_repo.list_all.return_value = [ _make_agent("Stale Agent", "/agents/agentcore-old", tags=["agentcore"]), ] agent_repo.delete.return_value = True # Skill repo: one stale skill skill_repo = AsyncMock() skill_repo.list_all.return_value = [ _make_skill("Stale Skill", "/skills/agentcore-old", tags=["agentcore"]), ] skill_repo.delete.return_value = True synced_paths = { "servers": set(), "agents": set(), "skills": set(), } with patch("registry.services.federation_reconciliation._record_reconciliation_metrics"): result = await reconcile_agentcore_records( config=config, server_service=server_service, server_repo=server_repo, agent_repo=agent_repo, skill_repo=skill_repo, synced_paths=synced_paths, dry_run=False, ) assert result["dry_run"] is False assert result["total_removed"] == 3 assert "Stale Server" in result["servers"]["removed"] assert "Stale Agent" in result["agents"]["removed"] assert "Stale Skill" in result["skills"]["removed"] @pytest.mark.asyncio async def test_disabled_agentcore_removes_all(self): """When agentcore is disabled, all agentcore records should be stale.""" config = FederationConfig( agentcore=AgentCoreFederationConfig(enabled=False), ) server_repo = AsyncMock() server_repo.list_by_source.return_value = { "/s1": {"server_name": "S1"}, } server_service = AsyncMock() server_service.remove_server.return_value = True agent_repo = AsyncMock() agent_repo.list_all.return_value = [ _make_agent("Agent X", "/agents/agentcore-x", tags=["agentcore"]), ] agent_repo.delete.return_value = True skill_repo = AsyncMock() skill_repo.list_all.return_value = [] with patch("registry.services.federation_reconciliation._record_reconciliation_metrics"): result = await reconcile_agentcore_records( config=config, server_service=server_service, server_repo=server_repo, agent_repo=agent_repo, skill_repo=skill_repo, synced_paths={"servers": {"/s1"}, "agents": set(), "skills": set()}, dry_run=False, ) # Even though /s1 was in synced_paths, disabled config means expected is empty assert result["total_removed"] == 2 assert "S1" in result["servers"]["removed"] assert "Agent X" in result["agents"]["removed"] ================================================ FILE: tests/unit/services/test_m2m_management_service.py ================================================ """Unit tests for registry.services.m2m_management_service. These tests mock the Motor collection so the service logic can be exercised without a live MongoDB. """ import logging from datetime import datetime from unittest.mock import AsyncMock, MagicMock import pytest from pymongo.errors import DuplicateKeyError from registry.schemas.idp_m2m_client import ( MANUAL_PROVIDER, IdPM2MClientCreate, IdPM2MClientPatch, ) from registry.services.m2m_management_service import ( COLLECTION_NAME, M2MClientConflict, M2MClientImmutable, M2MClientNotFound, M2MManagementService, ) logger = logging.getLogger(__name__) def _make_collection_mock() -> MagicMock: """Return a MagicMock that mimics an AsyncIOMotorCollection.""" collection = MagicMock() collection.insert_one = AsyncMock() collection.find_one = AsyncMock() collection.update_one = AsyncMock() collection.delete_one = AsyncMock() collection.count_documents = AsyncMock() collection.create_index = AsyncMock() # find() returns a chainable cursor stub. cursor = MagicMock() cursor.skip = MagicMock(return_value=cursor) cursor.limit = MagicMock(return_value=cursor) cursor.to_list = AsyncMock() collection.find = MagicMock(return_value=cursor) collection._cursor = cursor return collection @pytest.fixture def mock_collection() -> MagicMock: return _make_collection_mock() @pytest.fixture def mock_db(mock_collection: MagicMock) -> MagicMock: db = MagicMock() db.__getitem__ = MagicMock(return_value=mock_collection) return db @pytest.fixture def service(mock_db: MagicMock) -> M2MManagementService: return M2MManagementService(mock_db) @pytest.fixture def sample_manual_doc() -> dict: """A manual-provider document as stored in MongoDB.""" now = datetime.utcnow() return { "client_id": "test-client-id", "name": "Test Client", "description": "A test client", "groups": ["group-a"], "enabled": True, "provider": MANUAL_PROVIDER, "idp_app_id": None, "created_by": "alice", "created_at": now, "updated_at": now, } @pytest.fixture def sample_synced_doc() -> dict: """An IdP-synced document; must be immutable to this API.""" now = datetime.utcnow() return { "client_id": "synced-client-id", "name": "Synced Client", "description": None, "groups": ["group-b"], "enabled": True, "provider": "okta", "idp_app_id": "0oa1100", "created_at": now, "updated_at": now, } class TestEnsureIndexes: """Tests for ensure_indexes.""" @pytest.mark.asyncio async def test_creates_unique_index_on_client_id( self, service: M2MManagementService, mock_collection: MagicMock, ) -> None: await service.ensure_indexes() mock_collection.create_index.assert_awaited_once_with("client_id", unique=True) class TestCreate: """Tests for M2MManagementService.create.""" @pytest.mark.asyncio async def test_inserts_document_with_manual_provider( self, service: M2MManagementService, mock_collection: MagicMock, ) -> None: payload = IdPM2MClientCreate( client_id="new-client-id", client_name="New Client", groups=["g1", "g2"], description="desc", ) mock_collection.insert_one = AsyncMock() result = await service.create(payload, created_by="alice") mock_collection.insert_one.assert_awaited_once() inserted_doc = mock_collection.insert_one.await_args.args[0] assert inserted_doc["client_id"] == "new-client-id" assert inserted_doc["name"] == "New Client" assert inserted_doc["groups"] == ["g1", "g2"] assert inserted_doc["description"] == "desc" assert inserted_doc["provider"] == MANUAL_PROVIDER assert inserted_doc["created_by"] == "alice" assert inserted_doc["enabled"] is True assert inserted_doc["idp_app_id"] is None assert isinstance(inserted_doc["created_at"], datetime) assert isinstance(inserted_doc["updated_at"], datetime) assert result.client_id == "new-client-id" assert result.provider == MANUAL_PROVIDER @pytest.mark.asyncio async def test_raises_conflict_on_duplicate_key( self, service: M2MManagementService, mock_collection: MagicMock, ) -> None: mock_collection.insert_one = AsyncMock(side_effect=DuplicateKeyError("dup")) payload = IdPM2MClientCreate( client_id="dup-id", client_name="Dup", ) with pytest.raises(M2MClientConflict): await service.create(payload, created_by=None) class TestListPaged: """Tests for M2MManagementService.list_paged.""" @pytest.mark.asyncio async def test_returns_items_and_total( self, service: M2MManagementService, mock_collection: MagicMock, sample_manual_doc: dict, ) -> None: mock_collection.count_documents = AsyncMock(return_value=1) mock_collection._cursor.to_list = AsyncMock(return_value=[sample_manual_doc]) items, total = await service.list_paged(limit=10, skip=0) assert total == 1 assert len(items) == 1 assert items[0].client_id == "test-client-id" mock_collection.count_documents.assert_awaited_once_with({}) @pytest.mark.asyncio async def test_filters_by_provider( self, service: M2MManagementService, mock_collection: MagicMock, ) -> None: mock_collection.count_documents = AsyncMock(return_value=0) mock_collection._cursor.to_list = AsyncMock(return_value=[]) items, total = await service.list_paged(provider="manual", limit=10, skip=0) assert items == [] assert total == 0 mock_collection.count_documents.assert_awaited_once_with({"provider": "manual"}) mock_collection.find.assert_called_once_with({"provider": "manual"}) @pytest.mark.asyncio async def test_applies_skip_and_limit( self, service: M2MManagementService, mock_collection: MagicMock, ) -> None: mock_collection.count_documents = AsyncMock(return_value=0) mock_collection._cursor.to_list = AsyncMock(return_value=[]) await service.list_paged(limit=25, skip=100) mock_collection._cursor.skip.assert_called_once_with(100) mock_collection._cursor.limit.assert_called_once_with(25) class TestGet: """Tests for M2MManagementService.get.""" @pytest.mark.asyncio async def test_returns_client_when_found( self, service: M2MManagementService, mock_collection: MagicMock, sample_manual_doc: dict, ) -> None: mock_collection.find_one = AsyncMock(return_value=sample_manual_doc) result = await service.get("test-client-id") assert result.client_id == "test-client-id" mock_collection.find_one.assert_awaited_once_with({"client_id": "test-client-id"}) @pytest.mark.asyncio async def test_raises_not_found_when_missing( self, service: M2MManagementService, mock_collection: MagicMock, ) -> None: mock_collection.find_one = AsyncMock(return_value=None) with pytest.raises(M2MClientNotFound): await service.get("missing") class TestPatch: """Tests for M2MManagementService.patch.""" @pytest.mark.asyncio async def test_raises_not_found_when_missing( self, service: M2MManagementService, mock_collection: MagicMock, ) -> None: mock_collection.find_one = AsyncMock(return_value=None) with pytest.raises(M2MClientNotFound): await service.patch("missing", IdPM2MClientPatch(client_name="x")) @pytest.mark.asyncio async def test_raises_immutable_for_non_manual( self, service: M2MManagementService, mock_collection: MagicMock, sample_synced_doc: dict, ) -> None: mock_collection.find_one = AsyncMock(return_value=sample_synced_doc) with pytest.raises(M2MClientImmutable): await service.patch("synced-client-id", IdPM2MClientPatch(groups=["new-group"])) @pytest.mark.asyncio async def test_updates_only_provided_fields( self, service: M2MManagementService, mock_collection: MagicMock, sample_manual_doc: dict, ) -> None: mock_collection.find_one = AsyncMock(return_value=sample_manual_doc) await service.patch( "test-client-id", IdPM2MClientPatch(groups=["new-group"]), ) mock_collection.update_one.assert_awaited_once() filter_arg, update_arg = mock_collection.update_one.await_args.args assert filter_arg == {"client_id": "test-client-id"} assert update_arg["$set"]["groups"] == ["new-group"] # client_name and description were not provided, must not be in $set. assert "name" not in update_arg["$set"] assert "description" not in update_arg["$set"] assert "enabled" not in update_arg["$set"] @pytest.mark.asyncio async def test_allows_clearing_groups_with_empty_list( self, service: M2MManagementService, mock_collection: MagicMock, sample_manual_doc: dict, ) -> None: mock_collection.find_one = AsyncMock(return_value=sample_manual_doc) await service.patch( "test-client-id", IdPM2MClientPatch(groups=[]), ) _, update_arg = mock_collection.update_one.await_args.args assert update_arg["$set"]["groups"] == [] @pytest.mark.asyncio async def test_no_op_patch_skips_update_call( self, service: M2MManagementService, mock_collection: MagicMock, sample_manual_doc: dict, ) -> None: mock_collection.find_one = AsyncMock(return_value=sample_manual_doc) # Empty patch (no fields set) should not call update_one. await service.patch("test-client-id", IdPM2MClientPatch()) mock_collection.update_one.assert_not_awaited() class TestDelete: """Tests for M2MManagementService.delete.""" @pytest.mark.asyncio async def test_deletes_manual_record( self, service: M2MManagementService, mock_collection: MagicMock, sample_manual_doc: dict, ) -> None: mock_collection.find_one = AsyncMock(return_value=sample_manual_doc) await service.delete("test-client-id") mock_collection.delete_one.assert_awaited_once_with({"client_id": "test-client-id"}) @pytest.mark.asyncio async def test_raises_not_found_when_missing( self, service: M2MManagementService, mock_collection: MagicMock, ) -> None: mock_collection.find_one = AsyncMock(return_value=None) with pytest.raises(M2MClientNotFound): await service.delete("missing") @pytest.mark.asyncio async def test_raises_immutable_for_non_manual( self, service: M2MManagementService, mock_collection: MagicMock, sample_synced_doc: dict, ) -> None: mock_collection.find_one = AsyncMock(return_value=sample_synced_doc) with pytest.raises(M2MClientImmutable): await service.delete("synced-client-id") mock_collection.delete_one.assert_not_awaited() class TestClientIdValidation: """Tests for the IdPM2MClientCreate client_id validator.""" def test_accepts_alphanumerics(self) -> None: IdPM2MClientCreate(client_id="abc123", client_name="x") def test_accepts_dash_underscore_dot_colon(self) -> None: IdPM2MClientCreate(client_id="abc-def_ghi.jkl:mno", client_name="x") def test_rejects_whitespace(self) -> None: with pytest.raises(ValueError): IdPM2MClientCreate(client_id="abc 123", client_name="x") def test_rejects_special_chars(self) -> None: with pytest.raises(ValueError): IdPM2MClientCreate(client_id="abc$123", client_name="x") def test_rejects_control_chars(self) -> None: with pytest.raises(ValueError): IdPM2MClientCreate(client_id="abc\x00123", client_name="x") class TestCollectionName: """Sanity check that service writes to the right collection.""" def test_collection_name_is_idp_m2m_clients(self) -> None: assert COLLECTION_NAME == "idp_m2m_clients" ================================================ FILE: tests/unit/services/test_peer_federation_service.py ================================================ """ Unit tests for Peer Federation Service. Tests for peer registry federation configuration management, including CRUD operations, security, and state management. Note: Helper functions (_validate_peer_id, _get_safe_file_path, etc.) have been moved to registry.repositories.file.peer_federation_repository and are tested in tests/unit/repositories/test_file_peer_federation_repository.py. """ from threading import Thread from unittest.mock import AsyncMock, patch import pytest from registry.repositories.file.peer_federation_repository import ( _get_safe_file_path, _validate_peer_id, ) from registry.schemas.peer_federation_schema import ( PeerRegistryConfig, PeerSyncStatus, ) from registry.services.peer_federation_service import ( PeerFederationService, get_peer_federation_service, ) @pytest.fixture(autouse=True) def reset_singleton(): """Reset singleton before each test.""" PeerFederationService._instance = None yield PeerFederationService._instance = None @pytest.fixture def temp_peers_dir(tmp_path): """Create temp directory for peer configs.""" peers_dir = tmp_path / "peers" peers_dir.mkdir() return peers_dir @pytest.fixture def mock_repository(): """Create a mock repository for testing.""" mock_repo = AsyncMock() mock_repo.get_peer = AsyncMock(return_value=None) mock_repo.list_peers = AsyncMock(return_value=[]) mock_repo.create_peer = AsyncMock() mock_repo.update_peer = AsyncMock() mock_repo.delete_peer = AsyncMock(return_value=True) mock_repo.get_sync_status = AsyncMock(return_value=None) mock_repo.update_sync_status = AsyncMock() mock_repo.list_sync_statuses = AsyncMock(return_value=[]) mock_repo.load_all = AsyncMock() return mock_repo @pytest.fixture def sample_peer_config(): """Sample peer config for testing.""" return PeerRegistryConfig( peer_id="central-registry", name="Central Registry", endpoint="https://central.example.com", enabled=True, sync_mode="all", sync_interval_minutes=60, ) @pytest.fixture def sample_peer_config_2(): """Second sample peer config for testing.""" return PeerRegistryConfig( peer_id="backup-registry", name="Backup Registry", endpoint="https://backup.example.com", enabled=False, sync_mode="whitelist", whitelist_servers=["/server1", "/server2"], sync_interval_minutes=120, ) @pytest.mark.unit class TestValidatePeerId: """Tests for _validate_peer_id helper function from file repository.""" def test_valid_peer_id(self): """Test that valid peer IDs pass validation.""" # Should not raise _validate_peer_id("valid-peer-123") _validate_peer_id("peer_with_underscore") _validate_peer_id("alphanumeric123") def test_empty_peer_id_rejected(self): """Test that empty peer ID is rejected.""" with pytest.raises(ValueError, match="peer_id cannot be empty"): _validate_peer_id("") def test_path_traversal_dotdot_rejected(self): """Test that .. path traversal is rejected.""" with pytest.raises(ValueError, match="path traversal detected"): _validate_peer_id("../etc/passwd") def test_path_traversal_forward_slash_rejected(self): """Test that forward slash is rejected.""" with pytest.raises(ValueError, match="path traversal detected"): _validate_peer_id("path/to/file") def test_path_traversal_backslash_rejected(self): """Test that backslash is rejected.""" with pytest.raises(ValueError, match="path traversal detected"): _validate_peer_id("path\\to\\file") def test_invalid_character_less_than_rejected(self): """Test that < character is rejected.""" with pytest.raises(ValueError, match="invalid character"): _validate_peer_id("peer= 0 @pytest.mark.asyncio async def test_sync_peer_disabled_peer_raises_error( self, mock_repository, mock_server_service, mock_agent_service, sample_peer_config_disabled, ): """Test sync disabled peer raises ValueError.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() # Set up disabled peer in cache service.registered_peers[sample_peer_config_disabled.peer_id] = ( sample_peer_config_disabled ) service.peer_sync_status[sample_peer_config_disabled.peer_id] = PeerSyncStatus( peer_id=sample_peer_config_disabled.peer_id ) with pytest.raises(ValueError, match="is disabled"): await service.sync_peer(sample_peer_config_disabled.peer_id) @pytest.mark.asyncio async def test_sync_peer_nonexistent_peer_raises_error( self, mock_repository, mock_server_service, mock_agent_service ): """Test sync non-existent peer raises ValueError.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() with pytest.raises(ValueError, match="Peer not found"): await service.sync_peer("nonexistent-peer") @pytest.mark.asyncio async def test_sync_peer_network_error_handling( self, mock_repository, mock_server_service, mock_agent_service, sample_peer_config, ): """Test network error handling during sync.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() # Set up peer in cache service.registered_peers[sample_peer_config.peer_id] = sample_peer_config service.peer_sync_status[sample_peer_config.peer_id] = PeerSyncStatus( peer_id=sample_peer_config.peer_id ) # Mock PeerRegistryClient to raise exception with patch( "registry.services.peer_federation_service.PeerRegistryClient" ) as mock_client_class: mock_client = MagicMock() mock_client.fetch_servers.side_effect = Exception("Network error") mock_client_class.return_value = mock_client result = await service.sync_peer(sample_peer_config.peer_id) # Verify result assert result.success is False assert result.peer_id == sample_peer_config.peer_id assert result.servers_synced == 0 assert result.agents_synced == 0 assert "Network error" in result.error_message @pytest.mark.asyncio async def test_sync_peer_handles_none_responses_from_client( self, mock_repository, mock_server_service, mock_agent_service, sample_peer_config, ): """ Test sync fails when client returns None (indicates fetch error). Updated for issue #561 fix: None indicates an error (auth failure, network error, etc.), not an empty result. The sync should fail with a clear error message. """ with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() # Set up peer in cache service.registered_peers[sample_peer_config.peer_id] = sample_peer_config service.peer_sync_status[sample_peer_config.peer_id] = PeerSyncStatus( peer_id=sample_peer_config.peer_id ) # Mock PeerRegistryClient - return None to simulate fetch failure with patch( "registry.services.peer_federation_service.PeerRegistryClient" ) as mock_client_class: mock_client = MagicMock() mock_client.fetch_servers.return_value = None mock_client.fetch_agents.return_value = None mock_client.fetch_security_scans.return_value = None mock_client_class.return_value = mock_client result = await service.sync_peer(sample_peer_config.peer_id) # Should fail with error message assert result.success is False assert result.servers_synced == 0 assert result.agents_synced == 0 assert result.error_message is not None assert "Failed to fetch" in result.error_message assert ( "authentication" in result.error_message.lower() or "network" in result.error_message.lower() ) @pytest.mark.asyncio async def test_sync_peer_succeeds_with_empty_list_responses( self, mock_repository, mock_server_service, mock_agent_service, sample_peer_config, ): """ Test sync succeeds when client returns empty lists (legitimate empty result). Updated for issue #561 fix: Empty list [] indicates a legitimate empty result (peer has no servers/agents), not an error. This is different from None which indicates a fetch failure. """ with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() # Set up peer in cache service.registered_peers[sample_peer_config.peer_id] = sample_peer_config service.peer_sync_status[sample_peer_config.peer_id] = PeerSyncStatus( peer_id=sample_peer_config.peer_id ) # Mock PeerRegistryClient - return empty lists (legitimate empty result) with patch( "registry.services.peer_federation_service.PeerRegistryClient" ) as mock_client_class: mock_client = MagicMock() mock_client.fetch_servers.return_value = [] mock_client.fetch_agents.return_value = [] mock_client.fetch_security_scans.return_value = [] mock_client_class.return_value = mock_client result = await service.sync_peer(sample_peer_config.peer_id) # Should succeed with 0 items assert result.success is True assert result.servers_synced == 0 assert result.agents_synced == 0 assert result.error_message is None @pytest.mark.asyncio async def test_sync_peer_fails_with_partial_none_responses( self, mock_repository, mock_server_service, mock_agent_service, sample_peer_config, ): """ Test sync fails when any fetch returns None (partial failure). If servers fetch succeeds but agents fetch fails (None), the entire sync should be marked as failed with a clear error message indicating which fetch(es) failed. """ with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() # Set up peer in cache service.registered_peers[sample_peer_config.peer_id] = sample_peer_config service.peer_sync_status[sample_peer_config.peer_id] = PeerSyncStatus( peer_id=sample_peer_config.peer_id ) # Mock PeerRegistryClient - servers succeed, agents fail with patch( "registry.services.peer_federation_service.PeerRegistryClient" ) as mock_client_class: mock_client = MagicMock() mock_client.fetch_servers.return_value = [ {"path": "/server1", "name": "Server 1"} ] mock_client.fetch_agents.return_value = None # Failure mock_client.fetch_security_scans.return_value = [] mock_client_class.return_value = mock_client result = await service.sync_peer(sample_peer_config.peer_id) # Should fail even though servers fetch succeeded assert result.success is False assert result.error_message is not None assert "agents" in result.error_message @pytest.mark.unit class TestSyncAllPeers: """Tests for sync_all_peers method.""" @pytest.mark.asyncio async def test_sync_all_enabled_peers( self, mock_repository, mock_server_service, mock_agent_service, sample_peer_config, sample_peer_config_disabled, ): """Test sync_all syncs only enabled peers by default.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() # Set up peers in cache service.registered_peers[sample_peer_config.peer_id] = sample_peer_config service.registered_peers[sample_peer_config_disabled.peer_id] = ( sample_peer_config_disabled ) service.peer_sync_status[sample_peer_config.peer_id] = PeerSyncStatus( peer_id=sample_peer_config.peer_id ) service.peer_sync_status[sample_peer_config_disabled.peer_id] = PeerSyncStatus( peer_id=sample_peer_config_disabled.peer_id ) # Mock PeerRegistryClient with patch( "registry.services.peer_federation_service.PeerRegistryClient" ) as mock_client_class: mock_client = MagicMock() mock_client.fetch_servers.return_value = [] mock_client.fetch_agents.return_value = [] mock_client_class.return_value = mock_client results = await service.sync_all_peers(enabled_only=True) # Only enabled peer should be synced assert sample_peer_config.peer_id in results assert sample_peer_config_disabled.peer_id not in results assert results[sample_peer_config.peer_id].success is True @pytest.mark.asyncio async def test_sync_all_peers_continue_on_individual_failure( self, mock_repository, mock_server_service, mock_agent_service, ): """Test sync_all continues when individual peer fails.""" peer1 = PeerRegistryConfig( peer_id="peer1", name="Peer 1", endpoint="https://peer1.example.com", enabled=True, ) peer2 = PeerRegistryConfig( peer_id="peer2", name="Peer 2", endpoint="https://peer2.example.com", enabled=True, ) with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() # Set up peers in cache service.registered_peers["peer1"] = peer1 service.registered_peers["peer2"] = peer2 service.peer_sync_status["peer1"] = PeerSyncStatus(peer_id="peer1") service.peer_sync_status["peer2"] = PeerSyncStatus(peer_id="peer2") # Mock PeerRegistryClient - first fails, second succeeds call_count = [0] def mock_client_factory(*args, **kwargs): mock_client = MagicMock() call_count[0] += 1 if call_count[0] == 1: mock_client.fetch_servers.side_effect = Exception("Peer 1 error") else: mock_client.fetch_servers.return_value = [ {"path": "/server1", "name": "Server 1"} ] mock_client.fetch_agents.return_value = [] return mock_client with patch( "registry.services.peer_federation_service.PeerRegistryClient", side_effect=mock_client_factory, ): results = await service.sync_all_peers() # Both peers should have results assert len(results) == 2 # One failed, one succeeded successes = sum(1 for r in results.values() if r.success) failures = sum(1 for r in results.values() if not r.success) assert successes == 1 assert failures == 1 @pytest.mark.unit class TestFilterServersByConfig: """Tests for _filter_servers_by_config method.""" def test_sync_mode_all_returns_all_servers(self, mock_repository, sample_peer_config): """Test sync_mode=all returns all servers.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() servers = [ {"path": "/server1", "name": "Server 1"}, {"path": "/server2", "name": "Server 2"}, {"path": "/server3", "name": "Server 3"}, ] result = service._filter_servers_by_config(servers, sample_peer_config) assert len(result) == 3 assert result == servers def test_sync_mode_whitelist_filters_by_whitelist_servers( self, mock_repository, sample_peer_config_whitelist ): """Test sync_mode=whitelist filters servers.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() servers = [ {"path": "/server1", "name": "Server 1"}, {"path": "/server2", "name": "Server 2"}, {"path": "/server3", "name": "Server 3"}, ] result = service._filter_servers_by_config(servers, sample_peer_config_whitelist) assert len(result) == 2 paths = [s["path"] for s in result] assert "/server1" in paths assert "/server2" in paths assert "/server3" not in paths def test_sync_mode_whitelist_with_empty_whitelist_returns_empty(self, mock_repository): """Test sync_mode=whitelist with empty whitelist returns empty.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() peer_config = PeerRegistryConfig( peer_id="test-peer", name="Test Peer", endpoint="https://test.example.com", sync_mode="whitelist", whitelist_servers=[], ) servers = [ {"path": "/server1", "name": "Server 1"}, {"path": "/server2", "name": "Server 2"}, ] result = service._filter_servers_by_config(servers, peer_config) assert len(result) == 0 def test_sync_mode_tag_filter_filters_by_tags( self, mock_repository, sample_peer_config_tag_filter ): """Test sync_mode=tag_filter filters by tags.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() servers = [ {"path": "/server1", "name": "Server 1", "tags": ["production"]}, {"path": "/server2", "name": "Server 2", "tags": ["staging"]}, {"path": "/server3", "name": "Server 3", "tags": ["production", "api"]}, ] result = service._filter_servers_by_config(servers, sample_peer_config_tag_filter) # Should only include servers with "production" or "public" tags assert len(result) == 2 paths = [s["path"] for s in result] assert "/server1" in paths assert "/server3" in paths assert "/server2" not in paths def test_sync_mode_tag_filter_matches_categories(self, mock_repository): """Test tag filter also checks categories field.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() peer_config = PeerRegistryConfig( peer_id="test-peer", name="Test Peer", endpoint="https://test.example.com", sync_mode="tag_filter", tag_filters=["production"], ) servers = [ {"path": "/server1", "name": "Server 1", "categories": ["production"]}, {"path": "/server2", "name": "Server 2", "tags": ["staging"]}, ] result = service._filter_servers_by_config(servers, peer_config) assert len(result) == 1 assert result[0]["path"] == "/server1" @pytest.mark.unit class TestFilterAgentsByConfig: """Tests for _filter_agents_by_config method.""" def test_sync_mode_all_returns_all_agents(self, mock_repository, sample_peer_config): """Test sync_mode=all returns all agents.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() agents = [ {"path": "/agent1", "name": "Agent 1"}, {"path": "/agent2", "name": "Agent 2"}, ] result = service._filter_agents_by_config(agents, sample_peer_config) assert len(result) == 2 assert result == agents def test_sync_mode_whitelist_filters_by_whitelist_agents( self, mock_repository, sample_peer_config_whitelist ): """Test sync_mode=whitelist filters agents.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() agents = [ {"path": "/agent1", "name": "Agent 1"}, {"path": "/agent2", "name": "Agent 2"}, ] result = service._filter_agents_by_config(agents, sample_peer_config_whitelist) assert len(result) == 1 assert result[0]["path"] == "/agent1" @pytest.mark.unit class TestMatchesTagFilter: """Tests for _matches_tag_filter method.""" def test_matches_when_tag_in_tags_field(self, mock_repository): """Test tag filter matches tags field.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() item = {"tags": ["production", "api"]} result = service._matches_tag_filter(item, ["production"]) assert result is True def test_matches_when_tag_in_categories_field(self, mock_repository): """Test tag filter matches categories field.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() item = {"categories": ["production"]} result = service._matches_tag_filter(item, ["production"]) assert result is True def test_matches_with_multiple_filters(self, mock_repository): """Test tag filter with multiple filter tags.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() item = {"tags": ["staging"]} result = service._matches_tag_filter(item, ["production", "staging"]) assert result is True def test_returns_false_when_no_match(self, mock_repository): """Test returns False when no match.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() item = {"tags": ["staging"]} result = service._matches_tag_filter(item, ["production"]) assert result is False def test_returns_false_for_empty_tag_filters(self, mock_repository): """Test returns False for empty filter list.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() item = {"tags": ["production"]} result = service._matches_tag_filter(item, []) assert result is False def test_handles_missing_tags_field(self, mock_repository): """Test handles missing tags field gracefully.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() item = {} result = service._matches_tag_filter(item, ["production"]) assert result is False @pytest.mark.unit class TestStoreSyncedServers: """Tests for _store_synced_servers method.""" @pytest.mark.asyncio async def test_store_new_server_with_sync_metadata( self, mock_repository, mock_server_service, mock_agent_service, ): """Test storing new server adds sync metadata.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() servers = [{"path": "/server1", "name": "Server 1"}] stored_count = await service._store_synced_servers("test-peer", servers) assert stored_count == 1 # Verify register_server was called mock_server_service.register_server.assert_called_once() # Check the server data has sync_metadata call_args = mock_server_service.register_server.call_args server_data = call_args[0][0] assert "sync_metadata" in server_data assert server_data["sync_metadata"]["is_federated"] is True assert server_data["sync_metadata"]["source_peer_id"] == "test-peer" @pytest.mark.asyncio async def test_store_update_existing_server( self, mock_repository, mock_server_service, mock_agent_service, ): """Test updating existing server.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): # Server already exists mock_server_service.get_server_info.return_value = { "path": "/peer-test-peer/server1", "name": "Old Server 1", "sync_metadata": {}, } service = PeerFederationService() servers = [{"path": "/server1", "name": "Server 1 Updated"}] stored_count = await service._store_synced_servers("test-peer", servers) assert stored_count == 1 # Verify update_server was called (not register_server) mock_server_service.update_server.assert_called_once() @pytest.mark.asyncio async def test_store_path_prefixing_with_peer_id( self, mock_repository, mock_server_service, mock_agent_service, ): """Test server path is prefixed with peer ID.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() servers = [{"path": "/server1", "name": "Server 1"}] await service._store_synced_servers("my-peer", servers) # Verify the path is prefixed with peer_id # Implementation uses /{peer_id}{path}, e.g., /my-peer/server1 call_args = mock_server_service.register_server.call_args server_data = call_args[0][0] assert server_data["path"] == "/my-peer/server1" @pytest.mark.asyncio async def test_store_skip_servers_missing_path_field( self, mock_repository, mock_server_service, mock_agent_service, ): """Test servers without path field are skipped.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() servers = [ {"name": "Server without path"}, {"path": "/server1", "name": "Server 1"}, ] stored_count = await service._store_synced_servers("test-peer", servers) # Only one server should be stored assert stored_count == 1 @pytest.mark.unit class TestStoreSyncedAgents: """Tests for _store_synced_agents method.""" @pytest.mark.asyncio async def test_store_new_agent_with_sync_metadata( self, mock_repository, mock_server_service, mock_agent_service, ): """Test storing new agent adds sync metadata.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() agents = [ { "path": "/agent1", "name": "Agent 1", "version": "1.0.0", "description": "Test agent", "url": "https://example.com/agent", } ] stored_count = await service._store_synced_agents("test-peer", agents) assert stored_count == 1 # Verify register_agent was called mock_agent_service.register_agent.assert_called_once() @pytest.mark.asyncio async def test_store_skip_agents_missing_path_field( self, mock_repository, mock_server_service, mock_agent_service, ): """Test agents without path field are skipped.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): service = PeerFederationService() agents = [ {"name": "Agent without path"}, { "path": "/agent1", "name": "Agent 1", "version": "1.0.0", "description": "Test", "url": "https://example.com", }, ] stored_count = await service._store_synced_agents("test-peer", agents) # Only one agent should be stored assert stored_count == 1 @pytest.mark.unit class TestDetectOrphanedItems: """Tests for detect_orphaned_items method.""" @pytest.mark.asyncio async def test_detects_servers_missing_from_peer( self, mock_repository, mock_server_service, mock_agent_service, ): """Test detects orphaned servers.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): # Mock get_all_servers to return existing synced servers # Path format: /{peer_id}{original_path} mock_server_service.get_all_servers.return_value = { "/test-peer/server1": { "path": "/test-peer/server1", "sync_metadata": { "source_peer_id": "test-peer", "is_federated": True, "original_path": "/server1", }, }, "/test-peer/server2": { "path": "/test-peer/server2", "sync_metadata": { "source_peer_id": "test-peer", "is_federated": True, "original_path": "/server2", }, }, } service = PeerFederationService() # Only server1 is currently in peer current_server_paths = ["/server1"] current_agent_paths = [] orphaned_servers, orphaned_agents = await service.detect_orphaned_items( "test-peer", current_server_paths, current_agent_paths ) # server2 should be detected as orphaned assert len(orphaned_servers) == 1 assert "/test-peer/server2" in orphaned_servers @pytest.mark.asyncio async def test_returns_empty_lists_when_no_orphans( self, mock_repository, mock_server_service, mock_agent_service, ): """Test returns empty lists when no orphans.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): # No servers exist locally mock_server_service.get_all_servers.return_value = {} mock_agent_service.get_all_agents.return_value = [] service = PeerFederationService() orphaned_servers, orphaned_agents = await service.detect_orphaned_items( "test-peer", ["/server1"], ["/agent1"] ) assert len(orphaned_servers) == 0 assert len(orphaned_agents) == 0 @pytest.mark.unit class TestSetLocalOverride: """Tests for set_local_override method.""" @pytest.mark.asyncio async def test_sets_override_to_true_for_server( self, mock_repository, mock_server_service, mock_agent_service, ): """Test setting local override to True.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): # Server exists mock_server_service.get_server_info.return_value = { "path": "/peer-test/server1", "sync_metadata": {}, } service = PeerFederationService() result = await service.set_local_override("/peer-test/server1", "server", True) assert result is True mock_server_service.update_server.assert_called_once() @pytest.mark.asyncio async def test_handles_non_existent_server( self, mock_repository, mock_server_service, mock_agent_service, ): """Test handling non-existent server.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): # Server doesn't exist mock_server_service.get_server_info.return_value = None service = PeerFederationService() result = await service.set_local_override("/nonexistent", "server", True) assert result is False @pytest.mark.unit class TestIsLocallyOverridden: """Tests for is_locally_overridden method.""" def test_returns_true_when_override_is_set(self, mock_repository): """Test returns True when local_overrides is True.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() item = {"sync_metadata": {"local_overrides": True}} result = service.is_locally_overridden(item) assert result is True def test_returns_false_when_override_not_set(self, mock_repository): """Test returns False when local_overrides is False.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() item = {"sync_metadata": {"local_overrides": False}} result = service.is_locally_overridden(item) assert result is False def test_handles_missing_sync_metadata(self, mock_repository): """Test handles missing sync_metadata.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): service = PeerFederationService() item = {} result = service.is_locally_overridden(item) assert result is False @pytest.mark.unit class TestLocalOverrideIntegration: """Integration tests for local override behavior during sync.""" @pytest.mark.asyncio async def test_local_override_prevents_server_sync_update( self, mock_repository, mock_server_service, mock_agent_service, ): """Test locally overridden servers are not updated during sync.""" with patch( "registry.services.peer_federation_service.get_peer_federation_repository", return_value=mock_repository, ): with patch( "registry.services.peer_federation_service.server_service", mock_server_service, ): with patch( "registry.services.peer_federation_service.agent_service", mock_agent_service, ): # Existing server with local override mock_server_service.get_server_info.return_value = { "path": "/peer-test-peer/server1", "name": "Local Modified Server", "sync_metadata": { "source_peer_id": "test-peer", "is_federated": True, "local_overrides": True, }, } service = PeerFederationService() servers = [{"path": "/server1", "name": "Remote Server Name"}] stored_count = await service._store_synced_servers("test-peer", servers) # Server should be skipped (not updated) assert stored_count == 0 mock_server_service.update_server.assert_not_called() mock_server_service.register_server.assert_not_called() ================================================ FILE: tests/unit/services/test_registration_gate_service.py ================================================ """Unit tests for the registration gate (admission control) service.""" import logging from unittest.mock import ( AsyncMock, MagicMock, patch, ) import httpx import pytest from registry.schemas.registration_gate_models import ( RegistrationGateAuthType, RegistrationGateRequest, RegistrationGateResponse, RegistrationGateResult, ) from registry.services.registration_gate_service import ( GATE_ERROR_MAX_LENGTH, SENSITIVE_FIELD_NAMES, SENSITIVE_FIELD_SUBSTRINGS, SENSITIVE_HEADERS, _build_auth_headers, _extract_request_headers, _is_gate_configured, _sanitize_payload, _truncate_error, check_registration_gate, ) logging.basicConfig( level=logging.INFO, format="%(asctime)s,p%(process)s,{%(filename)s:%(lineno)d},%(levelname)s,%(message)s", ) logger = logging.getLogger(__name__) SETTINGS_PATH = "registry.services.registration_gate_service.settings" HTTPX_CLIENT_PATH = "registry.services.registration_gate_service.httpx.AsyncClient" ASYNCIO_SLEEP_PATH = "registry.services.registration_gate_service.asyncio.sleep" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_raw_headers( headers: dict[str, str], ) -> list[tuple[bytes, bytes]]: """Convert a plain dict to ASGI raw header tuples. Args: headers: Dict of header name to value. Returns: List of (name_bytes, value_bytes) tuples. """ return [ (k.encode("latin-1"), v.encode("latin-1")) for k, v in headers.items() ] def _make_mock_settings( gate_enabled: bool = True, gate_url: str = "https://gate.example.com/check", auth_type: str = "none", auth_credential: str = "", auth_header_name: str = "X-Api-Key", timeout_seconds: int = 5, max_retries: int = 2, ) -> MagicMock: """Build a MagicMock that mimics the settings object. Args: gate_enabled: Whether the gate is enabled. gate_url: URL of the gate endpoint. auth_type: Auth type string. auth_credential: Credential string. auth_header_name: Header name for api_key auth. timeout_seconds: Per-request timeout. max_retries: Max retries on transient failures. Returns: MagicMock configured with the given values. """ mock = MagicMock() mock.registration_gate_enabled = gate_enabled mock.registration_gate_url = gate_url mock.registration_gate_auth_type = auth_type mock.registration_gate_auth_credential = auth_credential mock.registration_gate_auth_header_name = auth_header_name mock.registration_gate_timeout_seconds = timeout_seconds mock.registration_gate_max_retries = max_retries return mock def _make_mock_http_client( response: AsyncMock | None = None, side_effect: Exception | None = None, ) -> AsyncMock: """Build an AsyncMock that acts as httpx.AsyncClient context manager. Args: response: Mock response to return from post(). side_effect: Exception to raise on post(). Returns: AsyncMock configured as an async context manager. """ mock_client = AsyncMock() if side_effect: mock_client.post = AsyncMock(side_effect=side_effect) elif response is not None: mock_client.post = AsyncMock(return_value=response) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) return mock_client def _make_mock_response( status_code: int = 200, json_data: dict | None = None, text: str = "", ) -> MagicMock: """Build a MagicMock that mimics an httpx.Response. Args: status_code: HTTP status code. json_data: Dict returned by response.json(). text: Text returned by response.text. Returns: MagicMock configured as an HTTP response. """ mock_response = MagicMock() mock_response.status_code = status_code mock_response.text = text if json_data is not None: mock_response.json = MagicMock(return_value=json_data) else: mock_response.json = MagicMock(side_effect=ValueError("No JSON")) return mock_response # =========================================================================== # Model tests # =========================================================================== class TestRegistrationGateRequest: """Tests for the RegistrationGateRequest Pydantic model.""" def test_valid_construction(self): """Model can be constructed with all required fields.""" req = RegistrationGateRequest( asset_type="server", operation="register", source_api="/api/v1/servers", registration_payload={"name": "my-server"}, request_headers={"host": "localhost"}, ) assert req.asset_type == "server" assert req.operation == "register" assert req.source_api == "/api/v1/servers" assert req.registration_payload == {"name": "my-server"} assert req.request_headers == {"host": "localhost"} def test_default_request_headers(self): """request_headers defaults to empty dict when not provided.""" req = RegistrationGateRequest( asset_type="agent", operation="update", source_api="/api/v1/agents", registration_payload={}, ) assert req.request_headers == {} def test_serialization_round_trip(self): """Model serializes to JSON and deserializes back correctly.""" req = RegistrationGateRequest( asset_type="skill", operation="register", source_api="/api/v1/skills", registration_payload={"name": "my-skill", "version": "1.0"}, request_headers={"content-type": "application/json"}, ) json_str = req.model_dump_json() restored = RegistrationGateRequest.model_validate_json(json_str) assert restored.asset_type == req.asset_type assert restored.registration_payload == req.registration_payload class TestRegistrationGateResponse: """Tests for the RegistrationGateResponse Pydantic model.""" def test_allowed_response(self): """Response with status='allowed' and no error.""" resp = RegistrationGateResponse(status="allowed") assert resp.status == "allowed" assert resp.error is None def test_denied_response_with_error(self): """Response with status='denied' and an error message.""" resp = RegistrationGateResponse( status="denied", error="Server name is reserved", ) assert resp.status == "denied" assert resp.error == "Server name is reserved" class TestRegistrationGateResult: """Tests for the RegistrationGateResult Pydantic model.""" def test_allowed_result(self): """Result with allowed=True and no error.""" result = RegistrationGateResult( allowed=True, error_message=None, gate_status_code=200, attempts=1, ) assert result.allowed is True assert result.error_message is None assert result.gate_status_code == 200 assert result.attempts == 1 def test_denied_result(self): """Result with allowed=False and error message.""" result = RegistrationGateResult( allowed=False, error_message="Policy violation: name is blacklisted", gate_status_code=403, attempts=1, ) assert result.allowed is False assert result.error_message == "Policy violation: name is blacklisted" assert result.gate_status_code == 403 def test_defaults(self): """Default values for optional fields.""" result = RegistrationGateResult(allowed=True) assert result.error_message is None assert result.gate_status_code is None assert result.attempts == 0 class TestRegistrationGateAuthType: """Tests for the RegistrationGateAuthType enum.""" def test_enum_values(self): """Enum contains expected values.""" assert RegistrationGateAuthType.NONE == "none" assert RegistrationGateAuthType.API_KEY == "api_key" assert RegistrationGateAuthType.BEARER == "bearer" # =========================================================================== # _sanitize_payload tests # =========================================================================== class TestSanitizePayload: """Tests for _sanitize_payload.""" def test_removes_exact_sensitive_field_names(self): """Fields in SENSITIVE_FIELD_NAMES are removed.""" payload = { "name": "my-server", "auth_credential": "secret123", "auth_credential_encrypted": "enc456", "auth_header_name": "X-Secret", "description": "A test server", } result = _sanitize_payload(payload) assert "auth_credential" not in result assert "auth_credential_encrypted" not in result assert "auth_header_name" not in result assert result["name"] == "my-server" assert result["description"] == "A test server" def test_removes_fields_matching_sensitive_substrings(self): """Fields containing any sensitive substring are removed.""" payload = { "name": "my-server", "user_credential": "cred", "db_secret": "s3cret", "auth_token": "tok", "user_password": "pw", "my_api_key": "key123", "description": "safe", } result = _sanitize_payload(payload) assert "user_credential" not in result assert "db_secret" not in result assert "auth_token" not in result assert "user_password" not in result assert "my_api_key" not in result assert result["name"] == "my-server" assert result["description"] == "safe" def test_substring_matching_is_case_insensitive(self): """Sensitive substrings are matched via lowercased key.""" payload = { "MyCredential": "hidden", "DB_SECRET": "hidden", "AuthToken": "hidden", "safe_field": "visible", } result = _sanitize_payload(payload) # "MyCredential" lowercased contains "credential" assert "MyCredential" not in result # "AuthToken" lowercased contains "token" assert "AuthToken" not in result assert result["safe_field"] == "visible" def test_preserves_all_non_sensitive_fields(self): """Non-sensitive fields are preserved exactly.""" payload = { "name": "test", "description": "A server", "tags": ["prod", "ml"], "num_tools": 5, "proxy_pass_url": "http://localhost:8080", } result = _sanitize_payload(payload) assert result == payload def test_empty_payload(self): """Empty payload returns empty dict.""" result = _sanitize_payload({}) assert result == {} def test_all_sensitive_payload(self): """Payload with only sensitive fields returns empty dict.""" payload = { "auth_credential": "secret", "user_token": "tok", "db_password": "pw", } result = _sanitize_payload(payload) assert result == {} # =========================================================================== # _build_auth_headers tests # =========================================================================== class TestBuildAuthHeaders: """Tests for _build_auth_headers.""" def test_returns_empty_when_auth_type_none(self): """No headers when auth_type is 'none'.""" with patch(SETTINGS_PATH) as mock_settings: mock_settings.registration_gate_auth_type = "none" mock_settings.registration_gate_auth_credential = "" headers = _build_auth_headers() assert headers == {} def test_returns_bearer_header(self): """Bearer token header when auth_type is 'bearer'.""" with patch(SETTINGS_PATH) as mock_settings: mock_settings.registration_gate_auth_type = "bearer" mock_settings.registration_gate_auth_credential = "my-jwt-token" headers = _build_auth_headers() assert headers == {"Authorization": "Bearer my-jwt-token"} def test_returns_api_key_header(self): """Custom API key header when auth_type is 'api_key'.""" with patch(SETTINGS_PATH) as mock_settings: mock_settings.registration_gate_auth_type = "api_key" mock_settings.registration_gate_auth_credential = "key-abc-123" mock_settings.registration_gate_auth_header_name = "X-Api-Key" headers = _build_auth_headers() assert headers == {"X-Api-Key": "key-abc-123"} def test_api_key_with_custom_header_name(self): """API key uses the configured header name.""" with patch(SETTINGS_PATH) as mock_settings: mock_settings.registration_gate_auth_type = "api_key" mock_settings.registration_gate_auth_credential = "my-key" mock_settings.registration_gate_auth_header_name = "X-Custom-Auth" headers = _build_auth_headers() assert headers == {"X-Custom-Auth": "my-key"} def test_bearer_with_empty_credential_returns_empty(self): """No headers when bearer auth has empty credential.""" with patch(SETTINGS_PATH) as mock_settings: mock_settings.registration_gate_auth_type = "bearer" mock_settings.registration_gate_auth_credential = "" headers = _build_auth_headers() assert headers == {} def test_api_key_with_empty_credential_returns_empty(self): """No headers when api_key auth has empty credential.""" with patch(SETTINGS_PATH) as mock_settings: mock_settings.registration_gate_auth_type = "api_key" mock_settings.registration_gate_auth_credential = "" mock_settings.registration_gate_auth_header_name = "X-Api-Key" headers = _build_auth_headers() assert headers == {} # =========================================================================== # _extract_request_headers tests # =========================================================================== class TestExtractRequestHeaders: """Tests for _extract_request_headers.""" def test_converts_raw_asgi_headers(self): """Raw byte tuples are decoded to string dict.""" raw = _make_raw_headers({ "host": "example.com", "content-type": "application/json", }) result = _extract_request_headers(raw) assert result["host"] == "example.com" assert result["content-type"] == "application/json" def test_filters_authorization_header(self): """The 'authorization' header is excluded.""" raw = _make_raw_headers({ "authorization": "Bearer secret-token", "host": "example.com", }) result = _extract_request_headers(raw) assert "authorization" not in result assert result["host"] == "example.com" def test_filters_cookie_header(self): """The 'cookie' header is excluded.""" raw = _make_raw_headers({ "cookie": "session=abc123", "accept": "application/json", }) result = _extract_request_headers(raw) assert "cookie" not in result assert result["accept"] == "application/json" def test_filters_csrf_token_header(self): """The 'x-csrf-token' header is excluded.""" raw = _make_raw_headers({ "x-csrf-token": "csrf-value", "user-agent": "test-client", }) result = _extract_request_headers(raw) assert "x-csrf-token" not in result assert result["user-agent"] == "test-client" def test_filters_multiple_sensitive_headers(self): """All sensitive headers are excluded simultaneously.""" raw = _make_raw_headers({ "authorization": "Bearer tok", "cookie": "sess=123", "x-csrf-token": "csrf", "host": "example.com", "x-request-id": "req-001", }) result = _extract_request_headers(raw) assert len(result) == 2 assert result["host"] == "example.com" assert result["x-request-id"] == "req-001" def test_empty_headers(self): """Empty header list returns empty dict.""" result = _extract_request_headers([]) assert result == {} def test_header_names_are_lowercased(self): """Header names are lowercased during extraction.""" raw = [ (b"Host", b"example.com"), (b"Content-Type", b"application/json"), ] result = _extract_request_headers(raw) assert "host" in result assert "content-type" in result # =========================================================================== # _is_gate_configured tests # =========================================================================== class TestIsGateConfigured: """Tests for _is_gate_configured.""" def test_returns_false_when_disabled(self): """Gate is not configured when disabled.""" with patch(SETTINGS_PATH) as mock_settings: mock_settings.registration_gate_enabled = False assert _is_gate_configured() is False def test_returns_false_when_enabled_but_url_empty(self, caplog): """Gate is not configured when enabled but URL is empty.""" with ( patch(SETTINGS_PATH) as mock_settings, caplog.at_level( logging.WARNING, logger="registry.services.registration_gate_service", ), ): mock_settings.registration_gate_enabled = True mock_settings.registration_gate_url = "" assert _is_gate_configured() is False assert any( "no URL is configured" in record.message for record in caplog.records ) def test_returns_true_when_enabled_and_url_set(self): """Gate is configured when enabled and URL is present.""" with patch(SETTINGS_PATH) as mock_settings: mock_settings.registration_gate_enabled = True mock_settings.registration_gate_url = "https://gate.example.com" assert _is_gate_configured() is True # =========================================================================== # _truncate_error tests # =========================================================================== class TestTruncateError: """Tests for _truncate_error.""" def test_short_message_unchanged(self): """Messages under the max length are returned as-is.""" msg = "Registration denied" assert _truncate_error(msg) == msg def test_exact_limit_unchanged(self): """Message exactly at the limit is not truncated.""" msg = "x" * GATE_ERROR_MAX_LENGTH assert _truncate_error(msg) == msg assert len(_truncate_error(msg)) == GATE_ERROR_MAX_LENGTH def test_over_limit_is_truncated(self): """Message over the limit is truncated with ellipsis.""" msg = "y" * (GATE_ERROR_MAX_LENGTH + 100) result = _truncate_error(msg) assert len(result) == GATE_ERROR_MAX_LENGTH + 3 assert result.endswith("...") assert result[:GATE_ERROR_MAX_LENGTH] == "y" * GATE_ERROR_MAX_LENGTH def test_empty_message(self): """Empty string is returned as-is.""" assert _truncate_error("") == "" # =========================================================================== # check_registration_gate tests # =========================================================================== class TestCheckRegistrationGate: """Tests for check_registration_gate (public entry point).""" async def test_returns_allowed_when_gate_not_configured(self): """Immediately returns allowed=True when gate is disabled.""" with patch(SETTINGS_PATH) as mock_settings: mock_settings.registration_gate_enabled = False result = await check_registration_gate( asset_type="server", operation="register", source_api="/api/v1/servers", registration_payload={"name": "test"}, raw_headers=[], ) assert result.allowed is True assert result.error_message is None assert result.gate_status_code is None assert result.attempts == 0 async def test_calls_gate_when_configured_and_returns_allowed(self): """Gate returns allowed on 200 response.""" mock_response = _make_mock_response(status_code=200) mock_client = _make_mock_http_client(response=mock_response) mock_settings = _make_mock_settings( gate_enabled=True, gate_url="https://gate.example.com/check", auth_type="none", ) with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): result = await check_registration_gate( asset_type="server", operation="register", source_api="/api/v1/servers", registration_payload={"name": "my-server"}, raw_headers=_make_raw_headers({"host": "localhost"}), ) assert result.allowed is True assert result.gate_status_code == 200 assert result.attempts == 1 async def test_returns_denied_on_403_with_json_error(self): """Gate returns denied with error message from JSON body on 403.""" mock_response = _make_mock_response( status_code=403, json_data={"status": "denied", "error": "Name is reserved"}, ) mock_client = _make_mock_http_client(response=mock_response) mock_settings = _make_mock_settings() with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): result = await check_registration_gate( asset_type="agent", operation="register", source_api="/api/v1/agents", registration_payload={"name": "reserved-name"}, raw_headers=[], ) assert result.allowed is False assert result.error_message == "Name is reserved" assert result.gate_status_code == 403 assert result.attempts == 1 async def test_returns_denied_on_403_with_raw_text(self): """Gate returns denied with raw text when JSON parsing fails on 403.""" mock_response = _make_mock_response( status_code=403, text="Forbidden by policy", ) mock_client = _make_mock_http_client(response=mock_response) mock_settings = _make_mock_settings() with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): result = await check_registration_gate( asset_type="server", operation="update", source_api="/api/v1/servers", registration_payload={"name": "test"}, raw_headers=[], ) assert result.allowed is False assert result.error_message == "Forbidden by policy" assert result.gate_status_code == 403 async def test_returns_denied_on_403_default_message_when_no_body(self): """Gate returns default denial message when 403 has empty body and invalid JSON.""" mock_response = _make_mock_response( status_code=403, text="", ) mock_client = _make_mock_http_client(response=mock_response) mock_settings = _make_mock_settings() with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): result = await check_registration_gate( asset_type="server", operation="register", source_api="/api/v1/servers", registration_payload={}, raw_headers=[], ) assert result.allowed is False assert result.error_message == "Registration denied by policy" async def test_sanitizes_payload_before_sending(self): """Sensitive fields are removed from the payload sent to gate.""" mock_response = _make_mock_response(status_code=200) mock_client = _make_mock_http_client(response=mock_response) mock_settings = _make_mock_settings() with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): await check_registration_gate( asset_type="server", operation="register", source_api="/api/v1/servers", registration_payload={ "name": "my-server", "auth_credential": "secret", "description": "A server", }, raw_headers=[], ) call_kwargs = mock_client.post.call_args sent_content = call_kwargs.kwargs.get("content", "") assert "secret" not in sent_content assert "my-server" in sent_content async def test_filters_sensitive_headers_before_sending(self): """Sensitive request headers are excluded from gate payload.""" mock_response = _make_mock_response(status_code=200) mock_client = _make_mock_http_client(response=mock_response) mock_settings = _make_mock_settings() with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): await check_registration_gate( asset_type="agent", operation="register", source_api="/api/v1/agents", registration_payload={"name": "agent1"}, raw_headers=_make_raw_headers({ "host": "localhost", "authorization": "Bearer secret-token", "x-request-id": "req-001", }), ) call_kwargs = mock_client.post.call_args sent_content = call_kwargs.kwargs.get("content", "") assert "secret-token" not in sent_content assert "localhost" in sent_content # =========================================================================== # _call_gate_endpoint tests (via check_registration_gate) # =========================================================================== class TestCallGateEndpoint: """Tests for _call_gate_endpoint retry and error handling.""" async def test_timeout_exhausts_retries_and_returns_denied(self): """Timeout on all attempts results in fail-closed denial.""" mock_client = _make_mock_http_client( side_effect=httpx.TimeoutException("timed out"), ) mock_settings = _make_mock_settings(max_retries=1) with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): result = await check_registration_gate( asset_type="server", operation="register", source_api="/api/v1/servers", registration_payload={"name": "test"}, raw_headers=[], ) assert result.allowed is False assert "unavailable" in result.error_message assert "fail-closed" in result.error_message # 1 initial attempt + 1 retry = 2 total assert result.attempts == 2 async def test_connection_error_exhausts_retries_and_returns_denied(self): """Connection error on all attempts results in fail-closed denial.""" mock_client = _make_mock_http_client( side_effect=httpx.ConnectError("connection refused"), ) mock_settings = _make_mock_settings(max_retries=1) with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): result = await check_registration_gate( asset_type="agent", operation="register", source_api="/api/v1/agents", registration_payload={"name": "test"}, raw_headers=[], ) assert result.allowed is False assert "unavailable" in result.error_message assert result.attempts == 2 async def test_unexpected_status_code_triggers_retry(self): """Unexpected status codes (e.g. 500) trigger retries.""" mock_response_500 = _make_mock_response(status_code=500, text="Internal error") mock_client = _make_mock_http_client(response=mock_response_500) mock_settings = _make_mock_settings(max_retries=1) with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): result = await check_registration_gate( asset_type="skill", operation="register", source_api="/api/v1/skills", registration_payload={"name": "test"}, raw_headers=[], ) assert result.allowed is False assert result.attempts == 2 assert mock_client.post.call_count == 2 async def test_retry_succeeds_on_second_attempt(self): """Gate call succeeds on retry after first attempt fails.""" mock_response_ok = _make_mock_response(status_code=200) mock_response_500 = _make_mock_response(status_code=500, text="error") mock_client = AsyncMock() mock_client.post = AsyncMock( side_effect=[mock_response_500, mock_response_ok], ) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_settings = _make_mock_settings(max_retries=1) with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): result = await check_registration_gate( asset_type="server", operation="register", source_api="/api/v1/servers", registration_payload={"name": "test"}, raw_headers=[], ) assert result.allowed is True assert result.gate_status_code == 200 assert result.attempts == 2 async def test_no_retries_when_max_retries_is_zero(self): """Only one attempt when max_retries is 0.""" mock_client = _make_mock_http_client( side_effect=httpx.TimeoutException("timed out"), ) mock_settings = _make_mock_settings(max_retries=0) with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): result = await check_registration_gate( asset_type="server", operation="register", source_api="/api/v1/servers", registration_payload={"name": "test"}, raw_headers=[], ) assert result.allowed is False assert result.attempts == 1 assert mock_client.post.call_count == 1 async def test_backoff_sleep_called_between_retries(self): """Exponential backoff sleep is called between retry attempts.""" mock_client = _make_mock_http_client( side_effect=httpx.TimeoutException("timed out"), ) mock_settings = _make_mock_settings(max_retries=2) with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock) as mock_sleep, ): await check_registration_gate( asset_type="server", operation="register", source_api="/api/v1/servers", registration_payload={"name": "test"}, raw_headers=[], ) # With max_retries=2, total attempts=3 # Sleep is called after attempt 1 and attempt 2 (not after the last) assert mock_sleep.call_count == 2 # First backoff: 0.5 * 2^0 = 0.5 mock_sleep.assert_any_call(0.5) # Second backoff: 0.5 * 2^1 = 1.0 mock_sleep.assert_any_call(1.0) async def test_includes_bearer_auth_in_gate_request(self): """Bearer auth headers are included in gate HTTP request.""" mock_response = _make_mock_response(status_code=200) mock_client = _make_mock_http_client(response=mock_response) mock_settings = _make_mock_settings( auth_type="bearer", auth_credential="jwt-token-xyz", ) with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): await check_registration_gate( asset_type="server", operation="register", source_api="/api/v1/servers", registration_payload={"name": "test"}, raw_headers=[], ) call_kwargs = mock_client.post.call_args headers_sent = call_kwargs.kwargs.get("headers", {}) assert headers_sent.get("Authorization") == "Bearer jwt-token-xyz" async def test_403_with_long_error_is_truncated(self): """Long error messages from 403 responses are truncated.""" long_error = "x" * 1000 mock_response = _make_mock_response( status_code=403, json_data={"status": "denied", "error": long_error}, ) mock_client = _make_mock_http_client(response=mock_response) mock_settings = _make_mock_settings() with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): result = await check_registration_gate( asset_type="server", operation="register", source_api="/api/v1/servers", registration_payload={"name": "test"}, raw_headers=[], ) assert result.allowed is False assert len(result.error_message) == GATE_ERROR_MAX_LENGTH + 3 assert result.error_message.endswith("...") async def test_403_json_without_error_field_uses_default(self): """When 403 JSON has no error field, default denial message is used.""" mock_response = _make_mock_response( status_code=403, json_data={"status": "denied"}, ) mock_client = _make_mock_http_client(response=mock_response) mock_settings = _make_mock_settings() with ( patch(SETTINGS_PATH, mock_settings), patch(HTTPX_CLIENT_PATH, return_value=mock_client), patch(ASYNCIO_SLEEP_PATH, new_callable=AsyncMock), ): result = await check_registration_gate( asset_type="server", operation="register", source_api="/api/v1/servers", registration_payload={"name": "test"}, raw_headers=[], ) assert result.allowed is False assert result.error_message == "Registration denied by policy" ================================================ FILE: tests/unit/services/test_server_service.py ================================================ """ Unit tests for registry.services.server_service module. This module tests the ServerService class which manages server registration, state management, and file-based storage operations. """ import json import logging from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from registry.services.server_service import ServerService logger = logging.getLogger(__name__) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def server_service( mock_server_repository, mock_search_repository, ): """ Create a fresh ServerService instance with mocked repositories. Args: mock_server_repository: Mocked server repository mock_search_repository: Mocked search repository Yields: ServerService instance with injected mocks """ # Directly inject mocked repositories into factory singletons from registry.repositories import factory # Save original values original_server_repo = factory._server_repo original_search_repo = factory._search_repo # Set mocked repositories factory._server_repo = mock_server_repository factory._search_repo = mock_search_repository # Create service (will use mocked singletons) service = ServerService() yield service # Restore original values factory._server_repo = original_server_repo factory._search_repo = original_search_repo @pytest.fixture def sample_server_dict() -> dict[str, Any]: """ Create a sample server dictionary for testing. Returns: Dictionary with sample server data """ return { "path": "/test-server", "server_name": "test-server", "description": "A test server", "tags": ["test", "data"], "num_tools": 5, "license": "MIT", "proxy_pass_url": "http://localhost:8080", "tool_list": ["tool1", "tool2"], } @pytest.fixture def sample_server_dict_2() -> dict[str, Any]: """ Create a second sample server dictionary for testing. Returns: Dictionary with sample server data """ return { "path": "/another-server", "server_name": "another-server", "description": "Another test server", "tags": ["test"], "num_tools": 3, "license": "Apache-2.0", "proxy_pass_url": "http://localhost:9090", "tool_list": ["tool3"], } @pytest.fixture def server_json_files( tmp_path: Path, sample_server_dict: dict[str, Any], ) -> Path: """ Create sample JSON server files in tmp_path. Args: tmp_path: Temporary directory path sample_server_dict: Sample server data Returns: Path to servers directory with JSON files """ servers_dir = tmp_path / "servers" servers_dir.mkdir(parents=True, exist_ok=True) # Create a valid server file server_file = servers_dir / "test_server.json" with open(server_file, "w") as f: json.dump(sample_server_dict, f, indent=2) # Create another valid server file server_2 = { "path": "/another-server", "server_name": "another-server", "description": "Another server", } server_file_2 = servers_dir / "another_server.json" with open(server_file_2, "w") as f: json.dump(server_2, f, indent=2) # Create an invalid server file (missing required fields) invalid_file = servers_dir / "invalid_server.json" with open(invalid_file, "w") as f: json.dump({"invalid": "data"}, f) # Create a malformed JSON file malformed_file = servers_dir / "malformed.json" with open(malformed_file, "w") as f: f.write("{invalid json") return servers_dir # ============================================================================= # TEST: ServerService Instantiation # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestServerServiceInstantiation: """Test ServerService initialization and basic properties.""" def test_init_creates_service_with_repositories( self, server_service: ServerService, mock_server_repository, mock_search_repository, ): """Test that __init__ creates service with repository dependencies.""" # Assert - service should have repository instances assert server_service._repo is mock_server_repository assert server_service._search_repo is mock_search_repository # ============================================================================= # TEST: Loading Servers and State # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestLoadServersAndState: """Test loading server definitions and state from disk.""" @pytest.mark.asyncio async def test_load_servers_and_state_calls_repository( self, server_service: ServerService, mock_server_repository, ): """Test that load_servers_and_state delegates to repository.load_all().""" # Act await server_service.load_servers_and_state() # Assert - verify orchestration mock_server_repository.load_all.assert_called_once() # NOTE: The following tests have been removed because they test implementation # details (file loading, JSON parsing, state management) that belong to the # repository layer, not the service layer. These tests should exist in the # repository tests instead: # # - test_load_servers_from_empty_directory # - test_load_servers_creates_directory_if_missing # - test_load_servers_from_json_files # - test_load_servers_adds_default_fields # - test_load_servers_skips_invalid_entries # - test_load_servers_handles_duplicate_paths # - test_load_servers_skips_state_file # - test_load_service_state_from_file # - test_load_service_state_handles_trailing_slash # - test_load_service_state_with_missing_file # - test_load_service_state_with_invalid_json # # The service layer should only test orchestration, not file I/O details. # ============================================================================= # TEST: Registering Servers # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestRegisterServer: """Test server registration functionality.""" @pytest.mark.asyncio async def test_register_new_server_success( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test successfully registering a new server.""" # Arrange mock_server_repository.get.return_value = None # Server doesn't exist mock_server_repository.create.return_value = True mock_server_repository.get_state.return_value = False # Act result = await server_service.register_server(sample_server_dict) # Assert - result is now a dict with success, message, is_new_version assert result["success"] is True assert result["is_new_version"] is False mock_server_repository.create.assert_called_once() mock_search_repository.index_server.assert_called_once() @pytest.mark.asyncio async def test_register_server_calls_repository_create( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test that registering a server calls repository create.""" # Arrange mock_server_repository.get.return_value = None # Server doesn't exist mock_server_repository.create.return_value = True mock_server_repository.get_state.return_value = False # Act await server_service.register_server(sample_server_dict) # Assert - verify orchestration (server_info now includes version fields) mock_server_repository.create.assert_called_once() @pytest.mark.asyncio async def test_register_server_duplicate_path_same_version_fails( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test that registering duplicate path with same version fails.""" # Arrange - server already exists with same version existing_server = {**sample_server_dict, "version": "v1.0.0"} mock_server_repository.get.return_value = existing_server # Act - try to register with same version server_with_version = {**sample_server_dict, "version": "v1.0.0"} result = await server_service.register_server(server_with_version) # Assert - should fail with conflict assert result["success"] is False assert "already exists" in result["message"] mock_server_repository.create.assert_not_called() @pytest.mark.asyncio async def test_register_server_indexes_in_search( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test that registering a server indexes it in search.""" # Arrange mock_server_repository.create.return_value = True mock_server_repository.get_state.return_value = False # Act await server_service.register_server(sample_server_dict) # Assert - verify search indexing mock_search_repository.index_server.assert_called_once_with( sample_server_dict["path"], sample_server_dict, False ) @pytest.mark.asyncio async def test_register_server_with_repository_failure( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test registering server when repository fails.""" # Arrange - server doesn't exist but repository create fails mock_server_repository.get.return_value = None mock_server_repository.create.return_value = False # Act result = await server_service.register_server(sample_server_dict) # Assert - result is now a dict assert result["success"] is False # Search should not be called if repository fails mock_search_repository.index_server.assert_not_called() # ============================================================================= # TEST: Updating Servers # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestUpdateServer: """Test server update functionality.""" @pytest.mark.asyncio async def test_update_existing_server_success( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test successfully updating an existing server.""" # Arrange updated_server = sample_server_dict.copy() updated_server["description"] = "Updated description" updated_server["num_tools"] = 10 mock_server_repository.update.return_value = True mock_server_repository.get_state.return_value = False # Act result = await server_service.update_server(sample_server_dict["path"], updated_server) # Assert assert result is True mock_server_repository.update.assert_called_once_with( sample_server_dict["path"], updated_server ) mock_search_repository.index_server.assert_called_once() @pytest.mark.asyncio async def test_update_nonexistent_server_fails( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test updating a nonexistent server fails.""" # Arrange mock_server_repository.update.return_value = False # Act result = await server_service.update_server("/nonexistent", sample_server_dict) # Assert assert result is False mock_server_repository.update.assert_called_once_with("/nonexistent", sample_server_dict) @pytest.mark.asyncio async def test_update_server_calls_repository( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test that update_server calls repository.update().""" # Arrange updated_server = sample_server_dict.copy() updated_server["description"] = "Updated description" mock_server_repository.update.return_value = True mock_server_repository.get_state.return_value = False # Act await server_service.update_server(sample_server_dict["path"], updated_server) # Assert - verify orchestration mock_server_repository.update.assert_called_once_with( sample_server_dict["path"], updated_server ) @pytest.mark.asyncio async def test_update_server_indexes_in_search( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test that updating server updates search index.""" # Arrange updated_server = sample_server_dict.copy() updated_server["description"] = "Updated description" mock_server_repository.update.return_value = True mock_server_repository.get_state.return_value = False # Act await server_service.update_server(sample_server_dict["path"], updated_server) # Assert - verify search indexing mock_search_repository.index_server.assert_called_once_with( sample_server_dict["path"], updated_server, False ) # NOTE: test_update_enabled_server_regenerates_nginx removed # This is more of an integration test and involves complex nginx mocking. # Nginx configuration regeneration is tested separately in integration tests. # ============================================================================= # TEST: Getting Server Info # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestGetServerInfo: """Test retrieving server information.""" @pytest.mark.asyncio async def test_get_server_info_delegates_to_repository( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test that get_server_info delegates to repository.get().""" # Arrange mock_server_repository.get.return_value = sample_server_dict # Act result = await server_service.get_server_info(sample_server_dict["path"]) # Assert mock_server_repository.get.assert_called_once_with(sample_server_dict["path"]) assert result == sample_server_dict @pytest.mark.asyncio async def test_get_server_info_returns_none_when_not_found( self, server_service: ServerService, mock_server_repository, ): """Test that get_server_info returns None when repository returns None.""" # Arrange mock_server_repository.get.return_value = None # Act result = await server_service.get_server_info("/nonexistent") # Assert mock_server_repository.get.assert_called_once_with("/nonexistent") assert result is None @pytest.mark.asyncio async def test_get_server_info_returns_server_data( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test that get_server_info returns server data from repository.""" # Arrange mock_server_repository.get.return_value = sample_server_dict # Act result = await server_service.get_server_info(sample_server_dict["path"]) # Assert assert result is not None assert result["path"] == sample_server_dict["path"] assert result["server_name"] == sample_server_dict["server_name"] # ============================================================================= # TEST: Getting All Servers # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestGetAllServers: """Test retrieving all servers.""" @pytest.mark.asyncio async def test_get_all_servers_delegates_to_repository( self, server_service: ServerService, mock_server_repository, ): """Test that get_all_servers delegates to repository.list_all().""" # Arrange mock_server_repository.list_all.return_value = {} # Act result = await server_service.get_all_servers() # Assert mock_server_repository.list_all.assert_called_once() assert result == {} @pytest.mark.asyncio async def test_get_all_servers_returns_repository_data( self, server_service: ServerService, sample_server_dict: dict[str, Any], sample_server_dict_2: dict[str, Any], mock_server_repository, ): """Test that get_all_servers returns data from repository.""" # Arrange servers = { sample_server_dict["path"]: sample_server_dict, sample_server_dict_2["path"]: sample_server_dict_2, } mock_server_repository.list_all.return_value = servers # Act result = await server_service.get_all_servers() # Assert assert len(result) == 2 assert sample_server_dict["path"] in result assert sample_server_dict_2["path"] in result # ============================================================================= # TEST: Filtering Servers # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestGetFilteredServers: """Test filtering servers by user access.""" @pytest.mark.asyncio async def test_get_filtered_servers_empty_access_list( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test filtering with empty accessible_servers list.""" # Arrange mock_server_repository.list_all.return_value = { sample_server_dict["path"]: sample_server_dict } # Act result = await server_service.get_filtered_servers([]) # Assert assert result == {} @pytest.mark.asyncio async def test_get_filtered_servers_delegates_to_repository( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test that get_filtered_servers delegates to repository.list_all().""" # Arrange mock_server_repository.list_all.return_value = { sample_server_dict["path"]: sample_server_dict } # Act await server_service.get_filtered_servers(["test-server"]) # Assert mock_server_repository.list_all.assert_called_once() @pytest.mark.asyncio async def test_get_filtered_servers_matches_technical_name( self, server_service: ServerService, mock_server_repository, ): """Test filtering matches by technical name (path without slashes).""" # Arrange server = { "path": "/test-server", "server_name": "Test Server Display Name", "description": "Test", } mock_server_repository.list_all.return_value = {server["path"]: server} # Act - use technical name (path without slashes) result = await server_service.get_filtered_servers(["test-server"]) # Assert assert len(result) == 1 assert "/test-server" in result @pytest.mark.asyncio async def test_get_filtered_servers_multiple_servers( self, server_service: ServerService, sample_server_dict: dict[str, Any], sample_server_dict_2: dict[str, Any], mock_server_repository, ): """Test filtering with multiple servers and partial access.""" # Arrange mock_server_repository.list_all.return_value = { sample_server_dict["path"]: sample_server_dict, sample_server_dict_2["path"]: sample_server_dict_2, } # Act - only grant access to one server accessible = ["test-server"] # Technical name from path result = await server_service.get_filtered_servers(accessible) # Assert assert len(result) == 1 assert "/test-server" in result assert "/another-server" not in result @pytest.mark.asyncio async def test_get_filtered_servers_with_trailing_slash_in_path( self, server_service: ServerService, mock_server_repository, ): """Test filtering handles trailing slash in path.""" # Arrange server = { "path": "/test-server/", "server_name": "test", "description": "Test", } mock_server_repository.list_all.return_value = {server["path"]: server} # Act result = await server_service.get_filtered_servers(["test-server"]) # Assert assert len(result) == 1 assert "/test-server/" in result @pytest.mark.asyncio async def test_get_filtered_servers_filters_correctly( self, server_service: ServerService, sample_server_dict: dict[str, Any], sample_server_dict_2: dict[str, Any], mock_server_repository, ): """Test that filtering logic correctly applies access control.""" # Arrange mock_server_repository.list_all.return_value = { sample_server_dict["path"]: sample_server_dict, sample_server_dict_2["path"]: sample_server_dict_2, } # Act - grant access to both servers accessible = ["test-server", "another-server"] result = await server_service.get_filtered_servers(accessible) # Assert assert len(result) == 2 assert "/test-server" in result assert "/another-server" in result @pytest.mark.asyncio async def test_user_can_access_server_path_success( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test user_can_access_server_path returns True for accessible server.""" # Arrange mock_server_repository.get.return_value = sample_server_dict # Act result = await server_service.user_can_access_server_path( sample_server_dict["path"], ["test-server"] ) # Assert assert result is True mock_server_repository.get.assert_called_once_with(sample_server_dict["path"]) @pytest.mark.asyncio async def test_user_can_access_server_path_denied( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test user_can_access_server_path returns False for inaccessible server.""" # Arrange mock_server_repository.get.return_value = sample_server_dict # Act result = await server_service.user_can_access_server_path( sample_server_dict["path"], ["different-server"] ) # Assert assert result is False @pytest.mark.asyncio async def test_user_can_access_server_path_nonexistent( self, server_service: ServerService, mock_server_repository, ): """Test user_can_access_server_path returns False for nonexistent server.""" # Arrange mock_server_repository.get.return_value = None # Act result = await server_service.user_can_access_server_path("/nonexistent", ["test-server"]) # Assert assert result is False mock_server_repository.get.assert_called_once_with("/nonexistent") # ============================================================================= # TEST: Get All Servers With Permissions # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestGetAllServersWithPermissions: """Test getting servers with permission filtering.""" @pytest.mark.asyncio async def test_get_all_servers_with_permissions_admin_access( self, server_service: ServerService, sample_server_dict: dict[str, Any], sample_server_dict_2: dict[str, Any], mock_server_repository, ): """Test admin access (accessible_servers=None) returns all servers.""" # Arrange mock_server_repository.list_all.return_value = { sample_server_dict["path"]: sample_server_dict, sample_server_dict_2["path"]: sample_server_dict_2, } # Act result = await server_service.get_all_servers_with_permissions( accessible_servers=None, ) # Assert assert len(result) == 2 assert sample_server_dict["path"] in result assert sample_server_dict_2["path"] in result @pytest.mark.asyncio async def test_get_all_servers_with_permissions_filtered_access( self, server_service: ServerService, sample_server_dict: dict[str, Any], sample_server_dict_2: dict[str, Any], mock_server_repository, ): """Test filtered access returns only accessible servers.""" # Arrange mock_server_repository.list_all.return_value = { sample_server_dict["path"]: sample_server_dict, sample_server_dict_2["path"]: sample_server_dict_2, } # Act result = await server_service.get_all_servers_with_permissions( accessible_servers=["test-server"], ) # Assert assert len(result) == 1 assert "/test-server" in result # ============================================================================= # TEST: Wildcard Server Scope Access # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestWildcardServerAccess: """Test that server: '*' in scopes grants full server visibility for non-admin users.""" @pytest.mark.asyncio async def test_get_filtered_servers_wildcard_returns_all( self, server_service: ServerService, sample_server_dict: dict[str, Any], sample_server_dict_2: dict[str, Any], mock_server_repository, ): """Test get_filtered_servers with ['*'] returns all servers.""" mock_server_repository.list_all.return_value = { sample_server_dict["path"]: sample_server_dict, sample_server_dict_2["path"]: sample_server_dict_2, } result = await server_service.get_filtered_servers(["*"]) assert len(result) == 2 assert sample_server_dict["path"] in result assert sample_server_dict_2["path"] in result @pytest.mark.asyncio async def test_get_filtered_servers_wildcard_respects_include_inactive( self, server_service: ServerService, mock_server_repository, ): """Test get_filtered_servers with ['*'] and include_inactive=True returns inactive servers.""" active = {"path": "/active", "server_name": "active", "is_active": True} inactive = {"path": "/inactive", "server_name": "inactive", "is_active": False} mock_server_repository.list_all.return_value = { "/active": active, "/inactive": inactive, } result = await server_service.get_filtered_servers(["*"], include_inactive=True) assert len(result) == 2 result_active_only = await server_service.get_filtered_servers( ["*"], include_inactive=False ) assert len(result_active_only) == 1 assert "/active" in result_active_only @pytest.mark.asyncio async def test_get_all_servers_with_permissions_wildcard_returns_all( self, server_service: ServerService, sample_server_dict: dict[str, Any], sample_server_dict_2: dict[str, Any], mock_server_repository, ): """Test get_all_servers_with_permissions with ['*'] returns all servers.""" mock_server_repository.list_all.return_value = { sample_server_dict["path"]: sample_server_dict, sample_server_dict_2["path"]: sample_server_dict_2, } result = await server_service.get_all_servers_with_permissions( accessible_servers=["*"], ) assert len(result) == 2 assert sample_server_dict["path"] in result assert sample_server_dict_2["path"] in result @pytest.mark.asyncio async def test_user_can_access_server_path_wildcard_existing( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test user_can_access_server_path with ['*'] returns True for existing server.""" mock_server_repository.get.return_value = sample_server_dict result = await server_service.user_can_access_server_path(sample_server_dict["path"], ["*"]) assert result is True @pytest.mark.asyncio async def test_user_can_access_server_path_wildcard_nonexistent( self, server_service: ServerService, mock_server_repository, ): """Test user_can_access_server_path with ['*'] returns False for nonexistent server.""" mock_server_repository.get.return_value = None result = await server_service.user_can_access_server_path("/nonexistent", ["*"]) assert result is False # ============================================================================= # TEST: Service State Management # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestServiceStateManagement: """Test service enabled/disabled state management.""" @pytest.mark.asyncio async def test_is_service_enabled_default_false( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test that is_service_enabled delegates to repository.""" # Arrange mock_server_repository.get_state.return_value = False # Act result = await server_service.is_service_enabled(sample_server_dict["path"]) # Assert assert result is False mock_server_repository.get_state.assert_called_once_with(sample_server_dict["path"]) @pytest.mark.asyncio async def test_is_service_enabled_returns_true_when_enabled( self, server_service: ServerService, mock_server_repository, ): """Test is_service_enabled returns True when repository state is enabled.""" # Arrange mock_server_repository.get_state.return_value = True # Act result = await server_service.is_service_enabled("/test-server") # Assert assert result is True mock_server_repository.get_state.assert_called_once_with("/test-server") @pytest.mark.asyncio async def test_is_service_enabled_nonexistent_returns_false( self, server_service: ServerService, mock_server_repository, ): """Test is_service_enabled returns False for nonexistent path.""" # Arrange mock_server_repository.get_state.return_value = False # Act result = await server_service.is_service_enabled("/nonexistent") # Assert assert result is False mock_server_repository.get_state.assert_called_once_with("/nonexistent") @pytest.mark.asyncio async def test_get_enabled_services_empty( self, server_service: ServerService, mock_server_repository, ): """Test get_enabled_services returns empty list when none enabled.""" # Arrange mock_server_repository.list_all.return_value = {} # Act result = await server_service.get_enabled_services() # Assert assert result == [] @pytest.mark.asyncio async def test_get_enabled_services_returns_enabled_paths( self, server_service: ServerService, sample_server_dict: dict[str, Any], sample_server_dict_2: dict[str, Any], mock_server_repository, ): """Test get_enabled_services returns only enabled server paths.""" # Arrange server_1 = sample_server_dict.copy() server_1["is_enabled"] = True server_2 = sample_server_dict_2.copy() server_2["is_enabled"] = False mock_server_repository.list_all.return_value = { sample_server_dict["path"]: server_1, sample_server_dict_2["path"]: server_2, } # Act result = await server_service.get_enabled_services() # Assert assert len(result) == 1 assert sample_server_dict["path"] in result assert sample_server_dict_2["path"] not in result # ============================================================================= # TEST: Toggle Service # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestToggleService: """Test toggling service enabled/disabled state.""" @pytest.mark.asyncio async def test_toggle_service_enable_calls_repository( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test enabling a service calls repository.set_state() correctly.""" # Arrange path = sample_server_dict["path"] mock_server_repository.set_state.return_value = True # Mock list_all to return empty dict (no enabled servers) mock_server_repository.list_all.return_value = {} # Mock nginx service with patch("registry.core.nginx_service.nginx_service") as mock_nginx_service: mock_nginx_service.generate_config_async = AsyncMock() # Act result = await server_service.toggle_service(path, True) # Assert assert result is True mock_server_repository.set_state.assert_called_once_with(path, True) mock_nginx_service.generate_config_async.assert_called_once() mock_nginx_service.reload_nginx.assert_called_once() @pytest.mark.asyncio async def test_toggle_service_disable_calls_repository( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test disabling a service calls repository.set_state() correctly.""" # Arrange path = sample_server_dict["path"] mock_server_repository.set_state.return_value = True # Mock list_all to return empty dict (no enabled servers) mock_server_repository.list_all.return_value = {} # Mock nginx service with patch("registry.core.nginx_service.nginx_service") as mock_nginx_service: mock_nginx_service.generate_config_async = AsyncMock() # Act result = await server_service.toggle_service(path, False) # Assert assert result is True mock_server_repository.set_state.assert_called_once_with(path, False) mock_nginx_service.generate_config_async.assert_called_once() @pytest.mark.asyncio async def test_toggle_service_nonexistent_server_fails( self, server_service: ServerService, mock_server_repository, ): """Test toggling nonexistent service returns False.""" # Arrange mock_server_repository.set_state.return_value = False # Act result = await server_service.toggle_service("/nonexistent", True) # Assert assert result is False @pytest.mark.asyncio async def test_toggle_service_repository_failure( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test toggling service when repository fails.""" # Arrange path = sample_server_dict["path"] mock_server_repository.set_state.return_value = False # Mock nginx service with patch("registry.core.nginx_service.nginx_service") as mock_nginx_service: mock_nginx_service.generate_config_async = AsyncMock() # Act result = await server_service.toggle_service(path, True) # Assert assert result is False mock_server_repository.set_state.assert_called_once_with(path, True) # Nginx should not be called if repository fails mock_nginx_service.generate_config_async.assert_not_called() mock_nginx_service.reload_nginx.assert_not_called() # ============================================================================= # TEST: Reload State From Disk # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestReloadStateFromDisk: """Test reloading service state from disk.""" @pytest.mark.asyncio async def test_reload_state_from_disk_calls_repository( self, server_service: ServerService, mock_server_repository, ): """Test that reload_state_from_disk delegates to repository.load_all().""" # Arrange # Mock list_all to return empty dict (no servers, no changes) mock_server_repository.list_all.return_value = {} # Act await server_service.reload_state_from_disk() # Assert - verify orchestration (load_all called twice - before and after) assert mock_server_repository.load_all.call_count == 1 @pytest.mark.asyncio async def test_reload_state_detects_changes( self, server_service: ServerService, mock_server_repository, sample_server_dict: dict[str, Any], ): """Test that reload_state_from_disk detects when enabled services change.""" # Arrange path = sample_server_dict["path"] # Enabled server for all calls enabled_server = sample_server_dict.copy() enabled_server["is_enabled"] = True # list_all returns different results to simulate state change # First call (before reload): empty, After reload: has enabled server mock_server_repository.list_all.return_value = {path: enabled_server} mock_server_repository.get.return_value = enabled_server # Mock nginx service to avoid integration issues with patch("registry.core.nginx_service.nginx_service") as mock_nginx_service: # Mock the nginx methods to succeed mock_nginx_service.generate_config_async = AsyncMock(return_value=None) mock_nginx_service.reload_nginx.return_value = None # Act await server_service.reload_state_from_disk() # Assert - verify that repository.load_all was called (the key orchestration) mock_server_repository.load_all.assert_called_once() # Verify list_all was called multiple times (for getting enabled services) assert mock_server_repository.list_all.call_count >= 2 @pytest.mark.asyncio async def test_reload_state_skips_nginx_when_no_changes( self, server_service: ServerService, mock_server_repository, ): """Test that nginx is not regenerated when no changes detected.""" # Arrange # Both calls return empty dict (no changes) mock_server_repository.list_all.return_value = {} # Mock nginx service with patch("registry.core.nginx_service.nginx_service") as mock_nginx_service: mock_nginx_service.generate_config_async = AsyncMock() # Act await server_service.reload_state_from_disk() # Assert mock_nginx_service.generate_config_async.assert_not_called() mock_nginx_service.reload_nginx.assert_not_called() # NOTE: The following tests have been removed because they test implementation # details (direct state file manipulation) that belong to the repository layer: # # - test_reload_state_from_disk_detects_changes (tested state_file manipulation) # - test_reload_state_no_changes_skips_nginx (integrated state_file + nginx) # # The service layer should only test orchestration with repositories. # ============================================================================= # TEST: Remove Server # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestRemoveServer: """Test server removal functionality.""" @pytest.mark.asyncio async def test_remove_server_success( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test successfully removing a server.""" # Arrange mock_server_repository.delete_with_versions.return_value = 1 # Act result = await server_service.remove_server(sample_server_dict["path"]) # Assert assert result is True mock_server_repository.delete_with_versions.assert_called_once_with( sample_server_dict["path"] ) mock_search_repository.remove_entity.assert_called_once_with(sample_server_dict["path"]) @pytest.mark.asyncio async def test_remove_server_deletes_all_versions( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test that remove_server deletes active and version documents.""" # Arrange - simulate active doc + 2 version docs deleted mock_server_repository.delete_with_versions.return_value = 3 # Act result = await server_service.remove_server(sample_server_dict["path"]) # Assert assert result is True mock_server_repository.delete_with_versions.assert_called_once_with( sample_server_dict["path"] ) @pytest.mark.asyncio async def test_remove_server_removes_from_search( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test that removing server removes it from search index.""" # Arrange mock_server_repository.delete_with_versions.return_value = 1 # Act await server_service.remove_server(sample_server_dict["path"]) # Assert - verify search removal mock_search_repository.remove_entity.assert_called_once_with(sample_server_dict["path"]) @pytest.mark.asyncio async def test_remove_server_nonexistent_fails( self, server_service: ServerService, mock_server_repository, mock_search_repository, ): """Test removing nonexistent server fails.""" # Arrange mock_server_repository.delete_with_versions.return_value = 0 # Act result = await server_service.remove_server("/nonexistent") # Assert assert result is False mock_server_repository.delete_with_versions.assert_called_once_with("/nonexistent") @pytest.mark.asyncio async def test_remove_server_with_repository_failure( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test removing server when repository fails.""" # Arrange - repository returns 0 (nothing deleted) mock_server_repository.delete_with_versions.return_value = 0 # Act result = await server_service.remove_server(sample_server_dict["path"]) # Assert assert result is False # Search should not be called if repository deletes nothing mock_search_repository.remove_entity.assert_not_called() # ============================================================================= # TEST: Helper Methods # ============================================================================= # NOTE: TestHelperMethods class removed - these tests have been moved to # tests/unit/repositories/test_file_server_repository.py where they properly # test the repository layer instead of the service layer. # The following methods were moved: # - test_path_to_filename_* (4 tests) # - test_save_server_to_file_* (2 tests) # - test_save_service_state_* (2 tests) # Total: 9 tests migrated to repository tests (now 16 tests in repository file) # ============================================================================= # TEST: Edge Cases and Error Handling # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestEdgeCasesAndErrorHandling: """Test edge cases and error handling.""" @pytest.mark.asyncio async def test_concurrent_state_modifications( self, server_service: ServerService, sample_server_dict: dict[str, Any], sample_server_dict_2: dict[str, Any], mock_server_repository, mock_search_repository, ): """Test handling concurrent state modifications.""" # Arrange mock_server_repository.get.return_value = None # Servers don't exist mock_server_repository.create.return_value = True mock_server_repository.get_state.return_value = False mock_server_repository.set_state.return_value = True mock_server_repository.list_all.return_value = {} # Act - register and toggle multiple services result1 = await server_service.register_server(sample_server_dict) result2 = await server_service.register_server(sample_server_dict_2) # Mock nginx for toggle operations with patch("registry.core.nginx_service.nginx_service"): toggle1 = await server_service.toggle_service(sample_server_dict["path"], True) toggle2 = await server_service.toggle_service(sample_server_dict_2["path"], True) # Assert - results are now dicts assert result1["success"] is True assert result2["success"] is True assert toggle1 is True assert toggle2 is True @pytest.mark.asyncio async def test_handle_unicode_in_server_data( self, server_service: ServerService, mock_server_repository, mock_search_repository, ): """Test handling unicode characters in server data.""" # Arrange unicode_server = { "path": "/unicode-server", "server_name": "测试服务器", "description": "A server with unicode: 日本語, Español, العربية", } # First get returns None (server doesn't exist), then returns the server mock_server_repository.get.side_effect = [None, unicode_server] mock_server_repository.create.return_value = True mock_server_repository.get_state.return_value = False # Act result = await server_service.register_server(unicode_server) # Assert - result is now a dict assert result["success"] is True mock_server_repository.create.assert_called_once() # Verify unicode data is preserved in repository call loaded = await server_service.get_server_info("/unicode-server") assert loaded["server_name"] == "测试服务器" @pytest.mark.asyncio async def test_empty_path_handling( self, server_service: ServerService, mock_server_repository, mock_search_repository, ): """Test handling empty or root path.""" # Arrange root_server = { "path": "/", "server_name": "root-server", "description": "Root server", } mock_server_repository.get.return_value = None # Server doesn't exist mock_server_repository.create.return_value = True mock_server_repository.get_state.return_value = False # Act result = await server_service.register_server(root_server) # Assert - result is now a dict assert result["success"] is True mock_server_repository.create.assert_called_once() @pytest.mark.asyncio async def test_long_path_handling( self, server_service: ServerService, mock_server_repository, mock_search_repository, ): """Test handling very long paths.""" # Arrange long_path = "/" + "/".join(["segment"] * 20) long_path_server = { "path": long_path, "server_name": "long-path-server", "description": "Server with long path", } mock_server_repository.get.return_value = None # Server doesn't exist mock_server_repository.create.return_value = True mock_server_repository.get_state.return_value = False # Act result = await server_service.register_server(long_path_server) # Assert - result is now a dict assert result["success"] is True mock_server_repository.create.assert_called_once() # NOTE: The following test has been removed because it tests implementation # details (file system loading) that belong to the repository layer: # # - test_load_servers_with_subdirectories (tested file system traversal) # # The service layer should only test orchestration, not file I/O details. # ============================================================================= # TEST: Server Version Management # ============================================================================= @pytest.mark.unit @pytest.mark.servers class TestServerVersionManagement: """Test server version management functionality.""" @pytest.fixture def sample_server_with_versions(self) -> dict[str, Any]: """Create a sample server with version data (separate-documents design).""" return { "path": "/versioned-server", "server_name": "versioned-server", "description": "A server with multiple versions", "proxy_pass_url": "http://localhost:8080", "version": "v1.0.0", "is_active": True, "version_group": "versioned-server", "other_version_ids": ["/versioned-server:v2.0.0"], } @pytest.mark.asyncio async def test_get_all_servers_filters_inactive_by_default( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test that get_all_servers filters out inactive servers by default.""" # Arrange - one active, one inactive server active_server = sample_server_dict.copy() active_server["is_active"] = True inactive_server = { "path": "/inactive-server", "server_name": "inactive-server", "description": "Inactive version", "is_active": False, } mock_server_repository.list_all.return_value = { active_server["path"]: active_server, inactive_server["path"]: inactive_server, } # Act result = await server_service.get_all_servers() # Assert - only active server should be returned assert len(result) == 1 assert sample_server_dict["path"] in result assert "/inactive-server" not in result @pytest.mark.asyncio async def test_get_all_servers_includes_inactive_when_requested( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test that get_all_servers includes inactive servers when requested.""" # Arrange - one active, one inactive server active_server = sample_server_dict.copy() active_server["is_active"] = True inactive_server = { "path": "/inactive-server", "server_name": "inactive-server", "description": "Inactive version", "is_active": False, } mock_server_repository.list_all.return_value = { active_server["path"]: active_server, inactive_server["path"]: inactive_server, } # Act result = await server_service.get_all_servers(include_inactive=True) # Assert - both servers should be returned assert len(result) == 2 assert sample_server_dict["path"] in result assert "/inactive-server" in result @pytest.mark.asyncio async def test_get_all_servers_treats_missing_is_active_as_true( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test that servers without is_active field are treated as active.""" # Arrange - server without is_active field (backward compatibility) legacy_server = sample_server_dict.copy() # No is_active field - should default to True mock_server_repository.list_all.return_value = { legacy_server["path"]: legacy_server, } # Act result = await server_service.get_all_servers() # Assert - server should be included (default is_active=True) assert len(result) == 1 assert sample_server_dict["path"] in result @pytest.mark.asyncio async def test_get_filtered_servers_filters_inactive_by_default( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test that get_filtered_servers filters out inactive servers.""" # Arrange - one active, one inactive server active_server = sample_server_dict.copy() active_server["is_active"] = True inactive_server = { "path": "/inactive-server", "server_name": "inactive-server", "description": "Inactive version", "is_active": False, } mock_server_repository.list_all.return_value = { active_server["path"]: active_server, inactive_server["path"]: inactive_server, } # Act - request both servers result = await server_service.get_filtered_servers(["test-server", "inactive-server"]) # Assert - only active server should be returned assert len(result) == 1 assert sample_server_dict["path"] in result assert "/inactive-server" not in result @pytest.mark.asyncio async def test_add_server_version_creates_separate_document( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test adding a version creates a separate document (separate-documents design).""" # Arrange - server without version_group (single-version) server_data = sample_server_dict.copy() server_data["proxy_pass_url"] = "http://localhost:8080" server_data["version"] = None # No version yet server_data["version_group"] = None mock_server_repository.get.side_effect = lambda path: ( server_data if path == sample_server_dict["path"] else None ) mock_server_repository.create.return_value = True mock_server_repository.update.return_value = True mock_server_repository.list_all.return_value = {} # Mock nginx service with async methods with patch("registry.core.nginx_service.nginx_service") as mock_nginx_service: mock_nginx_service.generate_config_async = AsyncMock() mock_nginx_service.reload_nginx = MagicMock() # Act result = await server_service.add_server_version( path=sample_server_dict["path"], version="v2.0.0", proxy_pass_url="http://localhost:8081", status="beta", is_default=False, ) # Assert assert result is True # Verify a new document was created for the inactive version mock_server_repository.create.assert_called_once() call_args = mock_server_repository.create.call_args new_doc = call_args[0][0] assert new_doc["path"] == f"{sample_server_dict['path']}:v2.0.0" assert new_doc["version"] == "v2.0.0" assert new_doc["is_active"] is False assert new_doc["proxy_pass_url"] == "http://localhost:8081" @pytest.mark.asyncio async def test_add_server_version_nonexistent_server( self, server_service: ServerService, mock_server_repository, ): """Test adding a version to nonexistent server raises ValueError.""" # Arrange mock_server_repository.get.return_value = None # Act & Assert with pytest.raises(ValueError, match="Server not found"): await server_service.add_server_version( path="/nonexistent", version="v1.0.0", proxy_pass_url="http://localhost:8080" ) mock_server_repository.update.assert_not_called() @pytest.mark.asyncio async def test_set_default_version_success( self, server_service: ServerService, mock_server_repository, mock_search_repository, ): """Test setting default version swaps documents (separate-documents design).""" # Arrange - active server with one inactive version active_server = { "path": "/versioned-server", "server_name": "versioned-server", "version": "v1.0.0", "proxy_pass_url": "http://localhost:8080", "is_active": True, "version_group": "versioned-server", "other_version_ids": ["/versioned-server:v2.0.0"], "is_enabled": True, } inactive_server = { "path": "/versioned-server:v2.0.0", "server_name": "versioned-server", "version": "v2.0.0", "proxy_pass_url": "http://localhost:8081", "is_active": False, "version_group": "versioned-server", "active_version_id": "/versioned-server", } def mock_get(path): if path == "/versioned-server": return active_server elif path == "/versioned-server:v2.0.0": return inactive_server return None mock_server_repository.get.side_effect = mock_get mock_server_repository.delete.return_value = True mock_server_repository.create.return_value = True mock_server_repository.list_all.return_value = {} # Mock nginx service and health service with ( patch("registry.core.nginx_service.nginx_service") as mock_nginx_service, patch("registry.health.service.health_service") as mock_health_service, ): mock_nginx_service.generate_config_async = AsyncMock() mock_nginx_service.reload_nginx = MagicMock() mock_health_service.perform_immediate_health_check = AsyncMock( return_value=("healthy", None) ) # Act result = await server_service.set_default_version( path="/versioned-server", version="v2.0.0" ) # Assert assert result is True # Verify documents were deleted and recreated assert mock_server_repository.delete.call_count == 2 assert mock_server_repository.create.call_count == 2 # Verify search index was updated mock_search_repository.index_server.assert_called_once() @pytest.mark.asyncio async def test_set_default_version_nonexistent_version( self, server_service: ServerService, mock_server_repository, ): """Test setting default to nonexistent version raises ValueError.""" # Arrange - active server with version_group active_server = { "path": "/versioned-server", "server_name": "versioned-server", "version": "v1.0.0", "proxy_pass_url": "http://localhost:8080", "is_active": True, "version_group": "versioned-server", "other_version_ids": [], } def mock_get(path): if path == "/versioned-server": return active_server # v99.0.0 doesn't exist return None mock_server_repository.get.side_effect = mock_get # Act & Assert with pytest.raises(ValueError, match="not found"): await server_service.set_default_version( path="/versioned-server", version="v99.0.0", # Does not exist ) mock_server_repository.create.assert_not_called() @pytest.mark.asyncio async def test_remove_server_version_success( self, server_service: ServerService, mock_server_repository, ): """Test removing an inactive version deletes its document (separate-documents design).""" # Arrange - active server with one inactive version active_server = { "path": "/versioned-server", "server_name": "versioned-server", "version": "v1.0.0", "proxy_pass_url": "http://localhost:8080", "is_active": True, "version_group": "versioned-server", "other_version_ids": ["/versioned-server:v2.0.0"], } inactive_server = { "path": "/versioned-server:v2.0.0", "server_name": "versioned-server", "version": "v2.0.0", "proxy_pass_url": "http://localhost:8081", "is_active": False, "version_group": "versioned-server", "active_version_id": "/versioned-server", } def mock_get(path): if path == "/versioned-server": return active_server elif path == "/versioned-server:v2.0.0": return inactive_server return None mock_server_repository.get.side_effect = mock_get mock_server_repository.delete.return_value = True mock_server_repository.update.return_value = True mock_server_repository.list_all.return_value = {} # Mock nginx service with async methods with patch("registry.core.nginx_service.nginx_service") as mock_nginx_service: mock_nginx_service.generate_config_async = AsyncMock() mock_nginx_service.reload_nginx = MagicMock() # Act result = await server_service.remove_server_version( path="/versioned-server", version="v2.0.0" ) # Assert assert result is True # Verify the inactive version document was deleted mock_server_repository.delete.assert_called_once_with("/versioned-server:v2.0.0") # Verify the active server was updated to remove from other_version_ids mock_server_repository.update.assert_called_once() @pytest.mark.asyncio async def test_remove_server_version_cannot_remove_active( self, server_service: ServerService, mock_server_repository, ): """Test that removing active version raises ValueError (separate-documents design).""" # Arrange - active server active_server = { "path": "/versioned-server", "server_name": "versioned-server", "version": "v1.0.0", "proxy_pass_url": "http://localhost:8080", "is_active": True, "version_group": "versioned-server", "other_version_ids": ["/versioned-server:v2.0.0"], } mock_server_repository.get.return_value = active_server # Act & Assert - try to remove active version with pytest.raises(ValueError, match="Cannot remove active version"): await server_service.remove_server_version( path="/versioned-server", version="v1.0.0", # This is the active version ) mock_server_repository.delete.assert_not_called() @pytest.mark.asyncio async def test_get_server_versions_returns_versions( self, server_service: ServerService, mock_server_repository, ): """Test getting versions returns version info from separate documents.""" # Arrange - active server with one inactive version active_server = { "path": "/versioned-server", "server_name": "versioned-server", "version": "v1.0.0", "proxy_pass_url": "http://localhost:8080", "status": "stable", "description": "Active version", "is_active": True, "version_group": "versioned-server", "other_version_ids": ["/versioned-server:v2.0.0"], } inactive_server = { "path": "/versioned-server:v2.0.0", "server_name": "versioned-server", "version": "v2.0.0", "proxy_pass_url": "http://localhost:8081", "status": "beta", "description": "Beta version", "is_active": False, "version_group": "versioned-server", "active_version_id": "/versioned-server", } def mock_get(path): if path == "/versioned-server": return active_server elif path == "/versioned-server:v2.0.0": return inactive_server return None mock_server_repository.get.side_effect = mock_get # Act result = await server_service.get_server_versions("/versioned-server") # Assert assert result["path"] == "/versioned-server" assert result["default_version"] == "v1.0.0" assert len(result["versions"]) == 2 # Check active version v1 = next(v for v in result["versions"] if v["version"] == "v1.0.0") assert v1["is_default"] is True assert v1["proxy_pass_url"] == "http://localhost:8080" # Check inactive version v2 = next(v for v in result["versions"] if v["version"] == "v2.0.0") assert v2["is_default"] is False assert v2["proxy_pass_url"] == "http://localhost:8081" @pytest.mark.asyncio async def test_get_server_versions_returns_single_version_for_legacy_server( self, server_service: ServerService, sample_server_dict: dict[str, Any], mock_server_repository, ): """Test getting versions for single-version server returns v1.0.0.""" # Arrange - server without versions field mock_server_repository.get.return_value = sample_server_dict # Act result = await server_service.get_server_versions(sample_server_dict["path"]) # Assert - should return synthetic v1.0.0 version assert result["path"] == sample_server_dict["path"] assert result["default_version"] == "v1.0.0" assert len(result["versions"]) == 1 assert result["versions"][0]["version"] == "v1.0.0" assert result["versions"][0]["is_default"] is True @pytest.mark.asyncio async def test_get_server_versions_nonexistent_server( self, server_service: ServerService, mock_server_repository, ): """Test getting versions for nonexistent server raises ValueError.""" # Arrange mock_server_repository.get.return_value = None # Act & Assert with pytest.raises(ValueError, match="Server not found"): await server_service.get_server_versions("/nonexistent") ================================================ FILE: tests/unit/services/test_webhook_service.py ================================================ """Unit tests for the registration webhook notification service.""" import logging from unittest.mock import ( AsyncMock, patch, ) import httpx import pytest from registry.services.webhook_service import ( _build_auth_headers, send_registration_webhook, ) SAMPLE_CARD = { "name": "test-server", "path": "test/server", "description": "A test server", } class TestBuildAuthHeaders: """Tests for _build_auth_headers.""" def test_authorization_header_prepends_bearer(self): """Bearer prefix is added when header is Authorization.""" with patch("registry.services.webhook_service.settings") as mock_settings: mock_settings.registration_webhook_auth_token = "my-secret-token" mock_settings.registration_webhook_auth_header = "Authorization" headers = _build_auth_headers() assert headers == {"Authorization": "Bearer my-secret-token"} def test_custom_header_sends_token_as_is(self): """Custom header names send the token without Bearer prefix.""" with patch("registry.services.webhook_service.settings") as mock_settings: mock_settings.registration_webhook_auth_token = "my-api-key" mock_settings.registration_webhook_auth_header = "X-API-Key" headers = _build_auth_headers() assert headers == {"X-API-Key": "my-api-key"} def test_no_token_returns_empty_dict(self): """No auth headers when token is not configured.""" with patch("registry.services.webhook_service.settings") as mock_settings: mock_settings.registration_webhook_auth_token = None mock_settings.registration_webhook_auth_header = "Authorization" headers = _build_auth_headers() assert headers == {} def test_authorization_header_case_insensitive(self): """Bearer prefix added regardless of Authorization casing.""" with patch("registry.services.webhook_service.settings") as mock_settings: mock_settings.registration_webhook_auth_token = "tok" mock_settings.registration_webhook_auth_header = "AUTHORIZATION" headers = _build_auth_headers() assert headers == {"AUTHORIZATION": "Bearer tok"} class TestSendRegistrationWebhook: """Tests for send_registration_webhook.""" @pytest.mark.asyncio async def test_registration_event_payload(self): """Webhook is called with correct payload for a registration event.""" mock_response = AsyncMock() mock_response.status_code = 200 mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with ( patch("registry.services.webhook_service.settings") as mock_settings, patch("registry.services.webhook_service.httpx.AsyncClient", return_value=mock_client), ): mock_settings.registration_webhook_url = "https://example.com/webhook" mock_settings.registration_webhook_auth_token = None mock_settings.registration_webhook_auth_header = "Authorization" mock_settings.registration_webhook_timeout_seconds = 10 await send_registration_webhook( event_type="registration", registration_type="server", card_data=SAMPLE_CARD, performed_by="alice", ) mock_client.post.assert_called_once() call_kwargs = mock_client.post.call_args payload = call_kwargs.kwargs["json"] assert payload["event_type"] == "registration" assert payload["registration_type"] == "server" assert payload["performed_by"] == "alice" assert payload["card"] == SAMPLE_CARD assert "timestamp" in payload @pytest.mark.asyncio async def test_deletion_event_payload(self): """Webhook is called with correct payload for a deletion event.""" mock_response = AsyncMock() mock_response.status_code = 200 mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with ( patch("registry.services.webhook_service.settings") as mock_settings, patch("registry.services.webhook_service.httpx.AsyncClient", return_value=mock_client), ): mock_settings.registration_webhook_url = "https://example.com/webhook" mock_settings.registration_webhook_auth_token = None mock_settings.registration_webhook_auth_header = "Authorization" mock_settings.registration_webhook_timeout_seconds = 10 await send_registration_webhook( event_type="deletion", registration_type="agent", card_data=SAMPLE_CARD, performed_by="bob", ) call_kwargs = mock_client.post.call_args payload = call_kwargs.kwargs["json"] assert payload["event_type"] == "deletion" assert payload["registration_type"] == "agent" assert payload["performed_by"] == "bob" @pytest.mark.asyncio async def test_failure_does_not_propagate(self): """Webhook HTTP errors are logged but not raised.""" mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=httpx.ConnectError("connection refused")) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with ( patch("registry.services.webhook_service.settings") as mock_settings, patch("registry.services.webhook_service.httpx.AsyncClient", return_value=mock_client), ): mock_settings.registration_webhook_url = "https://example.com/webhook" mock_settings.registration_webhook_auth_token = None mock_settings.registration_webhook_auth_header = "Authorization" mock_settings.registration_webhook_timeout_seconds = 10 await send_registration_webhook( event_type="registration", registration_type="server", card_data=SAMPLE_CARD, ) @pytest.mark.asyncio async def test_timeout_does_not_propagate(self): """Webhook timeout is logged but not raised.""" mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=httpx.TimeoutException("timed out")) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with ( patch("registry.services.webhook_service.settings") as mock_settings, patch("registry.services.webhook_service.httpx.AsyncClient", return_value=mock_client), ): mock_settings.registration_webhook_url = "https://example.com/webhook" mock_settings.registration_webhook_auth_token = None mock_settings.registration_webhook_auth_header = "Authorization" mock_settings.registration_webhook_timeout_seconds = 5 await send_registration_webhook( event_type="registration", registration_type="skill", card_data=SAMPLE_CARD, ) @pytest.mark.asyncio async def test_no_url_configured_skips_webhook(self): """Webhook is not called when URL is not configured.""" with patch("registry.services.webhook_service.settings") as mock_settings: mock_settings.registration_webhook_url = None with patch("registry.services.webhook_service.httpx.AsyncClient") as mock_async: await send_registration_webhook( event_type="registration", registration_type="server", card_data=SAMPLE_CARD, ) mock_async.assert_not_called() @pytest.mark.asyncio async def test_empty_url_skips_webhook(self): """Webhook is not called when URL is empty string.""" with patch("registry.services.webhook_service.settings") as mock_settings: mock_settings.registration_webhook_url = "" with patch("registry.services.webhook_service.httpx.AsyncClient") as mock_async: await send_registration_webhook( event_type="registration", registration_type="server", card_data=SAMPLE_CARD, ) mock_async.assert_not_called() @pytest.mark.asyncio async def test_http_url_logs_warning(self, caplog): """A WARNING is logged when webhook URL uses HTTP instead of HTTPS.""" mock_response = AsyncMock() mock_response.status_code = 200 mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) with ( patch("registry.services.webhook_service.settings") as mock_settings, patch("registry.services.webhook_service.httpx.AsyncClient", return_value=mock_client), caplog.at_level(logging.WARNING, logger="registry.services.webhook_service"), ): mock_settings.registration_webhook_url = "http://example.com/webhook" mock_settings.registration_webhook_auth_token = None mock_settings.registration_webhook_auth_header = "Authorization" mock_settings.registration_webhook_timeout_seconds = 10 await send_registration_webhook( event_type="registration", registration_type="server", card_data=SAMPLE_CARD, ) assert any("HTTP (not HTTPS)" in record.message for record in caplog.records) @pytest.mark.asyncio async def test_invalid_url_scheme_rejected(self, caplog): """URLs with non-http(s) schemes are rejected and logged as error.""" with ( patch("registry.services.webhook_service.settings") as mock_settings, caplog.at_level(logging.ERROR, logger="registry.services.webhook_service"), ): mock_settings.registration_webhook_url = "ftp://example.com/webhook" with patch("registry.services.webhook_service.httpx.AsyncClient") as mock_async: await send_registration_webhook( event_type="registration", registration_type="server", card_data=SAMPLE_CARD, ) mock_async.assert_not_called() assert any( "Invalid webhook URL scheme" in record.message for record in caplog.records ) ================================================ FILE: tests/unit/test_backend_session_repository.py ================================================ """Unit tests for backend session Pydantic models and internal API routes.""" from datetime import UTC, datetime from unittest.mock import AsyncMock, patch import pytest from pydantic import ValidationError from registry.schemas.backend_session_models import ( BackendSessionDocument, ClientSessionDocument, CreateClientSessionRequest, CreateClientSessionResponse, GetBackendSessionResponse, StoreSessionRequest, ) class TestBackendSessionDocument: """Tests for BackendSessionDocument model.""" def test_valid_document(self): """Test creating a valid backend session document.""" doc = BackendSessionDocument( client_session_id="vs-abc123", backend_key="/_vs_backend_weather_", backend_session_id="backend-sess-xyz", user_id="admin", virtual_server_path="/virtual/my-server", ) assert doc.client_session_id == "vs-abc123" assert doc.backend_key == "/_vs_backend_weather_" assert doc.backend_session_id == "backend-sess-xyz" assert doc.user_id == "admin" assert doc.virtual_server_path == "/virtual/my-server" assert doc.created_at is not None assert doc.last_used_at is not None def test_default_timestamps(self): """Test that created_at and last_used_at are set by default.""" doc = BackendSessionDocument( client_session_id="vs-abc123", backend_key="/_vs_backend_weather_", backend_session_id="backend-sess-xyz", user_id="admin", virtual_server_path="/virtual/my-server", ) assert isinstance(doc.created_at, datetime) assert isinstance(doc.last_used_at, datetime) assert doc.created_at.tzinfo is not None def test_custom_timestamps(self): """Test providing custom timestamps.""" now = datetime.now(UTC) doc = BackendSessionDocument( client_session_id="vs-abc123", backend_key="/_vs_backend_weather_", backend_session_id="backend-sess-xyz", user_id="admin", virtual_server_path="/virtual/my-server", created_at=now, last_used_at=now, ) assert doc.created_at == now assert doc.last_used_at == now def test_requires_client_session_id(self): """Test that client_session_id is required.""" with pytest.raises(ValidationError): BackendSessionDocument( backend_key="/_vs_backend_weather_", backend_session_id="backend-sess-xyz", user_id="admin", virtual_server_path="/virtual/my-server", ) def test_requires_backend_session_id(self): """Test that backend_session_id is required.""" with pytest.raises(ValidationError): BackendSessionDocument( client_session_id="vs-abc123", backend_key="/_vs_backend_weather_", user_id="admin", virtual_server_path="/virtual/my-server", ) def test_serialization_roundtrip(self): """Test JSON serialization and deserialization.""" doc = BackendSessionDocument( client_session_id="vs-abc123", backend_key="/_vs_backend_weather_", backend_session_id="backend-sess-xyz", user_id="admin", virtual_server_path="/virtual/my-server", ) json_data = doc.model_dump(mode="json") restored = BackendSessionDocument(**json_data) assert restored.client_session_id == doc.client_session_id assert restored.backend_session_id == doc.backend_session_id class TestClientSessionDocument: """Tests for ClientSessionDocument model.""" def test_valid_document(self): """Test creating a valid client session document.""" doc = ClientSessionDocument( client_session_id="vs-abc123", user_id="admin", virtual_server_path="/virtual/my-server", ) assert doc.client_session_id == "vs-abc123" assert doc.user_id == "admin" assert doc.virtual_server_path == "/virtual/my-server" assert doc.created_at is not None assert doc.last_used_at is not None def test_requires_client_session_id(self): """Test that client_session_id is required.""" with pytest.raises(ValidationError): ClientSessionDocument( user_id="admin", virtual_server_path="/virtual/my-server", ) def test_serialization_roundtrip(self): """Test JSON serialization and deserialization.""" doc = ClientSessionDocument( client_session_id="vs-abc123", user_id="admin", virtual_server_path="/virtual/my-server", ) json_data = doc.model_dump(mode="json") restored = ClientSessionDocument(**json_data) assert restored.client_session_id == doc.client_session_id assert restored.user_id == doc.user_id class TestStoreSessionRequest: """Tests for StoreSessionRequest model.""" def test_valid_request(self): """Test creating a valid store session request.""" req = StoreSessionRequest( backend_session_id="backend-sess-xyz", client_session_id="vs-abc123", user_id="admin", virtual_server_path="/virtual/my-server", ) assert req.backend_session_id == "backend-sess-xyz" assert req.client_session_id == "vs-abc123" def test_default_user_id(self): """Test that user_id defaults to anonymous.""" req = StoreSessionRequest( backend_session_id="backend-sess-xyz", client_session_id="vs-abc123", ) assert req.user_id == "anonymous" def test_default_virtual_server_path(self): """Test that virtual_server_path defaults to empty string.""" req = StoreSessionRequest( backend_session_id="backend-sess-xyz", client_session_id="vs-abc123", ) assert req.virtual_server_path == "" def test_requires_backend_session_id(self): """Test that backend_session_id is required.""" with pytest.raises(ValidationError): StoreSessionRequest( client_session_id="vs-abc123", ) def test_requires_client_session_id(self): """Test that client_session_id is required.""" with pytest.raises(ValidationError): StoreSessionRequest( backend_session_id="backend-sess-xyz", ) class TestCreateClientSessionRequest: """Tests for CreateClientSessionRequest model.""" def test_valid_request(self): """Test creating a valid create client session request.""" req = CreateClientSessionRequest( user_id="admin", virtual_server_path="/virtual/my-server", ) assert req.user_id == "admin" assert req.virtual_server_path == "/virtual/my-server" def test_defaults(self): """Test default values.""" req = CreateClientSessionRequest() assert req.user_id == "anonymous" assert req.virtual_server_path == "" class TestCreateClientSessionResponse: """Tests for CreateClientSessionResponse model.""" def test_valid_response(self): """Test creating a valid response.""" resp = CreateClientSessionResponse( client_session_id="vs-abc123", ) assert resp.client_session_id == "vs-abc123" def test_requires_client_session_id(self): """Test that client_session_id is required.""" with pytest.raises(ValidationError): CreateClientSessionResponse() class TestGetBackendSessionResponse: """Tests for GetBackendSessionResponse model.""" def test_valid_response(self): """Test creating a valid response.""" resp = GetBackendSessionResponse( backend_session_id="backend-sess-xyz", ) assert resp.backend_session_id == "backend-sess-xyz" def test_requires_backend_session_id(self): """Test that backend_session_id is required.""" with pytest.raises(ValidationError): GetBackendSessionResponse() class TestBackendSessionInternalAPI: """Tests for internal API routes using mock repository.""" @pytest.fixture def mock_repo(self): """Create a mock backend session repository.""" mock = AsyncMock() mock.create_client_session = AsyncMock() mock.validate_client_session = AsyncMock(return_value=True) mock.get_backend_session = AsyncMock(return_value="backend-sess-xyz") mock.store_backend_session = AsyncMock() mock.delete_backend_session = AsyncMock() return mock @pytest.mark.asyncio async def test_create_client_session_generates_vs_id(self, mock_repo): """Test that create_client_session generates vs- ID.""" with patch( "registry.api.internal_routes.get_backend_session_repository", return_value=mock_repo, ): from registry.api.internal_routes import create_client_session request = CreateClientSessionRequest( user_id="admin", virtual_server_path="/virtual/my-server", ) response = await create_client_session(request) assert response.client_session_id.startswith("vs-") assert len(response.client_session_id) > 3 mock_repo.create_client_session.assert_called_once() @pytest.mark.asyncio async def test_validate_client_session_found(self, mock_repo): """Test validate returns 200 for existing session.""" mock_repo.validate_client_session.return_value = True with patch( "registry.api.internal_routes.get_backend_session_repository", return_value=mock_repo, ): from registry.api.internal_routes import validate_client_session result = await validate_client_session("vs-abc123") assert result == {"status": "valid"} @pytest.mark.asyncio async def test_validate_client_session_not_found(self, mock_repo): """Test validate raises 404 for missing session.""" mock_repo.validate_client_session.return_value = False with patch( "registry.api.internal_routes.get_backend_session_repository", return_value=mock_repo, ): from fastapi import HTTPException from registry.api.internal_routes import validate_client_session with pytest.raises(HTTPException) as exc_info: await validate_client_session("vs-nonexistent") assert exc_info.value.status_code == 404 @pytest.mark.asyncio async def test_get_backend_session_found(self, mock_repo): """Test get returns backend session ID.""" mock_repo.get_backend_session.return_value = "backend-sess-xyz" with patch( "registry.api.internal_routes.get_backend_session_repository", return_value=mock_repo, ): from registry.api.internal_routes import get_backend_session result = await get_backend_session("vs-abc123:/_vs_backend_weather_") assert result.backend_session_id == "backend-sess-xyz" @pytest.mark.asyncio async def test_get_backend_session_not_found(self, mock_repo): """Test get raises 404 for missing session.""" mock_repo.get_backend_session.return_value = None with patch( "registry.api.internal_routes.get_backend_session_repository", return_value=mock_repo, ): from fastapi import HTTPException from registry.api.internal_routes import get_backend_session with pytest.raises(HTTPException) as exc_info: await get_backend_session("vs-abc123:/_vs_backend_weather_") assert exc_info.value.status_code == 404 @pytest.mark.asyncio async def test_get_backend_session_invalid_key(self, mock_repo): """Test get raises 400 for invalid session key format.""" with patch( "registry.api.internal_routes.get_backend_session_repository", return_value=mock_repo, ): from fastapi import HTTPException from registry.api.internal_routes import get_backend_session with pytest.raises(HTTPException) as exc_info: await get_backend_session("no-colon-in-key") assert exc_info.value.status_code == 400 @pytest.mark.asyncio async def test_store_backend_session(self, mock_repo): """Test store session calls repository correctly.""" with patch( "registry.api.internal_routes.get_backend_session_repository", return_value=mock_repo, ): from registry.api.internal_routes import store_backend_session request = StoreSessionRequest( backend_session_id="backend-sess-xyz", client_session_id="vs-abc123", user_id="admin", virtual_server_path="/virtual/my-server", ) result = await store_backend_session( "vs-abc123:/_vs_backend_weather_", request, ) assert result == {"status": "stored"} mock_repo.store_backend_session.assert_called_once_with( client_session_id="vs-abc123", backend_key="/_vs_backend_weather_", backend_session_id="backend-sess-xyz", user_id="admin", virtual_server_path="/virtual/my-server", ) @pytest.mark.asyncio async def test_delete_backend_session(self, mock_repo): """Test delete session calls repository correctly.""" with patch( "registry.api.internal_routes.get_backend_session_repository", return_value=mock_repo, ): from registry.api.internal_routes import delete_backend_session result = await delete_backend_session("vs-abc123:/_vs_backend_weather_") assert result == {"status": "deleted"} mock_repo.delete_backend_session.assert_called_once_with( client_session_id="vs-abc123", backend_key="/_vs_backend_weather_", ) @pytest.mark.asyncio async def test_repo_unavailable_returns_503(self): """Test that 503 is returned when repo is None.""" with patch( "registry.api.internal_routes.get_backend_session_repository", return_value=None, ): from fastapi import HTTPException from registry.api.internal_routes import create_client_session request = CreateClientSessionRequest(user_id="admin") with pytest.raises(HTTPException) as exc_info: await create_client_session(request) assert exc_info.value.status_code == 503 ================================================ FILE: tests/unit/test_deployment_mode.py ================================================ """ Unit tests for deployment mode configuration and validation. Tests the DeploymentMode/RegistryMode enums, validation logic, and nginx_updates_enabled property. """ import pytest from registry.core.config import ( DeploymentMode, RegistryMode, Settings, _validate_mode_combination, ) # ============================================================================= # TEST CLASS: Deployment Mode Validation # ============================================================================= @pytest.mark.unit class TestDeploymentModeValidation: """Test deployment mode validation logic.""" def test_default_mode_valid(self): """Default modes should be valid.""" deployment, registry, corrected = _validate_mode_combination( DeploymentMode.WITH_GATEWAY, RegistryMode.FULL ) assert deployment == DeploymentMode.WITH_GATEWAY assert registry == RegistryMode.FULL assert corrected is False def test_gateway_skills_only_invalid(self): """Gateway + skills-only should auto-correct to registry-only.""" deployment, registry, corrected = _validate_mode_combination( DeploymentMode.WITH_GATEWAY, RegistryMode.SKILLS_ONLY ) assert deployment == DeploymentMode.REGISTRY_ONLY assert registry == RegistryMode.SKILLS_ONLY assert corrected is True def test_registry_only_full_valid(self): """Registry-only + full should be valid.""" deployment, registry, corrected = _validate_mode_combination( DeploymentMode.REGISTRY_ONLY, RegistryMode.FULL ) assert deployment == DeploymentMode.REGISTRY_ONLY assert registry == RegistryMode.FULL assert corrected is False def test_registry_only_skills_valid(self): """Registry-only + skills-only should be valid.""" deployment, registry, corrected = _validate_mode_combination( DeploymentMode.REGISTRY_ONLY, RegistryMode.SKILLS_ONLY ) assert deployment == DeploymentMode.REGISTRY_ONLY assert registry == RegistryMode.SKILLS_ONLY assert corrected is False def test_gateway_mcp_servers_only_valid(self): """Gateway + mcp-servers-only should be valid.""" deployment, registry, corrected = _validate_mode_combination( DeploymentMode.WITH_GATEWAY, RegistryMode.MCP_SERVERS_ONLY ) assert deployment == DeploymentMode.WITH_GATEWAY assert registry == RegistryMode.MCP_SERVERS_ONLY assert corrected is False # ============================================================================= # TEST CLASS: Nginx Updates Enabled # ============================================================================= @pytest.mark.unit class TestNginxUpdatesEnabled: """Test nginx_updates_enabled property.""" def test_enabled_with_gateway(self): """Should be enabled in with-gateway mode.""" settings = Settings(deployment_mode=DeploymentMode.WITH_GATEWAY) assert settings.nginx_updates_enabled is True def test_disabled_registry_only(self): """Should be disabled in registry-only mode.""" settings = Settings(deployment_mode=DeploymentMode.REGISTRY_ONLY) assert settings.nginx_updates_enabled is False from unittest.mock import MagicMock, patch # ============================================================================= # TEST CLASS: Nginx Service Deployment Mode # ============================================================================= @pytest.mark.unit class TestNginxServiceDeploymentMode: """Test nginx service respects deployment mode.""" @patch("registry.core.nginx_service.NGINX_UPDATES_SKIPPED") @patch("registry.core.nginx_service.settings") @patch("registry.core.nginx_service.Path") def test_generate_config_skipped_in_registry_only( self, mock_path_class, mock_settings, mock_counter, ): """Nginx config generation should be skipped in registry-only mode.""" mock_settings.nginx_updates_enabled = False mock_settings.deployment_mode = MagicMock() mock_settings.deployment_mode.value = "registry-only" # Mock Path for constructor SSL checks mock_path_instance = MagicMock() mock_path_instance.exists.return_value = True mock_path_class.return_value = mock_path_instance from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = service.generate_config({}) assert result is True mock_counter.labels.assert_called_with(operation="generate_config") mock_counter.labels().inc.assert_called_once() @patch("registry.core.nginx_service.NGINX_UPDATES_SKIPPED") @patch("registry.core.nginx_service.settings") @patch("registry.core.nginx_service.Path") def test_reload_nginx_skipped_in_registry_only( self, mock_path_class, mock_settings, mock_counter, ): """Nginx reload should be skipped in registry-only mode.""" mock_settings.nginx_updates_enabled = False mock_settings.deployment_mode = MagicMock() mock_settings.deployment_mode.value = "registry-only" # Mock Path for constructor SSL checks mock_path_instance = MagicMock() mock_path_instance.exists.return_value = True mock_path_class.return_value = mock_path_instance from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = service.reload_nginx() assert result is True mock_counter.labels.assert_called_with(operation="reload") mock_counter.labels().inc.assert_called_once() ================================================ FILE: tests/unit/test_entra_manager.py ================================================ """ Unit tests for registry/utils/entra_manager.py Tests for Microsoft Entra ID group and user management utilities. Includes tests for: - GUID validation helper - Temporary password generation - Graph API token acquisition - User listing operations - Group CRUD operations """ import logging import string from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest from registry.utils.entra_manager import ( EntraAdminError, _build_prefix_odata_filter, _generate_temp_password, _is_guid, create_entra_group, delete_entra_group, list_entra_groups, list_entra_users, ) logger = logging.getLogger(__name__) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def mock_admin_token() -> str: """Provide a mock admin token for testing.""" return "mock-access-token-12345" @pytest.fixture def mock_token_response(mock_admin_token: str) -> dict[str, Any]: """Provide a mock token response from Entra ID.""" return { "access_token": mock_admin_token, "token_type": "Bearer", "expires_in": 3600, } @pytest.fixture def mock_users_response() -> dict[str, Any]: """Provide a mock users response from Graph API.""" return { "value": [ { "id": "user-id-123", "displayName": "John Doe", "userPrincipalName": "john.doe@example.com", "mail": "john.doe@example.com", "givenName": "John", "surname": "Doe", "accountEnabled": True, }, { "id": "user-id-456", "displayName": "Jane Smith", "userPrincipalName": "jane.smith@example.com", "mail": "jane.smith@example.com", "givenName": "Jane", "surname": "Smith", "accountEnabled": False, }, ] } @pytest.fixture def mock_groups_response() -> dict[str, Any]: """Provide a mock groups response from Graph API.""" return { "value": [ { "id": "group-id-123", "displayName": "Registry Admins", "description": "Admin group for registry", "securityEnabled": True, }, { "id": "group-id-456", "displayName": "Registry Users", "description": "User group for registry", "securityEnabled": True, }, ] } @pytest.fixture def mock_create_group_response() -> dict[str, Any]: """Provide a mock create group response from Graph API.""" return { "id": "new-group-id-789", "displayName": "New Test Group", "description": "A new test group", "securityEnabled": True, } @pytest.fixture def entra_env_vars(monkeypatch): """Set up environment variables for Entra ID authentication.""" monkeypatch.setenv("ENTRA_TENANT_ID", "test-tenant-id") monkeypatch.setenv("ENTRA_CLIENT_ID", "test-client-id") monkeypatch.setenv("ENTRA_CLIENT_SECRET", "test-client-secret") # Also patch the module-level constants monkeypatch.setattr("registry.utils.entra_manager.ENTRA_TENANT_ID", "test-tenant-id") monkeypatch.setattr("registry.utils.entra_manager.ENTRA_CLIENT_ID", "test-client-id") monkeypatch.setattr("registry.utils.entra_manager.ENTRA_CLIENT_SECRET", "test-client-secret") # ============================================================================= # TEST: _is_guid() helper # ============================================================================= @pytest.mark.unit class TestIsGuid: """Tests for _is_guid helper function.""" def test_valid_guid_lowercase(self): """Test valid lowercase GUID returns True.""" # Arrange valid_guid = "12345678-1234-1234-1234-123456789abc" # Act result = _is_guid(valid_guid) # Assert assert result is True def test_valid_guid_uppercase(self): """Test valid uppercase GUID returns True.""" # Arrange valid_guid = "12345678-1234-1234-1234-123456789ABC" # Act result = _is_guid(valid_guid) # Assert assert result is True def test_valid_guid_mixed_case(self): """Test valid mixed case GUID returns True.""" # Arrange valid_guid = "12345678-1234-1234-1234-123456789AbC" # Act result = _is_guid(valid_guid) # Assert assert result is True def test_invalid_guid_wrong_format(self): """Test invalid GUID format returns False.""" # Arrange invalid_guid = "12345678123412341234123456789abc" # No dashes # Act result = _is_guid(invalid_guid) # Assert assert result is False def test_invalid_guid_short_string(self): """Test short string returns False.""" # Arrange invalid_guid = "12345678" # Act result = _is_guid(invalid_guid) # Assert assert result is False def test_invalid_guid_display_name(self): """Test display name string returns False.""" # Arrange display_name = "Registry Admins" # Act result = _is_guid(display_name) # Assert assert result is False def test_invalid_guid_empty_string(self): """Test empty string returns False.""" # Arrange empty_string = "" # Act result = _is_guid(empty_string) # Assert assert result is False def test_invalid_guid_contains_invalid_chars(self): """Test GUID with invalid characters returns False.""" # Arrange invalid_guid = "12345678-1234-1234-1234-123456789xyz" # xyz not valid hex # Act result = _is_guid(invalid_guid) # Assert assert result is False def test_invalid_guid_wrong_segment_lengths(self): """Test GUID with wrong segment lengths returns False.""" # Arrange invalid_guid = "1234-12345678-1234-1234-123456789abc" # Wrong segment order # Act result = _is_guid(invalid_guid) # Assert assert result is False # ============================================================================= # TEST: _generate_temp_password() # ============================================================================= @pytest.mark.unit class TestGenerateTempPassword: """Tests for _generate_temp_password helper function.""" def test_password_length(self): """Test password meets length requirements (16 chars).""" # Act password = _generate_temp_password() # Assert assert len(password) == 16 def test_password_contains_allowed_characters(self): """Test password contains only allowed character types.""" # Arrange allowed_chars = string.ascii_letters + string.digits + "!@#$%^&*()" # Act password = _generate_temp_password() # Assert for char in password: assert char in allowed_chars, f"Character '{char}' not in allowed set" def test_password_randomness(self): """Test that generated passwords are different each time.""" # Act passwords = [_generate_temp_password() for _ in range(10)] # Assert - all passwords should be unique assert len(set(passwords)) == 10, "Passwords should be randomly generated" def test_password_contains_letters(self): """Test password typically contains letters.""" # Act - generate multiple to increase probability of coverage passwords = [_generate_temp_password() for _ in range(20)] # Assert - at least some passwords should contain letters has_letters = False for password in passwords: if any(c in string.ascii_letters for c in password): has_letters = True break assert has_letters, "At least some passwords should contain letters" def test_password_is_string(self): """Test password is returned as string.""" # Act password = _generate_temp_password() # Assert assert isinstance(password, str) # ============================================================================= # TEST: _get_entra_admin_token() error handling # ============================================================================= @pytest.mark.unit class TestGetEntraAdminToken: """Tests for _get_entra_admin_token error handling.""" @pytest.mark.asyncio async def test_raises_error_when_client_secret_missing(self, monkeypatch): """Test raises EntraAdminError when ENTRA_CLIENT_SECRET not set.""" # Arrange monkeypatch.setattr("registry.utils.entra_manager.ENTRA_CLIENT_SECRET", "") monkeypatch.setattr("registry.utils.entra_manager.ENTRA_TENANT_ID", "test-tenant") monkeypatch.setattr("registry.utils.entra_manager.ENTRA_CLIENT_ID", "test-client") # Import after patching from registry.utils.entra_manager import _get_entra_admin_token # Act & Assert with pytest.raises(EntraAdminError) as exc_info: await _get_entra_admin_token() assert "ENTRA_CLIENT_SECRET" in str(exc_info.value) @pytest.mark.asyncio async def test_raises_error_when_tenant_id_missing(self, monkeypatch): """Test raises EntraAdminError when ENTRA_TENANT_ID not set.""" # Arrange monkeypatch.setattr("registry.utils.entra_manager.ENTRA_CLIENT_SECRET", "test-secret") monkeypatch.setattr("registry.utils.entra_manager.ENTRA_TENANT_ID", "") monkeypatch.setattr("registry.utils.entra_manager.ENTRA_CLIENT_ID", "test-client") # Import after patching from registry.utils.entra_manager import _get_entra_admin_token # Act & Assert with pytest.raises(EntraAdminError) as exc_info: await _get_entra_admin_token() assert "ENTRA_TENANT_ID" in str(exc_info.value) @pytest.mark.asyncio async def test_raises_error_when_client_id_missing(self, monkeypatch): """Test raises EntraAdminError when ENTRA_CLIENT_ID not set.""" # Arrange monkeypatch.setattr("registry.utils.entra_manager.ENTRA_CLIENT_SECRET", "test-secret") monkeypatch.setattr("registry.utils.entra_manager.ENTRA_TENANT_ID", "test-tenant") monkeypatch.setattr("registry.utils.entra_manager.ENTRA_CLIENT_ID", "") # Import after patching from registry.utils.entra_manager import _get_entra_admin_token # Act & Assert with pytest.raises(EntraAdminError) as exc_info: await _get_entra_admin_token() assert "ENTRA_CLIENT_ID" in str(exc_info.value) @pytest.mark.asyncio async def test_raises_error_on_http_error(self, entra_env_vars): """Test raises EntraAdminError on HTTP error response.""" # Arrange from registry.utils.entra_manager import _get_entra_admin_token # Create a mock response that raises HTTPStatusError mock_response = MagicMock() mock_response.status_code = 401 mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Unauthorized", request=MagicMock(), response=mock_response ) mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act & Assert with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): with pytest.raises(EntraAdminError) as exc_info: await _get_entra_admin_token() assert "authentication failed" in str(exc_info.value).lower() @pytest.mark.asyncio async def test_raises_error_when_no_access_token_in_response(self, entra_env_vars): """Test raises EntraAdminError when response has no access_token.""" # Arrange from registry.utils.entra_manager import _get_entra_admin_token # Create a mock response with no access_token mock_response = MagicMock() mock_response.status_code = 200 mock_response.raise_for_status.return_value = None mock_response.json.return_value = {"error": "something went wrong"} mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act & Assert with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): with pytest.raises(EntraAdminError) as exc_info: await _get_entra_admin_token() assert "No access token" in str(exc_info.value) # ============================================================================= # TEST: list_entra_users() # ============================================================================= @pytest.mark.unit class TestListEntraUsers: """Tests for list_entra_users function.""" @pytest.mark.asyncio async def test_list_users_success( self, entra_env_vars, mock_token_response: dict[str, Any], mock_users_response: dict[str, Any], ): """Test listing users successfully.""" # Arrange mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_users_resp = MagicMock() mock_users_resp.status_code = 200 mock_users_resp.raise_for_status.return_value = None mock_users_resp.json.return_value = mock_users_response mock_groups_resp = MagicMock() mock_groups_resp.status_code = 200 mock_groups_resp.raise_for_status.return_value = None mock_groups_resp.json.return_value = {"value": []} mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.get.side_effect = [mock_users_resp, mock_groups_resp, mock_groups_resp] mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): result = await list_entra_users(include_groups=True) # Assert assert len(result) == 2 assert result[0]["id"] == "user-id-123" assert result[0]["username"] == "john.doe@example.com" assert result[0]["email"] == "john.doe@example.com" assert result[0]["firstName"] == "John" assert result[0]["lastName"] == "Doe" assert result[0]["enabled"] is True assert result[1]["id"] == "user-id-456" assert result[1]["username"] == "jane.smith@example.com" assert result[1]["enabled"] is False @pytest.mark.asyncio async def test_list_users_without_groups( self, entra_env_vars, mock_token_response: dict[str, Any], mock_users_response: dict[str, Any], ): """Test listing users without group memberships.""" # Arrange mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_users_resp = MagicMock() mock_users_resp.status_code = 200 mock_users_resp.raise_for_status.return_value = None mock_users_resp.json.return_value = mock_users_response mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.get.return_value = mock_users_resp mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): result = await list_entra_users(include_groups=False) # Assert assert len(result) == 2 # Groups should be empty lists since we didn't fetch them assert result[0]["groups"] == [] assert result[1]["groups"] == [] @pytest.mark.asyncio async def test_list_users_transforms_data_correctly( self, entra_env_vars, mock_token_response: dict[str, Any], ): """Test that user data is transformed correctly.""" # Arrange users_response = { "value": [ { "id": "user-123", "displayName": "Test User", "userPrincipalName": "test@example.com", "mail": "test.mail@example.com", "givenName": "Test", "surname": "User", "accountEnabled": True, }, ] } mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_users_resp = MagicMock() mock_users_resp.status_code = 200 mock_users_resp.raise_for_status.return_value = None mock_users_resp.json.return_value = users_response mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.get.return_value = mock_users_resp mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): result = await list_entra_users(include_groups=False) # Assert assert len(result) == 1 user = result[0] assert user["id"] == "user-123" assert user["username"] == "test@example.com" assert user["email"] == "test.mail@example.com" assert user["firstName"] == "Test" assert user["lastName"] == "User" assert user["enabled"] is True assert "groups" in user # ============================================================================= # TEST: create_entra_group() # ============================================================================= @pytest.mark.unit class TestCreateEntraGroup: """Tests for create_entra_group function.""" @pytest.mark.asyncio async def test_create_group_success( self, entra_env_vars, mock_token_response: dict[str, Any], mock_create_group_response: dict[str, Any], ): """Test creating a group successfully.""" # Arrange mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_create_resp = MagicMock() mock_create_resp.status_code = 201 mock_create_resp.raise_for_status.return_value = None mock_create_resp.json.return_value = mock_create_group_response mock_client = AsyncMock() mock_client.post.side_effect = [mock_token_resp, mock_create_resp] mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): result = await create_entra_group("New Test Group", "A new test group") # Assert assert result["id"] == "new-group-id-789" assert result["name"] == "New Test Group" assert result["path"] == "/New Test Group" assert "attributes" in result assert result["attributes"]["description"] == ["A new test group"] @pytest.mark.asyncio async def test_create_group_returns_correct_document( self, entra_env_vars, mock_token_response: dict[str, Any], ): """Test that create group returns correctly formatted document.""" # Arrange create_response = { "id": "group-abc-123", "displayName": "My Custom Group", "description": "Custom description", "securityEnabled": True, } mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_create_resp = MagicMock() mock_create_resp.status_code = 201 mock_create_resp.raise_for_status.return_value = None mock_create_resp.json.return_value = create_response mock_client = AsyncMock() mock_client.post.side_effect = [mock_token_resp, mock_create_resp] mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): result = await create_entra_group("My Custom Group", "Custom description") # Assert assert result["id"] == "group-abc-123" assert result["name"] == "My Custom Group" assert result["path"] == "/My Custom Group" assert result["attributes"]["description"] == ["Custom description"] @pytest.mark.asyncio async def test_create_group_already_exists_raises_error( self, entra_env_vars, mock_token_response: dict[str, Any], ): """Test that creating an existing group raises EntraAdminError.""" # Arrange mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_error_resp = MagicMock() mock_error_resp.status_code = 400 mock_error_resp.json.return_value = { "error": {"message": "A group with this name already exists."} } mock_client = AsyncMock() mock_client.post.side_effect = [mock_token_resp, mock_error_resp] mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act & Assert with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): with pytest.raises(EntraAdminError) as exc_info: await create_entra_group("Existing Group") assert "already exists" in str(exc_info.value) # ============================================================================= # TEST: delete_entra_group() # ============================================================================= @pytest.mark.unit class TestDeleteEntraGroup: """Tests for delete_entra_group function.""" @pytest.mark.asyncio async def test_delete_group_by_id_success( self, entra_env_vars, mock_token_response: dict[str, Any], ): """Test deleting a group by ID successfully.""" # Arrange group_id = "12345678-1234-1234-1234-123456789abc" mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_delete_resp = MagicMock() mock_delete_resp.status_code = 204 mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.delete.return_value = mock_delete_resp mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): result = await delete_entra_group(group_id) # Assert assert result is True @pytest.mark.asyncio async def test_delete_group_by_name_success( self, entra_env_vars, mock_token_response: dict[str, Any], ): """Test deleting a group by name successfully.""" # Arrange group_name = "Test Group" group_id = "12345678-1234-1234-1234-123456789abc" mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response # Mock finding the group by name mock_find_resp = MagicMock() mock_find_resp.status_code = 200 mock_find_resp.raise_for_status.return_value = None mock_find_resp.json.return_value = {"value": [{"id": group_id}]} mock_delete_resp = MagicMock() mock_delete_resp.status_code = 204 mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.get.return_value = mock_find_resp mock_client.delete.return_value = mock_delete_resp mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): result = await delete_entra_group(group_name) # Assert assert result is True @pytest.mark.asyncio async def test_delete_group_not_found_by_name_raises_error( self, entra_env_vars, mock_token_response: dict[str, Any], ): """Test that deleting a non-existent group by name raises error.""" # Arrange group_name = "Non Existent Group" mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response # Mock finding the group - returns empty mock_find_resp = MagicMock() mock_find_resp.status_code = 200 mock_find_resp.raise_for_status.return_value = None mock_find_resp.json.return_value = {"value": []} mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.get.return_value = mock_find_resp mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act & Assert with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): with pytest.raises(EntraAdminError) as exc_info: await delete_entra_group(group_name) assert "not found" in str(exc_info.value).lower() @pytest.mark.asyncio async def test_delete_group_404_raises_error( self, entra_env_vars, mock_token_response: dict[str, Any], ): """Test that 404 response raises EntraAdminError.""" # Arrange group_id = "12345678-1234-1234-1234-123456789abc" mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_delete_resp = MagicMock() mock_delete_resp.status_code = 404 mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.delete.return_value = mock_delete_resp mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act & Assert with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): with pytest.raises(EntraAdminError) as exc_info: await delete_entra_group(group_id) assert "not found" in str(exc_info.value).lower() # ============================================================================= # TEST: list_entra_groups() # ============================================================================= @pytest.mark.unit class TestListEntraGroups: """Tests for list_entra_groups function.""" @pytest.mark.asyncio async def test_list_groups_success( self, entra_env_vars, mock_token_response: dict[str, Any], mock_groups_response: dict[str, Any], ): """Test listing groups successfully.""" # Arrange mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_groups_resp = MagicMock() mock_groups_resp.status_code = 200 mock_groups_resp.raise_for_status.return_value = None mock_groups_resp.json.return_value = mock_groups_response mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.get.return_value = mock_groups_resp mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): result = await list_entra_groups() # Assert assert len(result) == 2 assert result[0]["id"] == "group-id-123" assert result[0]["name"] == "Registry Admins" assert result[0]["path"] == "/Registry Admins" assert result[1]["id"] == "group-id-456" assert result[1]["name"] == "Registry Users" assert result[1]["path"] == "/Registry Users" @pytest.mark.asyncio async def test_list_groups_transforms_correctly( self, entra_env_vars, mock_token_response: dict[str, Any], ): """Test that groups are transformed correctly to match Keycloak format.""" # Arrange groups_response = { "value": [ { "id": "group-abc", "displayName": "Test Group", "description": "Test description", "securityEnabled": True, }, ] } mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_groups_resp = MagicMock() mock_groups_resp.status_code = 200 mock_groups_resp.raise_for_status.return_value = None mock_groups_resp.json.return_value = groups_response mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.get.return_value = mock_groups_resp mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): result = await list_entra_groups() # Assert assert len(result) == 1 group = result[0] assert group["id"] == "group-abc" assert group["name"] == "Test Group" assert group["path"] == "/Test Group" assert "attributes" in group assert group["attributes"]["description"] == ["Test description"] assert group["attributes"]["securityEnabled"] is True @pytest.mark.asyncio async def test_list_groups_empty_response( self, entra_env_vars, mock_token_response: dict[str, Any], ): """Test listing groups with empty response.""" # Arrange mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_groups_resp = MagicMock() mock_groups_resp.status_code = 200 mock_groups_resp.raise_for_status.return_value = None mock_groups_resp.json.return_value = {"value": []} mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.get.return_value = mock_groups_resp mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client): result = await list_entra_groups() # Assert assert result == [] @pytest.mark.asyncio async def test_list_groups_with_single_prefix( self, entra_env_vars, mock_token_response: dict[str, Any], ): """Test that $filter is added when single prefix is configured.""" # Arrange mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_groups_resp = MagicMock() mock_groups_resp.status_code = 200 mock_groups_resp.raise_for_status.return_value = None mock_groups_resp.json.return_value = {"value": []} mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.get.return_value = mock_groups_resp mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with ( patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client), patch( "registry.utils.iam_manager.IDP_GROUP_FILTER_PREFIXES", ["mcp-"], ), ): await list_entra_groups() # Assert - verify $filter was passed in params call_args = mock_client.get.call_args params = call_args.kwargs.get("params", {}) assert "$filter" in params assert params["$filter"] == "startswith(displayName,'mcp-')" @pytest.mark.asyncio async def test_list_groups_with_multiple_prefixes( self, entra_env_vars, mock_token_response: dict[str, Any], ): """Test that $filter uses or-joined startswith for multiple prefixes.""" # Arrange mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_groups_resp = MagicMock() mock_groups_resp.status_code = 200 mock_groups_resp.raise_for_status.return_value = None mock_groups_resp.json.return_value = {"value": []} mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.get.return_value = mock_groups_resp mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with ( patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client), patch( "registry.utils.iam_manager.IDP_GROUP_FILTER_PREFIXES", ["mcp-", "registry-", "ai-"], ), ): await list_entra_groups() # Assert - verify $filter has or-joined conditions call_args = mock_client.get.call_args params = call_args.kwargs.get("params", {}) assert "$filter" in params expected_filter = ( "startswith(displayName,'mcp-') or " "startswith(displayName,'registry-') or " "startswith(displayName,'ai-')" ) assert params["$filter"] == expected_filter @pytest.mark.asyncio async def test_list_groups_without_prefix_no_filter( self, entra_env_vars, mock_token_response: dict[str, Any], ): """Test that no $filter is added when no prefix is configured.""" # Arrange mock_token_resp = MagicMock() mock_token_resp.status_code = 200 mock_token_resp.raise_for_status.return_value = None mock_token_resp.json.return_value = mock_token_response mock_groups_resp = MagicMock() mock_groups_resp.status_code = 200 mock_groups_resp.raise_for_status.return_value = None mock_groups_resp.json.return_value = {"value": []} mock_client = AsyncMock() mock_client.post.return_value = mock_token_resp mock_client.get.return_value = mock_groups_resp mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None # Act with ( patch("registry.utils.entra_manager.httpx.AsyncClient", return_value=mock_client), patch( "registry.utils.iam_manager.IDP_GROUP_FILTER_PREFIXES", [], ), ): await list_entra_groups() # Assert - no $filter param when prefix list is empty call_args = mock_client.get.call_args params = call_args.kwargs.get("params", {}) assert "$filter" not in params # ============================================================================= # TEST: _build_prefix_odata_filter() # ============================================================================= @pytest.mark.unit class TestBuildPrefixOdataFilter: """Tests for _build_prefix_odata_filter helper function.""" def test_single_prefix(self): """Test OData filter for a single prefix.""" # Act result = _build_prefix_odata_filter(["mcp-"]) # Assert assert result == "startswith(displayName,'mcp-')" def test_multiple_prefixes(self): """Test OData filter with or-joined conditions for multiple prefixes.""" # Act result = _build_prefix_odata_filter(["mcp-", "registry-", "ai-"]) # Assert expected = ( "startswith(displayName,'mcp-') or " "startswith(displayName,'registry-') or " "startswith(displayName,'ai-')" ) assert result == expected def test_two_prefixes(self): """Test OData filter with exactly two prefixes.""" # Act result = _build_prefix_odata_filter(["dev-", "staging-"]) # Assert expected = "startswith(displayName,'dev-') or startswith(displayName,'staging-')" assert result == expected # ============================================================================= # TEST: IDP_GROUP_FILTER_PREFIX validation # ============================================================================= @pytest.mark.unit class TestPrefixValidation: """Tests for IDP_GROUP_FILTER_PREFIX parsing and validation.""" def test_valid_single_prefix_parsing(self): """Test that a single prefix is parsed correctly.""" # Arrange raw = "mcp-" prefixes = [p.strip() for p in raw.split(",") if p.strip()] # Assert assert prefixes == ["mcp-"] def test_valid_multiple_prefixes_parsing(self): """Test that comma-separated prefixes are parsed correctly.""" # Arrange raw = "mcp-,registry-,ai-" prefixes = [p.strip() for p in raw.split(",") if p.strip()] # Assert assert prefixes == ["mcp-", "registry-", "ai-"] def test_whitespace_trimming(self): """Test that whitespace around prefixes is trimmed.""" # Arrange raw = " mcp- , registry- , ai- " prefixes = [p.strip() for p in raw.split(",") if p.strip()] # Assert assert prefixes == ["mcp-", "registry-", "ai-"] def test_empty_entries_skipped(self): """Test that empty entries from trailing commas are skipped.""" # Arrange raw = "mcp-,,registry-," prefixes = [p.strip() for p in raw.split(",") if p.strip()] # Assert assert prefixes == ["mcp-", "registry-"] def test_empty_string_gives_empty_list(self): """Test that empty string gives empty list.""" # Arrange raw = "" prefixes = [p.strip() for p in raw.split(",") if p.strip()] # Assert assert prefixes == [] def test_valid_prefix_characters(self): """Test that valid prefixes pass regex validation.""" import re valid_prefixes = ["mcp-", "registry_groups", "AI Teams", "test123"] for prefix in valid_prefixes: assert re.match(r"^[a-zA-Z0-9\-_ ]+$", prefix), f"Prefix '{prefix}' should be valid" def test_invalid_prefix_with_single_quote(self): """Test that single quotes are rejected (OData injection prevention).""" import re invalid_prefix = "mcp-') or (1 eq 1) or startswith(displayName,'" assert not re.match(r"^[a-zA-Z0-9\-_ ]+$", invalid_prefix) ================================================ FILE: tests/unit/test_github_auth.py ================================================ """Unit tests for GitHubAuthProvider.""" from unittest.mock import AsyncMock, MagicMock, patch import httpx import jwt import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa @pytest.fixture() def rsa_private_key_pem() -> str: """Generate a fresh RSA private key in PEM format for testing.""" private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) return private_key.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), ).decode() def _mock_app_settings(mock_settings, pem: str, pat: str = "") -> None: """Configure mock_settings for GitHub App auth tests.""" mock_settings.github_pat = pat mock_settings.github_app_id = "12345" mock_settings.github_app_installation_id = "67890" mock_settings.github_app_private_key = pem mock_settings.github_extra_hosts = "" mock_settings.github_api_base_url = "https://api.github.com" class TestDomainMatching: """Tests for _is_allowed_host and host allowlist logic.""" def test_github_com_is_allowed(self): """Public github.com is allowed by default.""" from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() assert provider._is_allowed_host("https://github.com/owner/repo") is True def test_raw_githubusercontent_is_allowed(self): """raw.githubusercontent.com is allowed by default.""" from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() assert ( provider._is_allowed_host("https://raw.githubusercontent.com/owner/repo/main/SKILL.md") is True ) def test_non_github_host_is_not_allowed(self): """Non-GitHub hosts are rejected.""" from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() assert provider._is_allowed_host("https://gitlab.com/owner/repo") is False def test_case_insensitive_matching(self): """Host matching is case-insensitive.""" from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() assert provider._is_allowed_host("https://GitHub.COM/owner/repo") is True @patch("registry.services.github_auth.settings") def test_extra_hosts_from_config(self, mock_settings): """Extra hosts from config are included in allowlist.""" mock_settings.github_pat = "" mock_settings.github_app_id = "" mock_settings.github_app_installation_id = "" mock_settings.github_app_private_key = "" mock_settings.github_extra_hosts = "github.mycompany.com,raw.github.mycompany.com" from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() assert provider._is_allowed_host("https://github.mycompany.com/org/repo") is True assert provider._is_allowed_host("https://raw.github.mycompany.com/org/repo/main/f") is True @patch("registry.services.github_auth.settings") def test_empty_extra_hosts(self, mock_settings): """Empty extra hosts config doesn't break anything.""" mock_settings.github_pat = "" mock_settings.github_app_id = "" mock_settings.github_app_installation_id = "" mock_settings.github_app_private_key = "" mock_settings.github_extra_hosts = "" from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() assert provider._is_allowed_host("https://github.com/owner/repo") is True assert provider._is_allowed_host("https://example.com/foo") is False class TestPATAuth: """Tests for Personal Access Token authentication.""" @patch("registry.services.github_auth.settings") async def test_pat_returns_bearer_header(self, mock_settings): """PAT produces Authorization: Bearer header.""" mock_settings.github_pat = "ghp_test_token_123" mock_settings.github_app_id = "" mock_settings.github_app_installation_id = "" mock_settings.github_app_private_key = "" mock_settings.github_extra_hosts = "" from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() headers = await provider.get_auth_headers("https://github.com/owner/repo") assert headers == {"Authorization": "Bearer ghp_test_token_123"} @patch("registry.services.github_auth.settings") async def test_no_credentials_returns_empty(self, mock_settings): """No credentials configured returns empty headers.""" mock_settings.github_pat = "" mock_settings.github_app_id = "" mock_settings.github_app_installation_id = "" mock_settings.github_app_private_key = "" mock_settings.github_extra_hosts = "" from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() headers = await provider.get_auth_headers("https://github.com/owner/repo") assert headers == {} @patch("registry.services.github_auth.settings") async def test_non_github_host_returns_empty_even_with_pat(self, mock_settings): """PAT is not sent to non-GitHub hosts.""" mock_settings.github_pat = "ghp_test_token_123" mock_settings.github_app_id = "" mock_settings.github_app_installation_id = "" mock_settings.github_app_private_key = "" mock_settings.github_extra_hosts = "" from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() headers = await provider.get_auth_headers("https://gitlab.com/owner/repo") assert headers == {} @patch("registry.services.github_auth.settings") async def test_pat_works_with_raw_githubusercontent(self, mock_settings): """PAT is sent to raw.githubusercontent.com.""" mock_settings.github_pat = "ghp_test_token_123" mock_settings.github_app_id = "" mock_settings.github_app_installation_id = "" mock_settings.github_app_private_key = "" mock_settings.github_extra_hosts = "" from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() headers = await provider.get_auth_headers( "https://raw.githubusercontent.com/owner/repo/main/SKILL.md" ) assert headers == {"Authorization": "Bearer ghp_test_token_123"} class TestJWTCreation: """Tests for GitHub App JWT creation.""" @patch("registry.services.github_auth.settings") def test_jwt_has_correct_claims(self, mock_settings, rsa_private_key_pem): """JWT contains iat, exp, iss claims.""" _mock_app_settings(mock_settings, rsa_private_key_pem) from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() token = provider._create_jwt() claims = jwt.decode(token, options={"verify_signature": False}) assert claims["iss"] == "12345" assert "iat" in claims assert "exp" in claims assert claims["exp"] - claims["iat"] <= 660 @patch("registry.services.github_auth.settings") def test_jwt_uses_rs256(self, mock_settings, rsa_private_key_pem): """JWT is signed with RS256 algorithm.""" _mock_app_settings(mock_settings, rsa_private_key_pem) from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() token = provider._create_jwt() header = jwt.get_unverified_header(token) assert header["alg"] == "RS256" class TestTokenExchange: """Tests for GitHub App token exchange and caching.""" @patch("registry.services.github_auth.settings") async def test_successful_token_exchange(self, mock_settings, rsa_private_key_pem): """Successful token exchange returns bearer header.""" _mock_app_settings(mock_settings, rsa_private_key_pem, pat="ghp_fallback") from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() mock_response = MagicMock() mock_response.status_code = 201 mock_response.json.return_value = {"token": "ghs_installation_token_abc"} with patch("registry.services.github_auth.httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client_cls.return_value = mock_client headers = await provider.get_auth_headers("https://github.com/owner/repo") assert headers == {"Authorization": "Bearer ghs_installation_token_abc"} @patch("registry.services.github_auth.settings") async def test_cached_token_reused(self, mock_settings, rsa_private_key_pem): """Second call within TTL reuses cached token.""" _mock_app_settings(mock_settings, rsa_private_key_pem) from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() mock_response = MagicMock() mock_response.status_code = 201 mock_response.json.return_value = {"token": "ghs_cached_token"} with patch("registry.services.github_auth.httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client_cls.return_value = mock_client headers1 = await provider.get_auth_headers("https://github.com/owner/repo") headers2 = await provider.get_auth_headers("https://github.com/owner/repo") assert headers1 == {"Authorization": "Bearer ghs_cached_token"} assert headers2 == {"Authorization": "Bearer ghs_cached_token"} assert mock_client.post.call_count == 1 @patch("registry.services.github_auth.settings") async def test_exchange_failure_falls_back_to_pat(self, mock_settings, rsa_private_key_pem): """Failed token exchange falls back to PAT.""" _mock_app_settings(mock_settings, rsa_private_key_pem, pat="ghp_fallback_token") from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() mock_response = MagicMock() mock_response.status_code = 401 mock_response.text = "Bad credentials" with patch("registry.services.github_auth.httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client_cls.return_value = mock_client headers = await provider.get_auth_headers("https://github.com/owner/repo") assert headers == {"Authorization": "Bearer ghp_fallback_token"} @patch("registry.services.github_auth.settings") async def test_exchange_failure_no_pat_returns_empty(self, mock_settings, rsa_private_key_pem): """Failed token exchange with no PAT returns empty headers.""" _mock_app_settings(mock_settings, rsa_private_key_pem) from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() with patch("registry.services.github_auth.httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.post.side_effect = httpx.ConnectError("Connection refused") mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client_cls.return_value = mock_client headers = await provider.get_auth_headers("https://github.com/owner/repo") assert headers == {} @patch("registry.services.github_auth.settings") async def test_custom_api_base_url_used_in_exchange(self, mock_settings, rsa_private_key_pem): """Custom github_api_base_url is used for token exchange requests.""" _mock_app_settings(mock_settings, rsa_private_key_pem) mock_settings.github_api_base_url = "https://github.mycompany.com/api/v3" from registry.services.github_auth import GitHubAuthProvider provider = GitHubAuthProvider() mock_response = MagicMock() mock_response.status_code = 201 mock_response.json.return_value = {"token": "ghs_enterprise_token"} with patch("registry.services.github_auth.httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client_cls.return_value = mock_client # Need to allow the enterprise host for auth headers mock_settings.github_extra_hosts = "github.mycompany.com" provider._allowed_hosts = provider._build_allowed_hosts() headers = await provider.get_auth_headers("https://github.mycompany.com/org/repo") assert headers == {"Authorization": "Bearer ghs_enterprise_token"} # Verify the POST was made to the custom API URL post_call = mock_client.post.call_args assert post_call.args[0] == ( "https://github.mycompany.com/api/v3/app/installations/67890/access_tokens" ) ================================================ FILE: tests/unit/test_iam_manager.py ================================================ """ Unit tests for registry.utils.iam_manager module. This module tests the IAM manager factory and provider-specific implementations (KeycloakIAMManager, EntraIAMManager) with mocked underlying provider functions. """ import logging from typing import Any from unittest.mock import AsyncMock, patch import pytest logger = logging.getLogger(__name__) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def sample_user_list() -> list[dict[str, Any]]: """ Create sample user list for testing. Returns: List of user dictionaries """ return [ { "id": "user-1", "username": "testuser1", "email": "user1@example.com", "firstName": "Test", "lastName": "User1", "groups": ["admin", "developers"], }, { "id": "user-2", "username": "testuser2", "email": "user2@example.com", "firstName": "Test", "lastName": "User2", "groups": ["developers"], }, ] @pytest.fixture def sample_group_list() -> list[dict[str, Any]]: """ Create sample group list for testing. Returns: List of group dictionaries """ return [ { "id": "group-1", "name": "admin", "description": "Admin group", }, { "id": "group-2", "name": "developers", "description": "Developers group", }, ] @pytest.fixture def sample_created_group() -> dict[str, Any]: """ Create sample created group response for testing. Returns: Dictionary with created group details """ return { "id": "new-group-id", "name": "new-group", "description": "A new group", } @pytest.fixture def sample_created_user() -> dict[str, Any]: """ Create sample created user response for testing. Returns: Dictionary with created user details """ return { "id": "new-user-id", "username": "newuser", "email": "newuser@example.com", "firstName": "New", "lastName": "User", "groups": ["developers"], } @pytest.fixture def sample_service_account() -> dict[str, Any]: """ Create sample service account response for testing. Returns: Dictionary with service account details """ return { "client_id": "test-service-account", "client_secret": "generated-secret-123", "groups": ["api-access"], } # ============================================================================= # TEST: get_iam_manager() Factory Function # ============================================================================= @pytest.mark.unit class TestGetIAMManagerFactory: """Test the get_iam_manager factory function.""" def test_returns_keycloak_manager_when_auth_provider_is_keycloak( self, monkeypatch, ): """Test returns KeycloakIAMManager when AUTH_PROVIDER is 'keycloak'.""" # Arrange monkeypatch.setenv("AUTH_PROVIDER", "keycloak") # Need to reimport to pick up the new env var import importlib import registry.utils.iam_manager as iam_module importlib.reload(iam_module) # Act manager = iam_module.get_iam_manager() # Assert assert isinstance(manager, iam_module.KeycloakIAMManager) def test_returns_entra_manager_when_auth_provider_is_entra( self, monkeypatch, ): """Test returns EntraIAMManager when AUTH_PROVIDER is 'entra'.""" # Arrange monkeypatch.setenv("AUTH_PROVIDER", "entra") # Need to reimport to pick up the new env var import importlib import registry.utils.iam_manager as iam_module importlib.reload(iam_module) # Act manager = iam_module.get_iam_manager() # Assert assert isinstance(manager, iam_module.EntraIAMManager) def test_returns_okta_manager_when_auth_provider_is_okta( self, monkeypatch, ): """Test returns OktaIAMManager when AUTH_PROVIDER is 'okta'.""" # Arrange monkeypatch.setenv("AUTH_PROVIDER", "okta") # Need to reimport to pick up the new env var import importlib import registry.utils.iam_manager as iam_module importlib.reload(iam_module) # Act manager = iam_module.get_iam_manager() # Assert assert isinstance(manager, iam_module.OktaIAMManager) def test_defaults_to_keycloak_when_auth_provider_not_set( self, monkeypatch, ): """Test defaults to KeycloakIAMManager when AUTH_PROVIDER is not set.""" # Arrange monkeypatch.delenv("AUTH_PROVIDER", raising=False) # Need to reimport to pick up the new env var import importlib import registry.utils.iam_manager as iam_module importlib.reload(iam_module) # Act manager = iam_module.get_iam_manager() # Assert assert isinstance(manager, iam_module.KeycloakIAMManager) def test_defaults_to_keycloak_for_unknown_provider( self, monkeypatch, ): """Test defaults to KeycloakIAMManager for unknown provider value.""" # Arrange monkeypatch.setenv("AUTH_PROVIDER", "unknown-provider") # Need to reimport to pick up the new env var import importlib import registry.utils.iam_manager as iam_module importlib.reload(iam_module) # Act manager = iam_module.get_iam_manager() # Assert assert isinstance(manager, iam_module.KeycloakIAMManager) def test_auth_provider_case_insensitive( self, monkeypatch, ): """Test AUTH_PROVIDER comparison is case-insensitive.""" # Arrange - test with uppercase monkeypatch.setenv("AUTH_PROVIDER", "KEYCLOAK") # Need to reimport to pick up the new env var import importlib import registry.utils.iam_manager as iam_module importlib.reload(iam_module) # Act manager = iam_module.get_iam_manager() # Assert assert isinstance(manager, iam_module.KeycloakIAMManager) def test_entra_auth_provider_case_insensitive( self, monkeypatch, ): """Test AUTH_PROVIDER='ENTRA' (uppercase) returns EntraIAMManager.""" # Arrange monkeypatch.setenv("AUTH_PROVIDER", "ENTRA") # Need to reimport to pick up the new env var import importlib import registry.utils.iam_manager as iam_module importlib.reload(iam_module) # Act manager = iam_module.get_iam_manager() # Assert assert isinstance(manager, iam_module.EntraIAMManager) # ============================================================================= # TEST: KeycloakIAMManager Methods # ============================================================================= @pytest.mark.unit class TestKeycloakIAMManager: """Test KeycloakIAMManager implementation.""" @pytest.mark.asyncio async def test_list_users_delegates_to_keycloak_manager( self, sample_user_list: list[dict[str, Any]], ): """Test list_users() delegates to list_keycloak_users().""" # Arrange from registry.utils.iam_manager import KeycloakIAMManager manager = KeycloakIAMManager() mock_list_users = AsyncMock(return_value=sample_user_list) # Act - patch at the keycloak_manager module where function is defined with patch( "registry.utils.keycloak_manager.list_keycloak_users", mock_list_users, ): result = await manager.list_users( search="test", max_results=100, include_groups=True, ) # Assert mock_list_users.assert_called_once_with( search="test", max_results=100, include_groups=True, ) assert result == sample_user_list @pytest.mark.asyncio async def test_create_group_delegates_to_keycloak_manager( self, sample_created_group: dict[str, Any], ): """Test create_group() delegates to create_keycloak_group().""" # Arrange from registry.utils.iam_manager import KeycloakIAMManager manager = KeycloakIAMManager() mock_create_group = AsyncMock(return_value=sample_created_group) # Act with patch( "registry.utils.keycloak_manager.create_keycloak_group", mock_create_group, ): result = await manager.create_group( group_name="new-group", description="A new group", ) # Assert mock_create_group.assert_called_once_with( group_name="new-group", description="A new group", ) assert result == sample_created_group @pytest.mark.asyncio async def test_delete_group_delegates_to_keycloak_manager(self): """Test delete_group() delegates to delete_keycloak_group().""" # Arrange from registry.utils.iam_manager import KeycloakIAMManager manager = KeycloakIAMManager() mock_delete_group = AsyncMock(return_value=True) # Act with patch( "registry.utils.keycloak_manager.delete_keycloak_group", mock_delete_group, ): result = await manager.delete_group(group_name="test-group") # Assert mock_delete_group.assert_called_once_with(group_name="test-group") assert result is True @pytest.mark.asyncio async def test_list_groups_delegates_to_keycloak_manager( self, sample_group_list: list[dict[str, Any]], ): """Test list_groups() delegates to list_keycloak_groups().""" # Arrange from registry.utils.iam_manager import KeycloakIAMManager manager = KeycloakIAMManager() mock_list_groups = AsyncMock(return_value=sample_group_list) # Act with patch( "registry.utils.keycloak_manager.list_keycloak_groups", mock_list_groups, ): result = await manager.list_groups() # Assert mock_list_groups.assert_called_once() assert result == sample_group_list @pytest.mark.asyncio async def test_create_human_user_delegates_to_keycloak_manager( self, sample_created_user: dict[str, Any], ): """Test create_human_user() delegates to create_human_user_account().""" # Arrange from registry.utils.iam_manager import KeycloakIAMManager manager = KeycloakIAMManager() mock_create_user = AsyncMock(return_value=sample_created_user) # Act with patch( "registry.utils.keycloak_manager.create_human_user_account", mock_create_user, ): result = await manager.create_human_user( username="newuser", email="newuser@example.com", first_name="New", last_name="User", groups=["developers"], password="temppass123", ) # Assert mock_create_user.assert_called_once_with( username="newuser", email="newuser@example.com", first_name="New", last_name="User", groups=["developers"], password="temppass123", ) assert result == sample_created_user @pytest.mark.asyncio async def test_delete_user_delegates_to_keycloak_manager(self): """Test delete_user() delegates to delete_keycloak_user().""" # Arrange from registry.utils.iam_manager import KeycloakIAMManager manager = KeycloakIAMManager() mock_delete_user = AsyncMock(return_value=True) # Act with patch( "registry.utils.keycloak_manager.delete_keycloak_user", mock_delete_user, ): result = await manager.delete_user(username="testuser") # Assert mock_delete_user.assert_called_once_with(username="testuser") assert result is True @pytest.mark.asyncio async def test_create_service_account_delegates_to_keycloak_manager( self, sample_service_account: dict[str, Any], ): """Test create_service_account() delegates to create_service_account_client().""" # Arrange from registry.utils.iam_manager import KeycloakIAMManager manager = KeycloakIAMManager() mock_create_sa = AsyncMock(return_value=sample_service_account) # Act with patch( "registry.utils.keycloak_manager.create_service_account_client", mock_create_sa, ): result = await manager.create_service_account( client_id="test-service-account", groups=["api-access"], description="Test service account", ) # Assert mock_create_sa.assert_called_once_with( client_id="test-service-account", group_names=["api-access"], description="Test service account", ) assert result == sample_service_account # ============================================================================= # TEST: EntraIAMManager Methods # ============================================================================= @pytest.mark.unit class TestEntraIAMManager: """Test EntraIAMManager implementation.""" @pytest.mark.asyncio async def test_list_users_delegates_to_entra_manager( self, sample_user_list: list[dict[str, Any]], ): """Test list_users() delegates to list_entra_users().""" # Arrange from registry.utils.iam_manager import EntraIAMManager manager = EntraIAMManager() mock_list_users = AsyncMock(return_value=sample_user_list) # Act with patch( "registry.utils.entra_manager.list_entra_users", mock_list_users, ): result = await manager.list_users( search="test", max_results=100, include_groups=True, ) # Assert mock_list_users.assert_called_once_with( search="test", max_results=100, include_groups=True, ) assert result == sample_user_list @pytest.mark.asyncio async def test_create_group_delegates_to_entra_manager( self, sample_created_group: dict[str, Any], ): """Test create_group() delegates to create_entra_group().""" # Arrange from registry.utils.iam_manager import EntraIAMManager manager = EntraIAMManager() mock_create_group = AsyncMock(return_value=sample_created_group) # Act with patch( "registry.utils.entra_manager.create_entra_group", mock_create_group, ): result = await manager.create_group( group_name="new-group", description="A new group", ) # Assert mock_create_group.assert_called_once_with( group_name="new-group", description="A new group", ) assert result == sample_created_group @pytest.mark.asyncio async def test_delete_group_delegates_to_entra_manager(self): """Test delete_group() delegates to delete_entra_group().""" # Arrange from registry.utils.iam_manager import EntraIAMManager manager = EntraIAMManager() mock_delete_group = AsyncMock(return_value=True) # Act with patch( "registry.utils.entra_manager.delete_entra_group", mock_delete_group, ): result = await manager.delete_group(group_name="test-group") # Assert mock_delete_group.assert_called_once_with(group_name_or_id="test-group") assert result is True @pytest.mark.asyncio async def test_list_groups_delegates_to_entra_manager( self, sample_group_list: list[dict[str, Any]], ): """Test list_groups() delegates to list_entra_groups().""" # Arrange from registry.utils.iam_manager import EntraIAMManager manager = EntraIAMManager() mock_list_groups = AsyncMock(return_value=sample_group_list) # Act with patch( "registry.utils.entra_manager.list_entra_groups", mock_list_groups, ): result = await manager.list_groups() # Assert mock_list_groups.assert_called_once() assert result == sample_group_list @pytest.mark.asyncio async def test_create_human_user_delegates_to_entra_manager( self, sample_created_user: dict[str, Any], ): """Test create_human_user() delegates to create_entra_human_user().""" # Arrange from registry.utils.iam_manager import EntraIAMManager manager = EntraIAMManager() mock_create_user = AsyncMock(return_value=sample_created_user) # Act with patch( "registry.utils.entra_manager.create_entra_human_user", mock_create_user, ): result = await manager.create_human_user( username="newuser", email="newuser@example.com", first_name="New", last_name="User", groups=["developers"], password="temppass123", ) # Assert mock_create_user.assert_called_once_with( username="newuser", email="newuser@example.com", first_name="New", last_name="User", groups=["developers"], password="temppass123", ) assert result == sample_created_user @pytest.mark.asyncio async def test_delete_user_delegates_to_entra_manager(self): """Test delete_user() delegates to delete_entra_user().""" # Arrange from registry.utils.iam_manager import EntraIAMManager manager = EntraIAMManager() mock_delete_user = AsyncMock(return_value=True) # Act with patch( "registry.utils.entra_manager.delete_entra_user", mock_delete_user, ): result = await manager.delete_user(username="testuser") # Assert mock_delete_user.assert_called_once_with(username_or_id="testuser") assert result is True @pytest.mark.asyncio async def test_create_service_account_delegates_to_entra_manager( self, sample_service_account: dict[str, Any], ): """Test create_service_account() delegates to create_service_principal_client().""" # Arrange from registry.utils.iam_manager import EntraIAMManager manager = EntraIAMManager() mock_create_sa = AsyncMock(return_value=sample_service_account) # Act with patch( "registry.utils.entra_manager.create_service_principal_client", mock_create_sa, ): result = await manager.create_service_account( client_id="test-service-account", groups=["api-access"], description="Test service account", ) # Assert mock_create_sa.assert_called_once_with( client_id_name="test-service-account", group_names=["api-access"], description="Test service account", ) assert result == sample_service_account # ============================================================================= # TEST: IAMManager Protocol Compliance # ============================================================================= @pytest.mark.unit class TestIAMManagerProtocol: """Test that IAM managers implement the IAMManager protocol.""" def test_keycloak_manager_is_runtime_checkable(self): """Test KeycloakIAMManager satisfies IAMManager protocol.""" from registry.utils.iam_manager import ( IAMManager, KeycloakIAMManager, ) manager = KeycloakIAMManager() # Check that manager is instance of protocol (runtime_checkable) assert isinstance(manager, IAMManager) def test_entra_manager_is_runtime_checkable(self): """Test EntraIAMManager satisfies IAMManager protocol.""" from registry.utils.iam_manager import ( EntraIAMManager, IAMManager, ) manager = EntraIAMManager() # Check that manager is instance of protocol (runtime_checkable) assert isinstance(manager, IAMManager) def test_keycloak_manager_has_all_protocol_methods(self): """Test KeycloakIAMManager has all required protocol methods.""" from registry.utils.iam_manager import KeycloakIAMManager manager = KeycloakIAMManager() # Verify all protocol methods exist assert hasattr(manager, "list_users") assert hasattr(manager, "create_human_user") assert hasattr(manager, "delete_user") assert hasattr(manager, "list_groups") assert hasattr(manager, "create_group") assert hasattr(manager, "delete_group") assert hasattr(manager, "create_service_account") # Verify methods are callable assert callable(manager.list_users) assert callable(manager.create_human_user) assert callable(manager.delete_user) assert callable(manager.list_groups) assert callable(manager.create_group) assert callable(manager.delete_group) assert callable(manager.create_service_account) def test_entra_manager_has_all_protocol_methods(self): """Test EntraIAMManager has all required protocol methods.""" from registry.utils.iam_manager import EntraIAMManager manager = EntraIAMManager() # Verify all protocol methods exist assert hasattr(manager, "list_users") assert hasattr(manager, "create_human_user") assert hasattr(manager, "delete_user") assert hasattr(manager, "list_groups") assert hasattr(manager, "create_group") assert hasattr(manager, "delete_group") assert hasattr(manager, "create_service_account") # Verify methods are callable assert callable(manager.list_users) assert callable(manager.create_human_user) assert callable(manager.delete_user) assert callable(manager.list_groups) assert callable(manager.create_group) assert callable(manager.delete_group) assert callable(manager.create_service_account) ================================================ FILE: tests/unit/test_lifecycle_status.py ================================================ """Tests for lifecycle status filtering and validation.""" import pytest from registry.repositories.documentdb.search_repository import _build_status_filter from registry.schemas.registry_card import _validate_lifecycle_status class TestBuildStatusFilter: """Tests for _build_status_filter MongoDB filter builder.""" def test_default_excludes_draft_and_deprecated_and_disabled(self): """Default call excludes draft, deprecated, and disabled.""" result = _build_status_filter() assert "$and" in result conditions = result["$and"] assert len(conditions) == 2 # First condition: status filtering status_cond = conditions[0] assert "$or" in status_cond status_nin = status_cond["$or"][0] assert "draft" in status_nin["status"]["$nin"] assert "deprecated" in status_nin["status"]["$nin"] # Second condition: enabled filtering enabled_cond = conditions[1] assert "$or" in enabled_cond def test_include_all_returns_empty_dict(self): """Including everything returns empty filter.""" result = _build_status_filter( include_draft=True, include_deprecated=True, include_disabled=True, ) assert result == {} def test_include_draft_only_excludes_deprecated(self): """Including draft still excludes deprecated.""" result = _build_status_filter(include_draft=True) # Should have $and with status filter (deprecated only) and enabled filter assert "$and" in result status_cond = result["$and"][0] status_nin = status_cond["$or"][0]["status"]["$nin"] assert "deprecated" in status_nin assert "draft" not in status_nin def test_include_deprecated_only_excludes_draft(self): """Including deprecated still excludes draft.""" result = _build_status_filter(include_deprecated=True) assert "$and" in result status_cond = result["$and"][0] status_nin = status_cond["$or"][0]["status"]["$nin"] assert "draft" in status_nin assert "deprecated" not in status_nin def test_include_disabled_still_filters_status(self): """Including disabled still filters draft and deprecated.""" result = _build_status_filter(include_disabled=True) # Only status filter, no enabled filter assert "$or" in result status_nin = result["$or"][0]["status"]["$nin"] assert "draft" in status_nin assert "deprecated" in status_nin def test_documents_without_status_field_pass_through(self): """Filter allows documents without a status field (backwards compat).""" result = _build_status_filter() status_cond = result["$and"][0] # Second $or clause should be {"status": {"$exists": False}} exists_clause = status_cond["$or"][1] assert exists_clause == {"status": {"$exists": False}} def test_documents_without_is_enabled_field_pass_through(self): """Filter allows documents without is_enabled field (backwards compat).""" result = _build_status_filter() enabled_cond = result["$and"][1] exists_clause = enabled_cond["$or"][1] assert exists_clause == {"is_enabled": {"$exists": False}} def test_include_draft_and_deprecated_only_filters_disabled(self): """Including both draft and deprecated leaves only the disabled filter.""" result = _build_status_filter( include_draft=True, include_deprecated=True, ) # Only enabled filter remains, returned directly (not wrapped in $and) assert "$or" in result assert result["$or"][0] == {"is_enabled": True} class TestValidateLifecycleStatus: """Tests for _validate_lifecycle_status function.""" def test_valid_status_accepted(self): """Valid enum status is accepted.""" result = _validate_lifecycle_status("active") assert result == "active" def test_status_normalized_to_lowercase(self): """Status input is normalized to lowercase.""" result = _validate_lifecycle_status("ACTIVE") assert result == "active" def test_invalid_status_rejected(self): """Invalid status raises ValueError.""" with pytest.raises(ValueError, match="Invalid status"): _validate_lifecycle_status("unknown") def test_all_enum_values_accepted(self): """All LifecycleStatus enum values are accepted.""" for status in ["active", "deprecated", "draft", "beta"]: result = _validate_lifecycle_status(status) assert result == status class TestModelDefaults: """Tests for model default status values.""" def test_agent_registration_defaults_to_draft(self): """New agent registrations default to draft status.""" from registry.schemas.agent_models import AgentRegistrationRequest request = AgentRegistrationRequest( name="test-agent", url="https://example.com/agent", supportedProtocol="a2a", ) assert request.status == "draft" def test_agent_card_defaults_to_active(self): """Existing agent cards default to active (backwards compat).""" from registry.schemas.agent_models import AgentCard card = AgentCard( name="test-agent", description="A test agent", url="https://example.com/agent", version="1.0.0", ) assert card.status == "active" def test_skill_registration_defaults_to_draft(self): """New skill registrations default to draft status.""" from registry.schemas.skill_models import SkillRegistrationRequest request = SkillRegistrationRequest( name="test-skill", description="A test skill", skill_md_url="https://github.com/test/skill/blob/main/SKILL.md", ) assert request.status == "draft" def test_skill_card_defaults_to_active(self): """Existing skill cards default to active (backwards compat).""" from registry.schemas.skill_models import SkillCard card = SkillCard( name="test-skill", description="A test skill", path="/skills/test-skill", skill_md_url="https://example.com/SKILL.md", ) assert card.status == "active" ================================================ FILE: tests/unit/test_safe_eval_arithmetic.py ================================================ """Unit tests for safe arithmetic evaluation.""" import importlib.util import sys from pathlib import Path from unittest.mock import MagicMock # agents/ is a standalone script directory, not an installed package. # Add it to sys.path so that `from registry_client import ...` inside # agents/agent.py resolves to agents/registry_client.py. _AGENTS_DIR = str(Path(__file__).resolve().parents[2] / "agents") if _AGENTS_DIR not in sys.path: sys.path.insert(0, _AGENTS_DIR) # IMPORTANT: Pre-load agents/registry_client.py into sys.modules as 'registry_client' # before agents/agent.py tries to import it. This ensures pytest-cov doesn't # resolve the import to api/registry_client.py (which lacks _format_tool_result). if "registry_client" not in sys.modules: _registry_client_path = Path(__file__).resolve().parents[2] / "agents" / "registry_client.py" _spec = importlib.util.spec_from_file_location("registry_client", _registry_client_path) _registry_client = importlib.util.module_from_spec(_spec) sys.modules["registry_client"] = _registry_client _spec.loader.exec_module(_registry_client) # The root conftest installs a MockFaissModule into sys.modules["faiss"] that # lacks a __spec__ attribute. When agents.agent imports langchain_anthropic, # which imports transformers, which calls importlib.util.find_spec("faiss"), # Python raises ValueError: faiss.__spec__ is not set. Patch __spec__ here so # the import chain succeeds. if "faiss" in sys.modules: faiss_mod = sys.modules["faiss"] if getattr(faiss_mod, "__spec__", None) is None: faiss_mod.__spec__ = MagicMock(name="faiss.__spec__") else: _faiss_mock = MagicMock() _faiss_mock.__spec__ = MagicMock(name="faiss.__spec__") sys.modules["faiss"] = _faiss_mock import pytest from agents.agent import _safe_eval_arithmetic class TestSafeEvalArithmetic: """Tests for _safe_eval_arithmetic function.""" def test_basic_addition(self): """Test basic addition.""" assert _safe_eval_arithmetic("2 + 2") == 4 assert _safe_eval_arithmetic("10 + 5") == 15 def test_basic_subtraction(self): """Test basic subtraction.""" assert _safe_eval_arithmetic("10 - 3") == 7 assert _safe_eval_arithmetic("5 - 10") == -5 def test_basic_multiplication(self): """Test basic multiplication.""" assert _safe_eval_arithmetic("4 * 5") == 20 assert _safe_eval_arithmetic("3 * 7") == 21 def test_basic_division(self): """Test basic division.""" assert _safe_eval_arithmetic("20 / 4") == 5.0 assert _safe_eval_arithmetic("10 / 2") == 5.0 def test_exponentiation(self): """Test exponentiation.""" assert _safe_eval_arithmetic("2 ** 3") == 8 assert _safe_eval_arithmetic("5 ** 2") == 25 def test_floor_division(self): """Test floor division.""" assert _safe_eval_arithmetic("10 // 3") == 3 assert _safe_eval_arithmetic("20 // 4") == 5 def test_modulo(self): """Test modulo operation.""" assert _safe_eval_arithmetic("10 % 3") == 1 assert _safe_eval_arithmetic("20 % 7") == 6 def test_complex_expression(self): """Test complex nested expression.""" assert _safe_eval_arithmetic("2 + 3 * 4") == 14 assert _safe_eval_arithmetic("(2 + 3) * 4") == 20 def test_negative_numbers(self): """Test negative numbers.""" assert _safe_eval_arithmetic("-5") == -5 assert _safe_eval_arithmetic("-5 + 3") == -2 def test_float_operations(self): """Test floating point operations.""" assert _safe_eval_arithmetic("3.5 + 2.5") == 6.0 assert _safe_eval_arithmetic("10.0 / 4.0") == 2.5 def test_division_by_zero(self): """Test division by zero raises ZeroDivisionError.""" with pytest.raises(ZeroDivisionError): _safe_eval_arithmetic("10 / 0") def test_blocks_import(self): """Test that __import__ is blocked.""" with pytest.raises(ValueError, match="Unsupported expression type"): _safe_eval_arithmetic("__import__('os')") def test_blocks_eval(self): """Test that eval function call is blocked.""" with pytest.raises(ValueError, match="Unsupported expression type"): _safe_eval_arithmetic("eval('2+2')") def test_blocks_function_calls(self): """Test that arbitrary function calls are blocked.""" with pytest.raises(ValueError, match="Unsupported expression type"): _safe_eval_arithmetic("print(5)") def test_blocks_attribute_access(self): """Test that attribute access is blocked.""" with pytest.raises(ValueError, match="Unsupported expression type"): _safe_eval_arithmetic("os.system('ls')") def test_blocks_names(self): """Test that variable names are blocked.""" with pytest.raises(ValueError, match="Unsupported expression type"): _safe_eval_arithmetic("x + 5") def test_length_limit_protection(self): """Test that long valid expressions are handled correctly.""" long_expr = " + ".join(["1"] * 50) result = _safe_eval_arithmetic(long_expr) assert result == 50 def test_blocks_large_exponents(self): """Test that exponents over 100 are blocked.""" with pytest.raises(ValueError, match="Exponent too large"): _safe_eval_arithmetic("2 ** 101") with pytest.raises(ValueError, match="Exponent too large"): _safe_eval_arithmetic("9 ** 999") # Normal exponents should still work assert _safe_eval_arithmetic("2 ** 10") == 1024 assert _safe_eval_arithmetic("3 ** 4") == 81 ================================================ FILE: tests/unit/test_skill_models.py ================================================ """Unit tests for skill models.""" from uuid import uuid4 import pytest from registry.schemas.skill_models import ( CompatibilityRequirement, SkillCard, SkillInfo, SkillRegistrationRequest, ToolReference, VisibilityEnum, ) class TestSkillCard: """Tests for SkillCard model.""" def test_valid_skill_name(self): """Test valid skill names are accepted.""" valid_names = ["pdf-processing", "code-review", "data-analysis", "a1"] for name in valid_names: skill = SkillCard( path=f"/skills/{name}", name=name, description="Test description", skill_md_url="https://github.com/test/skill/SKILL.md", ) assert skill.name == name def test_invalid_skill_name_uppercase(self): """Test uppercase names are rejected.""" with pytest.raises(ValueError, match="lowercase"): SkillCard( path="/skills/PDF-Processing", name="PDF-Processing", description="Test", skill_md_url="https://test.com/SKILL.md", ) def test_invalid_skill_name_consecutive_hyphens(self): """Test consecutive hyphens are rejected.""" with pytest.raises(ValueError): SkillCard( path="/skills/pdf--processing", name="pdf--processing", description="Test", skill_md_url="https://test.com/SKILL.md", ) def test_invalid_skill_name_leading_hyphen(self): """Test names starting with hyphen are rejected.""" with pytest.raises(ValueError): SkillCard( path="/skills/-pdf-processing", name="-pdf-processing", description="Test", skill_md_url="https://test.com/SKILL.md", ) def test_invalid_skill_name_trailing_hyphen(self): """Test names ending with hyphen are rejected.""" with pytest.raises(ValueError): SkillCard( path="/skills/pdf-processing-", name="pdf-processing-", description="Test", skill_md_url="https://test.com/SKILL.md", ) def test_invalid_path_format(self): """Test path must start with /skills/.""" with pytest.raises(ValueError, match="/skills/"): SkillCard( path="/agents/test", name="test", description="Test", skill_md_url="https://test.com/SKILL.md", ) def test_visibility_enum_default(self): """Test visibility defaults to public.""" skill = SkillCard( path="/skills/test", name="test", description="Test", skill_md_url="https://test.com/SKILL.md", ) assert skill.visibility == VisibilityEnum.PUBLIC def test_visibility_enum_private(self): """Test visibility can be set to private.""" skill = SkillCard( path="/skills/test", name="test", description="Test", skill_md_url="https://test.com/SKILL.md", visibility=VisibilityEnum.PRIVATE, ) assert skill.visibility == VisibilityEnum.PRIVATE def test_visibility_enum_group(self): """Test visibility can be set to group.""" skill = SkillCard( path="/skills/test", name="test", description="Test", skill_md_url="https://test.com/SKILL.md", visibility=VisibilityEnum.GROUP, allowed_groups=["developers"], ) assert skill.visibility == VisibilityEnum.GROUP assert "developers" in skill.allowed_groups def test_default_values(self): """Test default values are set correctly.""" skill = SkillCard( path="/skills/test", name="test", description="Test", skill_md_url="https://test.com/SKILL.md", ) assert skill.is_enabled is True assert skill.registry_name == "local" assert skill.tags == [] assert skill.allowed_tools == [] assert skill.requirements == [] assert skill.target_agents == [] class TestToolReference: """Tests for ToolReference model.""" def test_tool_reference_minimal(self): """Test minimal ToolReference.""" tool = ToolReference(tool_name="Bash") assert tool.tool_name == "Bash" assert tool.capabilities == [] assert tool.server_path is None def test_tool_reference_with_capabilities(self): """Test ToolReference with capabilities.""" tool = ToolReference(tool_name="Bash", capabilities=["git:*", "docker:*"]) assert tool.tool_name == "Bash" assert len(tool.capabilities) == 2 assert "git:*" in tool.capabilities def test_tool_reference_with_server_path(self): """Test ToolReference with server path.""" tool = ToolReference(tool_name="Read", server_path="/servers/filesystem") assert tool.server_path == "/servers/filesystem" class TestCompatibilityRequirement: """Tests for CompatibilityRequirement model.""" def test_compatibility_requirement_product(self): """Test product type requirement.""" req = CompatibilityRequirement(type="product", target="claude-code", min_version="1.0.0") assert req.type == "product" assert req.target == "claude-code" assert req.required is True def test_compatibility_requirement_tool(self): """Test tool type requirement.""" req = CompatibilityRequirement(type="tool", target="python>=3.10", required=True) assert req.type == "tool" assert req.required is True def test_compatibility_requirement_optional(self): """Test optional requirement.""" req = CompatibilityRequirement(type="api", target="openai-api", required=False) assert req.required is False class TestSkillRegistrationRequest: """Tests for SkillRegistrationRequest model.""" def test_valid_request(self): """Test valid registration request.""" request = SkillRegistrationRequest( name="pdf-processing", description="Extract text from PDFs", skill_md_url="https://github.com/org/skills/SKILL.md", tags=["pdf", "extraction"], visibility=VisibilityEnum.PUBLIC, ) assert request.name == "pdf-processing" assert len(request.tags) == 2 def test_request_with_tools(self): """Test request with allowed tools.""" request = SkillRegistrationRequest( name="git-workflow", description="Git workflow automation", skill_md_url="https://github.com/org/skills/SKILL.md", allowed_tools=[ ToolReference(tool_name="Bash", capabilities=["git:*"]), ToolReference(tool_name="Read"), ], ) assert len(request.allowed_tools) == 2 def test_url_validation_valid(self): """Test valid URL is accepted.""" request = SkillRegistrationRequest( name="test", description="Test", skill_md_url="https://raw.githubusercontent.com/org/repo/main/SKILL.md", ) assert str(request.skill_md_url).startswith("https://") def test_url_validation_invalid(self): """Test invalid URL is rejected.""" with pytest.raises(ValueError): SkillRegistrationRequest(name="test", description="Test", skill_md_url="not-a-url") def test_name_validation(self): """Test name validation in request.""" with pytest.raises(ValueError, match="lowercase"): SkillRegistrationRequest( name="INVALID", description="Test", skill_md_url="https://test.com/SKILL.md" ) class TestSkillInfo: """Tests for SkillInfo model.""" def test_skill_info_minimal(self): """Test minimal SkillInfo.""" info = SkillInfo( id=uuid4(), path="/skills/test", name="test", description="Test skill", skill_md_url="https://test.com/SKILL.md", ) assert info.path == "/skills/test" assert info.is_enabled is True assert info.visibility == VisibilityEnum.PUBLIC def test_skill_info_with_author(self): """Test SkillInfo with author.""" info = SkillInfo( id=uuid4(), path="/skills/test", name="test", description="Test skill", skill_md_url="https://test.com/SKILL.md", author="John Doe", version="1.0.0", ) assert info.author == "John Doe" assert info.version == "1.0.0" class TestVisibilityEnum: """Tests for VisibilityEnum.""" def test_enum_values(self): """Test enum values.""" assert VisibilityEnum.PUBLIC.value == "public" assert VisibilityEnum.PRIVATE.value == "private" assert VisibilityEnum.GROUP.value == "group" def test_enum_string_comparison(self): """Test enum string comparison.""" assert VisibilityEnum.PUBLIC == "public" assert VisibilityEnum.PRIVATE == "private" assert VisibilityEnum.GROUP == "group" ================================================ FILE: tests/unit/test_skill_routes_github_auth.py ================================================ """Tests that GitHub auth headers are injected into skill routes httpx calls.""" from unittest.mock import AsyncMock, MagicMock, patch def _make_mock_skill( auth_scheme: str = "none", ) -> MagicMock: """Create a mock SkillCard with sensible defaults.""" mock_skill = MagicMock() mock_skill.skill_md_raw_url = "https://raw.githubusercontent.com/o/r/main/SKILL.md" mock_skill.skill_md_url = "https://github.com/o/r/blob/main/SKILL.md" mock_skill.skill_md_content = None mock_skill.content_integrity = None mock_skill.resource_manifest = None mock_skill.tags = [] mock_skill.auth_scheme = auth_scheme mock_skill.auth_credential_encrypted = None mock_skill.auth_header_name = None return mock_skill class TestGetSkillContentAuth: """Tests for auth header injection in get_skill_content.""" @patch("registry.services.skill_service._github_auth") @patch("registry.services.skill_service._is_safe_url", return_value=True) @patch("registry.api.skill_routes._user_can_access_skill", return_value=True) @patch("registry.api.skill_routes.get_skill_service") async def test_global_credentials_sends_github_headers( self, mock_get_service, mock_access, mock_safe_url, mock_auth ): """auth_scheme=global_credentials sends global GitHub auth headers.""" mock_auth.get_auth_headers = AsyncMock( return_value={"Authorization": "Bearer ghp_test"}, ) mock_skill = _make_mock_skill(auth_scheme="global_credentials") mock_service = AsyncMock() mock_service.get_skill.return_value = mock_skill mock_get_service.return_value = mock_service mock_response = MagicMock() mock_response.status_code = 200 mock_response.text = "# My Skill" mock_response.url = "https://raw.githubusercontent.com/o/r/main/SKILL.md" with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client_cls.return_value = mock_client from registry.api.skill_routes import get_skill_content result = await get_skill_content( user_context={"sub": "test-user"}, skill_path="test/skill", resource=None, ) call_kwargs = mock_client.get.call_args assert call_kwargs.kwargs.get("headers") == {"Authorization": "Bearer ghp_test"} assert result["content"] == "# My Skill" @patch("registry.services.skill_service._github_auth") @patch("registry.services.skill_service._is_safe_url", return_value=True) @patch("registry.api.skill_routes._user_can_access_skill", return_value=True) @patch("registry.api.skill_routes.get_skill_service") async def test_none_scheme_sends_no_auth_headers( self, mock_get_service, mock_access, mock_safe_url, mock_auth ): """auth_scheme=none sends no auth headers at all.""" mock_auth.get_auth_headers = AsyncMock( return_value={"Authorization": "Bearer ghp_should_not_appear"}, ) mock_skill = _make_mock_skill(auth_scheme="none") mock_service = AsyncMock() mock_service.get_skill.return_value = mock_skill mock_get_service.return_value = mock_service mock_response = MagicMock() mock_response.status_code = 200 mock_response.text = "# Public Skill" mock_response.url = "https://raw.githubusercontent.com/o/r/main/SKILL.md" with patch("httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client_cls.return_value = mock_client from registry.api.skill_routes import get_skill_content result = await get_skill_content( user_context={"sub": "test-user"}, skill_path="test/skill", resource=None, ) call_kwargs = mock_client.get.call_args assert call_kwargs.kwargs.get("headers") == {} assert result["content"] == "# Public Skill" ================================================ FILE: tests/unit/test_skill_routes_security.py ================================================ """ Tests for skill security scan API endpoints and registration integration. # Feature: skill-scanner-integration # Property 4: Unsafe skill disabling and tagging **Validates: Requirements 4.2, 4.3, 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 4.5, 8.4** """ from unittest.mock import AsyncMock, MagicMock, patch import pytest from hypothesis import given, settings from hypothesis import strategies as st from registry.schemas.skill_security import SkillSecurityScanResult VALID_ANALYZERS = ["static", "behavioral", "llm", "meta", "virustotal", "ai-defense"] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_mock_skill(path="/test-skill", tags=None, skill_md_url="https://example.com/SKILL.md"): """Create a mock SkillCard.""" mock = MagicMock() mock.path = path mock.name = "test-skill" mock.tags = tags or [] mock.skill_md_url = skill_md_url mock.skill_md_raw_url = None mock.visibility = "public" mock.owner = "testuser" mock.allowed_groups = [] return mock def _make_unsafe_scan_result(skill_path, critical=1, high=1): """Create an unsafe SkillSecurityScanResult.""" return SkillSecurityScanResult( skill_path=skill_path, scan_timestamp="2026-02-16T10:00:00Z", is_safe=False, critical_issues=critical, high_severity=high, analyzers_used=["static"], raw_output={}, scan_failed=False, ) def _make_safe_scan_result(skill_path): """Create a safe SkillSecurityScanResult.""" return SkillSecurityScanResult( skill_path=skill_path, scan_timestamp="2026-02-16T10:00:00Z", is_safe=True, critical_issues=0, high_severity=0, analyzers_used=["static"], raw_output={}, scan_failed=False, ) # --------------------------------------------------------------------------- # Property 4: Unsafe skill disabling and tagging # --------------------------------------------------------------------------- def _unsafe_result_strategy(): """Strategy for generating unsafe scan results.""" return st.builds( SkillSecurityScanResult, skill_path=st.from_regex(r"/[a-z][a-z0-9\-]{0,20}", fullmatch=True), scan_timestamp=st.just("2026-02-16T10:00:00Z"), is_safe=st.just(False), critical_issues=st.integers(min_value=0, max_value=10), high_severity=st.integers(min_value=1, max_value=10), analyzers_used=st.just(["static"]), raw_output=st.just({}), scan_failed=st.just(False), ) class TestUnsafeSkillDisablingAndTagging: """Property 4: Unsafe skill disabling and tagging.""" @given(scan_result=_unsafe_result_strategy()) @settings(max_examples=50) @pytest.mark.asyncio async def test_unsafe_skill_disabled_and_tagged(self, scan_result): """When scan is unsafe and blocking is enabled, skill is disabled and tagged.""" from registry.api.skill_routes import _perform_skill_security_scan_on_registration mock_skill = _make_mock_skill(path=scan_result.skill_path) mock_service = AsyncMock() mock_service.toggle_skill = AsyncMock() mock_service.update_skill = AsyncMock() mock_config = MagicMock() mock_config.enabled = True mock_config.scan_on_registration = True mock_config.block_unsafe_skills = True mock_config.add_security_pending_tag = True mock_scanner = MagicMock() mock_scanner.get_scan_config.return_value = mock_config mock_scanner.scan_skill = AsyncMock(return_value=scan_result) with patch( "registry.services.skill_scanner.skill_scanner_service", mock_scanner, ): await _perform_skill_security_scan_on_registration(mock_skill, mock_service) mock_service.toggle_skill.assert_called_once_with(scan_result.skill_path, enabled=False) mock_service.update_skill.assert_called_once() call_args = mock_service.update_skill.call_args assert "security-pending" in call_args[0][1]["tags"] # --------------------------------------------------------------------------- # Unit tests for API endpoints # --------------------------------------------------------------------------- class TestGetSkillSecurityScan: """Tests for GET /api/skills/{path}/security-scan.""" @pytest.mark.asyncio async def test_returns_scan_result_when_exists(self): """Returns scan result for a skill with existing scan data.""" from registry.api.skill_routes import get_skill_security_scan mock_skill = _make_mock_skill() mock_result = {"skill_path": "/test-skill", "is_safe": True} mock_service = AsyncMock() mock_service.get_skill = AsyncMock(return_value=mock_skill) mock_scanner = MagicMock() mock_scanner.get_scan_result = AsyncMock(return_value=mock_result) user_context = {"is_admin": True, "username": "admin", "groups": []} with ( patch("registry.api.skill_routes.get_skill_service", return_value=mock_service), patch("registry.services.skill_scanner.skill_scanner_service", mock_scanner), ): result = await get_skill_security_scan( user_context=user_context, skill_path="test-skill", ) assert result["is_safe"] is True @pytest.mark.asyncio async def test_returns_no_results_message_when_none(self): """Returns message when no scan results exist.""" from registry.api.skill_routes import get_skill_security_scan mock_skill = _make_mock_skill() mock_service = AsyncMock() mock_service.get_skill = AsyncMock(return_value=mock_skill) mock_scanner = MagicMock() mock_scanner.get_scan_result = AsyncMock(return_value=None) user_context = {"is_admin": True, "username": "admin", "groups": []} with ( patch("registry.api.skill_routes.get_skill_service", return_value=mock_service), patch("registry.services.skill_scanner.skill_scanner_service", mock_scanner), ): result = await get_skill_security_scan( user_context=user_context, skill_path="test-skill", ) assert "No security scan results available" in result["message"] @pytest.mark.asyncio async def test_returns_404_for_nonexistent_skill(self): """Returns 404 when skill does not exist.""" from fastapi import HTTPException from registry.api.skill_routes import get_skill_security_scan mock_service = AsyncMock() mock_service.get_skill = AsyncMock(return_value=None) user_context = {"is_admin": True, "username": "admin", "groups": []} with patch("registry.api.skill_routes.get_skill_service", return_value=mock_service): with pytest.raises(HTTPException) as exc_info: await get_skill_security_scan( user_context=user_context, skill_path="nonexistent", ) assert exc_info.value.status_code == 404 class TestRescanSkill: """Tests for POST /api/skills/{path}/rescan.""" @pytest.mark.asyncio async def test_non_admin_returns_403(self): """Non-admin user gets 403 on rescan.""" from fastapi import HTTPException from registry.api.skill_routes import rescan_skill user_context = {"is_admin": False, "username": "user", "groups": []} mock_request = MagicMock() with pytest.raises(HTTPException) as exc_info: await rescan_skill( http_request=mock_request, user_context=user_context, skill_path="test-skill", ) assert exc_info.value.status_code == 403 @pytest.mark.asyncio async def test_returns_404_for_nonexistent_skill(self): """Returns 404 when skill does not exist.""" from fastapi import HTTPException from registry.api.skill_routes import rescan_skill mock_service = AsyncMock() mock_service.get_skill = AsyncMock(return_value=None) user_context = {"is_admin": True, "username": "admin", "groups": []} mock_request = MagicMock() with patch("registry.api.skill_routes.get_skill_service", return_value=mock_service): with pytest.raises(HTTPException) as exc_info: await rescan_skill( http_request=mock_request, user_context=user_context, skill_path="nonexistent", ) assert exc_info.value.status_code == 404 class TestRegistrationWithScanning: """Tests for scan-on-registration behavior.""" @pytest.mark.asyncio async def test_scanning_skipped_when_disabled(self): """Security scan is skipped when scan_on_registration is disabled.""" from registry.api.skill_routes import _perform_skill_security_scan_on_registration mock_skill = _make_mock_skill() mock_service = AsyncMock() mock_config = MagicMock() mock_config.enabled = True mock_config.scan_on_registration = False mock_scanner = MagicMock() mock_scanner.get_scan_config.return_value = mock_config mock_scanner.scan_skill = AsyncMock() with patch( "registry.services.skill_scanner.skill_scanner_service", mock_scanner, ): await _perform_skill_security_scan_on_registration(mock_skill, mock_service) mock_scanner.scan_skill.assert_not_called() @pytest.mark.asyncio async def test_safe_skill_not_disabled(self): """Safe skill is not disabled after scan.""" from registry.api.skill_routes import _perform_skill_security_scan_on_registration mock_skill = _make_mock_skill() mock_service = AsyncMock() mock_service.toggle_skill = AsyncMock() safe_result = _make_safe_scan_result("/test-skill") mock_config = MagicMock() mock_config.enabled = True mock_config.scan_on_registration = True mock_config.block_unsafe_skills = True mock_config.add_security_pending_tag = True mock_scanner = MagicMock() mock_scanner.get_scan_config.return_value = mock_config mock_scanner.scan_skill = AsyncMock(return_value=safe_result) with patch( "registry.services.skill_scanner.skill_scanner_service", mock_scanner, ): await _perform_skill_security_scan_on_registration(mock_skill, mock_service) mock_service.toggle_skill.assert_not_called() ================================================ FILE: tests/unit/test_skill_scanner_service.py ================================================ """ Tests for SkillScannerService: property tests and unit tests. # Feature: skill-scanner-integration # Property 2: Scanner output parsing preserves findings # Property 3: Safety determination invariant **Validates: Requirements 3.2, 3.3, 3.5, 3.6, 3.7, 8.1, 8.2, 8.3, 9.1, 9.2** """ import json import subprocess from pathlib import Path from unittest.mock import MagicMock, patch import pytest from hypothesis import given, settings from hypothesis import strategies as st from registry.services.skill_scanner import SkillScannerService VALID_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] VALID_ANALYZERS = ["static", "behavioral", "llm", "meta", "virustotal", "ai-defense"] FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _build_scanner_json_output(findings: list) -> str: """Build a valid JSON string mimicking skill-scanner CLI output.""" return json.dumps({"findings": findings}) def _make_finding(severity: str, analyzer: str) -> dict: """Create a minimal finding dict.""" return { "severity": severity, "analyzer": analyzer, "threat_names": [f"test-{severity.lower()}"], "threat_summary": f"Test {severity} finding", "is_safe": severity not in ("CRITICAL", "HIGH"), } def _create_service() -> SkillScannerService: """Create a SkillScannerService with a mocked repository.""" with patch( "registry.services.skill_scanner.get_skill_security_scan_repository" ) as mock_factory: mock_repo = MagicMock() mock_repo.create = MagicMock(return_value=True) mock_repo.get_latest = MagicMock(return_value=None) mock_factory.return_value = mock_repo service = SkillScannerService() # Force the lazy property to use our mock service._scan_repo = mock_repo return service # --------------------------------------------------------------------------- # Property 2: Scanner output parsing preserves findings # --------------------------------------------------------------------------- def _finding_strategy(): """Strategy for generating a single finding dict.""" return st.fixed_dictionaries( { "severity": st.sampled_from(VALID_SEVERITIES), "analyzer": st.sampled_from(VALID_ANALYZERS), "threat_names": st.lists(st.text(min_size=1, max_size=20), max_size=3), "threat_summary": st.text(max_size=100), "is_safe": st.booleans(), } ) def _ansi_prefix_strategy(): """Strategy for optional ANSI escape code prefix.""" return st.one_of( st.just(""), st.just("\x1b[31m"), st.just("\x1b[0m"), st.just("\x1b[1;32m"), ) class TestParseOutputPreservesFindings: """Property 2: Scanner output parsing preserves findings.""" @given( findings=st.lists(_finding_strategy(), min_size=0, max_size=10), ansi_prefix=_ansi_prefix_strategy(), ) @settings(max_examples=100) def test_all_findings_preserved_by_analyzer(self, findings, ansi_prefix): """Parsing output preserves all findings under their correct analyzer keys.""" service = _create_service() raw_json = _build_scanner_json_output(findings) stdout = ansi_prefix + raw_json parsed = service._parse_scanner_output(stdout) # Count total findings across all analyzers in parsed output total_parsed = 0 for analyzer_data in parsed["analysis_results"].values(): total_parsed += len(analyzer_data.get("findings", [])) assert total_parsed == len(findings) # Verify each finding is under the correct analyzer key for finding in findings: analyzer = finding["analyzer"] analyzer_findings = parsed["analysis_results"].get(analyzer, {}).get("findings", []) assert finding in analyzer_findings # --------------------------------------------------------------------------- # Property 3: Safety determination invariant # --------------------------------------------------------------------------- def _severity_list_strategy(): """Strategy for generating a list of severity strings.""" return st.lists(st.sampled_from(VALID_SEVERITIES), min_size=0, max_size=20) class TestSafetyDeterminationInvariant: """Property 3: Safety determination invariant.""" @given(severities=_severity_list_strategy()) @settings(max_examples=100) def test_safety_matches_severity_counts(self, severities): """is_safe is True iff critical==0 and high==0; severity sum equals total findings.""" service = _create_service() # Build raw_output with findings findings = [_make_finding(sev, "static") for sev in severities] raw_output = {"analysis_results": {"static": {"findings": findings}}} is_safe, critical, high, medium, low = service._analyze_scan_results(raw_output) expected_critical = severities.count("CRITICAL") expected_high = severities.count("HIGH") expected_medium = severities.count("MEDIUM") expected_low = severities.count("LOW") assert critical == expected_critical assert high == expected_high assert medium == expected_medium assert low == expected_low assert is_safe == (expected_critical == 0 and expected_high == 0) assert critical + high + medium + low == len(severities) # --------------------------------------------------------------------------- # Unit tests for skill scanner service (Task 5.4) # --------------------------------------------------------------------------- class TestSkillScannerServiceUnit: """Unit tests for SkillScannerService edge cases and error handling.""" def test_parse_safe_fixture(self): """Parsing safe fixture produces no findings and is_safe=True.""" service = _create_service() with open(FIXTURES_DIR / "skill_scan_safe_output.json") as f: raw_json = f.read() parsed = service._parse_scanner_output(raw_json) is_safe, critical, high, medium, low = service._analyze_scan_results(parsed) assert is_safe is True assert critical == 0 assert high == 0 assert medium == 0 assert low == 0 def test_parse_unsafe_fixture(self): """Parsing unsafe fixture produces correct severity counts and is_safe=False.""" service = _create_service() with open(FIXTURES_DIR / "skill_scan_unsafe_output.json") as f: raw_json = f.read() parsed = service._parse_scanner_output(raw_json) is_safe, critical, high, medium, low = service._analyze_scan_results(parsed) assert is_safe is False assert critical == 1 assert high == 1 def test_parse_medium_fixture(self): """Parsing medium fixture produces correct counts and is_safe=True.""" service = _create_service() with open(FIXTURES_DIR / "skill_scan_medium_output.json") as f: raw_json = f.read() parsed = service._parse_scanner_output(raw_json) is_safe, critical, high, medium, low = service._analyze_scan_results(parsed) assert is_safe is True assert critical == 0 assert high == 0 assert medium == 1 assert low == 1 def test_run_skill_scanner_timeout(self): """CLI timeout raises RuntimeError with timeout message.""" service = _create_service() with patch("registry.services.skill_scanner.subprocess.run") as mock_run: mock_run.side_effect = subprocess.TimeoutExpired(cmd="skill-scanner", timeout=5) with pytest.raises(RuntimeError, match="timed out"): service._run_skill_scanner( skill_path="/test", skill_content_path="/tmp/test", analyzers="static", timeout=5, ) def test_run_skill_scanner_nonzero_exit(self): """CLI non-zero exit raises RuntimeError with stderr.""" service = _create_service() with patch("registry.services.skill_scanner.subprocess.run") as mock_run: mock_run.side_effect = subprocess.CalledProcessError( returncode=1, cmd="skill-scanner", stderr="scanner error" ) with pytest.raises(RuntimeError, match="Skill scanner failed"): service._run_skill_scanner( skill_path="/test", skill_content_path="/tmp/test", analyzers="static", timeout=120, ) def test_run_skill_scanner_no_target_raises(self): """Missing both content_path and md_url raises ValueError.""" service = _create_service() with pytest.raises(ValueError, match="Either skill_content_path or skill_md_url"): service._run_skill_scanner( skill_path="/test", analyzers="static", timeout=120, ) def test_analyze_empty_results(self): """Empty analysis_results returns safe with zero counts.""" service = _create_service() is_safe, critical, high, medium, low = service._analyze_scan_results( {"analysis_results": {}} ) assert is_safe is True assert critical == 0 assert high == 0 assert medium == 0 assert low == 0 def test_parse_strips_ansi_codes(self): """ANSI escape codes are stripped before JSON parsing.""" service = _create_service() ansi_json = '\x1b[31m{"findings": []}\x1b[0m' parsed = service._parse_scanner_output(ansi_json) assert parsed["scan_results"] == {"findings": []} assert parsed["analysis_results"] == {} ================================================ FILE: tests/unit/test_skill_security_schemas.py ================================================ """ Property-based tests for skill security schema round-trip serialization. # Feature: skill-scanner-integration, Property 1: Schema model round-trip serialization **Validates: Requirements 2.5, 9.3** """ from hypothesis import given, settings from hypothesis import strategies as st from registry.schemas.skill_security import ( SkillSecurityScanConfig, SkillSecurityScanFinding, SkillSecurityScanResult, SkillSecurityStatus, ) VALID_SEVERITIES = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] VALID_ANALYZERS = ["static", "behavioral", "llm", "meta", "virustotal", "ai-defense"] VALID_SCAN_STATUSES = ["pending", "completed", "failed"] def _finding_strategy(): """Strategy for generating valid SkillSecurityScanFinding instances.""" return st.builds( SkillSecurityScanFinding, file_path=st.one_of(st.none(), st.text(min_size=1, max_size=100)), line_number=st.one_of(st.none(), st.integers(min_value=0, max_value=10000)), severity=st.sampled_from(VALID_SEVERITIES), threat_names=st.lists(st.text(min_size=1, max_size=50), max_size=5), threat_summary=st.text(max_size=200), analyzer=st.sampled_from(VALID_ANALYZERS), is_safe=st.booleans(), ) def _scan_result_strategy(): """Strategy for generating valid SkillSecurityScanResult instances.""" return st.builds( SkillSecurityScanResult, skill_path=st.text(min_size=1, max_size=100), skill_md_url=st.one_of(st.none(), st.text(min_size=1, max_size=200)), scan_timestamp=st.text(min_size=1, max_size=50), is_safe=st.booleans(), critical_issues=st.integers(min_value=0, max_value=100), high_severity=st.integers(min_value=0, max_value=100), medium_severity=st.integers(min_value=0, max_value=100), low_severity=st.integers(min_value=0, max_value=100), analyzers_used=st.lists(st.sampled_from(VALID_ANALYZERS), max_size=6), raw_output=st.fixed_dictionaries({}), output_file=st.one_of(st.none(), st.text(min_size=1, max_size=100)), scan_failed=st.booleans(), error_message=st.one_of(st.none(), st.text(min_size=1, max_size=200)), ) def _scan_config_strategy(): """Strategy for generating valid SkillSecurityScanConfig instances.""" return st.builds( SkillSecurityScanConfig, enabled=st.booleans(), scan_on_registration=st.booleans(), block_unsafe_skills=st.booleans(), analyzers=st.text(min_size=1, max_size=50), scan_timeout_seconds=st.integers(min_value=1, max_value=600), llm_api_key=st.one_of(st.none(), st.text(min_size=1, max_size=100)), virustotal_api_key=st.one_of(st.none(), st.text(min_size=1, max_size=100)), ai_defense_api_key=st.one_of(st.none(), st.text(min_size=1, max_size=100)), add_security_pending_tag=st.booleans(), ) def _security_status_strategy(): """Strategy for generating valid SkillSecurityStatus instances.""" return st.builds( SkillSecurityStatus, skill_path=st.text(min_size=1, max_size=100), skill_name=st.text(min_size=1, max_size=100), is_safe=st.booleans(), last_scan_timestamp=st.one_of(st.none(), st.text(min_size=1, max_size=50)), critical_issues=st.integers(min_value=0, max_value=100), high_severity=st.integers(min_value=0, max_value=100), scan_status=st.sampled_from(VALID_SCAN_STATUSES), is_disabled_for_security=st.booleans(), ) class TestSkillSecuritySchemaRoundTrip: """Property 1: Schema model round-trip serialization.""" @given(finding=_finding_strategy()) @settings(max_examples=100) def test_finding_round_trip(self, finding: SkillSecurityScanFinding): """Serializing and reconstructing a SkillSecurityScanFinding produces an equal object.""" dumped = finding.model_dump() reconstructed = SkillSecurityScanFinding(**dumped) assert reconstructed == finding @given(result=_scan_result_strategy()) @settings(max_examples=100) def test_scan_result_round_trip(self, result: SkillSecurityScanResult): """Serializing and reconstructing a SkillSecurityScanResult produces an equal object.""" dumped = result.model_dump() reconstructed = SkillSecurityScanResult(**dumped) assert reconstructed == result @given(config=_scan_config_strategy()) @settings(max_examples=100) def test_scan_config_round_trip(self, config: SkillSecurityScanConfig): """Serializing and reconstructing a SkillSecurityScanConfig produces an equal object.""" dumped = config.model_dump() reconstructed = SkillSecurityScanConfig(**dumped) assert reconstructed == config @given(status=_security_status_strategy()) @settings(max_examples=100) def test_security_status_round_trip(self, status: SkillSecurityStatus): """Serializing and reconstructing a SkillSecurityStatus produces an equal object.""" dumped = status.model_dump() reconstructed = SkillSecurityStatus(**dumped) assert reconstructed == status ================================================ FILE: tests/unit/test_skill_service_github_auth.py ================================================ """Tests that GitHub auth headers are injected into skill service httpx calls.""" from unittest.mock import AsyncMock, MagicMock, patch class TestValidateSkillMdUrlAuth: """Tests for auth header injection in _validate_skill_md_url.""" @patch("registry.services.skill_service._github_auth") @patch("registry.services.skill_service._is_safe_url", return_value=True) async def test_auth_headers_passed_to_get(self, mock_safe_url, mock_auth): """Auth headers from GitHubAuthProvider are passed to httpx.get.""" mock_auth.get_auth_headers = AsyncMock(return_value={"Authorization": "Bearer ghp_test"}) mock_response = MagicMock() mock_response.status_code = 200 mock_response.content = b"# Test Skill" mock_response.url = "https://raw.githubusercontent.com/o/r/main/SKILL.md" with patch("registry.services.skill_service.httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client_cls.return_value = mock_client from registry.services.skill_service import _validate_skill_md_url result = await _validate_skill_md_url( "https://raw.githubusercontent.com/o/r/main/SKILL.md" ) call_kwargs = mock_client.get.call_args assert call_kwargs.kwargs.get("headers") == {"Authorization": "Bearer ghp_test"} assert result["valid"] is True @patch("registry.services.skill_service._github_auth") @patch("registry.services.skill_service._is_safe_url", return_value=True) async def test_empty_headers_when_no_credentials(self, mock_safe_url, mock_auth): """Empty headers passed when no credentials configured.""" mock_auth.get_auth_headers = AsyncMock(return_value={}) mock_response = MagicMock() mock_response.status_code = 200 mock_response.content = b"# Test Skill" mock_response.url = "https://raw.githubusercontent.com/o/r/main/SKILL.md" with patch("registry.services.skill_service.httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client_cls.return_value = mock_client from registry.services.skill_service import _validate_skill_md_url await _validate_skill_md_url("https://raw.githubusercontent.com/o/r/main/SKILL.md") call_kwargs = mock_client.get.call_args assert call_kwargs.kwargs.get("headers") == {} class TestParseSkillMdContentAuth: """Tests for auth header injection in _parse_skill_md_content.""" @patch("registry.services.skill_service._github_auth") @patch("registry.services.skill_service._is_safe_url", return_value=True) @patch("registry.services.skill_service.translate_skill_url") async def test_auth_headers_passed_to_get(self, mock_translate, mock_safe_url, mock_auth): """global_credentials sends GitHub auth headers when parsing SKILL.md.""" mock_auth.get_auth_headers = AsyncMock(return_value={"Authorization": "Bearer ghp_test"}) mock_translate.return_value = ( "https://github.com/o/r/blob/main/SKILL.md", "https://raw.githubusercontent.com/o/r/refs/heads/main/SKILL.md", ) mock_response = MagicMock() mock_response.status_code = 200 mock_response.content = b"---\nname: test\n---\n# Test Skill" mock_response.text = "---\nname: test\n---\n# Test Skill" mock_response.url = "https://raw.githubusercontent.com/o/r/refs/heads/main/SKILL.md" with patch("registry.services.skill_service.httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client_cls.return_value = mock_client from registry.services.skill_service import _parse_skill_md_content result = await _parse_skill_md_content( "https://github.com/o/r/blob/main/SKILL.md", auth_scheme="global_credentials", ) call_kwargs = mock_client.get.call_args assert call_kwargs.kwargs.get("headers") == {"Authorization": "Bearer ghp_test"} assert result["name"] == "test" @patch("registry.services.skill_service._github_auth") @patch("registry.services.skill_service._is_safe_url", return_value=True) @patch("registry.services.skill_service.translate_skill_url") async def test_none_scheme_sends_no_headers(self, mock_translate, mock_safe_url, mock_auth): """auth_scheme=none sends no auth headers when parsing SKILL.md.""" mock_auth.get_auth_headers = AsyncMock(return_value={"Authorization": "Bearer ghp_should_not_appear"}) mock_translate.return_value = ( "https://github.com/o/r/blob/main/SKILL.md", "https://raw.githubusercontent.com/o/r/refs/heads/main/SKILL.md", ) mock_response = MagicMock() mock_response.status_code = 200 mock_response.content = b"---\nname: test\n---\n# Test Skill" mock_response.text = "---\nname: test\n---\n# Test Skill" mock_response.url = "https://raw.githubusercontent.com/o/r/refs/heads/main/SKILL.md" with patch("registry.services.skill_service.httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.get.return_value = mock_response mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client_cls.return_value = mock_client from registry.services.skill_service import _parse_skill_md_content result = await _parse_skill_md_content( "https://github.com/o/r/blob/main/SKILL.md", auth_scheme="none", ) call_kwargs = mock_client.get.call_args assert call_kwargs.kwargs.get("headers") == {} assert result["name"] == "test" class TestCheckSkillHealthAuth: """Tests for auth header injection in _check_skill_health.""" @patch("registry.services.skill_service._github_auth") @patch("registry.services.skill_service._is_safe_url", return_value=True) async def test_auth_headers_passed_to_head(self, mock_safe_url, mock_auth): """Auth headers are passed to httpx.head in health check.""" mock_auth.get_auth_headers = AsyncMock(return_value={"Authorization": "Bearer ghp_test"}) mock_response = MagicMock() mock_response.status_code = 200 mock_response.url = "https://raw.githubusercontent.com/o/r/main/SKILL.md" with patch("registry.services.skill_service.httpx.AsyncClient") as mock_client_cls: mock_client = AsyncMock() mock_client.head.return_value = mock_response mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) mock_client_cls.return_value = mock_client from registry.services.skill_service import _check_skill_health result = await _check_skill_health( "https://raw.githubusercontent.com/o/r/main/SKILL.md" ) call_kwargs = mock_client.head.call_args assert call_kwargs.kwargs.get("headers") == {"Authorization": "Bearer ghp_test"} assert result["healthy"] is True ================================================ FILE: tests/unit/test_skill_service_parsing.py ================================================ """Unit tests for skill service YAML frontmatter parsing.""" import re # Extract the parsing logic for unit testing (avoiding HTTP calls) def _parse_frontmatter( content: str, ) -> dict: """Parse YAML frontmatter from SKILL.md content. Supports multiple formats: 1. Standard: --- at start of file 2. Code block with ---: ```yaml\n---\n...\n---\n``` 3. Code block without ---: ```yaml\n...\n``` Args: content: Raw SKILL.md content Returns: Dict with parsed name, description, version, tags """ result = { "name": None, "description": None, "version": None, "tags": [], } frontmatter = None frontmatter_end_pos = 0 # Format 1: Standard frontmatter at start of file frontmatter_match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL) if frontmatter_match: frontmatter = frontmatter_match.group(1) frontmatter_end_pos = frontmatter_match.end() else: # Format 2: YAML code block with --- markers inside codeblock_with_markers = re.search( r"```ya?ml\s*\n---\s*\n(.*?)\n---\s*\n```", content, re.DOTALL | re.IGNORECASE, ) if codeblock_with_markers: frontmatter = codeblock_with_markers.group(1) frontmatter_end_pos = codeblock_with_markers.end() else: # Format 3: YAML code block without --- markers codeblock_no_markers = re.search( r"```ya?ml\s*\n(.*?)\n```", content, re.DOTALL | re.IGNORECASE, ) if codeblock_no_markers: frontmatter = codeblock_no_markers.group(1) frontmatter_end_pos = codeblock_no_markers.end() if frontmatter: # Parse simple YAML key: value pairs for line in frontmatter.split("\n"): if ":" in line: key, value = line.split(":", 1) key = key.strip().lower() value = value.strip().strip('"').strip("'") if key == "name": result["name"] = value elif key == "description": result["description"] = value elif key == "version": result["version"] = value elif key == "tags": if value.startswith("["): value = value.strip("[]") result["tags"] = [ t.strip().strip('"').strip("'") for t in value.split(",") if t.strip() ] return result class TestFrontmatterParsing: """Tests for SKILL.md frontmatter parsing.""" def test_standard_frontmatter_at_start(self): """Test parsing standard YAML frontmatter at file start.""" content = """--- name: pdf-tool description: Extract text from PDF files version: 1.0.0 tags: pdf, extraction --- # PDF Tool This skill handles PDF processing. """ result = _parse_frontmatter(content) assert result["name"] == "pdf-tool" assert result["description"] == "Extract text from PDF files" assert result["version"] == "1.0.0" assert result["tags"] == ["pdf", "extraction"] def test_yaml_codeblock_with_markers(self): """Test parsing YAML in code block with --- markers (React skill format).""" content = """# Feature Flags Skill ```yaml --- name: flags description: Use when you need to check feature flag states version: 2.0.0 --- ``` ## Overview This skill manages feature flag inspection. """ result = _parse_frontmatter(content) assert result["name"] == "flags" assert result["description"] == "Use when you need to check feature flag states" assert result["version"] == "2.0.0" def test_yaml_codeblock_without_markers(self): """Test parsing YAML in code block without --- markers.""" content = """# Simple Skill ```yaml name: simple-skill description: A simple skill example tags: example, demo ``` ## Usage Just use it! """ result = _parse_frontmatter(content) assert result["name"] == "simple-skill" assert result["description"] == "A simple skill example" assert result["tags"] == ["example", "demo"] def test_yml_extension_codeblock(self): """Test parsing with ```yml instead of ```yaml.""" content = """# YML Skill ```yml name: yml-skill description: Uses yml extension ``` ## Details """ result = _parse_frontmatter(content) assert result["name"] == "yml-skill" assert result["description"] == "Uses yml extension" def test_tags_as_yaml_list(self): """Test parsing tags as YAML list format.""" content = """--- name: list-tags description: Test list tags tags: [tag1, tag2, tag3] --- """ result = _parse_frontmatter(content) assert result["tags"] == ["tag1", "tag2", "tag3"] def test_tags_with_quotes(self): """Test parsing tags with quotes.""" content = """--- name: quoted-tags description: Test quoted tags tags: "tag-a", 'tag-b', tag-c --- """ result = _parse_frontmatter(content) assert result["tags"] == ["tag-a", "tag-b", "tag-c"] def test_quoted_values(self): """Test parsing values with quotes.""" content = """--- name: "quoted-name" description: 'Single quoted description' version: "1.2.3" --- """ result = _parse_frontmatter(content) assert result["name"] == "quoted-name" assert result["description"] == "Single quoted description" assert result["version"] == "1.2.3" def test_no_frontmatter(self): """Test content with no frontmatter returns None values.""" content = """# Just a Heading Some content without any frontmatter. """ result = _parse_frontmatter(content) assert result["name"] is None assert result["description"] is None assert result["version"] is None assert result["tags"] == [] def test_standard_frontmatter_priority(self): """Test standard frontmatter takes priority over code blocks.""" content = """--- name: priority-name description: Priority description --- # Some Heading ```yaml name: ignored-name description: This should be ignored ``` """ result = _parse_frontmatter(content) assert result["name"] == "priority-name" assert result["description"] == "Priority description" def test_multiline_description_in_codeblock(self): """Test that only first line of description is captured.""" content = """```yaml name: multiline-test description: First line of description version: 1.0.0 ``` """ result = _parse_frontmatter(content) assert result["description"] == "First line of description" def test_facebook_react_flags_skill_format(self): """Test exact format from facebook/react flags skill.""" content = """# Feature Flags Skill ```yaml --- name: flags description: Use when you need to check feature flag states, compare channels, or debug why a feature behaves differently across release channels. --- ``` ## Overview This skill manages feature flag inspection across multiple release channels. """ result = _parse_frontmatter(content) assert result["name"] == "flags" assert "feature flag states" in result["description"] assert "release channels" in result["description"] def test_case_insensitive_yaml_tag(self): """Test YAML/yaml/YML/yml all work.""" formats = [ "```YAML\nname: test1\n```", "```yaml\nname: test2\n```", "```YML\nname: test3\n```", "```yml\nname: test4\n```", ] for i, content in enumerate(formats, 1): result = _parse_frontmatter(content) assert result["name"] == f"test{i}", f"Failed for format: {content}" def test_empty_content(self): """Test empty content returns None values.""" result = _parse_frontmatter("") assert result["name"] is None assert result["description"] is None def test_whitespace_handling(self): """Test whitespace in frontmatter is handled correctly.""" content = """--- name: spaced-name description: Spaced description --- """ result = _parse_frontmatter(content) assert result["name"] == "spaced-name" assert result["description"] == "Spaced description" ================================================ FILE: tests/unit/test_stats_endpoint.py ================================================ """ Unit tests for system stats endpoint and repository count methods. Tests the new /api/stats endpoint and count() methods added to repositories. """ import logging from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient logger = logging.getLogger(__name__) # ============================================================================= # FIXTURES # ============================================================================= @pytest.fixture def mock_repositories(): """Mock repository instances with count() methods.""" mock_server_repo = AsyncMock() mock_server_repo.count = AsyncMock(return_value=10) mock_agent_repo = AsyncMock() mock_agent_repo.count = AsyncMock(return_value=5) mock_skill_repo = AsyncMock() mock_skill_repo.count = AsyncMock(return_value=3) return { "server": mock_server_repo, "agent": mock_agent_repo, "skill": mock_skill_repo, } @pytest.fixture def mock_documentdb_client(): """Mock DocumentDB client for database status check.""" mock_db = AsyncMock() mock_db.command = AsyncMock(return_value={"ok": 1}) return mock_db # ============================================================================= # TEST: Repository count() Methods # ============================================================================= @pytest.mark.unit @pytest.mark.repositories class TestRepositoryCountMethods: """Tests for count() methods in repositories.""" @pytest.mark.asyncio async def test_file_server_repository_count(self): """Test FileServerRepository count() method.""" from registry.repositories.file.server_repository import FileServerRepository with patch("registry.repositories.file.server_repository.settings") as mock_settings: # Setup mock settings mock_servers_dir = MagicMock() mock_servers_dir.mkdir = MagicMock() mock_state_path = MagicMock() mock_state_path.exists = MagicMock(return_value=False) mock_settings.servers_dir = mock_servers_dir mock_settings.state_file_path = mock_state_path # Create repository repo = FileServerRepository() # Add some test servers repo._servers = { "/server1": {"path": "/server1", "server_name": "Server 1"}, "/server2": {"path": "/server2", "server_name": "Server 2"}, "/server3": {"path": "/server3", "server_name": "Server 3"}, } # Act count = await repo.count() # Assert assert count == 3 @pytest.mark.asyncio async def test_file_agent_repository_count(self): """Test FileAgentRepository count() method.""" from registry.repositories.file.agent_repository import FileAgentRepository from registry.schemas.agent_models import AgentCard with patch("registry.repositories.file.agent_repository.settings") as mock_settings: # Setup mock settings mock_agents_dir = MagicMock() mock_agents_dir.mkdir = MagicMock() mock_agents_dir.glob = MagicMock(return_value=[]) mock_state_file = MagicMock() mock_state_file.exists = MagicMock(return_value=False) mock_settings.agents_dir = mock_agents_dir mock_settings.agent_state_file_path = mock_state_file # Create repository repo = FileAgentRepository() # Mock get_all to return test data with patch.object(repo, "get_all", new_callable=AsyncMock) as mock_get_all: mock_get_all.return_value = { "/agent1": MagicMock(spec=AgentCard), "/agent2": MagicMock(spec=AgentCard), } # Act count = await repo.count() # Assert assert count == 2 # ============================================================================= # TEST: Helper Functions # ============================================================================= @pytest.mark.unit class TestDetectDeploymentType: """Tests for _detect_deployment_type helper function.""" def test_detect_kubernetes(self): """Test detection of Kubernetes environment.""" from registry.api.system_routes import _detect_deployment_type with patch.dict("os.environ", {"KUBERNETES_SERVICE_HOST": "10.0.0.1"}): result = _detect_deployment_type() assert result == "Kubernetes" def test_detect_ecs(self): """Test detection of ECS environment.""" from registry.api.system_routes import _detect_deployment_type with patch.dict( "os.environ", {"ECS_CONTAINER_METADATA_URI": "http://169.254.170.2/v3"}, clear=True, ): result = _detect_deployment_type() assert result == "ECS" def test_detect_ecs_v4(self): """Test detection of ECS environment with v4 metadata.""" from registry.api.system_routes import _detect_deployment_type with patch.dict( "os.environ", {"ECS_CONTAINER_METADATA_URI_V4": "http://169.254.170.2/v4"}, clear=True, ): result = _detect_deployment_type() assert result == "ECS" def test_detect_ec2(self): """Test detection of EC2 environment.""" from registry.api.system_routes import _detect_deployment_type with patch.dict("os.environ", {"AWS_EXECUTION_ENV": "AWS_ECS_EC2"}, clear=True): result = _detect_deployment_type() assert result == "EC2" def test_detect_local(self): """Test detection of local environment.""" from registry.api.system_routes import _detect_deployment_type with patch.dict("os.environ", {}, clear=True): result = _detect_deployment_type() assert result == "Local" @pytest.mark.unit class TestGetRegistryStats: """Tests for _get_registry_stats function.""" @pytest.mark.asyncio async def test_get_registry_stats_success(self, mock_repositories): """Test successful stats collection.""" from registry.api.system_routes import _get_registry_stats with patch( "registry.repositories.factory.get_server_repository", return_value=mock_repositories["server"], ): with patch( "registry.repositories.factory.get_agent_repository", return_value=mock_repositories["agent"], ): with patch( "registry.repositories.factory.get_skill_repository", return_value=mock_repositories["skill"], ): # Act stats = await _get_registry_stats() # Assert assert stats["servers"] == 10 assert stats["agents"] == 5 assert stats["skills"] == 3 @pytest.mark.asyncio async def test_get_registry_stats_error_handling(self): """Test error handling in stats collection.""" from registry.api.system_routes import _get_registry_stats with patch( "registry.repositories.factory.get_server_repository", side_effect=Exception("DB error") ): # Act stats = await _get_registry_stats() # Assert - should return zeros on error assert stats["servers"] == 0 assert stats["agents"] == 0 assert stats["skills"] == 0 @pytest.mark.unit class TestGetDatabaseStatus: """Tests for _get_database_status function.""" @pytest.mark.asyncio async def test_database_status_file_backend(self): """Test database status with file backend.""" from registry.api.system_routes import _get_database_status with patch("registry.api.system_routes.settings") as mock_settings: mock_settings.storage_backend = "file" # Act status = await _get_database_status() # Assert assert status["backend"] == "file" assert status["status"] == "N/A" assert status["host"] == "N/A" @pytest.mark.asyncio async def test_database_status_documentdb_healthy(self, mock_documentdb_client): """Test database status with healthy DocumentDB.""" from registry.api.system_routes import _get_database_status with patch("registry.api.system_routes.settings") as mock_settings: mock_settings.storage_backend = "documentdb" mock_settings.documentdb_host = "localhost" mock_settings.documentdb_port = 27017 with patch( "registry.repositories.documentdb.client.get_documentdb_client", new_callable=AsyncMock, return_value=mock_documentdb_client, ): # Act status = await _get_database_status() # Assert assert status["backend"] == "documentdb" assert status["status"] == "Healthy" assert status["host"] == "localhost:27017" @pytest.mark.asyncio async def test_database_status_documentdb_unhealthy(self): """Test database status with unhealthy DocumentDB.""" from registry.api.system_routes import _get_database_status with patch("registry.api.system_routes.settings") as mock_settings: mock_settings.storage_backend = "documentdb" mock_settings.documentdb_host = "localhost" mock_settings.documentdb_port = 27017 with patch( "registry.repositories.documentdb.client.get_documentdb_client", new_callable=AsyncMock, side_effect=Exception("Connection failed"), ): # Act status = await _get_database_status() # Assert assert status["backend"] == "documentdb" assert status["status"] == "Unhealthy" assert status["host"] == "localhost:27017" @pytest.mark.unit class TestGetCachedStats: """Tests for _get_cached_stats function.""" @pytest.mark.asyncio async def test_cached_stats_cache_miss(self, mock_repositories): """Test stats collection on cache miss.""" import registry.api.system_routes # Reset cache registry.api.system_routes._stats_cache = None registry.api.system_routes._stats_cache_time = None registry.api.system_routes._server_start_time = datetime.now(UTC) with patch( "registry.repositories.factory.get_server_repository", return_value=mock_repositories["server"], ): with patch( "registry.repositories.factory.get_agent_repository", return_value=mock_repositories["agent"], ): with patch( "registry.repositories.factory.get_skill_repository", return_value=mock_repositories["skill"], ): with patch("registry.api.system_routes.settings") as mock_settings: mock_settings.storage_backend = "file" mock_settings.deployment_mode.value = "standalone" # Act stats = await registry.api.system_routes._get_cached_stats() # Assert assert "uptime_seconds" in stats assert "started_at" in stats assert "version" in stats assert "deployment_type" in stats assert "deployment_mode" in stats assert "registry_stats" in stats assert stats["registry_stats"]["servers"] == 10 assert stats["registry_stats"]["agents"] == 5 assert stats["registry_stats"]["skills"] == 3 # ============================================================================= # TEST: Stats Endpoint # ============================================================================= @pytest.mark.unit class TestStatsEndpoint: """Tests for /api/stats endpoint.""" @pytest.mark.asyncio async def test_stats_endpoint_success(self, mock_repositories): """Test successful stats endpoint call.""" import registry.api.system_routes # Reset cache registry.api.system_routes._stats_cache = None registry.api.system_routes._stats_cache_time = None registry.api.system_routes._server_start_time = datetime.now(UTC) with patch( "registry.repositories.factory.get_server_repository", return_value=mock_repositories["server"], ): with patch( "registry.repositories.factory.get_agent_repository", return_value=mock_repositories["agent"], ): with patch( "registry.repositories.factory.get_skill_repository", return_value=mock_repositories["skill"], ): with patch("registry.api.system_routes.settings") as mock_settings: mock_settings.storage_backend = "file" mock_settings.deployment_mode.value = "standalone" from registry.main import app client = TestClient(app) # Act response = client.get("/api/stats") # Assert assert response.status_code == 200 data = response.json() assert "uptime_seconds" in data assert "started_at" in data assert "version" in data assert "deployment_type" in data assert "registry_stats" in data ================================================ FILE: tests/unit/test_url_validation.py ================================================ """Unit tests for URL scheme validation.""" import pytest from cli.mcp_utils import _validate_url_scheme class TestUrlValidation: """Tests for _validate_url_scheme function.""" def test_allows_http(self): """Test that http:// URLs are allowed.""" _validate_url_scheme("http://example.com") _validate_url_scheme("http://localhost:8080") _validate_url_scheme("http://192.168.1.1/api") # Should not raise def test_allows_https(self): """Test that https:// URLs are allowed.""" _validate_url_scheme("https://example.com") _validate_url_scheme("https://api.github.com") _validate_url_scheme("https://secure.example.com:443/path") # Should not raise def test_blocks_file_scheme(self): """Test that file:// URLs are blocked.""" with pytest.raises(ValueError, match="Invalid URL scheme 'file'"): _validate_url_scheme("file:///etc/passwd") def test_blocks_ftp_scheme(self): """Test that ftp:// URLs are blocked.""" with pytest.raises(ValueError, match="Invalid URL scheme 'ftp'"): _validate_url_scheme("ftp://example.com") def test_blocks_gopher_scheme(self): """Test that gopher:// URLs are blocked.""" with pytest.raises(ValueError, match="Invalid URL scheme 'gopher'"): _validate_url_scheme("gopher://example.com") def test_blocks_javascript_scheme(self): """Test that javascript: URLs are blocked.""" with pytest.raises(ValueError, match="Invalid URL scheme 'javascript'"): _validate_url_scheme("javascript:alert(1)") def test_blocks_data_scheme(self): """Test that data: URLs are blocked.""" with pytest.raises(ValueError, match="Invalid URL scheme 'data'"): _validate_url_scheme("data:text/html,") def test_error_message_format(self): """Test that error message includes scheme and allowed schemes.""" try: _validate_url_scheme("ftp://example.com") pytest.fail("Should have raised ValueError") except ValueError as e: error_msg = str(e) assert "ftp" in error_msg assert "http" in error_msg assert "https" in error_msg ================================================ FILE: tests/unit/test_virtual_server_models.py ================================================ """Unit tests for virtual server Pydantic models.""" import pytest from pydantic import ValidationError from registry.schemas.virtual_server_models import ( CreateVirtualServerRequest, ResolvedTool, ToggleVirtualServerRequest, ToolCatalogEntry, ToolMapping, ToolScopeOverride, UpdateVirtualServerRequest, VirtualServerConfig, VirtualServerInfo, ) class TestToolMapping: """Tests for ToolMapping model.""" def test_valid_tool_mapping(self): """Test creating a valid tool mapping.""" mapping = ToolMapping( tool_name="search", backend_server_path="/github", ) assert mapping.tool_name == "search" assert mapping.backend_server_path == "/github" assert mapping.alias is None assert mapping.backend_version is None assert mapping.description_override is None def test_tool_mapping_with_alias(self): """Test tool mapping with alias and version pin.""" mapping = ToolMapping( tool_name="search", alias="github_search", backend_server_path="/github", backend_version="v1.5.0", description_override="Search GitHub repos", ) assert mapping.alias == "github_search" assert mapping.backend_version == "v1.5.0" assert mapping.description_override == "Search GitHub repos" def test_tool_mapping_requires_tool_name(self): """Test that tool_name is required.""" with pytest.raises(ValidationError): ToolMapping( backend_server_path="/github", ) def test_tool_mapping_requires_backend_path(self): """Test that backend_server_path is required.""" with pytest.raises(ValidationError): ToolMapping( tool_name="search", ) def test_backend_path_must_start_with_slash(self): """Test that backend_server_path must start with /.""" with pytest.raises(ValidationError, match="must start with '/'"): ToolMapping( tool_name="search", backend_server_path="github", ) def test_backend_path_empty_string_rejected(self): """Test that empty backend_server_path is rejected.""" with pytest.raises(ValidationError): ToolMapping( tool_name="search", backend_server_path="", ) def test_tool_name_empty_string_rejected(self): """Test that empty tool_name is rejected.""" with pytest.raises(ValidationError): ToolMapping( tool_name="", backend_server_path="/github", ) class TestToolScopeOverride: """Tests for ToolScopeOverride model.""" def test_valid_scope_override(self): """Test creating a valid scope override.""" override = ToolScopeOverride( tool_alias="github_search", required_scopes=["tools:github:read"], ) assert override.tool_alias == "github_search" assert override.required_scopes == ["tools:github:read"] def test_multiple_scopes(self): """Test ToolScopeOverride with multiple scopes.""" override = ToolScopeOverride( tool_alias="get_data", required_scopes=["read:data", "write:data"], ) assert len(override.required_scopes) == 2 def test_scope_override_requires_scopes(self): """Test that required_scopes must be non-empty.""" with pytest.raises(ValidationError): ToolScopeOverride( tool_alias="search", required_scopes=[], ) def test_empty_tool_alias_rejected(self): """Test that empty tool_alias is rejected.""" with pytest.raises(ValidationError): ToolScopeOverride( tool_alias="", required_scopes=["read:data"], ) class TestVirtualServerConfig: """Tests for VirtualServerConfig model.""" def test_valid_config(self): """Test creating a valid virtual server config.""" config = VirtualServerConfig( path="/virtual/dev-essentials", server_name="Dev Essentials", ) assert config.path == "/virtual/dev-essentials" assert config.server_name == "Dev Essentials" assert config.description == "" assert config.tool_mappings == [] assert config.required_scopes == [] assert config.tool_scope_overrides == [] assert config.is_enabled is False assert config.tags == [] assert config.supported_transports == ["streamable-http"] def test_full_config(self): """Test creating a config with all fields.""" config = VirtualServerConfig( path="/virtual/dev-essentials", server_name="Dev Essentials", description="Tools for everyday development", tool_mappings=[ ToolMapping( tool_name="search", backend_server_path="/github", ), ToolMapping( tool_name="create_issue", alias="jira_create_issue", backend_server_path="/jira", ), ], required_scopes=["dev-team"], tool_scope_overrides=[ ToolScopeOverride( tool_alias="jira_create_issue", required_scopes=["jira:write"], ), ], is_enabled=True, tags=["dev", "productivity"], created_by="admin", ) assert len(config.tool_mappings) == 2 assert len(config.tool_scope_overrides) == 1 assert config.is_enabled is True assert config.created_by == "admin" def test_path_must_start_with_virtual(self): """Test that path must start with /virtual/.""" with pytest.raises(ValidationError, match="must start with '/virtual/'"): VirtualServerConfig( path="/my-server", server_name="Test", ) def test_path_requires_name_after_virtual(self): """Test that path must have a name after /virtual/.""" with pytest.raises(ValidationError, match="must have a name"): VirtualServerConfig( path="/virtual/", server_name="Test", ) def test_path_name_must_be_lowercase_alphanumeric(self): """Test that path segment must be lowercase alphanumeric.""" with pytest.raises(ValidationError, match="lowercase alphanumeric"): VirtualServerConfig( path="/virtual/My_Server", server_name="Test", ) def test_path_uppercase_rejected(self): """Test that uppercase characters in path are rejected.""" with pytest.raises(ValidationError, match="lowercase alphanumeric"): VirtualServerConfig( path="/virtual/DevTools", server_name="Test", ) def test_path_special_chars_rejected(self): """Test that special characters in path are rejected.""" with pytest.raises(ValidationError, match="lowercase alphanumeric"): VirtualServerConfig( path="/virtual/dev_tools", server_name="Test", ) def test_path_name_allows_hyphens(self): """Test that path segment allows single hyphens.""" config = VirtualServerConfig( path="/virtual/dev-essentials", server_name="Dev Essentials", ) assert config.path == "/virtual/dev-essentials" def test_path_name_allows_multi_segment_hyphens(self): """Test valid path with multiple hyphenated segments.""" config = VirtualServerConfig( path="/virtual/dev-tools-v2", server_name="Dev Tools V2", ) assert config.path == "/virtual/dev-tools-v2" def test_path_name_disallows_consecutive_hyphens(self): """Test that path segment disallows consecutive hyphens.""" with pytest.raises(ValidationError, match="lowercase alphanumeric"): VirtualServerConfig( path="/virtual/dev--essentials", server_name="Test", ) def test_path_leading_hyphen_rejected(self): """Test that leading hyphen in path segment is rejected.""" with pytest.raises(ValidationError, match="lowercase alphanumeric"): VirtualServerConfig( path="/virtual/-devtools", server_name="Test", ) def test_path_trailing_hyphen_rejected(self): """Test that trailing hyphen in path segment is rejected.""" with pytest.raises(ValidationError, match="lowercase alphanumeric"): VirtualServerConfig( path="/virtual/devtools-", server_name="Test", ) def test_server_name_cannot_be_empty(self): """Test that server_name cannot be empty.""" with pytest.raises(ValidationError): VirtualServerConfig( path="/virtual/test", server_name="", ) def test_server_name_strips_whitespace(self): """Test that server name is stripped of whitespace.""" config = VirtualServerConfig( path="/virtual/test", server_name=" Dev Essentials ", ) assert config.server_name == "Dev Essentials" def test_server_name_whitespace_only_rejected(self): """Test that whitespace-only server name is rejected.""" with pytest.raises(ValidationError, match="empty or whitespace-only"): VirtualServerConfig( path="/virtual/test", server_name=" ", ) def test_default_is_enabled_false(self): """Test that is_enabled defaults to False.""" config = VirtualServerConfig( path="/virtual/test", server_name="Test", ) assert config.is_enabled is False def test_default_tags_empty(self): """Test that tags defaults to empty list.""" config = VirtualServerConfig( path="/virtual/test", server_name="Test", ) assert config.tags == [] def test_default_supported_transports(self): """Test that supported_transports defaults to streamable-http.""" config = VirtualServerConfig( path="/virtual/test", server_name="Test", ) assert config.supported_transports == ["streamable-http"] def test_default_timestamps_set(self): """Test that created_at and updated_at are set by default.""" config = VirtualServerConfig( path="/virtual/test", server_name="Test", ) assert config.created_at is not None assert config.updated_at is not None def test_serialization_roundtrip(self): """Test JSON serialization and deserialization round trip.""" config = VirtualServerConfig( path="/virtual/dev-essentials", server_name="Dev Essentials", description="Testing serialization", tool_mappings=[ ToolMapping( tool_name="search", alias="gh-search", backend_server_path="/github", ), ], tags=["dev"], is_enabled=True, ) json_data = config.model_dump(mode="json") restored = VirtualServerConfig(**json_data) assert restored.path == config.path assert restored.server_name == config.server_name assert restored.description == config.description assert len(restored.tool_mappings) == 1 assert restored.tool_mappings[0].tool_name == "search" assert restored.tool_mappings[0].alias == "gh-search" assert restored.tags == ["dev"] assert restored.is_enabled is True class TestVirtualServerInfo: """Tests for VirtualServerInfo model.""" def test_valid_info(self): """Test creating a valid info summary.""" info = VirtualServerInfo( path="/virtual/dev-essentials", server_name="Dev Essentials", tool_count=5, backend_count=2, backend_paths=["/github", "/jira"], is_enabled=True, ) assert info.tool_count == 5 assert info.backend_count == 2 assert len(info.backend_paths) == 2 def test_info_defaults(self): """Test VirtualServerInfo default values.""" info = VirtualServerInfo( path="/virtual/test", server_name="Test", ) assert info.tool_count == 0 assert info.backend_count == 0 assert info.backend_paths == [] assert info.is_enabled is False assert info.tags == [] assert info.created_by is None assert info.created_at is None class TestCreateVirtualServerRequest: """Tests for CreateVirtualServerRequest model.""" def test_minimal_request(self): """Test creating request with only required fields.""" req = CreateVirtualServerRequest( server_name="Dev Essentials", ) assert req.server_name == "Dev Essentials" assert req.path is None assert req.description == "" assert req.tool_mappings == [] assert req.required_scopes == [] assert req.tags == [] def test_full_request(self): """Test creating request with all fields.""" req = CreateVirtualServerRequest( server_name="Dev Essentials", path="/virtual/dev-essentials", description="Tools for development", tool_mappings=[ ToolMapping( tool_name="search", backend_server_path="/github", ), ], required_scopes=["dev-team"], tags=["dev"], ) assert req.path == "/virtual/dev-essentials" assert len(req.tool_mappings) == 1 def test_default_supported_transports(self): """Test CreateVirtualServerRequest default supported transports.""" req = CreateVirtualServerRequest( server_name="My Server", ) assert req.supported_transports == ["streamable-http"] class TestUpdateVirtualServerRequest: """Tests for UpdateVirtualServerRequest model.""" def test_partial_update(self): """Test creating request with partial fields.""" req = UpdateVirtualServerRequest( description="Updated description", ) assert req.description == "Updated description" assert req.server_name is None assert req.tool_mappings is None def test_update_all_none(self): """Test UpdateVirtualServerRequest with no fields set.""" req = UpdateVirtualServerRequest() data = req.model_dump(exclude_unset=True) assert data == {} def test_update_exclude_unset(self): """Test that exclude_unset only includes provided fields.""" req = UpdateVirtualServerRequest( server_name="New Name", description="New description", ) data = req.model_dump(exclude_unset=True) assert "server_name" in data assert "description" in data assert "tool_mappings" not in data assert "tags" not in data def test_update_with_tool_mappings(self): """Test UpdateVirtualServerRequest with tool_mappings update.""" req = UpdateVirtualServerRequest( tool_mappings=[ ToolMapping( tool_name="new_tool", backend_server_path="/new-backend", ), ], ) data = req.model_dump(exclude_unset=True) assert "tool_mappings" in data assert len(data["tool_mappings"]) == 1 class TestToggleVirtualServerRequest: """Tests for ToggleVirtualServerRequest model.""" def test_toggle_enabled(self): """Test toggle request with enabled=True.""" req = ToggleVirtualServerRequest(enabled=True) assert req.enabled is True def test_toggle_disabled(self): """Test toggle request with enabled=False.""" req = ToggleVirtualServerRequest(enabled=False) assert req.enabled is False def test_toggle_requires_enabled(self): """Test that enabled field is required.""" with pytest.raises(ValidationError): ToggleVirtualServerRequest() class TestToolCatalogEntry: """Tests for ToolCatalogEntry model.""" def test_valid_entry(self): """Test creating a valid catalog entry.""" entry = ToolCatalogEntry( tool_name="search", server_path="/github", server_name="GitHub", description="Search GitHub repositories", input_schema={ "type": "object", "properties": {"query": {"type": "string"}}, }, available_versions=["v1.0.0", "v1.5.0"], ) assert entry.tool_name == "search" assert len(entry.available_versions) == 2 def test_entry_defaults(self): """Test ToolCatalogEntry default values.""" entry = ToolCatalogEntry( tool_name="get_data", server_path="/github", ) assert entry.server_name == "" assert entry.description == "" assert entry.input_schema == {} assert entry.available_versions == [] class TestResolvedTool: """Tests for ResolvedTool model.""" def test_valid_resolved_tool(self): """Test creating a valid resolved tool.""" tool = ResolvedTool( name="github_search", original_name="search", backend_server_path="/github", backend_version="v1.5.0", description="Search GitHub repos", input_schema={"type": "object"}, required_scopes=["github:read"], ) assert tool.name == "github_search" assert tool.original_name == "search" assert tool.backend_version == "v1.5.0" assert tool.required_scopes == ["github:read"] def test_resolved_tool_defaults(self): """Test ResolvedTool default values.""" tool = ResolvedTool( name="get_data", original_name="get_data", backend_server_path="/github", ) assert tool.backend_version is None assert tool.description == "" assert tool.input_schema == {} assert tool.required_scopes == [] def test_resolved_tool_with_alias(self): """Test ResolvedTool where name differs from original_name (aliased).""" tool = ResolvedTool( name="fetch_data", original_name="get_data", backend_server_path="/github", ) assert tool.name == "fetch_data" assert tool.original_name == "get_data" ================================================ FILE: tests/unit/test_virtual_server_nginx.py ================================================ """Unit tests for virtual server nginx configuration generation.""" from unittest.mock import MagicMock, mock_open, patch import pytest from registry.schemas.virtual_server_models import ( ToolMapping, ToolScopeOverride, VirtualServerConfig, ) def _make_vs_config( path="/virtual/dev-essentials", server_name="Dev Essentials", tool_mappings=None, tool_scope_overrides=None, is_enabled=True, ): """Helper to build VirtualServerConfig objects for tests.""" if tool_mappings is None: tool_mappings = [ ToolMapping( tool_name="search", backend_server_path="/github", ), ] if tool_scope_overrides is None: tool_scope_overrides = [] return VirtualServerConfig( path=path, server_name=server_name, tool_mappings=tool_mappings, tool_scope_overrides=tool_scope_overrides, is_enabled=is_enabled, ) class TestGenerateVirtualServerBlocks: """Tests for _generate_virtual_server_blocks. Uses the conftest-provided mock_virtual_server_repository (autouse fixture). """ @pytest.mark.asyncio async def test_no_enabled_virtual_servers(self, mock_virtual_server_repository): """Test empty string returned when no enabled virtual servers exist.""" mock_virtual_server_repository.list_enabled.return_value = [] from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = await service._generate_virtual_server_blocks() assert result == "" @pytest.mark.asyncio async def test_generates_location_block(self, mock_virtual_server_repository): """Test location block is generated for an enabled virtual server.""" vs = _make_vs_config() mock_virtual_server_repository.list_enabled.return_value = [vs] from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = await service._generate_virtual_server_blocks() assert "/virtual/dev-essentials" in result @pytest.mark.asyncio async def test_block_includes_set_virtual_server_id(self, mock_virtual_server_repository): """Test that generated block includes set $virtual_server_id.""" vs = _make_vs_config() mock_virtual_server_repository.list_enabled.return_value = [vs] from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = await service._generate_virtual_server_blocks() assert 'set $virtual_server_id "dev-essentials"' in result @pytest.mark.asyncio async def test_block_includes_auth_request(self, mock_virtual_server_repository): """Test that generated block includes auth_request directive.""" vs = _make_vs_config() mock_virtual_server_repository.list_enabled.return_value = [vs] from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = await service._generate_virtual_server_blocks() assert "auth_request /validate" in result @pytest.mark.asyncio async def test_block_includes_lua_directives(self, mock_virtual_server_repository): """Test that generated block includes Lua directives.""" vs = _make_vs_config() mock_virtual_server_repository.list_enabled.return_value = [vs] from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = await service._generate_virtual_server_blocks() assert "rewrite_by_lua_file" in result assert "content_by_lua_file" in result assert "virtual_router.lua" in result @pytest.mark.asyncio async def test_multiple_virtual_servers(self, mock_virtual_server_repository): """Test that multiple virtual servers produce multiple location blocks.""" vs1 = _make_vs_config(path="/virtual/dev", server_name="Dev") vs2 = _make_vs_config(path="/virtual/staging", server_name="Staging") mock_virtual_server_repository.list_enabled.return_value = [vs1, vs2] from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = await service._generate_virtual_server_blocks() assert "/virtual/dev" in result assert "/virtual/staging" in result class TestGenerateVirtualBackendLocations: """Tests for _generate_virtual_backend_locations. Uses the conftest-provided mock_server_repository (autouse fixture). """ @pytest.mark.asyncio async def test_no_backends(self, mock_server_repository): """Test empty string returned when virtual servers have no tool mappings.""" vs = _make_vs_config(tool_mappings=[]) from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = await service._generate_virtual_backend_locations([vs]) assert result == "" @pytest.mark.asyncio async def test_generates_internal_locations(self, mock_server_repository): """Test that internal location blocks are generated for backends.""" vs = _make_vs_config() mock_server_repository.get.return_value = { "proxy_pass_url": "https://api.github.com", } from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = await service._generate_virtual_backend_locations([vs]) assert "/_vs_backend" in result assert "internal;" in result assert "proxy_pass https://api.github.com" in result @pytest.mark.asyncio async def test_deduplicates_backends(self, mock_server_repository): """Test that duplicate backend paths are deduplicated.""" mappings = [ ToolMapping(tool_name="search", backend_server_path="/github"), ToolMapping(tool_name="issues", backend_server_path="/github"), ] vs = _make_vs_config(tool_mappings=mappings) mock_server_repository.get.return_value = { "proxy_pass_url": "https://api.github.com", } from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = await service._generate_virtual_backend_locations([vs]) # Should only have one /_vs_backend block for /github assert result.count("/_vs_backend") == 1 @pytest.mark.asyncio async def test_skips_missing_backends(self, mock_server_repository): """Test that missing backend servers are skipped.""" vs = _make_vs_config() mock_server_repository.get.return_value = None from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = await service._generate_virtual_backend_locations([vs]) assert result == "" @pytest.mark.asyncio async def test_skips_backends_without_proxy_url(self, mock_server_repository): """Test that backends without proxy_pass_url are skipped.""" vs = _make_vs_config() mock_server_repository.get.return_value = { "server_name": "GitHub", } from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = await service._generate_virtual_backend_locations([vs]) assert result == "" class TestWriteVirtualServerMappings: """Tests for _write_virtual_server_mappings. Uses the conftest-provided mock_server_repository (autouse fixture). """ @pytest.mark.asyncio async def test_writes_mapping_file(self, mock_server_repository): """Test that mapping JSON file is written for each virtual server.""" vs = _make_vs_config() mock_server_repository.get.return_value = { "server_name": "GitHub", "tool_list": [ { "name": "search", "description": "Search repos", "inputSchema": {"type": "object"}, }, ], } m = mock_open() with patch("registry.core.nginx_service.Path") as mock_path_cls, patch("builtins.open", m): mock_mappings_dir = MagicMock() mock_path_cls.return_value = mock_mappings_dir mock_mapping_file = MagicMock() mock_mappings_dir.__truediv__ = MagicMock(return_value=mock_mapping_file) from registry.core.nginx_service import NginxConfigService service = NginxConfigService() await service._write_virtual_server_mappings([vs]) # Verify open was called for writing m.assert_called() @pytest.mark.asyncio async def test_mapping_contains_tools(self, mock_server_repository): """Test that mapping JSON contains tool data with alias.""" vs = _make_vs_config( tool_mappings=[ ToolMapping( tool_name="search", alias="gh-search", backend_server_path="/github", ), ], ) mock_server_repository.get.return_value = { "server_name": "GitHub", "tool_list": [ { "name": "search", "description": "Search repos", "inputSchema": {"type": "object"}, }, ], } written_data = {} def capture_write(data, f, **kwargs): written_data.update(data) with ( patch("registry.core.nginx_service.Path") as mock_path_cls, patch("json.dump", side_effect=capture_write), ): mock_mappings_dir = MagicMock() mock_path_cls.return_value = mock_mappings_dir mock_mapping_file = MagicMock() mock_mappings_dir.__truediv__ = MagicMock(return_value=mock_mapping_file) m = mock_open() with patch("builtins.open", m): from registry.core.nginx_service import NginxConfigService service = NginxConfigService() await service._write_virtual_server_mappings([vs]) assert "tools" in written_data assert len(written_data["tools"]) == 1 assert written_data["tools"][0]["name"] == "gh-search" assert written_data["tools"][0]["original_name"] == "search" @pytest.mark.asyncio async def test_mapping_includes_scope_overrides(self, mock_server_repository): """Test that mapping JSON includes per-tool scope overrides.""" vs = _make_vs_config( tool_mappings=[ ToolMapping(tool_name="search", backend_server_path="/github"), ], tool_scope_overrides=[ ToolScopeOverride( tool_alias="search", required_scopes=["github:read"], ), ], ) mock_server_repository.get.return_value = { "server_name": "GitHub", "tool_list": [ {"name": "search", "description": "Search", "inputSchema": {}}, ], } written_data = {} def capture_write(data, f, **kwargs): written_data.update(data) with ( patch("registry.core.nginx_service.Path") as mock_path_cls, patch("json.dump", side_effect=capture_write), ): mock_mappings_dir = MagicMock() mock_path_cls.return_value = mock_mappings_dir mock_mapping_file = MagicMock() mock_mappings_dir.__truediv__ = MagicMock(return_value=mock_mapping_file) m = mock_open() with patch("builtins.open", m): from registry.core.nginx_service import NginxConfigService service = NginxConfigService() await service._write_virtual_server_mappings([vs]) assert written_data["tools"][0]["required_scopes"] == ["github:read"] @pytest.mark.asyncio async def test_mapping_includes_backend_map(self, mock_server_repository): """Test that mapping JSON includes tool_backend_map.""" vs = _make_vs_config( tool_mappings=[ ToolMapping(tool_name="search", backend_server_path="/github"), ], ) mock_server_repository.get.return_value = { "server_name": "GitHub", "tool_list": [ {"name": "search", "description": "Search", "inputSchema": {}}, ], } written_data = {} def capture_write(data, f, **kwargs): written_data.update(data) with ( patch("registry.core.nginx_service.Path") as mock_path_cls, patch("json.dump", side_effect=capture_write), ): mock_mappings_dir = MagicMock() mock_path_cls.return_value = mock_mappings_dir mock_mapping_file = MagicMock() mock_mappings_dir.__truediv__ = MagicMock(return_value=mock_mapping_file) m = mock_open() with patch("builtins.open", m): from registry.core.nginx_service import NginxConfigService service = NginxConfigService() await service._write_virtual_server_mappings([vs]) assert "tool_backend_map" in written_data assert "search" in written_data["tool_backend_map"] assert "/_vs_backend" in written_data["tool_backend_map"]["search"]["backend_location"] class TestSanitizePathForLocation: """Tests for _sanitize_path_for_location.""" def test_sanitize_simple_path(self): """Test sanitizing a simple server path.""" from registry.core.nginx_service import NginxConfigService service = NginxConfigService() assert service._sanitize_path_for_location("/github") == "_github" def test_sanitize_path_with_hyphens(self): """Test sanitizing a path with hyphens.""" from registry.core.nginx_service import NginxConfigService service = NginxConfigService() assert service._sanitize_path_for_location("/my-server") == "_my_server" def test_sanitize_path_with_dots(self): """Test sanitizing a path with dots.""" from registry.core.nginx_service import NginxConfigService service = NginxConfigService() result = service._sanitize_path_for_location("/ai.smithery-test") assert "/" not in result assert "-" not in result assert "." not in result ================================================ FILE: tests/unit/test_virtual_server_service.py ================================================ """Unit tests for virtual server service layer.""" import logging from unittest.mock import AsyncMock, patch import pytest from registry.exceptions import ( VirtualServerAlreadyExistsError, VirtualServerNotFoundError, VirtualServerValidationError, ) from registry.schemas.virtual_server_models import ( CreateVirtualServerRequest, ToolMapping, UpdateVirtualServerRequest, VirtualServerConfig, ) from registry.services.virtual_server_service import ( VirtualServerService, _generate_path_from_name, _get_effective_tool_name, _get_unique_backends, ) # --- Unit tests for helper functions --- class TestHelperFunctions: """Tests for private helper functions.""" def test_generate_path_from_name(self): """Test path generation from server name.""" assert _generate_path_from_name("Dev Essentials") == "/virtual/dev-essentials" def test_generate_path_special_chars(self): """Test path generation strips special characters.""" assert _generate_path_from_name("My Server (v2)!") == "/virtual/my-server-v2" def test_generate_path_multiple_spaces(self): """Test path generation handles multiple spaces.""" assert _generate_path_from_name("Dev Essentials") == "/virtual/dev-essentials" def test_generate_path_empty_fallback(self): """Test path generation with empty name falls back.""" assert _generate_path_from_name("!!!") == "/virtual/virtual-server" def test_get_effective_tool_name_with_alias(self): """Test effective name returns alias when set.""" mapping = ToolMapping( tool_name="search", alias="github_search", backend_server_path="/github", ) assert _get_effective_tool_name(mapping) == "github_search" def test_get_effective_tool_name_without_alias(self): """Test effective name returns original when no alias.""" mapping = ToolMapping( tool_name="search", backend_server_path="/github", ) assert _get_effective_tool_name(mapping) == "search" def test_get_unique_backends(self): """Test extracting unique backend paths.""" mappings = [ ToolMapping(tool_name="search", backend_server_path="/github"), ToolMapping(tool_name="issues", backend_server_path="/github"), ToolMapping(tool_name="tickets", backend_server_path="/jira"), ] backends = _get_unique_backends(mappings) assert set(backends) == {"/github", "/jira"} # --- Unit tests for service validation --- class TestVirtualServerServiceValidation: """Tests for VirtualServerService validation logic.""" @pytest.fixture def mock_vs_repo(self): """Create mock virtual server repository.""" return AsyncMock() @pytest.fixture def mock_server_repo(self): """Create mock server repository.""" return AsyncMock() @pytest.fixture def service(self, mock_vs_repo, mock_server_repo): """Create VirtualServerService with mocked repositories.""" with ( patch( "registry.services.virtual_server_service.get_virtual_server_repository", return_value=mock_vs_repo, ), patch( "registry.services.virtual_server_service.get_server_repository", return_value=mock_server_repo, ), ): svc = VirtualServerService() return svc @pytest.mark.asyncio async def test_validate_unique_tool_names_no_duplicates(self, service): """Test validation passes with unique tool names.""" mappings = [ ToolMapping(tool_name="search", backend_server_path="/github"), ToolMapping(tool_name="tickets", backend_server_path="/jira"), ] # Should not raise service._validate_unique_tool_names(mappings) @pytest.mark.asyncio async def test_validate_unique_tool_names_duplicate_detected(self, service): """Test validation fails with duplicate tool names.""" mappings = [ ToolMapping(tool_name="search", backend_server_path="/github"), ToolMapping(tool_name="search", backend_server_path="/jira"), ] with pytest.raises(VirtualServerValidationError, match="Duplicate tool names"): service._validate_unique_tool_names(mappings) @pytest.mark.asyncio async def test_validate_unique_tool_names_alias_resolves_conflict(self, service): """Test that aliases resolve name conflicts.""" mappings = [ ToolMapping(tool_name="search", backend_server_path="/github"), ToolMapping( tool_name="search", alias="jira_search", backend_server_path="/jira", ), ] # Should not raise because alias makes names unique service._validate_unique_tool_names(mappings) @pytest.mark.asyncio async def test_validate_tool_mappings_missing_backend(self, service, mock_server_repo): """Test validation fails when backend server doesn't exist.""" mock_server_repo.get.return_value = None mappings = [ ToolMapping(tool_name="search", backend_server_path="/nonexistent"), ] with pytest.raises(VirtualServerValidationError, match="does not exist"): await service._validate_tool_mappings(mappings) @pytest.mark.asyncio async def test_validate_tool_mappings_missing_tool(self, service, mock_server_repo): """Test validation fails when tool doesn't exist in backend.""" mock_server_repo.get.return_value = { "server_name": "GitHub", "tool_list": [ {"name": "create_issue", "description": "Create issue"}, ], } mappings = [ ToolMapping(tool_name="nonexistent_tool", backend_server_path="/github"), ] with pytest.raises(VirtualServerValidationError, match="not found in backend"): await service._validate_tool_mappings(mappings) @pytest.mark.asyncio async def test_validate_tool_mappings_valid(self, service, mock_server_repo): """Test validation passes with valid tool mappings.""" mock_server_repo.get.return_value = { "server_name": "GitHub", "tool_list": [ {"name": "search", "description": "Search repos"}, ], } mappings = [ ToolMapping(tool_name="search", backend_server_path="/github"), ] # Should not raise await service._validate_tool_mappings(mappings) @pytest.mark.asyncio async def test_validate_tool_mappings_version_not_found(self, service, mock_server_repo): """Test validation fails when pinned version doesn't exist.""" # First call: server exists # Second call: version doc doesn't exist mock_server_repo.get.side_effect = [ { "server_name": "GitHub", "tool_list": [ {"name": "search", "description": "Search"}, ], }, None, # Version doc not found ] mappings = [ ToolMapping( tool_name="search", backend_server_path="/github", backend_version="v99.0.0", ), ] with pytest.raises(VirtualServerValidationError, match="Version"): await service._validate_tool_mappings(mappings) # --- Unit tests for service CRUD operations --- class TestVirtualServerServiceCRUD: """Tests for VirtualServerService CRUD operations.""" @pytest.fixture def mock_vs_repo(self): """Create mock virtual server repository.""" return AsyncMock() @pytest.fixture def mock_server_repo(self): """Create mock server repository.""" return AsyncMock() @pytest.fixture def service(self, mock_vs_repo, mock_server_repo): """Create VirtualServerService with mocked repos.""" with ( patch( "registry.services.virtual_server_service.get_virtual_server_repository", return_value=mock_vs_repo, ), patch( "registry.services.virtual_server_service.get_server_repository", return_value=mock_server_repo, ), ): svc = VirtualServerService() return svc @pytest.mark.asyncio async def test_create_virtual_server(self, service, mock_vs_repo): """Test creating a virtual server.""" request = CreateVirtualServerRequest( server_name="Dev Essentials", path="/virtual/dev-essentials", description="Tools for development", ) created = VirtualServerConfig( path="/virtual/dev-essentials", server_name="Dev Essentials", description="Tools for development", ) mock_vs_repo.create.return_value = created result = await service.create_virtual_server(request, created_by="admin") mock_vs_repo.create.assert_called_once() assert result.path == "/virtual/dev-essentials" assert result.server_name == "Dev Essentials" @pytest.mark.asyncio async def test_create_virtual_server_auto_generates_path(self, service, mock_vs_repo): """Test that path is auto-generated from name when not provided.""" request = CreateVirtualServerRequest( server_name="My Cool Server", ) mock_vs_repo.create.return_value = VirtualServerConfig( path="/virtual/my-cool-server", server_name="My Cool Server", ) result = await service.create_virtual_server(request, created_by="admin") call_args = mock_vs_repo.create.call_args[0][0] assert call_args.path == "/virtual/my-cool-server" @pytest.mark.asyncio async def test_list_virtual_servers(self, service, mock_vs_repo): """Test listing virtual servers.""" mock_vs_repo.list_all.return_value = [ VirtualServerConfig( path="/virtual/dev", server_name="Dev", tool_mappings=[ ToolMapping(tool_name="search", backend_server_path="/github"), ], ), ] result = await service.list_virtual_servers() assert len(result) == 1 assert result[0].path == "/virtual/dev" assert result[0].tool_count == 1 assert result[0].backend_count == 1 @pytest.mark.asyncio async def test_get_virtual_server(self, service, mock_vs_repo): """Test getting a single virtual server.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", ) result = await service.get_virtual_server("/virtual/dev") assert result is not None assert result.server_name == "Dev" @pytest.mark.asyncio async def test_get_virtual_server_not_found(self, service, mock_vs_repo): """Test getting a nonexistent virtual server returns None.""" mock_vs_repo.get.return_value = None result = await service.get_virtual_server("/virtual/nonexistent") assert result is None @pytest.mark.asyncio async def test_delete_virtual_server(self, service, mock_vs_repo): """Test deleting a virtual server.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", is_enabled=False, ) mock_vs_repo.delete.return_value = True with patch.object(service, "_trigger_nginx_reload", new_callable=AsyncMock): result = await service.delete_virtual_server("/virtual/dev") assert result is True mock_vs_repo.delete.assert_called_once_with("/virtual/dev") @pytest.mark.asyncio async def test_delete_virtual_server_not_found(self, service, mock_vs_repo): """Test deleting a nonexistent virtual server raises error.""" mock_vs_repo.get.return_value = None with pytest.raises(VirtualServerNotFoundError): await service.delete_virtual_server("/virtual/nonexistent") @pytest.mark.asyncio async def test_toggle_virtual_server_enable(self, service, mock_vs_repo): """Test enabling a virtual server.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", tool_mappings=[ ToolMapping(tool_name="search", backend_server_path="/github"), ], ) mock_vs_repo.set_state.return_value = True with ( patch.object(service, "_validate_tool_mappings", new_callable=AsyncMock), patch.object(service, "_trigger_nginx_reload", new_callable=AsyncMock), ): result = await service.toggle_virtual_server("/virtual/dev", True) assert result is True mock_vs_repo.set_state.assert_called_once_with("/virtual/dev", True) @pytest.mark.asyncio async def test_toggle_enable_with_no_tools_fails(self, service, mock_vs_repo): """Test enabling fails when no tool mappings configured.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", tool_mappings=[], ) with pytest.raises(VirtualServerValidationError, match="no tool mappings"): await service.toggle_virtual_server("/virtual/dev", True) @pytest.mark.asyncio async def test_toggle_virtual_server_disable(self, service, mock_vs_repo): """Test disabling a virtual server.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", is_enabled=True, tool_mappings=[ ToolMapping(tool_name="search", backend_server_path="/github"), ], ) mock_vs_repo.set_state.return_value = True with patch.object(service, "_trigger_nginx_reload", new_callable=AsyncMock): result = await service.toggle_virtual_server("/virtual/dev", False) assert result is True mock_vs_repo.set_state.assert_called_once_with("/virtual/dev", False) @pytest.mark.asyncio async def test_toggle_virtual_server_not_found(self, service, mock_vs_repo): """Test toggling a nonexistent virtual server raises error.""" mock_vs_repo.get.return_value = None with pytest.raises(VirtualServerNotFoundError): await service.toggle_virtual_server("/virtual/nonexistent", True) @pytest.mark.asyncio async def test_update_virtual_server_happy_path(self, service, mock_vs_repo): """Test updating a virtual server with valid data.""" existing = VirtualServerConfig( path="/virtual/dev", server_name="Dev", description="Old description", ) mock_vs_repo.get.return_value = existing updated = VirtualServerConfig( path="/virtual/dev", server_name="Dev Updated", description="New description", ) mock_vs_repo.update.return_value = updated request = UpdateVirtualServerRequest( server_name="Dev Updated", description="New description", ) with patch.object(service, "_trigger_nginx_reload", new_callable=AsyncMock): result = await service.update_virtual_server("/virtual/dev", request) assert result is not None assert result.server_name == "Dev Updated" mock_vs_repo.update.assert_called_once() @pytest.mark.asyncio async def test_update_virtual_server_not_found(self, service, mock_vs_repo): """Test updating a nonexistent virtual server raises error.""" mock_vs_repo.get.return_value = None request = UpdateVirtualServerRequest(description="New description") with pytest.raises(VirtualServerNotFoundError): await service.update_virtual_server("/virtual/nonexistent", request) @pytest.mark.asyncio async def test_update_virtual_server_with_new_tool_mappings( self, service, mock_vs_repo, mock_server_repo ): """Test updating tool_mappings validates backend servers.""" existing = VirtualServerConfig( path="/virtual/dev", server_name="Dev", ) mock_vs_repo.get.return_value = existing mock_server_repo.get.return_value = { "server_name": "GitHub", "tool_list": [ {"name": "search", "description": "Search repos"}, ], } updated = VirtualServerConfig( path="/virtual/dev", server_name="Dev", tool_mappings=[ ToolMapping(tool_name="search", backend_server_path="/github"), ], ) mock_vs_repo.update.return_value = updated request = UpdateVirtualServerRequest( tool_mappings=[ ToolMapping(tool_name="search", backend_server_path="/github"), ], ) with patch.object(service, "_trigger_nginx_reload", new_callable=AsyncMock): result = await service.update_virtual_server("/virtual/dev", request) assert result is not None mock_server_repo.get.assert_called() @pytest.mark.asyncio async def test_create_virtual_server_duplicate_path(self, service, mock_vs_repo): """Test creating virtual server with duplicate path raises error.""" mock_vs_repo.create.side_effect = VirtualServerAlreadyExistsError("/virtual/dev-essentials") request = CreateVirtualServerRequest( server_name="Dev Essentials", path="/virtual/dev-essentials", ) with pytest.raises(VirtualServerAlreadyExistsError): await service.create_virtual_server(request, created_by="admin") @pytest.mark.asyncio async def test_create_virtual_server_invalid_backend(self, service, mock_server_repo): """Test creating virtual server with invalid backend raises validation error.""" mock_server_repo.get.return_value = None request = CreateVirtualServerRequest( server_name="Dev Essentials", path="/virtual/dev-essentials", tool_mappings=[ ToolMapping(tool_name="search", backend_server_path="/nonexistent"), ], ) with pytest.raises(VirtualServerValidationError, match="does not exist"): await service.create_virtual_server(request, created_by="admin") # --- Unit tests for tool resolution --- class TestToolResolution: """Tests for tool resolution logic.""" @pytest.fixture def mock_vs_repo(self): """Create mock virtual server repository.""" return AsyncMock() @pytest.fixture def mock_server_repo(self): """Create mock server repository.""" return AsyncMock() @pytest.fixture def service(self, mock_vs_repo, mock_server_repo): """Create service with mocked repos.""" with ( patch( "registry.services.virtual_server_service.get_virtual_server_repository", return_value=mock_vs_repo, ), patch( "registry.services.virtual_server_service.get_server_repository", return_value=mock_server_repo, ), ): svc = VirtualServerService() return svc @pytest.mark.asyncio async def test_resolve_tools(self, service, mock_vs_repo, mock_server_repo): """Test resolving tools from virtual server config.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", tool_mappings=[ ToolMapping( tool_name="search", alias="github_search", backend_server_path="/github", ), ], ) mock_server_repo.get.return_value = { "server_name": "GitHub", "tool_list": [ { "name": "search", "description": "Search repos", "inputSchema": {"type": "object"}, }, ], } tools = await service.resolve_tools("/virtual/dev") assert len(tools) == 1 assert tools[0].name == "github_search" assert tools[0].original_name == "search" assert tools[0].description == "Search repos" @pytest.mark.asyncio async def test_resolve_tools_with_description_override( self, service, mock_vs_repo, mock_server_repo ): """Test that description_override replaces original description.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", tool_mappings=[ ToolMapping( tool_name="search", backend_server_path="/github", description_override="Custom description", ), ], ) mock_server_repo.get.return_value = { "server_name": "GitHub", "tool_list": [ { "name": "search", "description": "Original description", "inputSchema": {}, }, ], } tools = await service.resolve_tools("/virtual/dev") assert len(tools) == 1 assert tools[0].description == "Custom description" @pytest.mark.asyncio async def test_resolve_tools_not_found(self, service, mock_vs_repo): """Test resolving tools for nonexistent server raises error.""" mock_vs_repo.get.return_value = None with pytest.raises(VirtualServerNotFoundError): await service.resolve_tools("/virtual/nonexistent") @pytest.mark.asyncio async def test_resolve_tools_with_scope_overrides( self, service, mock_vs_repo, mock_server_repo ): """Test that scope overrides are applied to resolved tools.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", tool_mappings=[ ToolMapping( tool_name="search", backend_server_path="/github", ), ], tool_scope_overrides=[ { "tool_alias": "search", "required_scopes": ["github:read"], }, ], ) mock_server_repo.get.return_value = { "server_name": "GitHub", "tool_list": [ { "name": "search", "description": "Search", "inputSchema": {}, }, ], } tools = await service.resolve_tools("/virtual/dev") assert len(tools) == 1 assert tools[0].required_scopes == ["github:read"] # --- Unit tests for nginx trigger --- class TestNginxTrigger: """Tests verifying nginx config regeneration is triggered on changes.""" @pytest.fixture def mock_vs_repo(self): """Create mock virtual server repository.""" return AsyncMock() @pytest.fixture def mock_server_repo(self): """Create mock server repository.""" return AsyncMock() @pytest.fixture def service(self, mock_vs_repo, mock_server_repo): """Create VirtualServerService with mocked repos.""" with ( patch( "registry.services.virtual_server_service.get_virtual_server_repository", return_value=mock_vs_repo, ), patch( "registry.services.virtual_server_service.get_server_repository", return_value=mock_server_repo, ), ): svc = VirtualServerService() return svc @pytest.mark.asyncio async def test_create_triggers_nginx_reload(self, service, mock_vs_repo): """Test that creating a virtual server triggers nginx reload.""" request = CreateVirtualServerRequest( server_name="Dev Tools", path="/virtual/dev-tools", ) mock_vs_repo.create.return_value = VirtualServerConfig( path="/virtual/dev-tools", server_name="Dev Tools", ) with patch.object(service, "_trigger_nginx_reload", new_callable=AsyncMock) as mock_reload: await service.create_virtual_server(request, created_by="admin") mock_reload.assert_called_once() @pytest.mark.asyncio async def test_delete_triggers_nginx_reload(self, service, mock_vs_repo): """Test that deleting a virtual server triggers nginx reload.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", ) mock_vs_repo.delete.return_value = True with patch.object(service, "_trigger_nginx_reload", new_callable=AsyncMock) as mock_reload: await service.delete_virtual_server("/virtual/dev") mock_reload.assert_called_once() @pytest.mark.asyncio async def test_toggle_triggers_nginx_reload(self, service, mock_vs_repo): """Test that toggling a virtual server triggers nginx reload.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", tool_mappings=[ ToolMapping(tool_name="search", backend_server_path="/github"), ], ) mock_vs_repo.set_state.return_value = True with ( patch.object(service, "_validate_tool_mappings", new_callable=AsyncMock), patch.object(service, "_trigger_nginx_reload", new_callable=AsyncMock) as mock_reload, ): await service.toggle_virtual_server("/virtual/dev", True) mock_reload.assert_called_once() @pytest.mark.asyncio async def test_update_triggers_nginx_reload(self, service, mock_vs_repo): """Test that updating a virtual server triggers nginx reload.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", ) mock_vs_repo.update.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev Updated", ) request = UpdateVirtualServerRequest(server_name="Dev Updated") with patch.object(service, "_trigger_nginx_reload", new_callable=AsyncMock) as mock_reload: await service.update_virtual_server("/virtual/dev", request) mock_reload.assert_called_once() # --- Unit tests for ToolCatalogService --- class TestToolCatalogService: """Tests for ToolCatalogService aggregation logic.""" @pytest.fixture def mock_server_repo(self): """Create mock server repository.""" return AsyncMock() @pytest.fixture def catalog_service(self, mock_server_repo): """Create ToolCatalogService with mocked repository.""" with patch( "registry.services.tool_catalog_service.get_server_repository", return_value=mock_server_repo, ): from registry.services.tool_catalog_service import ToolCatalogService svc = ToolCatalogService() return svc @pytest.mark.asyncio async def test_catalog_aggregates_from_multiple_servers( self, catalog_service, mock_server_repo ): """Test that catalog aggregates tools from multiple enabled servers.""" mock_server_repo.list_all.return_value = { "/github": { "server_name": "GitHub", "tool_list": [ {"name": "search", "description": "Search repos"}, {"name": "create_issue", "description": "Create issue"}, ], }, "/jira": { "server_name": "Jira", "tool_list": [ {"name": "get_ticket", "description": "Get ticket"}, ], }, } mock_server_repo.get_state.return_value = True catalog = await catalog_service.get_tool_catalog() assert len(catalog) == 3 tool_names = [entry.tool_name for entry in catalog] assert "search" in tool_names assert "create_issue" in tool_names assert "get_ticket" in tool_names @pytest.mark.asyncio async def test_catalog_filters_disabled_servers(self, catalog_service, mock_server_repo): """Test that catalog excludes tools from disabled servers.""" mock_server_repo.list_all.return_value = { "/github": { "server_name": "GitHub", "tool_list": [ {"name": "search", "description": "Search repos"}, ], }, "/jira": { "server_name": "Jira", "tool_list": [ {"name": "get_ticket", "description": "Get ticket"}, ], }, } # GitHub enabled, Jira disabled mock_server_repo.get_state.side_effect = [True, False] catalog = await catalog_service.get_tool_catalog() assert len(catalog) == 1 assert catalog[0].tool_name == "search" assert catalog[0].server_path == "/github" @pytest.mark.asyncio async def test_catalog_filters_by_server_path(self, catalog_service, mock_server_repo): """Test that catalog can filter by server_path.""" mock_server_repo.list_all.return_value = { "/github": { "server_name": "GitHub", "tool_list": [ {"name": "search", "description": "Search repos"}, ], }, "/jira": { "server_name": "Jira", "tool_list": [ {"name": "get_ticket", "description": "Get ticket"}, ], }, } mock_server_repo.get_state.return_value = True catalog = await catalog_service.get_tool_catalog(server_path_filter="/github") assert len(catalog) == 1 assert catalog[0].server_path == "/github" @pytest.mark.asyncio async def test_catalog_skips_version_documents(self, catalog_service, mock_server_repo): """Test that catalog skips version documents (paths with ':').""" mock_server_repo.list_all.return_value = { "/github": { "server_name": "GitHub", "tool_list": [ {"name": "search", "description": "Search repos"}, ], }, "/github:v1.5.0": { "server_name": "GitHub v1.5.0", "tool_list": [ {"name": "search", "description": "Search repos v1.5"}, ], }, } mock_server_repo.get_state.return_value = True catalog = await catalog_service.get_tool_catalog() assert len(catalog) == 1 assert catalog[0].server_path == "/github" @pytest.mark.asyncio async def test_catalog_empty_when_no_servers(self, catalog_service, mock_server_repo): """Test that catalog returns empty list when no servers exist.""" mock_server_repo.list_all.return_value = {} catalog = await catalog_service.get_tool_catalog() assert catalog == [] @pytest.mark.asyncio async def test_catalog_includes_available_versions(self, catalog_service, mock_server_repo): """Test that catalog entries include available versions.""" mock_server_repo.list_all.return_value = { "/github": { "server_name": "GitHub", "version": "v2.0.0", "other_version_ids": ["/github:v1.5.0"], "tool_list": [ {"name": "search", "description": "Search repos"}, ], }, } mock_server_repo.get_state.return_value = True catalog = await catalog_service.get_tool_catalog() assert len(catalog) == 1 assert "v2.0.0" in catalog[0].available_versions assert "v1.5.0" in catalog[0].available_versions @pytest.mark.asyncio async def test_catalog_skips_tools_without_name(self, catalog_service, mock_server_repo): """Test that catalog skips tool entries that have no name.""" mock_server_repo.list_all.return_value = { "/github": { "server_name": "GitHub", "tool_list": [ {"name": "search", "description": "Search repos"}, {"name": "", "description": "Unnamed tool"}, {"description": "No name field"}, ], }, } mock_server_repo.get_state.return_value = True catalog = await catalog_service.get_tool_catalog() assert len(catalog) == 1 assert catalog[0].tool_name == "search" @pytest.mark.asyncio async def test_catalog_filters_by_user_scopes(self, catalog_service, mock_server_repo): """Test that catalog filters out servers the user lacks scopes for.""" mock_server_repo.list_all.return_value = { "/github": { "server_name": "GitHub", "required_scopes": ["read:repos"], "tool_list": [ {"name": "search", "description": "Search repos"}, ], }, "/jira": { "server_name": "Jira", "required_scopes": ["admin:jira"], "tool_list": [ {"name": "get_ticket", "description": "Get ticket"}, ], }, "/slack": { "server_name": "Slack", "tool_list": [ {"name": "send_message", "description": "Send message"}, ], }, } mock_server_repo.get_state.return_value = True # User has read:repos but not admin:jira catalog = await catalog_service.get_tool_catalog(user_scopes=["read:repos"]) assert len(catalog) == 2 tool_names = [entry.tool_name for entry in catalog] assert "search" in tool_names assert "send_message" in tool_names assert "get_ticket" not in tool_names @pytest.mark.asyncio async def test_catalog_no_filtering_when_scopes_none(self, catalog_service, mock_server_repo): """Test that passing user_scopes=None returns all servers (no filtering).""" mock_server_repo.list_all.return_value = { "/github": { "server_name": "GitHub", "required_scopes": ["admin:everything"], "tool_list": [ {"name": "search", "description": "Search repos"}, ], }, } mock_server_repo.get_state.return_value = True catalog = await catalog_service.get_tool_catalog(user_scopes=None) assert len(catalog) == 1 assert catalog[0].tool_name == "search" @pytest.mark.asyncio async def test_catalog_empty_scopes_filters_restricted_servers( self, catalog_service, mock_server_repo ): """Test that user with empty scopes is filtered out from restricted servers.""" mock_server_repo.list_all.return_value = { "/github": { "server_name": "GitHub", "required_scopes": ["read:repos"], "tool_list": [ {"name": "search", "description": "Search repos"}, ], }, "/slack": { "server_name": "Slack", "tool_list": [ {"name": "send_message", "description": "Send message"}, ], }, } mock_server_repo.get_state.return_value = True catalog = await catalog_service.get_tool_catalog(user_scopes=[]) assert len(catalog) == 1 assert catalog[0].tool_name == "send_message" @pytest.mark.asyncio async def test_catalog_user_with_all_scopes_sees_all(self, catalog_service, mock_server_repo): """Test that user with all required scopes sees all servers.""" mock_server_repo.list_all.return_value = { "/github": { "server_name": "GitHub", "required_scopes": ["read:repos"], "tool_list": [ {"name": "search", "description": "Search repos"}, ], }, "/jira": { "server_name": "Jira", "required_scopes": ["admin:jira", "read:projects"], "tool_list": [ {"name": "get_ticket", "description": "Get ticket"}, ], }, } mock_server_repo.get_state.return_value = True catalog = await catalog_service.get_tool_catalog( user_scopes=["read:repos", "admin:jira", "read:projects"] ) assert len(catalog) == 2 # --- Unit tests for nginx reload failure handling --- class TestNginxReloadFailureHandling: """Tests verifying CRUD operations succeed even when nginx reload fails.""" @pytest.fixture def mock_vs_repo(self): """Create mock virtual server repository.""" return AsyncMock() @pytest.fixture def mock_server_repo(self): """Create mock server repository.""" return AsyncMock() @pytest.fixture def service(self, mock_vs_repo, mock_server_repo): """Create VirtualServerService with mocked repos.""" with ( patch( "registry.services.virtual_server_service.get_virtual_server_repository", return_value=mock_vs_repo, ), patch( "registry.services.virtual_server_service.get_server_repository", return_value=mock_server_repo, ), ): svc = VirtualServerService() return svc @pytest.mark.asyncio async def test_create_succeeds_when_nginx_reload_fails( self, service, mock_vs_repo, ): """Test that create succeeds even if nginx reload returns False.""" request = CreateVirtualServerRequest( server_name="Dev Tools", path="/virtual/dev-tools", ) created = VirtualServerConfig( path="/virtual/dev-tools", server_name="Dev Tools", ) mock_vs_repo.create.return_value = created with patch.object( service, "_trigger_nginx_reload", new_callable=AsyncMock, return_value=False, ): result = await service.create_virtual_server(request, created_by="admin") # CRUD operation should succeed regardless assert result.path == "/virtual/dev-tools" mock_vs_repo.create.assert_called_once() @pytest.mark.asyncio async def test_trigger_nginx_reload_returns_false_on_exception(self, service): """Test that _trigger_nginx_reload returns False when an exception occurs.""" mock_nginx = AsyncMock() mock_server_svc = AsyncMock() mock_server_svc.get_enabled_services = AsyncMock( side_effect=RuntimeError("connection refused"), ) with ( patch( "registry.core.nginx_service.nginx_service", mock_nginx, ), patch( "registry.services.server_service.server_service", mock_server_svc, ), ): result = await service._trigger_nginx_reload() assert result is False @pytest.mark.asyncio async def test_trigger_nginx_reload_logs_error_on_failure(self, service, caplog): """Test that _trigger_nginx_reload logs error when it fails.""" mock_nginx = AsyncMock() mock_server_svc = AsyncMock() mock_server_svc.get_enabled_services = AsyncMock( side_effect=RuntimeError("connection refused"), ) with ( patch( "registry.core.nginx_service.nginx_service", mock_nginx, ), patch( "registry.services.server_service.server_service", mock_server_svc, ), caplog.at_level(logging.ERROR), ): result = await service._trigger_nginx_reload() assert result is False assert any( "Failed to regenerate nginx config" in record.message for record in caplog.records ) @pytest.mark.asyncio async def test_trigger_nginx_reload_returns_true_on_success(self, service): """Test that _trigger_nginx_reload returns True on success.""" mock_nginx = AsyncMock() mock_nginx.generate_config_async = AsyncMock(return_value=True) mock_server_svc = AsyncMock() mock_server_svc.get_enabled_services = AsyncMock(return_value=[]) with ( patch( "registry.core.nginx_service.nginx_service", mock_nginx, ), patch( "registry.services.server_service.server_service", mock_server_svc, ), ): result = await service._trigger_nginx_reload() assert result is True @pytest.mark.asyncio async def test_delete_succeeds_when_nginx_reload_fails( self, service, mock_vs_repo, ): """Test that delete succeeds even if nginx reload fails.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", ) mock_vs_repo.delete.return_value = True with patch.object( service, "_trigger_nginx_reload", new_callable=AsyncMock, return_value=False, ): result = await service.delete_virtual_server("/virtual/dev") assert result is True mock_vs_repo.delete.assert_called_once_with("/virtual/dev") @pytest.mark.asyncio async def test_update_succeeds_when_nginx_reload_fails( self, service, mock_vs_repo, ): """Test that update succeeds even if nginx reload fails.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", ) updated = VirtualServerConfig( path="/virtual/dev", server_name="Dev Updated", ) mock_vs_repo.update.return_value = updated request = UpdateVirtualServerRequest(server_name="Dev Updated") with patch.object( service, "_trigger_nginx_reload", new_callable=AsyncMock, return_value=False, ): result = await service.update_virtual_server("/virtual/dev", request) assert result is not None assert result.server_name == "Dev Updated" # --- Unit tests for path auto-generation collision --- class TestPathAutoGenerationCollision: """Tests for path auto-generation and collision handling.""" @pytest.fixture def mock_vs_repo(self): """Create mock virtual server repository.""" return AsyncMock() @pytest.fixture def mock_server_repo(self): """Create mock server repository.""" return AsyncMock() @pytest.fixture def service(self, mock_vs_repo, mock_server_repo): """Create VirtualServerService with mocked repos.""" with ( patch( "registry.services.virtual_server_service.get_virtual_server_repository", return_value=mock_vs_repo, ), patch( "registry.services.virtual_server_service.get_server_repository", return_value=mock_server_repo, ), ): svc = VirtualServerService() return svc @pytest.mark.asyncio async def test_auto_generated_path_collision_raises_error( self, service, mock_vs_repo, ): """Test that auto-generated path collision raises VirtualServerAlreadyExistsError.""" mock_vs_repo.create.side_effect = VirtualServerAlreadyExistsError("/virtual/my-cool-server") request = CreateVirtualServerRequest( server_name="My Cool Server", # No explicit path -- will be auto-generated as /virtual/my-cool-server ) with pytest.raises(VirtualServerAlreadyExistsError) as exc_info: await service.create_virtual_server(request, created_by="admin") assert "/virtual/my-cool-server" in str(exc_info.value) @pytest.mark.asyncio async def test_explicit_path_collision_raises_error( self, service, mock_vs_repo, ): """Test that explicit path collision raises VirtualServerAlreadyExistsError.""" mock_vs_repo.create.side_effect = VirtualServerAlreadyExistsError("/virtual/dev-essentials") request = CreateVirtualServerRequest( server_name="Dev Essentials", path="/virtual/dev-essentials", ) with pytest.raises(VirtualServerAlreadyExistsError): await service.create_virtual_server(request, created_by="admin") @pytest.mark.asyncio async def test_auto_generate_path_produces_valid_slug(self): """Test various name-to-path conversions produce valid slugs.""" test_cases = [ ("Dev Essentials", "/virtual/dev-essentials"), ("My Server (v2)!", "/virtual/my-server-v2"), (" spaces everywhere ", "/virtual/spaces-everywhere"), ("UPPERCASE", "/virtual/uppercase"), ("with---dashes", "/virtual/with-dashes"), ("a", "/virtual/a"), ] for name, expected_path in test_cases: result = _generate_path_from_name(name) assert result == expected_path, f"Failed for name='{name}'" # --- Unit tests for nginx reload lock serialization --- class TestNginxReloadLock: """Tests verifying nginx reload lock serializes concurrent operations.""" @pytest.fixture def mock_vs_repo(self): """Create mock virtual server repository.""" return AsyncMock() @pytest.fixture def mock_server_repo(self): """Create mock server repository.""" return AsyncMock() @pytest.fixture def service(self, mock_vs_repo, mock_server_repo): """Create VirtualServerService with mocked repos.""" with ( patch( "registry.services.virtual_server_service.get_virtual_server_repository", return_value=mock_vs_repo, ), patch( "registry.services.virtual_server_service.get_server_repository", return_value=mock_server_repo, ), ): svc = VirtualServerService() return svc @pytest.mark.asyncio async def test_reload_lock_exists(self): """Test that the module-level nginx reload lock is an asyncio.Lock.""" import asyncio from registry.services.virtual_server_service import _nginx_reload_lock assert isinstance(_nginx_reload_lock, asyncio.Lock) @pytest.mark.asyncio async def test_concurrent_reloads_are_serialized(self, service): """Test that concurrent nginx reloads are serialized by the lock.""" import asyncio call_order = [] async def mock_generate(*args, **kwargs): call_order.append("start") await asyncio.sleep(0.01) call_order.append("end") return True mock_nginx = AsyncMock() mock_nginx.generate_config_async = mock_generate mock_server_svc = AsyncMock() mock_server_svc.get_enabled_services = AsyncMock(return_value=[]) with ( patch( "registry.core.nginx_service.nginx_service", mock_nginx, ), patch( "registry.services.server_service.server_service", mock_server_svc, ), ): # Launch two reloads concurrently results = await asyncio.gather( service._trigger_nginx_reload(), service._trigger_nginx_reload(), ) # Both should succeed assert all(results) # The lock ensures serialization: start-end-start-end, not start-start-end-end assert call_order == ["start", "end", "start", "end"] # --- Unit tests for rating functionality --- class TestVirtualServerRating: """Tests for VirtualServerService rating operations.""" @pytest.fixture def mock_vs_repo(self): """Create mock virtual server repository.""" return AsyncMock() @pytest.fixture def mock_server_repo(self): """Create mock server repository.""" return AsyncMock() @pytest.fixture def service(self, mock_vs_repo, mock_server_repo): """Create VirtualServerService with mocked repos.""" with ( patch( "registry.services.virtual_server_service.get_virtual_server_repository", return_value=mock_vs_repo, ), patch( "registry.services.virtual_server_service.get_server_repository", return_value=mock_server_repo, ), ): svc = VirtualServerService() return svc @pytest.mark.asyncio async def test_rate_virtual_server_new_rating(self, service, mock_vs_repo): """Test rating a virtual server for the first time.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", num_stars=0.0, rating_details=[], ) mock_vs_repo.update_rating.return_value = True result = await service.rate_virtual_server( path="/virtual/dev", username="testuser", rating=4, ) assert result["average_rating"] == 4.0 assert result["is_new_rating"] is True assert result["total_ratings"] == 1 mock_vs_repo.update_rating.assert_called_once() @pytest.mark.asyncio async def test_rate_virtual_server_update_existing(self, service, mock_vs_repo): """Test updating an existing rating.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", num_stars=4.0, rating_details=[{"user": "testuser", "rating": 4}], ) mock_vs_repo.update_rating.return_value = True result = await service.rate_virtual_server( path="/virtual/dev", username="testuser", rating=5, ) assert result["average_rating"] == 5.0 assert result["is_new_rating"] is False assert result["total_ratings"] == 1 @pytest.mark.asyncio async def test_rate_virtual_server_multiple_users(self, service, mock_vs_repo): """Test rating with multiple users.""" mock_vs_repo.get.return_value = VirtualServerConfig( path="/virtual/dev", server_name="Dev", num_stars=4.0, rating_details=[{"user": "user1", "rating": 4}], ) mock_vs_repo.update_rating.return_value = True result = await service.rate_virtual_server( path="/virtual/dev", username="user2", rating=5, ) assert result["average_rating"] == 4.5 assert result["is_new_rating"] is True assert result["total_ratings"] == 2 @pytest.mark.asyncio async def test_rate_virtual_server_not_found(self, service, mock_vs_repo): """Test rating a nonexistent virtual server raises error.""" mock_vs_repo.get.return_value = None with pytest.raises(VirtualServerNotFoundError): await service.rate_virtual_server( path="/virtual/nonexistent", username="testuser", rating=4, ) @pytest.mark.asyncio async def test_rate_virtual_server_invalid_rating_low(self, service): """Test rating with value below minimum raises error.""" with pytest.raises(ValueError, match="between 1 and 5"): await service.rate_virtual_server( path="/virtual/dev", username="testuser", rating=0, ) @pytest.mark.asyncio async def test_rate_virtual_server_invalid_rating_high(self, service): """Test rating with value above maximum raises error.""" with pytest.raises(ValueError, match="between 1 and 5"): await service.rate_virtual_server( path="/virtual/dev", username="testuser", rating=6, ) @pytest.mark.asyncio async def test_get_virtual_server_rating(self, service, mock_vs_repo): """Test getting rating information.""" mock_vs_repo.get_rating.return_value = { "num_stars": 4.5, "rating_details": [ {"user": "user1", "rating": 4}, {"user": "user2", "rating": 5}, ], } result = await service.get_virtual_server_rating("/virtual/dev") assert result["num_stars"] == 4.5 assert len(result["rating_details"]) == 2 mock_vs_repo.get_rating.assert_called_once_with("/virtual/dev") @pytest.mark.asyncio async def test_get_virtual_server_rating_not_found(self, service, mock_vs_repo): """Test getting rating for nonexistent virtual server raises error.""" mock_vs_repo.get_rating.return_value = None with pytest.raises(VirtualServerNotFoundError): await service.get_virtual_server_rating("/virtual/nonexistent") @pytest.mark.asyncio async def test_get_virtual_server_rating_no_ratings(self, service, mock_vs_repo): """Test getting rating for server with no ratings.""" mock_vs_repo.get_rating.return_value = { "num_stars": 0.0, "rating_details": [], } result = await service.get_virtual_server_rating("/virtual/dev") assert result["num_stars"] == 0.0 assert result["rating_details"] == [] @pytest.mark.asyncio async def test_list_virtual_servers_includes_rating(self, service, mock_vs_repo): """Test that list_virtual_servers includes rating info.""" mock_vs_repo.list_all.return_value = [ VirtualServerConfig( path="/virtual/dev", server_name="Dev", tool_mappings=[ ToolMapping(tool_name="search", backend_server_path="/github"), ], num_stars=4.5, rating_details=[{"user": "user1", "rating": 4}], ), ] result = await service.list_virtual_servers() assert len(result) == 1 assert result[0].num_stars == 4.5 assert len(result[0].rating_details) == 1 ================================================ FILE: tests/unit/utils/__init__.py ================================================ ================================================ FILE: tests/unit/utils/test_credential_encryption.py ================================================ """ Unit tests for registry.utils.credential_encryption. Validates Fernet-based credential encryption, decryption, dict-level helpers, credential stripping, and legacy auth_type to auth_scheme migration. """ import base64 from unittest.mock import patch import pytest from cryptography.fernet import Fernet from registry.utils.credential_encryption import ( ENCRYPTED_FIELD, PLAINTEXT_FIELD, _derive_fernet_key, _migrate_auth_type_to_auth_scheme, decrypt_credential, encrypt_credential, encrypt_credential_in_server_dict, strip_credentials_from_dict, ) class TestDeriveFernetKey: """Tests for _derive_fernet_key.""" def test_derive_fernet_key_produces_valid_key(self): """Verifies _derive_fernet_key returns a 44-byte base64-encoded key.""" # Arrange secret = "test-secret-key-for-derivation" # Act key = _derive_fernet_key(secret) # Assert assert isinstance(key, bytes) assert len(key) == 44 # Verify it is valid base64 decoded = base64.urlsafe_b64decode(key) assert len(decoded) == 32 def test_derive_fernet_key_deterministic(self): """Same secret must always produce the same key.""" # Arrange secret = "reproducible-secret" # Act key_a = _derive_fernet_key(secret) key_b = _derive_fernet_key(secret) # Assert assert key_a == key_b def test_derive_fernet_key_different_secrets_produce_different_keys(self): """Different secrets must produce different keys.""" # Act key_a = _derive_fernet_key("secret-one") key_b = _derive_fernet_key("secret-two") # Assert assert key_a != key_b class TestEncryptDecryptRoundtrip: """Tests for encrypt_credential and decrypt_credential working together.""" @patch("registry.utils.credential_encryption._get_fernet") def test_encrypt_decrypt_roundtrip(self, mock_get_fernet): """Encrypt then decrypt returns original string.""" # Arrange key = Fernet.generate_key() mock_get_fernet.return_value = Fernet(key) plaintext = "sk-abc123def456" # Act encrypted = encrypt_credential(plaintext) decrypted = decrypt_credential(encrypted) # Assert assert decrypted == plaintext assert encrypted != plaintext @patch("registry.utils.credential_encryption._get_fernet") def test_encrypt_produces_different_ciphertext_each_time(self, mock_get_fernet): """Fernet includes a timestamp so each encryption is unique.""" # Arrange key = Fernet.generate_key() mock_get_fernet.return_value = Fernet(key) plaintext = "my-api-key-value" # Act encrypted_a = encrypt_credential(plaintext) encrypted_b = encrypt_credential(plaintext) # Assert assert encrypted_a != encrypted_b class TestEncryptCredentialErrors: """Tests for encrypt_credential error conditions.""" @patch("registry.utils.credential_encryption._get_fernet") def test_encrypt_credential_raises_without_secret_key(self, mock_get_fernet): """When no SECRET_KEY is available, encrypt raises ValueError.""" # Arrange mock_get_fernet.return_value = None # Act / Assert with pytest.raises(ValueError, match="SECRET_KEY is not configured"): encrypt_credential("some-credential") class TestDecryptCredentialErrors: """Tests for decrypt_credential error conditions.""" @patch("registry.utils.credential_encryption._get_fernet") def test_decrypt_credential_returns_none_without_secret_key(self, mock_get_fernet): """When no SECRET_KEY is available, decrypt returns None.""" # Arrange mock_get_fernet.return_value = None # Act result = decrypt_credential("some-encrypted-token") # Assert assert result is None @patch("registry.utils.credential_encryption._get_fernet") def test_decrypt_credential_returns_none_for_invalid_token(self, mock_get_fernet): """When token is garbage, decrypt returns None.""" # Arrange key = Fernet.generate_key() mock_get_fernet.return_value = Fernet(key) # Act result = decrypt_credential("not-a-valid-fernet-token") # Assert assert result is None @patch("registry.utils.credential_encryption._get_fernet") def test_decrypt_credential_returns_none_for_wrong_key(self, mock_get_fernet): """Token encrypted with a different key cannot be decrypted.""" # Arrange - encrypt with one key key_a = Fernet.generate_key() fernet_a = Fernet(key_a) encrypted = fernet_a.encrypt(b"secret-data").decode() # Arrange - try to decrypt with a different key key_b = Fernet.generate_key() mock_get_fernet.return_value = Fernet(key_b) # Act result = decrypt_credential(encrypted) # Assert assert result is None class TestEncryptCredentialInServerDict: """Tests for encrypt_credential_in_server_dict dict-level helper.""" @patch("registry.utils.credential_encryption._get_fernet") def test_encrypt_credential_in_server_dict(self, mock_get_fernet): """Encrypts credential, removes plaintext, adds timestamp.""" # Arrange key = Fernet.generate_key() mock_get_fernet.return_value = Fernet(key) server_dict = { "path": "/test-server", PLAINTEXT_FIELD: "bearer-token-12345", } # Act result = encrypt_credential_in_server_dict(server_dict) # Assert assert PLAINTEXT_FIELD not in result assert ENCRYPTED_FIELD in result assert "credential_updated_at" in result assert result["path"] == "/test-server" # Verify the encrypted value can be decrypted back decrypted = decrypt_credential(result[ENCRYPTED_FIELD]) assert decrypted == "bearer-token-12345" def test_encrypt_credential_in_server_dict_no_credential(self): """Dict without credential is unchanged.""" # Arrange server_dict = { "path": "/test-server", "transport": "streamable-http", } original_keys = set(server_dict.keys()) # Act result = encrypt_credential_in_server_dict(server_dict) # Assert assert set(result.keys()) == original_keys assert ENCRYPTED_FIELD not in result assert PLAINTEXT_FIELD not in result def test_encrypt_credential_in_server_dict_empty_credential(self): """Dict with empty string credential has the plaintext field removed.""" # Arrange server_dict = { "path": "/test-server", PLAINTEXT_FIELD: "", } # Act result = encrypt_credential_in_server_dict(server_dict) # Assert assert PLAINTEXT_FIELD not in result assert ENCRYPTED_FIELD not in result class TestStripCredentialsFromDict: """Tests for strip_credentials_from_dict.""" def test_strip_credentials_from_dict(self): """Removes both encrypted and plaintext credential fields.""" # Arrange server_dict = { "path": "/test-server", PLAINTEXT_FIELD: "my-secret-token", ENCRYPTED_FIELD: "gAAAAABf_encrypted_data", "credential_updated_at": "2025-01-01T00:00:00+00:00", } # Act result = strip_credentials_from_dict(server_dict) # Assert assert PLAINTEXT_FIELD not in result assert ENCRYPTED_FIELD not in result assert result["path"] == "/test-server" assert "credential_updated_at" in result def test_strip_credentials_from_dict_no_credential_fields(self): """Dict without credential fields is returned unchanged.""" # Arrange server_dict = { "path": "/test-server", "transport": "streamable-http", } # Act result = strip_credentials_from_dict(server_dict) # Assert assert result == {"path": "/test-server", "transport": "streamable-http"} class TestMigrateAuthTypeToAuthScheme: """Tests for _migrate_auth_type_to_auth_scheme.""" def test_migrate_auth_type_oauth(self): """auth_type='oauth' should map to auth_scheme='bearer'.""" # Arrange server_dict = {"auth_type": "oauth"} # Act result = _migrate_auth_type_to_auth_scheme(server_dict) # Assert assert result["auth_scheme"] == "bearer" def test_migrate_auth_type_api_key(self): """auth_type='api-key' (hyphenated) should map to auth_scheme='api_key'.""" # Arrange server_dict = {"auth_type": "api-key"} # Act result = _migrate_auth_type_to_auth_scheme(server_dict) # Assert assert result["auth_scheme"] == "api_key" def test_migrate_auth_type_api_key_underscore(self): """auth_type='api_key' (underscore) should map to auth_scheme='api_key'.""" # Arrange server_dict = {"auth_type": "api_key"} # Act result = _migrate_auth_type_to_auth_scheme(server_dict) # Assert assert result["auth_scheme"] == "api_key" def test_migrate_auth_type_none(self): """auth_type='none' should map to auth_scheme='none'.""" # Arrange server_dict = {"auth_type": "none"} # Act result = _migrate_auth_type_to_auth_scheme(server_dict) # Assert assert result["auth_scheme"] == "none" def test_migrate_auth_type_custom(self): """auth_type='custom' should map to auth_scheme='bearer'.""" # Arrange server_dict = {"auth_type": "custom"} # Act result = _migrate_auth_type_to_auth_scheme(server_dict) # Assert assert result["auth_scheme"] == "bearer" def test_migrate_auth_type_unknown_defaults_to_none(self): """Unknown auth_type value should default to auth_scheme='none'.""" # Arrange server_dict = {"auth_type": "something-unknown"} # Act result = _migrate_auth_type_to_auth_scheme(server_dict) # Assert assert result["auth_scheme"] == "none" def test_migrate_no_overwrite(self): """If auth_scheme already exists, migration does not overwrite.""" # Arrange server_dict = { "auth_type": "oauth", "auth_scheme": "api_key", } # Act result = _migrate_auth_type_to_auth_scheme(server_dict) # Assert assert result["auth_scheme"] == "api_key" def test_migrate_no_auth_type(self): """Dict without auth_type is unchanged.""" # Arrange server_dict = {"path": "/test-server"} # Act result = _migrate_auth_type_to_auth_scheme(server_dict) # Assert assert "auth_scheme" not in result assert result == {"path": "/test-server"} ================================================ FILE: tests/unit/utils/test_logging_setup.py ================================================ """Unit tests for registry/utils/logging_setup.py and mongodb_log_handler.py.""" import logging from logging.handlers import RotatingFileHandler from unittest.mock import patch # ============================================================================= # LOGGING SETUP TESTS # ============================================================================= class TestSetupLogging: """Test the shared setup_logging function.""" def test_creates_console_handler(self, tmp_path): with patch("registry.core.config.settings") as mock_settings: mock_settings.app_log_level = "INFO" mock_settings.app_log_max_bytes = 50 * 1024 * 1024 mock_settings.app_log_backup_count = 5 mock_settings.app_log_centralized_enabled = False mock_settings.log_dir = tmp_path from registry.utils.logging_setup import setup_logging setup_logging(service_name="test-service", log_file=tmp_path / "test.log") root = logging.getLogger() handler_types = [type(h) for h in root.handlers] assert logging.StreamHandler in handler_types def test_creates_rotating_file_handler(self, tmp_path): with patch("registry.core.config.settings") as mock_settings: mock_settings.app_log_level = "INFO" mock_settings.app_log_max_bytes = 50 * 1024 * 1024 mock_settings.app_log_backup_count = 5 mock_settings.app_log_centralized_enabled = False mock_settings.log_dir = tmp_path from registry.utils.logging_setup import setup_logging log_path = setup_logging( service_name="test-service", log_file=tmp_path / "test.log", ) assert log_path == tmp_path / "test.log" root = logging.getLogger() handler_types = [type(h) for h in root.handlers] assert RotatingFileHandler in handler_types def test_rotating_handler_uses_settings(self, tmp_path): with patch("registry.core.config.settings") as mock_settings: mock_settings.app_log_level = "WARNING" mock_settings.app_log_max_bytes = 10 * 1024 * 1024 mock_settings.app_log_backup_count = 3 mock_settings.app_log_centralized_enabled = False mock_settings.log_dir = tmp_path from registry.utils.logging_setup import setup_logging setup_logging(service_name="test-service", log_file=tmp_path / "test.log") root = logging.getLogger() rotating_handlers = [h for h in root.handlers if isinstance(h, RotatingFileHandler)] assert len(rotating_handlers) == 1 assert rotating_handlers[0].maxBytes == 10 * 1024 * 1024 assert rotating_handlers[0].backupCount == 3 def test_default_log_file_path(self, tmp_path): with patch("registry.core.config.settings") as mock_settings: mock_settings.app_log_level = "INFO" mock_settings.app_log_max_bytes = 50 * 1024 * 1024 mock_settings.app_log_backup_count = 5 mock_settings.app_log_centralized_enabled = False mock_settings.log_dir = tmp_path from registry.utils.logging_setup import setup_logging log_path = setup_logging(service_name="registry") assert log_path == tmp_path / "registry.log" def test_mongodb_handler_not_added_when_disabled(self, tmp_path): with patch("registry.core.config.settings") as mock_settings: mock_settings.app_log_level = "INFO" mock_settings.app_log_max_bytes = 50 * 1024 * 1024 mock_settings.app_log_backup_count = 5 mock_settings.app_log_centralized_enabled = False mock_settings.log_dir = tmp_path from registry.utils.logging_setup import setup_logging setup_logging(service_name="test", log_file=tmp_path / "test.log") root = logging.getLogger() from registry.utils.mongodb_log_handler import MongoDBLogHandler mongo_handlers = [h for h in root.handlers if isinstance(h, MongoDBLogHandler)] assert len(mongo_handlers) == 0 def test_mongodb_handler_skipped_for_file_backend(self, tmp_path): with patch("registry.core.config.settings") as mock_settings: mock_settings.app_log_level = "INFO" mock_settings.app_log_max_bytes = 50 * 1024 * 1024 mock_settings.app_log_backup_count = 5 mock_settings.app_log_centralized_enabled = True mock_settings.storage_backend = "file" mock_settings.log_dir = tmp_path from registry.utils.logging_setup import setup_logging setup_logging(service_name="test", log_file=tmp_path / "test.log") root = logging.getLogger() from registry.utils.mongodb_log_handler import MongoDBLogHandler mongo_handlers = [h for h in root.handlers if isinstance(h, MongoDBLogHandler)] assert len(mongo_handlers) == 0 def test_clears_existing_handlers(self, tmp_path): root = logging.getLogger() dummy_handler = logging.StreamHandler() root.addHandler(dummy_handler) initial_count = len(root.handlers) with patch("registry.core.config.settings") as mock_settings: mock_settings.app_log_level = "INFO" mock_settings.app_log_max_bytes = 50 * 1024 * 1024 mock_settings.app_log_backup_count = 5 mock_settings.app_log_centralized_enabled = False mock_settings.log_dir = tmp_path from registry.utils.logging_setup import setup_logging setup_logging(service_name="test", log_file=tmp_path / "test.log") # Should have exactly 2 handlers: console + file assert len(root.handlers) == 2 # ============================================================================= # MONGODB LOG HANDLER TESTS # ============================================================================= class TestMongoDBLogHandler: """Test the MongoDBLogHandler class.""" def test_emit_buffers_record(self): with patch("registry.core.config.settings") as mock_settings: mock_settings.documentdb_namespace = "test" mock_settings.documentdb_host = "localhost" mock_settings.documentdb_port = 27017 mock_settings.documentdb_use_iam = False mock_settings.documentdb_username = None mock_settings.documentdb_password = None mock_settings.documentdb_use_tls = False mock_settings.documentdb_tls_ca_file = "" mock_settings.documentdb_direct_connection = True mock_settings.documentdb_database = "test_db" mock_settings.storage_backend = "mongodb-ce" from registry.utils.mongodb_log_handler import MongoDBLogHandler handler = MongoDBLogHandler( service_name="test-service", buffer_size=100, flush_interval=999, ttl_days=7, ) handler.setFormatter(logging.Formatter("%(message)s")) record = logging.LogRecord( name="test", level=logging.INFO, pathname="test.py", lineno=1, msg="test message", args=(), exc_info=None, ) handler.emit(record) assert len(handler._buffer) == 1 assert handler._buffer[0]["service"] == "test-service" assert handler._buffer[0]["level"] == "INFO" assert handler._buffer[0]["message"] == "test message" handler._closed = True def test_emit_ignored_when_closed(self): with patch("registry.core.config.settings") as mock_settings: mock_settings.documentdb_namespace = "test" mock_settings.documentdb_host = "localhost" mock_settings.documentdb_port = 27017 mock_settings.documentdb_use_iam = False mock_settings.documentdb_username = None mock_settings.documentdb_password = None mock_settings.documentdb_use_tls = False mock_settings.documentdb_tls_ca_file = "" mock_settings.documentdb_direct_connection = True mock_settings.documentdb_database = "test_db" mock_settings.storage_backend = "mongodb-ce" from registry.utils.mongodb_log_handler import MongoDBLogHandler handler = MongoDBLogHandler( service_name="test", buffer_size=100, flush_interval=999, ttl_days=7, ) handler._closed = True record = logging.LogRecord( name="test", level=logging.INFO, pathname="test.py", lineno=1, msg="ignored", args=(), exc_info=None, ) handler.emit(record) assert len(handler._buffer) == 0 def test_flush_triggers_at_buffer_size(self): with ( patch("registry.core.config.settings") as mock_settings, patch("registry.utils.mongodb_log_handler.MongoDBLogHandler._flush") as mock_flush, ): mock_settings.documentdb_namespace = "test" mock_settings.documentdb_host = "localhost" mock_settings.documentdb_port = 27017 mock_settings.documentdb_use_iam = False mock_settings.documentdb_username = None mock_settings.documentdb_password = None mock_settings.documentdb_use_tls = False mock_settings.documentdb_tls_ca_file = "" mock_settings.documentdb_direct_connection = True mock_settings.documentdb_database = "test_db" mock_settings.storage_backend = "mongodb-ce" from registry.utils.mongodb_log_handler import MongoDBLogHandler handler = MongoDBLogHandler( service_name="test", buffer_size=2, flush_interval=999, ttl_days=7, ) handler.setFormatter(logging.Formatter("%(message)s")) for i in range(2): record = logging.LogRecord( name="test", level=logging.INFO, pathname="test.py", lineno=1, msg=f"msg-{i}", args=(), exc_info=None, ) handler.emit(record) mock_flush.assert_called() handler._closed = True ================================================ FILE: tests/unit/utils/test_metadata.py ================================================ """Unit tests for registry.utils.metadata module.""" import pytest from registry.utils.metadata import flatten_metadata_to_text class TestFlattenMetadataToText: """Tests for the metadata flattening utility.""" def test_simple_string_values(self): """Flat dict with string values produces key-value tokens.""" metadata = {"team": "finance", "region": "us-east"} result = flatten_metadata_to_text(metadata) assert "team" in result assert "finance" in result assert "region" in result assert "us-east" in result def test_list_values_flattened(self): """List values are expanded into individual tokens.""" metadata = {"langs": ["python", "go", "rust"]} result = flatten_metadata_to_text(metadata) assert "langs" in result assert "python" in result assert "go" in result assert "rust" in result def test_nested_dict_values_flattened(self): """Nested dict values are included.""" metadata = {"contact": {"name": "Alice", "role": "lead"}} result = flatten_metadata_to_text(metadata) assert "contact" in result assert "Alice" in result assert "lead" in result def test_empty_dict_returns_empty_string(self): """Empty dict returns empty string.""" assert flatten_metadata_to_text({}) == "" def test_none_returns_empty_string(self): """None input returns empty string.""" assert flatten_metadata_to_text(None) == "" def test_non_dict_returns_empty_string(self): """Non-dict input returns empty string.""" assert flatten_metadata_to_text("not a dict") == "" def test_numeric_values_converted_to_string(self): """Numeric values are converted to strings.""" metadata = {"version": 3, "priority": 1.5} result = flatten_metadata_to_text(metadata) assert "version" in result assert "3" in result assert "priority" in result assert "1.5" in result def test_boolean_values_converted_to_string(self): """Boolean values are converted to strings.""" metadata = {"active": True, "deprecated": False} result = flatten_metadata_to_text(metadata) assert "True" in result assert "False" in result def test_mixed_value_types(self): """Mixed value types all appear in output.""" metadata = { "team": "platform", "tags": ["internal", "v2"], "config": {"timeout": 30}, "priority": 1, } result = flatten_metadata_to_text(metadata) assert "team" in result assert "platform" in result assert "internal" in result assert "v2" in result assert "30" in result assert "priority" in result assert "1" in result ================================================ FILE: tests/unit/utils/test_mongodb_log_handler.py ================================================ """Unit tests for registry/utils/mongodb_log_handler.py - MongoDB log handler.""" import logging import threading from datetime import datetime from unittest.mock import MagicMock, patch import pytest from registry.utils.mongodb_log_handler import EXCLUDED_LOGGERS_DEFAULT, MongoDBLogHandler @pytest.fixture def mock_settings(): s = MagicMock() s.documentdb_namespace = "test" s.documentdb_database = "registry_test" return s @pytest.fixture def handler(mock_settings): with ( patch("registry.utils.mongodb_log_handler.build_connection_string"), patch("registry.utils.mongodb_log_handler.build_client_options", return_value={}), patch("registry.utils.mongodb_log_handler.build_tls_kwargs", return_value={}), patch("registry.core.config.settings", mock_settings), ): h = MongoDBLogHandler.__new__(MongoDBLogHandler) logging.Handler.__init__(h) h._service_name = "test-service" h._hostname = "test-host" h._buffer = [] h._buffer_lock = threading.Lock() h._buffer_size = 50 h._flush_interval = 5.0 h._ttl_days = 7 h._excluded_loggers = EXCLUDED_LOGGERS_DEFAULT h._flush_failure_count = 0 h._closed = False h._collection_name = "application_logs_test" h._client = None h._collection = None h._connect_error_logged = False h._flush_thread = threading.Thread(target=lambda: None, daemon=True) yield h h._closed = True class TestExcludedLoggers: """Test recursion guard via _is_excluded.""" def test_exact_match(self, handler): assert handler._is_excluded("pymongo") is True def test_child_logger_excluded(self, handler): assert handler._is_excluded("pymongo.collection") is True def test_unrelated_logger_allowed(self, handler): assert handler._is_excluded("registry.api.server_routes") is False def test_partial_name_not_excluded(self, handler): assert handler._is_excluded("pymongo_extra") is False def test_default_exclusions_present(self): assert "pymongo" in EXCLUDED_LOGGERS_DEFAULT assert "motor" in EXCLUDED_LOGGERS_DEFAULT assert "uvicorn.access" in EXCLUDED_LOGGERS_DEFAULT assert "httpx" in EXCLUDED_LOGGERS_DEFAULT assert "registry.utils.mongodb_log_handler" in EXCLUDED_LOGGERS_DEFAULT def test_custom_exclusions(self, handler): handler._excluded_loggers = frozenset({"myapp"}) assert handler._is_excluded("myapp") is True assert handler._is_excluded("myapp.sub") is True assert handler._is_excluded("pymongo") is False class TestEmit: """Test the emit method buffers records correctly.""" def test_record_buffered(self, handler): record = logging.LogRecord( name="registry.api", level=logging.INFO, pathname="api.py", lineno=10, msg="Test message", args=None, exc_info=None, ) handler.emit(record) assert len(handler._buffer) == 1 doc = handler._buffer[0] assert doc["service"] == "test-service" assert doc["hostname"] == "test-host" assert doc["level"] == "INFO" assert doc["level_no"] == 20 assert doc["message"] == "Test message" assert doc["process"] is not None assert isinstance(doc["timestamp"], datetime) assert isinstance(doc["created_at"], datetime) def test_excluded_logger_not_buffered(self, handler): record = logging.LogRecord( name="pymongo.collection", level=logging.INFO, pathname="collection.py", lineno=1, msg="Internal message", args=None, exc_info=None, ) handler.emit(record) assert len(handler._buffer) == 0 def test_closed_handler_no_buffer(self, handler): handler._closed = True record = logging.LogRecord( name="registry.api", level=logging.ERROR, pathname="api.py", lineno=5, msg="Should be ignored", args=None, exc_info=None, ) handler.emit(record) assert len(handler._buffer) == 0 def test_buffer_flush_on_size(self, handler): handler._buffer_size = 2 mock_collection = MagicMock() handler._collection = mock_collection for i in range(2): record = logging.LogRecord( name="registry.api", level=logging.INFO, pathname="api.py", lineno=i, msg=f"Message {i}", args=None, exc_info=None, ) handler.emit(record) mock_collection.insert_many.assert_called_once() assert len(handler._buffer) == 0 class TestFlush: """Test the _flush method.""" def test_flush_empty_buffer_noop(self, handler): handler._flush() assert handler._collection is None def test_flush_failure_increments_counter(self, handler): from pymongo.errors import PyMongoError mock_collection = MagicMock() mock_collection.insert_many.side_effect = PyMongoError("write error") handler._collection = mock_collection handler._buffer = [{"message": "test"}] with patch("registry.core.metrics.APP_LOG_FLUSH_FAILURES") as mock_metric: handler._flush() assert handler._flush_failure_count == 1 mock_metric.labels.assert_called_once_with(service="test-service") class TestFlushFailureCount: """Test the flush_failure_count property.""" def test_initial_count_zero(self, handler): assert handler.flush_failure_count == 0 def test_count_reflects_failures(self, handler): handler._flush_failure_count = 5 assert handler.flush_failure_count == 5 class TestDocumentSchema: """Test that emitted documents match the expected schema.""" def test_document_has_all_fields(self, handler): record = logging.LogRecord( name="registry.main", level=logging.WARNING, pathname="main.py", lineno=42, msg="Test warning", args=None, exc_info=None, ) handler.emit(record) doc = handler._buffer[0] expected_fields = { "timestamp", "hostname", "service", "level", "level_no", "logger", "filename", "lineno", "process", "message", "created_at", } assert set(doc.keys()) == expected_fields def test_level_no_matches_record(self, handler): for level, expected_no in [ (logging.DEBUG, 10), (logging.INFO, 20), (logging.WARNING, 30), (logging.ERROR, 40), (logging.CRITICAL, 50), ]: handler._buffer.clear() record = logging.LogRecord( name="registry.test", level=level, pathname="test.py", lineno=1, msg="msg", args=None, exc_info=None, ) handler.emit(record) assert handler._buffer[0]["level_no"] == expected_no class TestClose: """Test handler close behavior.""" def test_close_flushes_remaining(self, handler): mock_collection = MagicMock() handler._collection = mock_collection handler._client = MagicMock() handler._buffer = [{"message": "final"}] handler.close() mock_collection.insert_many.assert_called_once() handler._client.close.assert_called_once() assert handler._closed is True def test_double_close_safe(self, handler): handler._client = MagicMock() handler.close() handler.close() assert handler._closed is True ================================================ FILE: tests/unit/utils/test_okta_manager.py ================================================ """Unit tests for OktaIAMManager (okta_manager.py).""" from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest def _make_response(json_data, status_code=200, links=None, headers=None): """Create a mock httpx.Response with synchronous json().""" resp = MagicMock(spec=httpx.Response) resp.status_code = status_code resp.json.return_value = json_data resp.raise_for_status = MagicMock() resp.links = links or {} resp.headers = headers or {} return resp def _make_async_client(**overrides): """Create a mock async httpx client.""" client = AsyncMock() for method, value in overrides.items(): setattr(client, method, value) client.__aenter__ = AsyncMock(return_value=client) client.__aexit__ = AsyncMock(return_value=False) return client # ============================================================================= # USER MANAGEMENT TESTS # ============================================================================= class TestOktaUserManagement: """Tests for Okta user management functions.""" @pytest.mark.asyncio @patch("registry.utils.okta_manager.OKTA_API_TOKEN", "test-api-token") @patch("registry.utils.okta_manager.OKTA_DOMAIN", "dev-123.okta.com") async def test_list_users_ssws_auth(self): """Verifies SSWS authorization header is sent.""" from registry.utils.okta_manager import list_okta_users resp = _make_response([]) mock_client = _make_async_client() mock_client.get = AsyncMock(return_value=resp) with patch("registry.utils.okta_manager.httpx.AsyncClient", return_value=mock_client): await list_okta_users() call_args = mock_client.get.call_args headers = call_args[1]["headers"] assert headers["Authorization"] == "SSWS test-api-token" @pytest.mark.asyncio @patch("registry.utils.okta_manager.OKTA_API_TOKEN", "tok") @patch("registry.utils.okta_manager.OKTA_DOMAIN", "dev-123.okta.com") async def test_list_users_pagination(self): """Pagination across multiple pages (200 per page).""" from registry.utils.okta_manager import list_okta_users page1_users = [ { "id": f"u{i}", "profile": { "login": f"u{i}@t.com", "email": f"u{i}@t.com", "firstName": "F", "lastName": "L", }, "status": "ACTIVE", "created": "2026-01-01", } for i in range(3) ] page2_users = [ { "id": "u99", "profile": { "login": "u99@t.com", "email": "u99@t.com", "firstName": "F", "lastName": "L", }, "status": "ACTIVE", "created": "2026-01-01", } ] resp1 = _make_response( page1_users, links={"next": {"url": "https://dev-123.okta.com/api/v1/users?after=abc"}} ) resp2 = _make_response(page2_users) groups_resp = _make_response([{"profile": {"name": "users"}}]) mock_client = _make_async_client() mock_client.get = AsyncMock( side_effect=[resp1, resp2, groups_resp, groups_resp, groups_resp, groups_resp] ) with patch("registry.utils.okta_manager.httpx.AsyncClient", return_value=mock_client): result = await list_okta_users(include_groups=True) assert len(result) == 4 @pytest.mark.asyncio @patch("registry.utils.okta_manager.OKTA_API_TOKEN", "tok") @patch("registry.utils.okta_manager.OKTA_DOMAIN", "dev-123.okta.com") async def test_create_human_user_with_group_assignment(self): """User creation and group assignment flow.""" from registry.utils.okta_manager import create_okta_human_user mock_client = _make_async_client() mock_client.post = AsyncMock( return_value=_make_response({"id": "u1", "profile": {"login": "new@t.com"}}) ) mock_client.get = AsyncMock( return_value=_make_response([{"id": "g1", "profile": {"name": "devs"}}]) ) mock_client.put = AsyncMock() with patch("registry.utils.okta_manager.httpx.AsyncClient", return_value=mock_client): result = await create_okta_human_user("new@t.com", "new@t.com", "New", "User", ["devs"]) assert result["username"] == "new@t.com" assert result["groups"] == ["devs"] mock_client.put.assert_called_once() @pytest.mark.asyncio @patch("registry.utils.okta_manager.OKTA_API_TOKEN", "tok") @patch("registry.utils.okta_manager.OKTA_DOMAIN", "dev-123.okta.com") async def test_delete_user_deactivates_then_deletes(self): """Two-step deactivate + delete flow.""" from registry.utils.okta_manager import delete_okta_user mock_client = _make_async_client() mock_client.get = AsyncMock(return_value=_make_response({"id": "u1"}, status_code=200)) mock_client.post = AsyncMock() mock_client.delete = AsyncMock(return_value=_make_response(None)) with patch("registry.utils.okta_manager.httpx.AsyncClient", return_value=mock_client): result = await delete_okta_user("user@test.com") assert result is True mock_client.post.assert_called_once() # deactivate mock_client.delete.assert_called_once() # delete @pytest.mark.asyncio async def test_rate_limit_429_raises_with_retry_after(self): """HTTP 429 raises ValueError with Retry-After.""" from registry.utils.okta_manager import _check_rate_limit resp = _make_response( None, status_code=429, headers={"Retry-After": "30", "X-Rate-Limit-Remaining": "0"} ) with pytest.raises(ValueError, match="Retry after 30 seconds"): _check_rate_limit(resp) # ============================================================================= # GROUP MANAGEMENT TESTS # ============================================================================= class TestOktaGroupManagement: """Tests for Okta group management functions.""" @pytest.mark.asyncio @patch("registry.utils.okta_manager.OKTA_API_TOKEN", "tok") @patch("registry.utils.okta_manager.OKTA_DOMAIN", "dev-123.okta.com") async def test_list_groups_returns_all_fields(self): """Returns id, name, description, type for each group.""" from registry.utils.okta_manager import list_okta_groups api_groups = [ { "id": "g1", "profile": {"name": "admins", "description": "Admin group"}, "type": "OKTA_GROUP", }, { "id": "g2", "profile": {"name": "users", "description": "User group"}, "type": "OKTA_GROUP", }, ] mock_client = _make_async_client() mock_client.get = AsyncMock(return_value=_make_response(api_groups)) with patch("registry.utils.okta_manager.httpx.AsyncClient", return_value=mock_client): result = await list_okta_groups() assert len(result) == 2 assert result[0]["id"] == "g1" assert result[0]["name"] == "admins" assert result[0]["description"] == "Admin group" assert result[0]["type"] == "OKTA_GROUP" @pytest.mark.asyncio @patch("registry.utils.okta_manager.OKTA_API_TOKEN", "tok") @patch("registry.utils.okta_manager.OKTA_DOMAIN", "dev-123.okta.com") async def test_create_group(self): """Group creation via Admin API.""" from registry.utils.okta_manager import create_okta_group mock_client = _make_async_client() mock_client.post = AsyncMock( return_value=_make_response({"id": "g-new", "profile": {"name": "new-group"}}) ) with patch("registry.utils.okta_manager.httpx.AsyncClient", return_value=mock_client): result = await create_okta_group("new-group", "A new group") assert result["name"] == "new-group" assert result["id"] == "g-new" @pytest.mark.asyncio @patch("registry.utils.okta_manager.OKTA_API_TOKEN", "tok") @patch("registry.utils.okta_manager.OKTA_DOMAIN", "dev-123.okta.com") async def test_delete_group_resolves_name_to_id(self): """Name-to-ID resolution before deletion.""" from registry.utils.okta_manager import delete_okta_group mock_client = _make_async_client() mock_client.get = AsyncMock( return_value=_make_response([{"id": "g1", "profile": {"name": "target"}}]) ) mock_client.delete = AsyncMock(return_value=_make_response(None)) with patch("registry.utils.okta_manager.httpx.AsyncClient", return_value=mock_client): result = await delete_okta_group("target") assert result is True delete_url = mock_client.delete.call_args[0][0] assert "g1" in delete_url @pytest.mark.asyncio @patch("registry.utils.okta_manager.OKTA_API_TOKEN", "tok") @patch("registry.utils.okta_manager.OKTA_DOMAIN", "dev-123.okta.com") async def test_delete_group_not_found_raises(self): """ValueError when group name doesn't match.""" from registry.utils.okta_manager import delete_okta_group mock_client = _make_async_client() mock_client.get = AsyncMock(return_value=_make_response([])) with patch("registry.utils.okta_manager.httpx.AsyncClient", return_value=mock_client): with pytest.raises(ValueError, match="Group not found"): await delete_okta_group("nonexistent") # ============================================================================= # SERVICE ACCOUNT TESTS # ============================================================================= class TestOktaServiceAccount: """Tests for Okta service account management.""" @pytest.mark.asyncio @patch("registry.utils.okta_manager.OKTA_API_TOKEN", "tok") @patch("registry.utils.okta_manager.OKTA_DOMAIN", "dev-123.okta.com") async def test_create_service_account(self): """OIDC service app with client_credentials grant type and group assignment.""" from registry.utils.okta_manager import create_okta_service_account created_app = { "id": "app1", "credentials": {"oauthClient": {"client_id": "gen-cid", "client_secret": "gen-cs"}}, } group_search = [{"id": "g1", "profile": {"name": "agents"}}] mock_client = _make_async_client() mock_client.post = AsyncMock(return_value=_make_response(created_app)) mock_client.get = AsyncMock(return_value=_make_response(group_search)) mock_client.put = AsyncMock() with patch("registry.utils.okta_manager.httpx.AsyncClient", return_value=mock_client): result = await create_okta_service_account("my-agent", ["agents"]) assert result["client_id"] == "gen-cid" assert result["client_secret"] == "gen-cs" assert result["groups"] == ["agents"] app_data = mock_client.post.call_args[1]["json"] assert "client_credentials" in app_data["settings"]["oauthClient"]["grant_types"] assert app_data["settings"]["oauthClient"]["application_type"] == "service" # ============================================================================= # UPDATE OPERATIONS TESTS # ============================================================================= class TestOktaUpdateOperations: """Tests for Okta update operations.""" @pytest.mark.asyncio @patch("registry.utils.okta_manager.OKTA_API_TOKEN", "tok") @patch("registry.utils.okta_manager.OKTA_DOMAIN", "dev-123.okta.com") async def test_update_user_groups(self): """Update user groups calculates minimal diff.""" from registry.utils.okta_manager import update_okta_user_groups user_resp = _make_response({"id": "u1"}, status_code=200) current_groups = [ {"id": "g1", "profile": {"name": "old-group"}, "type": "OKTA_GROUP"}, {"id": "g2", "profile": {"name": "keep-group"}, "type": "OKTA_GROUP"}, ] current_groups_resp = _make_response(current_groups) all_groups = [ {"id": "g1", "profile": {"name": "old-group"}}, {"id": "g2", "profile": {"name": "keep-group"}}, {"id": "g3", "profile": {"name": "new-group"}}, ] all_groups_resp = _make_response(all_groups) mock_client = _make_async_client() mock_client.get = AsyncMock(side_effect=[user_resp, current_groups_resp, all_groups_resp]) mock_client.delete = AsyncMock() mock_client.put = AsyncMock() with patch("registry.utils.okta_manager.httpx.AsyncClient", return_value=mock_client): result = await update_okta_user_groups("user@test.com", ["keep-group", "new-group"]) assert result["groups"] == ["keep-group", "new-group"] mock_client.delete.assert_called_once() # remove old-group mock_client.put.assert_called_once() # add new-group @pytest.mark.asyncio @patch("registry.utils.okta_manager.OKTA_API_TOKEN", "tok") @patch("registry.utils.okta_manager.OKTA_DOMAIN", "dev-123.okta.com") async def test_update_group(self): """Update group description resolves name to ID.""" from registry.utils.okta_manager import update_okta_group search_resp = _make_response([{"id": "g1", "profile": {"name": "my-group"}}]) put_resp = _make_response(None) mock_client = _make_async_client() mock_client.get = AsyncMock(return_value=search_resp) mock_client.put = AsyncMock(return_value=put_resp) with patch("registry.utils.okta_manager.httpx.AsyncClient", return_value=mock_client): result = await update_okta_group("my-group", "Updated description") assert result["name"] == "my-group" assert result["description"] == "Updated description" put_url = mock_client.put.call_args[0][0] assert "g1" in put_url @pytest.mark.asyncio @patch("registry.utils.okta_manager.OKTA_API_TOKEN", "tok") @patch("registry.utils.okta_manager.OKTA_DOMAIN", "dev-123.okta.com") async def test_update_group_not_found_raises(self): """Update group raises ValueError when not found.""" from registry.utils.okta_manager import update_okta_group mock_client = _make_async_client() mock_client.get = AsyncMock(return_value=_make_response([])) with patch("registry.utils.okta_manager.httpx.AsyncClient", return_value=mock_client): with pytest.raises(ValueError, match="Group not found"): await update_okta_group("nonexistent", "desc") ================================================ FILE: tests/unit/utils/test_request_utils.py ================================================ """ Unit tests for registry.utils.request_utils. Validates IP extraction and sanitization from proxied requests. """ from unittest.mock import MagicMock from registry.utils.request_utils import get_client_ip def _make_request(headers=None, client_host="127.0.0.1", client=None): """Create a minimal mock FastAPI Request.""" request = MagicMock() request.headers = headers or {} if client is False: request.client = None else: request.client = MagicMock() request.client.host = client_host return request class TestGetClientIp: """Tests for get_client_ip utility function.""" def test_returns_first_ip_from_forwarded_for(self): """Should return the first IP from X-Forwarded-For header.""" request = _make_request( headers={"X-Forwarded-For": "33.111.22.33, 10.0.0.1"}, ) assert get_client_ip(request) == "33.111.22.33" def test_returns_single_forwarded_for_ip(self): """Should handle a single IP in X-Forwarded-For.""" request = _make_request( headers={"X-Forwarded-For": "192.168.1.1"}, ) assert get_client_ip(request) == "192.168.1.1" def test_falls_back_to_client_host_when_no_header(self): """Should use request.client.host when X-Forwarded-For is absent.""" request = _make_request(client_host="10.0.0.5") assert get_client_ip(request) == "10.0.0.5" def test_returns_unknown_when_no_client(self): """Should return 'unknown' when both header and client are missing.""" request = _make_request(client=False) assert get_client_ip(request) == "unknown" def test_rejects_malformed_forwarded_for(self): """Should ignore non-IP values in X-Forwarded-For and fall back.""" request = _make_request( headers={"X-Forwarded-For": ""}, client_host="10.0.0.1", ) assert get_client_ip(request) == "10.0.0.1" def test_rejects_arbitrary_string_in_header(self): """Should ignore random strings in X-Forwarded-For.""" request = _make_request( headers={"X-Forwarded-For": "not-an-ip, 10.1.2.3"}, client_host="10.0.0.1", ) assert get_client_ip(request) == "10.0.0.1" def test_handles_ipv6_address(self): """Should accept valid IPv6 addresses in X-Forwarded-For.""" request = _make_request( headers={"X-Forwarded-For": "2001:db8::1, 10.1.2.3"}, ) assert get_client_ip(request) == "2001:db8::1" def test_handles_whitespace_around_ip(self): """Should strip whitespace from the extracted IP.""" request = _make_request( headers={"X-Forwarded-For": " 33.111.22.33 , 10.0.0.1"}, ) assert get_client_ip(request) == "33.111.22.33" def test_empty_forwarded_for_falls_back(self): """Should fall back to client.host when header is empty string.""" request = _make_request( headers={"X-Forwarded-For": ""}, client_host="10.0.0.1", ) assert get_client_ip(request) == "10.0.0.1" ================================================ FILE: tests/unit/utils/test_url_utils.py ================================================ """ Unit tests for registry.utils.url_utils.extract_repository_url. Validates extraction of GitHub repository URLs from SKILL.md URLs, including public GitHub, raw.githubusercontent.com, and enterprise instances. """ from registry.utils.url_utils import extract_repository_url class TestExtractRepositoryUrl: """Tests for extract_repository_url utility function.""" def test_github_blob_url(self): """Should extract repo URL from a standard GitHub blob URL.""" # Arrange url = "https://github.com/anthropics/skills/blob/main/skills/art/SKILL.md" # Act result = extract_repository_url(url) # Assert assert result == "https://github.com/anthropics/skills" def test_raw_githubusercontent_url(self): """Should extract repo URL from a raw.githubusercontent.com URL.""" # Arrange url = ( "https://raw.githubusercontent.com/anthropics/skills" "/refs/heads/main/skills/art/SKILL.md" ) # Act result = extract_repository_url(url) # Assert assert result == "https://github.com/anthropics/skills" def test_enterprise_github_blob_url(self): """Should extract repo URL from an enterprise GitHub blob URL.""" # Arrange url = "https://github.mycompany.com/org/repo/blob/main/SKILL.md" # Act result = extract_repository_url(url) # Assert assert result == "https://github.mycompany.com/org/repo" def test_enterprise_raw_url(self): """Should extract repo URL from an enterprise raw GitHub URL.""" # Arrange url = "https://raw.github.mycompany.com/org/repo/refs/heads/main/SKILL.md" # Act result = extract_repository_url(url) # Assert assert result == "https://github.mycompany.com/org/repo" def test_non_github_url_returns_none(self): """Should return None for non-GitHub URLs.""" # Arrange url = "https://gitlab.com/org/repo/raw/main/SKILL.md" # Act result = extract_repository_url(url) # Assert assert result is None def test_empty_string_returns_none(self): """Should return None for an empty string.""" # Arrange url = "" # Act result = extract_repository_url(url) # Assert assert result is None def test_url_with_no_path_returns_none(self): """Should return None when the URL has no path segments.""" # Arrange url = "https://github.com" # Act result = extract_repository_url(url) # Assert assert result is None def test_url_with_only_owner_returns_none(self): """Should return None when the URL has only an owner, no repo.""" # Arrange url = "https://github.com/anthropics" # Act result = extract_repository_url(url) # Assert assert result is None ================================================ FILE: tests/unit/utils/test_visibility.py ================================================ """Unit tests for the shared visibility normalization utilities.""" import pytest from registry.utils.visibility import ( VALID_VISIBILITY_VALUES, _normalize_visibility, validate_visibility, ) # --------------------------------------------------------------------------- # _normalize_visibility # --------------------------------------------------------------------------- @pytest.mark.unit class TestNormalizeVisibility: """Tests for the _normalize_visibility helper.""" def test_internal_normalized_to_private(self): """'internal' should be normalized to 'private'.""" assert _normalize_visibility("internal") == "private" def test_group_normalized_to_group_restricted(self): """'group' should be normalized to 'group-restricted'.""" assert _normalize_visibility("group") == "group-restricted" def test_public_unchanged(self): """'public' should remain 'public'.""" assert _normalize_visibility("public") == "public" def test_private_unchanged(self): """'private' should remain 'private'.""" assert _normalize_visibility("private") == "private" def test_group_restricted_unchanged(self): """'group-restricted' should remain 'group-restricted'.""" assert _normalize_visibility("group-restricted") == "group-restricted" def test_case_insensitive_internal(self): """'Internal' (mixed case) should normalize to 'private'.""" assert _normalize_visibility("Internal") == "private" def test_case_insensitive_public(self): """'PUBLIC' (uppercase) should normalize to 'public'.""" assert _normalize_visibility("PUBLIC") == "public" def test_unknown_value_passed_through_lowered(self): """Unknown values are lowered but not aliased.""" assert _normalize_visibility("CUSTOM") == "custom" # --------------------------------------------------------------------------- # validate_visibility # --------------------------------------------------------------------------- @pytest.mark.unit class TestValidateVisibility: """Tests for the validate_visibility function.""" def test_all_canonical_values_accepted(self): """All three canonical visibility values should be accepted.""" for value in VALID_VISIBILITY_VALUES: assert validate_visibility(value) == value def test_internal_alias_accepted(self): """'internal' should be accepted and normalized to 'private'.""" assert validate_visibility("internal") == "private" def test_group_alias_accepted(self): """'group' should be accepted and normalized to 'group-restricted'.""" assert validate_visibility("group") == "group-restricted" def test_case_insensitive(self): """Mixed case input should be accepted.""" assert validate_visibility("INTERNAL") == "private" assert validate_visibility("Public") == "public" assert validate_visibility("GROUP") == "group-restricted" def test_invalid_value_rejected(self): """Invalid visibility values should raise ValueError.""" with pytest.raises(ValueError, match="Visibility must be one of"): validate_visibility("secret") def test_empty_string_rejected(self): """Empty string should raise ValueError.""" with pytest.raises(ValueError, match="Visibility must be one of"): validate_visibility("") def test_unknown_value_rejected(self): """Unknown value that isn't an alias should be rejected.""" with pytest.raises(ValueError, match="Visibility must be one of"): validate_visibility("hidden") # --------------------------------------------------------------------------- # VALID_VISIBILITY_VALUES constant # --------------------------------------------------------------------------- @pytest.mark.unit class TestValidVisibilityValues: """Tests for the VALID_VISIBILITY_VALUES constant.""" def test_contains_three_values(self): """Constant should contain exactly three values.""" assert len(VALID_VISIBILITY_VALUES) == 3 def test_contains_expected_values(self): """Constant should contain public, private, and group-restricted.""" assert "public" in VALID_VISIBILITY_VALUES assert "private" in VALID_VISIBILITY_VALUES assert "group-restricted" in VALID_VISIBILITY_VALUES def test_does_not_contain_internal(self): """Constant should NOT contain 'internal' (it's an alias, not canonical).""" assert "internal" not in VALID_VISIBILITY_VALUES